Excessive versioning: the basics
What does it mean for an S3 bucket to carry excessive versions?
S3 versioning is a per-bucket switch that, once enabled, preserves every prior state of every object. A PutObject to a key that already exists doesn't overwrite — it adds a new version. A DeleteObject doesn't delete — it inserts a delete-marker and tucks the previous version away as noncurrent. The default ListObjectsV2 call only shows current versions, so the bucket looks the same size it has always been while the storage bill quietly climbs.
A bucket is flagged as having excessive versioning when versioning is enabled and there are no lifecycle rules expiring noncurrent versions. The signal is structural, not metric — the wastage check doesn't need to know how much noncurrent storage you carry to know you're guaranteed to accumulate it forever. Three years of daily build artefacts, log rotations, or terraform-state overwrites compound into thousands of noncurrent copies for a single key.
The pattern that gets flagged in practice is the unintentional kind: versioning was enabled at bucket creation as a "safety net" or by a compliance template, the team writes to the bucket like it's a regular store, and nobody adds a NoncurrentVersionExpiration rule. Months later S3 Storage Lens shows that 80% of the bucket's bytes are noncurrent. Each of those bytes bills at the full Standard rate — roughly $0.023/GB-month — until lifecycle catches up.
In this lesson you'll learn how versioning and writes actually interact under the hood, why the default ls view hides the cost, and how to size the noncurrent tail using list-object-versions and Storage Lens. You'll see the two-rule lifecycle pattern that fixes the leak permanently, and how Intelligent-Tiering can drop noncurrent bytes to Archive Access automatically when retention is non-negotiable.
The terraform-state iceberg
A team running Terraform against a versioned tfstate bucket discovered they were paying for 47 GB of storage on a bucket that showed 9 MB in the console. Every terraform apply had been writing a fresh state file for two years — roughly 8,000 noncurrent versions of terraform.tfstate, each a few MB, none ever expired. The fix was a 30-day NoncurrentVersionExpiration rule that immediately reclaimed 99.98% of the bytes on the next lifecycle sweep. The bill drop was rounding-error money for them, but the same pattern at petabyte scale is how organisations end up paying six figures a year for storage nobody can name.
Excessive versioning in action
Nina runs platform engineering at an analytics company. A FinOps review flags a bucket called acme-data-warehouse-staging as carrying excessive versions — versioning on, no lifecycle, $4,200/month in S3 Standard.
She runs a quick s3 ls --summarize and the bucket reports 320 GB of current objects. At $0.023/GB-month that should be roughly $7.40 — three orders of magnitude below the bill. The discrepancy can only be one thing: noncurrent versions.
She drops to list-object-versions and the picture clears up. Current versions: 320 GB. Noncurrent versions: 178,000 entries totalling 174 TB. The bucket is a parquet staging area; every nightly ETL writes the same key paths, and every prior run is still there, two years deep.
First, confirm versioning is on and check whether any lifecycle rule expires noncurrent objects.
The exact signature the wastage check looks for: versioning enabled, no lifecycle rule.
Now size the noncurrent tail. list-object-versions returns every version and delete-marker — count and sum the ones where IsLatest is false.
Noncurrent versions outweigh current data by more than 500×. The bill is the bill.
Excessive versioning under the hooddeep dive
S3 versioning has three states per bucket: Unversioned (the default), Enabled, and Suspended. Once a bucket has ever been Enabled, you can't go back to Unversioned — only Suspended, which stops new versions from being created but leaves every existing one in place. The cost path is one-way: the only way to actually reduce a bucket's footprint is to delete noncurrent versions and delete-markers explicitly, either by hand or via lifecycle.
Each write generates a new version with a unique VersionId. The previous version isn't deleted, moved, or tagged — it stays at the same key, same storage class, billed at the full per-GB rate of that class. DeleteObject on a versioned bucket inserts a zero-byte delete-marker (which itself is a version) and demotes the previously-current version to noncurrent. DeleteObject followed by PutObject on the same key therefore leaves three version records: the delete-marker, the old noncurrent version, and the new current version. Multipart uploads that fail or are abandoned add yet another category: incomplete multipart upload parts, billed by part size, invisible in the version list, and only cleanable via AbortIncompleteMultipartUpload.
Lifecycle is the only at-scale tool to clean this up. NoncurrentVersionExpiration deletes versions that have been noncurrent for N days. NoncurrentVersionTransition moves them to a cheaper class first (e.g. Glacier Instant Retrieval at $0.004/GB-month, an 83% drop from Standard). AbortIncompleteMultipartUpload cleans up stranded parts. Lifecycle runs asynchronously once a day — expect a 24-48 hour lag between applying the rule and seeing the bill drop, and S3 doesn't charge for the lifecycle delete actions themselves.
# Inspect noncurrent storage at scale via S3 Storage Lens (free metrics dashboard).
aws s3control get-storage-lens-configuration \
--account-id 123456789012 \
--config-id default-account-dashboard
# The metric you want is NonCurrentVersionStorageBytes — broken down by bucket and prefix.
# Storage Lens samples daily; treat it as the authoritative source for noncurrent footprint. What is the impact of unbounded versioning?
The direct cost is linear and merciless. Every overwrite of a 50 MB parquet file in a daily ETL adds 50 MB of noncurrent storage. After a year of daily runs you're paying for ~18 GB of revision history per key. Multiply by every key the job touches and the bucket's footprint outruns the visible data by 1-2 orders of magnitude. At Standard rates a multi-TB noncurrent tail is the difference between a $50/month bucket and a $5,000/month bucket — and the team responsible for the bucket genuinely doesn't see it in the console.
The second-order impact is investigative cost. When someone finally notices, the first reaction is usually "can we just delete the old versions?" — which is correct, but you can't s3 rm your way out of millions of versions in any reasonable time. You either write a lifecycle rule and wait 24-48 hours, or you script a bulk-delete via delete-objects with VersionId set, hammer the bucket for hours, and pay the per-request charge ($0.0005 per 1,000 deletes) for the privilege. A bucket with 10M noncurrent versions costs $5 just to clean up.
There's also a security and compliance angle. Versioning is often kept on for genuine reasons — ransomware resilience, audit trails, immutability for SOX or HIPAA. The wastage check isn't telling you to turn it off; it's telling you that without lifecycle, you can't distinguish "versions we need" from "versions we forgot." When an auditor asks how long a particular document has been retained, an unbounded version list is harder to attest against than a deliberate 90-day retention policy.
Finally, suspended versioning is a trap. Teams often hit Suspended thinking they've stopped the bleeding — but every existing noncurrent version still bills exactly the same, and writes after suspension still create a version-id of null that behaves oddly with subsequent re-enables. The fix is lifecycle, not the versioning switch.
How do you tame versioning safely?
Excessive versioning is one of the cleanest leaks to fix in S3 — a single lifecycle policy stops it permanently. The four-step loop below scales from a single bucket to a fleet of thousands.
1. Inventory the noncurrent tail
Turn on S3 Storage Lens (free tier covers 28 days of organisation-level metrics) and sort buckets by NonCurrentVersionStorageBytes descending. The top 5-10 buckets almost always carry 90%+ of the waste. For each one, run aws s3api list-object-versions --bucket <name> --query 'Versions[?IsLatest==\false`].Size' | jq 'add'` to confirm the magnitude before you change anything.
2. Apply the two-rule lifecycle pattern
Every versioned bucket should have at least two lifecycle rules: NoncurrentVersionExpiration set to 30-90 days (start at 90 for safety; shorten once you've watched a few cycles), and AbortIncompleteMultipartUpload set to 7 days. Together these cover the two ways S3 silently accumulates orphan bytes. If retention is non-negotiable for compliance, add a NoncurrentVersionTransition to Glacier Instant Retrieval or Intelligent-Tiering Archive Access at 30 days — you keep the version, you stop paying Standard rates for it.
3. Verify with Storage Lens, not the console
The console shows current object size, not bucket footprint. After applying lifecycle, wait 48 hours and check Storage Lens — NonCurrentVersionStorageBytes should fall sharply, and the Cost & Usage Report will show the StorageObjectSize line item drop on the next billing cycle. If the number doesn't move, the most common cause is a rule scoped to a prefix that doesn't match the keys you thought it did. Always test with aws s3api get-bucket-lifecycle-configuration and re-read the filter.
4. Prevent recurrence with AWS Config or SCP
AWS Config managed rule s3-version-lifecycle-policy-check flags any versioned bucket without a NoncurrentVersionExpiration rule — wire it into your remediation pipeline (or a Slack channel) so a new bucket created without lifecycle is flagged within minutes, not months. For multi-account orgs an SCP can deny s3:PutBucketVersioning unless paired with s3:PutBucketLifecycleConfiguration in the same template — bluntly forcing every new versioned bucket to ship with a retention policy.
# The standard two-rule lifecycle for a versioned bucket.
cat > lifecycle.json <<'EOF'
{
"Rules": [
{
"ID": "expire-noncurrent-versions",
"Status": "Enabled",
"Filter": {},
"NoncurrentVersionExpiration": { "NoncurrentDays": 90 }
},
{
"ID": "abort-incomplete-multipart",
"Status": "Enabled",
"Filter": {},
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
}
]
}
EOF
aws s3api put-bucket-lifecycle-configuration \
--bucket acme-data-warehouse-staging \
--lifecycle-configuration file://lifecycle.json
# Lifecycle is async — give it 24-48 hours, then verify via Storage Lens. Quick quiz
Question 1 of 5A versioned bucket shows 50 GB in the console but $1,200/month on the bill. What's the right first move?
You scored
0 / 5
Keep learning
Dig deeper into S3 versioning, lifecycle, and the visibility tooling around them.
- S3 — Managing object lifecycle Reference for all lifecycle transition and expiration rules, including noncurrent-version actions.
- S3 — Using versioning in S3 buckets How versioning works, what Suspended actually means, and how delete-markers behave.
- S3 Storage Lens Free organisation-wide dashboard for noncurrent storage, incomplete multipart uploads, and bucket-level cost drivers.
- FinOps Foundation — Storage cost optimization Where lifecycle and retention policies sit in the broader FinOps optimisation capability.
You've completed Tame excessive S3 object versioning. You now know why the default object list hides the cost, how to size the noncurrent tail with list-object-versions and Storage Lens, and the two-rule lifecycle pattern that stops the leak permanently. The next time a versioned bucket shows up on a FinOps report, you'll have the four-step loop — inventory, lifecycle, verify, prevent — ready to apply.