Cloud Infrastructure · Cloud Storage
Versioning, Delete Markers, and What a Delete Actually Removes
Enabling versioning turns overwrite into append and delete into a placeholder. The state machine has three modes, and the suspended mode is where most surprises live.
Three modes, not a boolean
Versioning is usually described as a bucket setting that is either on or off. The state machine has three states, and the third is the one that causes trouble.
In the unversioned state, a key maps to at most one object. A PUT replaces it and the previous bytes are gone once the replacement completes; a DELETE removes the mapping. Storage cost equals the sum of the current objects, and the cost of an overwrite is invisible because there is nothing left to bill.
In the enabled state, every write to a key creates a new version with a service-assigned version identifier, and the key’s “current” pointer moves to the newest one. Older versions remain addressable by their identifiers and remain billable. A DELETE without a version identifier does not remove anything: it inserts a delete marker, a zero-byte object with its own version identifier, and moves the current pointer to that marker. A read of the key then returns 404 with a response header naming the marker’s version, and the underlying bytes are still present under their original version identifier.
In the suspended state, the bucket keeps the versions it already has but stops creating new ones for overwrites. Writes to a key whose current version is a real object overwrite that version in place, discarding the previous bytes, which is the unversioned behaviour, while writes to a key whose current version is a delete marker create a new version with a null identifier and remove the marker. This is the mode that exists so an operator can stop paying for version accumulation without deleting history, and it produces a keyspace in which some keys have a lineage of versions and others have a single mutable one.
The distinction between enabled and suspended is not cosmetic. Code that lists object versions to find “the version before this one” gets a coherent answer only while the bucket stays enabled. After a suspension, the null version can be overwritten, and the previous content disappears without any version to recover it from.
The version identifier is opaque and ordered per key
Version identifiers are strings the service generates. They are not timestamps that clients can construct, and their sort order is not documented as meaningful, although implementations tend to make newer identifiers sort later. The only guaranteed relationships are per key: the version named as current is the one a plain GET returns, and a DELETE naming a specific version removes exactly that version.
That per-key scoping matters for concurrency. Two writers overwriting the same key concurrently produce two versions; both writes succeed, the later one becomes current, and the earlier one remains recoverable. There is no conflict signal, no merge, and no notion of a lost update being detected. Versioning is a history mechanism, not a concurrency control mechanism. Detecting that a write clobbered someone else’s requires a conditional write that fails when the key exists, or a check of the entity tag before writing, as described in conditional writes and prefix-based coordination.
Deleting a specific version is a real delete: the bytes are released and the version identifier becomes invalid. It is also the only way to reclaim the space held by superseded versions, short of a lifecycle rule that does it on a schedule.
What a delete marker does to every read path
A delete marker participates in the keyspace as if it were an object, which is why it can be listed and deleted like one. Its effects are uniform across operations but easy to misread:
- A
GETorHEADof the key returns 404 and, on the major services, a header indicating that a delete marker is the current version. The distinction matters because it separates “this key was deleted” from “this key never existed”. - A
LISTof the bucket returns the key only if the current version is not a delete marker, and the response header on the listing reports whether the bucket currently has any delete markers. - A
LISTof object versions returns the marker with a flag set, alongside every real version. - A
PUTof the key creates a new version that becomes current; the marker stays in history. The key reappears in plain listings.
A version identifier can be supplied on GET, HEAD, DELETE, and on copy operations, which makes any surviving version readable regardless of what the current pointer says. This is what makes recovery from an accidental delete a two-step operation: find the version that preceded the marker, then either copy it forward under the same key or remove the marker so that the previous version becomes current again. Removing the marker is the cheaper step, and it is the one that has to be done deliberately, since it is a delete of the marker’s version identifier rather than a delete of the key.
Reclaiming space without losing the wrong thing
Every retained version is billed at the storage class it was written under, and noncurrent versions retain their original class unless a lifecycle rule transitions them. Two lifecycle mechanisms address this, and they interact.
Expiration of noncurrent versions deletes any version that has not been current for more than a stated number of days. The count starts when a version stops being current, not when it was created, so a long-lived object that is overwritten today starts its clock today. A related rule expires delete markers, and on the major services a marker with no noncurrent versions beneath it can be removed automatically, which is what stops markers from accumulating on keys that were created and deleted repeatedly.
The interaction to watch is with transitions. A rule that moves noncurrent versions to an archival class and a rule that expires them after a shorter interval can produce objects that are transitioned and then deleted before the archival class’s minimum storage duration has elapsed. The minimum duration is billed whether or not the object survives, so the transition is pure cost. The minimum durations and their billing consequences are covered in lifecycle transitions and minimum storage durations.
There is also a size effect. Overwrites of large objects double the storage footprint for as long as both versions exist, and a workload that rewrites a 10 GiB object hourly will accumulate versions far faster than its nominal dataset size suggests. The same arithmetic applies to multipart uploads whose parts were written but never committed: those parts are billed as storage until an abort rule removes them, which is one of the reasons multipart upload part sizing recommends an explicit abort policy alongside any large-object write path.
Where the model leaks
Versioning is bucket-wide and cannot be enabled for a prefix. A workload that needs history for one dataset and none for another must use separate buckets, because the choice cannot be scoped by key.
Enabling versioning is one-way in practice. The setting can be suspended but not returned to a state in which the existing versions cease to exist; those versions continue to occupy storage and continue to be returned by version listings until they are explicitly deleted. A bucket that has held versioned data and is later “unversioned” is still, structurally, a bucket with history.
Replication adds a wrinkle. Under a replication rule, a delete marker can be replicated to the destination, which makes the object appear deleted in both regions, or it can be excluded, which leaves the destination serving the last replicated version while the source serves 404. Both are defensible and they differ in what a failover produces. The lag between the source write and the destination’s visibility is the subject of cross-region replication and its consistency window.
Finally, versioning does not protect against a bucket-level deletion or a lifecycle rule that removes all versions. The guarantee is per object and per version, and it is only as strong as the policy that governs deletion of the bucket itself. The general durability argument, including why an object that survives disk failure can still be destroyed by a single successful request, is in durability and read-after-write consistency.
References
- Amazon S3 User Guide — Using versioning in S3 buckets — Amazon Web Services
- Google Cloud Storage documentation — Object versioning — Google Cloud
- Windows Azure Storage: A Highly Available Cloud Storage Service with Strong Consistency — Calder et al., SOSP 2011