The bill that does not go down
A team is asked to clean up an S3 bucket. They run a script that lists every object and deletes the ones older than a year. The script reports thousands of successful deletions. The bucket listing in the console looks smaller. The next month's AWS bill arrives, and the S3 line item is roughly the same — or higher.
This is not a billing error. It is the most predictable failure mode in S3 cost management, and it traces back to a feature that is genuinely useful in every other respect: versioning.
What versioning actually does to a delete
When versioning is enabled on a bucket, the words "update" and "delete" do not mean what most engineers assume.
An update does not overwrite an object. It creates a new version of the object alongside the previous one. The previous version becomes "noncurrent" — invisible in the standard listing, but still physically stored, still incurring storage charges.
A delete does not remove the data. It creates a delete marker — a special object that hides the previous version from the standard listing without touching the bytes underneath. The console shows the file as deleted because the listing logic respects the delete marker. The storage layer does not.
The result is that a version-enabled bucket containing 10 GB of "visible" data may be billed for 50 GB or 100 GB of actual stored data. The difference is the accumulated history that has gone silent.
The four kinds of cost that hide in a version-enabled bucket
The total S3 bill on a long-lived bucket is rarely dominated by the data the application is actively using. It is dominated by what the application stopped using months or years ago.
Noncurrent versions. Every time an object is updated, the previous version becomes noncurrent and stays stored. A heavily-updated file (a daily-rotated log, a frequently-modified document, a counter file) can produce hundreds of noncurrent versions over a year, each contributing to the bill.
Delete markers covering noncurrent versions. When versioning is enabled and an object is deleted, the delete marker is created on top of the existing object. The object is no longer "current" but it is still stored. The delete marker itself is essentially free; the data underneath is not.
Historical versions of long-since-deleted files. A file that was deleted two years ago, on a bucket that has had versioning enabled the whole time, is still in the bucket. Not visible. Not surfaced in any default report. Still billed.
Incomplete multipart uploads. Separate from versioning but in the same family of "stored but invisible." When a large upload is interrupted, the parts that did upload remain in the bucket as multipart upload fragments. They do not appear in object listings. They appear on the invoice.
Each of these is a quiet storage class — visible only when you look for them specifically, never surfaced by the default tooling, and almost always under-monitored.
Why disabling versioning does not fix what already exists
The first reaction, once an engineering team understands what is happening, is usually to turn versioning off. This does two things and neither of them is "reduce the bill."
Disabling versioning prevents new versions from being created going forward. Future updates will overwrite. Future deletes will permanently remove the current version. This is the desired behavior for buckets that should not have versioning in the first place.
It does not, however, remove any of the existing noncurrent versions or delete markers that already accumulated while versioning was on. Those have to be cleaned up explicitly. A bucket can be in "versioning suspended" status with terabytes of historical versions sitting underneath, none of which the suspended state affects.
This is the architectural point that catches teams off guard: turning the feature off is not the same as undoing what the feature did. The historical data is its own problem.
The right way to think about versioning
Versioning is not a flag to turn off. It is a feature to manage.
It exists for good reasons. It is the most effective defense against accidental deletes, ransomware encryption of bucket contents, and the class of mistakes where someone overwrites a file they should not have. For any bucket holding data the business cannot easily reconstruct, versioning is a load-bearing safety net.
The cost only becomes pathological when the safety net never gets pruned. A version-enabled bucket with no lifecycle policy is a bucket that remembers everything forever, and the bill is the price of that memory.
A version-enabled bucket with lifecycle policies that expire noncurrent versions after a defined window — 30 days, 90 days, a year, depending on the retention requirement — keeps the safety net intact for the window where mistakes are usually noticed, and prunes everything older. The storage cost stays bounded. The recovery capability is preserved for the period that actually matters.
What to do first on an existing bucket
If you suspect this is happening on a real bucket, four steps in order:
1. Confirm whether versioning is enabled. aws s3api get-bucket-versioning --bucket <name> returns Enabled, Suspended, or nothing (never enabled). Suspended counts; the historical versions are still there.
2. Quantify the gap. The CloudWatch metric BucketSizeBytes has dimensions for StandardStorage, StandardIAStorage, and so on — and crucially, AllStorageTypes returns the total billable bytes. Compare this to the bytes you would expect from the visible object listing. The gap is the accumulated history.
3. Inventory the bucket. For any bucket large enough that listing in real time is slow, enable S3 Inventory with the Versions field included. The inventory report writes a list of every current version, noncurrent version, and delete marker to a destination bucket on a schedule. This is the structured dataset you need to make cleanup decisions on.
4. Decide the retention policy before you delete anything. Do not run a script that deletes all noncurrent versions older than X. Decide what X should be — based on the business reason versioning was enabled in the first place — and write that as a lifecycle policy. Lifecycle policies execute against the same logic continuously, including for the data already in the bucket.
The pattern that prevents the problem from coming back
The structural fix is not "remember to clean up." It is to treat every bucket as having three policies that exist in writing from day one:
- A versioning policy: on or off, and if on, what kinds of mistakes it is protecting against.
- A retention policy: how long current versions should live, how long noncurrent versions should live, how long delete markers should persist.
- A storage class transition policy: when current objects move to Standard-IA, when to Glacier, when to Deep Archive.
These translate directly into a lifecycle configuration that S3 applies automatically. The buckets we audit that have lifecycle policies in place behave predictably — costs grow with the volume of new data, and historical accumulation is bounded. The buckets without them grow on a curve that is hard to explain to finance.
The follow-up article walks through the lifecycle configuration patterns that close this gap, and the one after that covers what to do when the bucket already has years of accumulation and lifecycle policies alone are not enough.
The takeaway from this one is shorter than the article it took to explain it: in a version-enabled S3 bucket, deletion is not removal. The cost is sitting in the history. Until you write a policy that prunes the history, the bill keeps climbing — and no amount of clicking "delete" in the console will fix it.
