Versioning turns an overwrite and a delete into appends. Instead of replacing bytes, a versioned bucket mints a new version ID and keeps the old one, so a mistaken PUT or a mistaken DELETE is reversible. That is genuine cheap disaster recovery for anything you manage through the bucket rather than through a database.
The same property is the problem. Every version you keep is storage you pay for, and the failure mode is not a dramatic one — it is a bucket whose bill climbs with write volume while the object count you can see stays flat.
Version IDs and the null version
With versioning on, every PUT produces a new version ID and the newest version becomes current. A GET without a version ID returns the current version; a GET with one returns exactly that version. So “the object” really means “the current version of the object”, and any tool that assumes one object per key is quietly wrong.
Objects written before versioning was enabled carry the version ID null, and enabling versioning does not retroactively version them. Replaying that sequence: a PUT before versioning stores null; enable versioning, then two overwrites, and the key now has three versions totalling 80 bytes. The null version is still there and is still a single slot that a non-versioned write could land on.
A delete marker is a tombstone, not a delete
A DELETE with no version ID on a versioned bucket writes a delete marker, and the marker becomes the current version. GET then returns 404 — but nothing was freed. In the replay the bucket holds 80 bytes before the delete and 80 bytes after it. The marker is a zero-byte entry that shadows everything underneath it.
The correct undo is to delete the delete marker, which makes the previous version current again and restores the 200. The wrong undo is to delete the newest object version while the marker is still on top: the key stays 404, and the newest recoverable copy is gone. That is the mistake a “clean up the bucket” script makes, and it is unrecoverable. MFA delete exists for exactly this class of action — permanently deleting a version, or turning versioning off, requires the MFA token.
Lifecycle is the only thing that bounds the bill
Two separate rules, and only one of them stops the growth. Expiration acts on the current version. NoncurrentVersionExpiration acts on everything underneath, and without it a bucket accumulates one live version per overwrite forever. Simulating 365 daily overwrites of one key: with no noncurrent rule the bucket ends the year holding 365 versions and 14,742 bytes; with a 30-day noncurrent rule it holds 29 versions and 1,186 bytes, a 12.4-fold difference. The current-version rule was set in both cases and did nothing to prevent it.
You can also transition noncurrent versions to a colder class instead of expiring them, which keeps the recoverable window cheap rather than free. That is usually the right trade for anything you might genuinely need to restore, but it costs a transition request per version, and on a bucket with millions of small objects the requests can exceed the storage saving.
When it breaks
The first failure is a bucket filling up because nothing expires noncurrent versions. It presents as a storage bill rising linearly with write volume while the visible object count is flat, and it is easy to misread as a leak. Diagnose by comparing the object count from a plain listing against the version count from a versioned listing: a hundred-to-one ratio is the signature, and the fix is one NoncurrentVersionExpiration rule, not a bigger bucket.
The second failure is an undelete that restores a delete marker instead of the object. A restore script that copies “the latest version” back into place copies the marker, because the marker is the latest version. The key stays invisible and the operator concludes the data is gone when it is sitting one entry down. Restore by explicit version ID, never by “latest”, and rehearse the restore before you need it.
Check it quickly
Read the version stack for one key, then the bucket’s rules:
aws s3api list-object-versions --bucket B --prefix reports/q3.pdf \
--query 'sort_by(Versions,&LastModified)[-3:].[VersionId,IsLatest,Size]' --output table
aws s3api list-object-versions --bucket B --prefix reports/q3.pdf --query 'DeleteMarkers'
aws s3api head-object --bucket B --key reports/q3.pdf --version-id <vid>
aws s3api get-bucket-lifecycle-configuration --bucket B
The first shows the newest three versions with sizes, and null appearing there means pre-versioning data you cannot expire by version. The second names any delete marker that is currently shadowing the key. The third proves a specific version is readable, which is the only form of restore worth testing, and the fourth tells you whether a noncurrent-version rule exists at all — if it does not, the bucket has no ceiling.
Sources
- AWS S3 versioning, how versioning and the null version work, checked 2026-09-16.
- AWS S3 delete markers, what a delete marker does and how to undo it, checked 2026-09-16.
- AWS S3 lifecycle management, current and noncurrent expiration and transition rules, checked 2026-09-16.