Delete Markers and Noncurrent Versions
In one line
When you turn on versioning, a delete is no longer erasing but uploading one more "deleted marker (delete marker)." The original remains as it is, and the charges keep coming.
Why it was needed
S3 was eventually consistent for a long time. If you overwrote an object and read it immediately, the old value could come back, and because of this many applications added retry logic. Since December 2020, AWS S3 provides strong read-after-write consistency. A GET right after a PUT sees the new value.
But you need to know exactly the scope of this guarantee.
| Operation | Guarantee now | Note |
|---|---|---|
| GET after PUT (new key) | Strong consistency | — |
| GET after PUT (overwrite) | Strong consistency | — |
| GET after DELETE | Strong consistency | With versioning on, the data remains after the 404 |
| LIST | Strong consistency | Other implementations are usually weak here |
| Multi-region replication | Asynchronous | Replicas can lag behind at any time |
You must not apply the sentence "S3 now has strong consistency" as it is to every S3-compatible storage. MinIO is strongly consistent, but Ceph RGW depends on configuration, and in SeaweedFS listing can lag depending on the filer setup. The storage in this lab is the same. What guarantee the code you write stands on is verified by measurement, not by documentation.
Preventing races with conditional requests
Even with strong consistency, the problem of two processes writing to the same key at the same time remains. The one that writes later wins, so the earlier update quietly disappears. S3 prevents this with conditional headers.
# 없을 때만 만든다 — 있으면 412 Precondition Failed
s3.put_object(Bucket=b, Key=k, Body=data, IfNoneMatch="*")
# 내가 읽은 그 버전일 때만 덮어쓴다(낙관적 잠금)
head = s3.head_object(Bucket=b, Key=k)
s3.put_object(Bucket=b, Key=k, Body=new, IfMatch=head["ETag"])
If IfMatch returns 412, someone changed it in between. Read it again, merge and retry. Without this pattern, you get the incident "we both hit save at the same time and only one person's survived."
Versioning solves a different problem
What versioning solves is not consistency but the problem of undoing accidental overwrites and deletions.
When you turn on versioning, a new version ID is created every time you write to the same key. The latest one is the current version, and the rest are noncurrent versions. You can read an old version at any time by its version ID.
Deletion is interesting. If you DELETE without a version ID, nothing is actually erased; a special version called a delete marker goes on top. So an ordinary GET gets a 404, but the data is still there below.
키 report.csv 의 버전 스택 (위가 최신)
delete marker (v4) ← 지금 GET 하면 404
────────────────────
2026-09-05 판 (v3) ← v4 를 지우면 이것이 다시 현행이 된다
2026-09-04 판 (v2)
2026-09-03 판 (v1)
If you delete the delete marker, the version just below becomes current again. This is the mechanism of mistake recovery. To truly delete, you must DELETE with an explicit version ID, and this action cannot be undone.
One more thing. You can turn versioning on, but you cannot turn it off. You can only change it to the Suspended state, and the versions already accumulated remain. An object written in the Suspended state becomes a special version whose version ID is null, and the next write overwrites it. This is why you decide the lifecycle rules before turning it on.
What it looks like in the field
The cost of versioning is often overlooked. If you turn on versioning for code that overwrites a log file at the same key every day, after a year 365 versions have piled up on that one key. The list shows a single object, but the charge is 365 times. And the situation "I deleted everything but the charge is the same" is also because of delete markers.
There is one more hidden cost. Failed multipart uploads. If an upload of a 5GB file is cut off at 80%, the pieces already uploaded are billed without being visible anywhere. They do not show up in the list or in the version list. They appear only with list-multipart-uploads.
So when you turn on versioning, always set lifecycle rules along with it. Three rules are the basic set.
{"Rules": [
{"ID": "expire-noncurrent", "Status": "Enabled", "Filter": {},
"NoncurrentVersionExpiration": {"NoncurrentDays": 30}},
{"ID": "clean-delete-markers", "Status": "Enabled", "Filter": {},
"Expiration": {"ExpiredObjectDeleteMarker": true}},
{"ID": "abort-incomplete-mpu", "Status": "Enabled", "Filter": {},
"AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 7}}
]}
The rules run asynchronously once a day, so they do not take effect immediately. It is normal if capacity starts to shrink the day after applying.
For data where irreversible deletion must be blocked altogether, use Object Lock. GOVERNANCE mode can be lifted with special permission, and COMPLIANCE mode cannot be deleted even by the root account within the period. Use the latter for logs subject to audit.
What we do in the next lab
You turn on versioning, write to the same key three times to accumulate versions, read an old version, check the delete marker after deleting, remove it to recover, calculate the capacity taken up by noncurrent versions, and then apply lifecycle rules. Finally, you confirm yourself that a conditional PUT returns 412.