Skip to content

Recovering deleted S3 objects with versioning (and the delete-marker trap)

How to recover deleted S3 objects on a versioned bucket: find and remove the delete marker, restore specific versions, and avoid the lifecycle rules that make deletion permanent.

If the bucket had versioning enabled when the object was deleted, the data is almost certainly still there: a plain delete on a versioned bucket does not remove anything — it adds a delete marker on top of the version stack, and removing that marker brings the object back exactly as it was. If versioning was off (or the delete specified a version ID), the object is gone from S3 itself, and your options are replication copies, backups, or nothing. Check which world you are in before promising anyone a recovery.

What’s happening

On a versioning-enabled bucket, every object key carries a stack of versions. A DeleteObject call without a version ID does not touch any of them; it pushes a delete marker — a zero-byte placeholder — onto the top of the stack and makes it the “latest” version. GET requests return 404 because the latest version is a marker, which is why the object looks gone. Every previous version is intact underneath and retrievable by its version ID.

The trap has three prongs. First, deleting the “object” again just stacks another marker — people click delete repeatedly in the console and build marker towers while the data sits untouched below. Second, a DeleteObject with a specific --version-id is a permanent, unrecoverable removal of that version — including when the version ID you pass is a delete marker’s (that one is the recovery move) or a data version’s (that one is destruction). The direction of the same command differs only by which ID you feed it. Third, lifecycle rules with NoncurrentVersionExpiration quietly and permanently purge old versions after N days — versioning only protects you until the lifecycle policy takes the versions away, so recovery is time-boxed on many buckets without anyone remembering that.

Suspended versioning is the half-way state: existing versions survive, but new writes overwrite the null version, and deletes still create markers.

What to check first

  • Was versioning enabled, and since when? Objects written before versioning was turned on have a single null version and the same recovery rules apply to it.
  • How was the delete issued — console, CLI without version ID, or something passing --version-id? The last one may be unrecoverable.
  • Do lifecycle rules expire noncurrent versions? If yes, note the retention window — the clock is running.
  • Is there replication or AWS Backup on the bucket? If S3-side recovery fails, the replica or vault is plan B.

How to verify

Confirm versioning state and find the markers:

aws s3api get-bucket-versioning --bucket my-bucket
# "Status": "Enabled" is what you want to see

aws s3api list-object-versions --bucket my-bucket \
  --prefix reports/q2-2026.xlsx \
  --query '{Markers:DeleteMarkers[].[VersionId,IsLatest,LastModified],Versions:Versions[].[VersionId,IsLatest,LastModified,Size]}'

A recoverable situation shows a delete marker with IsLatest: true and one or more data versions below it. Verify a candidate version is readable before touching anything:

aws s3api get-object --bucket my-bucket \
  --key reports/q2-2026.xlsx \
  --version-id 3sL4kqtJlcpXroDTDmJ+rmSpXd3dIbrHY+MTRCxf3vjVBH40Nr8X8gdRQBpUMLUo \
  /tmp/q2-check.xlsx

Console path: S3 > Buckets > my-bucket, toggle Show versions — markers and versions appear inline. Check the lifecycle threat under Management > Lifecycle rules, looking for Permanently delete noncurrent versions of objects.

The runbook

  1. Recover one object: delete its delete marker. This is the counter-intuitive move — you delete the deletion:
aws s3api delete-object --bucket my-bucket \
  --key reports/q2-2026.xlsx \
  --version-id 8QWCvJgb8jNDL7q3jTQm9dTSGnQK2fDx
# version-id here is the DELETE MARKER's id, from list-object-versions

The previous data version becomes latest again and GETs succeed immediately. Triple-check you are passing the marker’s ID, not a data version’s.

  1. Alternative: copy a good version over the top. When you want a specific older version (not just the newest) or prefer not to remove markers, copy it onto the same key — it becomes a new latest version and preserves the full history:
aws s3 cp "s3://my-bucket/reports/q2-2026.xlsx" \
  "s3://my-bucket/reports/q2-2026.xlsx" \
  --source-version-id 3sL4kqtJlcpXroDTDmJ+rmSpXd3dIbrHY+MTRCxf3vjVBH40Nr8X8gdRQBpUMLUo
  1. Mass recovery: enumerate markers, delete them in batch. For a bad script that deleted a whole prefix, page through list-object-versions, filter DeleteMarkers[?IsLatest==true], and feed the key+versionId pairs to delete-objects in batches of up to 1,000. Dry-run the list first and count — the number should match your incident’s blast radius, not exceed it.
  2. If versions were permanently deleted, switch to secondary copies. Same-region or cross-region replicas, AWS Backup restore points, or downstream consumers that still hold the data. Move quickly if lifecycle expiry is what took the versions — replicas may share the same lifecycle mistake.
  3. After recovery, remove the cause. Fix the deleting code path, and tighten s3:DeleteObjectVersion / s3:PutLifecycleConfiguration permissions — very few identities need either.

When this is the wrong move

Versioning is not a backup strategy, and this runbook is not disaster recovery. It does not protect against bucket deletion, a compromised identity with s3:DeleteObjectVersion (which can purge every version), or region-level problems. If the data matters, it needs a copy outside the bucket — replication to another account, or AWS Backup — and if you are mid-security-incident, stop and preserve state before “fixing” anything: markers and CloudTrail entries are evidence.

Common mistakes

  • Passing a data version’s ID to delete-object while trying to remove a marker — the one keystroke that turns recovery into permanent loss. List first, read twice, delete once.
  • Assuming versioning was on because “it is our standard” — check get-bucket-versioning before promising recovery to anyone.
  • Forgetting that lifecycle NoncurrentVersionExpiration has been silently discarding old versions, so the version you need expired weeks ago.
  • Restoring the object but not noticing storage class: versions transitioned to Glacier tiers need a restore request before they are readable, which adds hours and retrieval mechanics.

Prevention

  • Enable versioning on every bucket whose contents you would miss, and pair it with a NoncurrentVersionExpiration window long enough to survive a long weekend plus discovery time — 30 days is a sane floor.
  • Deny s3:DeleteObjectVersion to everything except a break-glass role; markers then become the only kind of delete most identities can perform, and every delete stays reversible.
  • Consider S3 Object Lock (compliance or governance mode) for data with retention obligations — versioning plus lock is the tamper-resistant tier; details at https://docs.aws.amazon.com/AmazonS3/ .
  • Alert on unusual spikes of DeleteObject calls via CloudTrail so a runaway script is caught in minutes.
  • Test the recovery path once, on purpose, in staging — the delete-marker dance should not be learned during an incident.

The whole recovery usually takes five minutes once you can read list-object-versions output calmly — the risk is entirely in rushing the version-ID handling. If nobody on your team truly owns the AWS environment end to end, that is the root of most of these problems — Stack Harbor runs AWS accounts daily under AWS Operations, and you can book an AWS review.