S3 versioning is one checkbox, and turning it on is nearly always right for buckets that hold anything you care about. It turns two everyday accidents, overwriting a file and deleting one, into recoverable events. But it’s often described as “backup”, and it isn’t quite. Knowing what it keeps, how to get things back, and which failures it does nothing about is what separates a good answer from a recited one.
What changes when versioning is on
A bucket is in one of three states: never versioned, versioning enabled, or versioning suspended. Once enabled, a bucket can never go back to the never-versioned state; you can only suspend it, which stops creating new versions but keeps the existing ones.
With versioning enabled:
- Every
PutObjectto a key creates a new version with its own version ID. The previous one becomes a noncurrent version, still stored and still billed. - A
DeleteObjectwithout a version ID doesn’t delete data. It adds a delete marker on top, which becomes the current version. A plainGETnow returns404 Not Found(with thex-amz-delete-marker: trueresponse header), and the key disappears from normal listings. - A
DeleteObjectwith a version ID permanently removes that one version. That’s the only way data actually goes away (apart from lifecycle rules, which do the same thing on a schedule).
Objects that existed before versioning was turned on have the version ID null. They’re kept like any other version once you start overwriting them.
Watching versions pile up
Here’s a small Python model of a single key in a versioned bucket. It keeps a stack of versions, newest first, and follows the three rules above. Version IDs are short labels for readability; real ones are long opaque strings.
# Model of one key in a versioning-enabled bucket: a stack of versions, newest first.
import itertools
ids = (f"v{n}" for n in itertools.count(1))
stack = [] # newest first; each entry: (version_id, body or None for a delete marker)
def put(body): stack.insert(0, (next(ids), body))
def delete(version=None):
if version is None: stack.insert(0, (next(ids), None)) # adds a delete marker
else: stack[:] = [v for v in stack if v[0] != version] # permanent
def get(version=None):
vid, body = stack[0] if version is None else next(v for v in stack if v[0] == version)
return "404 NoSuchKey (delete marker)" if body is None else f"200 {body!r}"
def show(label): print(f"{label:<34} versions={stack} GET -> {get()}")
put("price=10"); show("put price=10")
put("price=12"); show("put price=12 (overwrite)")
delete(); show("delete (no version id)")
print(f"{'':<34} GET ?versionId=v2 -> {get('v2')}")
delete("v3"); show("delete the delete marker v3")
put("price=0"); show("bad deploy writes price=0")
delete("v4"); delete("v2"); show("permanently delete v4 and v2")Output from python3 versions.py:
put price=10 versions=[('v1', 'price=10')] GET -> 200 'price=10'
put price=12 (overwrite) versions=[('v2', 'price=12'), ('v1', 'price=10')] GET -> 200 'price=12'
delete (no version id) versions=[('v3', None), ('v2', 'price=12'), ('v1', 'price=10')] GET -> 404 NoSuchKey (delete marker)
GET ?versionId=v2 -> 200 'price=12'
delete the delete marker v3 versions=[('v2', 'price=12'), ('v1', 'price=10')] GET -> 200 'price=12'
bad deploy writes price=0 versions=[('v4', 'price=0'), ('v2', 'price=12'), ('v1', 'price=10')] GET -> 200 'price=0'
permanently delete v4 and v2 versions=[('v1', 'price=10')] GET -> 200 'price=10'Three lines are worth slowing down for. After the plain delete, the object looks gone, but GET ?versionId=v2 still returns it: the data never left. Deleting the delete marker (by its version ID) “undeletes” the object, and the previous version becomes current again. And the last line shows a restore done by deleting newer versions, which works but destroys evidence of what happened. There’s a better way.
Restoring the right way
There are two kinds of accident and two restore moves.
Accidental delete. The current version is a delete marker. Delete the marker and the previous version becomes current. With the CLI (illustrative; bucket and IDs are made up, nothing here was run against AWS):
aws s3api list-object-versions --bucket acme-config --prefix pricing/eu.json
aws s3api delete-object --bucket acme-config --key pricing/eu.json \
--version-id 3HL4kqtJvjVBH40NrjfkdBad overwrite. The current version has wrong content. Copy the good version on top of the key. That creates a new current version with the old content, and keeps the bad one as a noncurrent version you can inspect later:
aws s3api copy-object --bucket acme-config --key pricing/eu.json \
--copy-source "acme-config/pricing/eu.json?versionId=Rb1dvZlJ8wLZp2Kq"Copying is the safer default. It’s reversible, it leaves a trail, and it doesn’t need permission to permanently delete versions.
At scale (someone ran aws s3 rm s3://acme-config/ --recursive and put a delete marker on 400,000 keys), you’d list versions, select the delete markers created in the incident window, and delete them in batches, often with S3 Batch Operations. The lesson people take from that day is usually to restrict who can run recursive deletes in production.
Versions cost money: lifecycle rules
Every version is billed as stored data. A key overwritten hourly with a 50 MB file keeps adding 50 MB of noncurrent data every hour, forever, unless something removes it. That’s how versioned buckets quietly become the biggest line on an S3 bill.
Lifecycle rules handle it. A typical rule keeps noncurrent versions for 30 days, always keeps the three most recent, and cleans up delete markers that no longer have any versions behind them:
{
"Rules": [
{
"ID": "trim-old-versions",
"Status": "Enabled",
"Filter": {},
"NoncurrentVersionExpiration": { "NoncurrentDays": 30, "NewerNoncurrentVersions": 3 },
"Expiration": { "ExpiredObjectDeleteMarker": true },
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
}
]
}The number you choose here is your recovery window. If noncurrent versions expire after 30 days, a bad overwrite noticed on day 31 can’t be recovered from this bucket. Say that out loud in an interview; it shows you see the trade-off between cost and recoverability.
What versioning doesn’t protect against
This is where “versioning is our backup” falls apart:
- Someone with the right permissions. Any principal allowed
s3:DeleteObjectVersioncan permanently delete every version. A compromised admin role or a careless script with broad permissions removes everything, versions included. - Lifecycle rules. A misconfigured rule (expire noncurrent versions after 1 day, applied to the wrong prefix) deletes history automatically and with full authority.
- Account-level events. If the account is compromised or closed, or the bucket is emptied and deleted, versions go with it. Everything is still in one account, one bucket, one Region.
- Corruption written over time. If an application writes subtly wrong data for two months, versioning has kept every bad version, but the good ones may have expired.
The tools that close those gaps:
- S3 Object Lock makes versions immutable for a retention period. In governance mode, users with a special permission can still override it; in compliance mode, nobody can, including the root user, until the retention period ends. It needs versioning and is the usual answer for ransomware resilience and regulatory retention.
- Replication to a separate account. Cross-Region or same-Region replication into a bucket owned by a different AWS account, with tight access, means a compromise of the source account doesn’t reach the copies. Replication doesn’t propagate permanent version deletions, which is exactly what you want here.
- AWS Backup for S3, which gives point-in-time restores and backup vaults that can themselves be locked.
- MFA delete, which requires an MFA code to permanently delete versions or change versioning state. It can only be enabled by the root user via the API or CLI, which makes it awkward, so Object Lock is more common now.
And the step that’s always skipped: actually restore something from your chosen mechanism, on a schedule, and time it.
Interview practice: the overwritten config and the 7-day rule
A service reads feature-flags.json from a versioned bucket. A bad deploy overwrote it ten days ago, nobody noticed, and since then it’s been overwritten twice more by legitimate changes. The bucket has a lifecycle rule expiring noncurrent versions after 7 days. Can you get the original file back? What would you change?
Answer
Probably not from this bucket. The original became noncurrent when the bad deploy overwrote it ten days ago, and the lifecycle rule expires noncurrent versions 7 days after they become noncurrent. It’s been gone for about three days. I’d still run list-object-versions to confirm (lifecycle actions are asynchronous, so expiry can lag slightly), and check whether replication, AWS Backup or a deployment artifact repository has a copy.
Changes: first, set the retention window from how long it takes to notice problems, not just storage cost. Using NewerNoncurrentVersions (keep, say, the last 10 versions regardless of age) protects files that change rarely. Second, treat config as code: keep feature-flags.json in version control and deploy it from there, so S3 isn’t the only history. Third, add validation in the deploy so a malformed or obviously wrong file isn’t written at all. And for anything critical, replicate to another account so a deletion or a bad lifecycle rule in this one doesn’t take the history with it.