An S3 lifecycle expiration rule is not deleting your objects because the bucket is versioned: the rule adds a delete marker and the underlying versions stay billable. Forcing removal means adding a NoncurrentVersionExpiration rule, expiring delete markers, and checking that prefix and tag filters actually match.
You set the rule in Terraform, waited out the 30-day window, refreshed the console, and there they are. That is expected behaviour, not a broken rule. On a versioned bucket an Expiration action performs a DELETE with no version ID, which writes a delete marker as the new current version and demotes the real object to noncurrent. The bytes never move. You keep paying for them, and a subsequent GET returns 404 while the object still occupies storage.
The cost surprise is usually the delete markers themselves. Each one is a small object with its own storage charge and, critically, its own request charge on every listing operation. A bucket with a 30-day expiration on 40 million objects and no marker expiry accumulates roughly 1.3 million markers a month that nothing will ever clean up. S3 Storage Lens reports this for free in its default metrics dashboard, split out as noncurrent bytes and version count, which is the fastest way to see the gap between what your rule promised and what your bill shows.
Filters are the other silent failure. A rule scoped to logs/ does nothing to log/, and a rule filtered on tag:retention=30d ignores every object uploaded by a tool that never applied the tag, including anything written by SDK code that sets metadata instead. The Lifecycle tab will happily show the rule as enabled with a past date. Note also that AWS enforces minimums here: noncurrent versions cannot be expired sooner than 1 day after they become noncurrent, and the same 1-day floor applies to delete markers.
- Versioned buckets never hard-delete: an S3 Expiration rule creates a delete marker only, so the current object version remains stored and billable indefinitely.
- Noncurrent versions need their own action: NoncurrentVersionExpiration is a separate rule, and AWS enforces a minimum of 1 day after a version becomes noncurrent before it can be deleted.
- Delete markers expire separately: an Expiration action configured to apply to delete markers has the same 1-day minimum; without it, markers accumulate with their own request and storage charges.
- Filters exclude silently: a rule matching prefix
logs/or tagretention=30donly ever touches objects that carry that exact prefix or tag. - CLI bypasses the timing:
aws s3api list-object-versionsfollowed bydelete-objects --version-idremoves specific versions immediately, with no waiting period.
Why does my S3 lifecycle expiration rule show 'expired' but the object is still there?
Check the bucket's versioning status first. If S3 Versioning is enabled or was ever enabled, an expiration action does not remove anything: it adds a delete marker as the new current version and demotes the object you were trying to get rid of to a noncurrent version. The console then labels the rule action "expired" because the lifecycle engine did exactly what an expiration is defined to do on a versioned bucket. The object version is still sitting there, still billed.
That is the whole trick of the terminology. "Expired" describes the rule's evaluation of age, not the fate of the bytes. A delete marker is a tiny zero-byte object that makes GET return 404 while every prior version stays retrievable and chargeable. On a bucket holding 900 GB of noncurrent data, that misreading is worth roughly $20.70 per month at the S3 Standard rate of $0.023 per GB per month in us-east-1, and far more if the versions are large or the bucket is in a pricier region. Only a second rule targeting noncurrent versions actually frees the storage. Noncurrent version expiration can be configured to start as soon as 1 day after a version becomes noncurrent, but it will not run at all unless you create it.
Two rules, not one, is the usual correct shape for a versioned bucket:
- Expiration on current versions, which drops the delete marker in place.
- Noncurrent version expiration, set to 1 to 30 days, which deletes the demoted versions.
- Expiration of expired delete markers, which clears the markers themselves so they do not accumulate as noncurrent objects with their own storage line.
What the console will not tell you
Lifecycle actions run once daily in a batch, so the earliest a noncurrent version can physically disappear is within 24 hours of the action becoming eligible, and depending on the hour your rule was created it can take a second cycle. If you are staring at objects after a day and a half, that is not the problem. The problem is almost always the filter. A rule with a prefix of logs/ and a tag filter of env=prod matches only objects satisfying both; a typo, a case difference in the tag key, or an object uploaded before the tag was attached all leave the object untouched while the rule reports healthy. AWS lifted the old 100-rule ceiling per bucket in 2025, so the quota is now 1,000 rules per bucket — adding a noncurrent version rule plus a delete marker rule is cheap. Before you add anything, confirm the scope with S3 Storage Lens: the default metrics are free and include the "Noncurrent version bytes" metric, which tells you within a few hours whether your noncurrent data is actually shrinking. If the number is flat, your filter is wrong, not your timing. S3 Batch Operations is the fallback when you need deletion now rather than at the next daily batch.
The exact rules you need: a step-by-step fix
This procedure applies when your bucket has S3 Versioning enabled and an expiration rule has already run, the console shows expired on the objects, and the bytes are still on your bill. It needs write access to the bucket's lifecycle configuration, which in most accounts is s3:PutLifecycleConfiguration plus s3:GetLifecycleConfiguration on the bucket ARN. Budget 20 minutes for the configuration work; the deletions themselves land on AWS's schedule, not yours. Lifecycle actions run once daily in a batch, so the earliest a rule can physically delete a noncurrent version is within 24 hours of the action becoming eligible. Nothing about this is instant.
- Confirm the object has noncurrent versions at all. Run
aws s3api list-object-versions --bucket my-bucket --prefix reports/ --query 'Versions[?IsLatest==`false`]'. If that returns nothing, your "expired" objects are probably only delete markers with no older versions behind them, and you need step 4 instead of step 2. This is where most people misdiagnose: they see the object name in the console and assume data survives, when the listing is showing a delete marker as the current version. - Add a NoncurrentVersionExpiration rule with
NoncurrentDaysset to 1. One day is the minimum AWS accepts for the noncurrent transition, so you cannot rush this with a 0 or a fractional value. Put it in the same lifecycle configuration block as the original expiration rule rather than creating a second configuration, because a bucket has exactly one lifecycle configuration and a secondput-bucket-lifecycle-configurationcall replaces the first outright. - Add an expiration rule for delete markers, expressed as Expiration with
ExpiredObjectDeleteMarker: true. This only removes a delete marker when it is the sole remaining version of the object, which is the behaviour you want: it will not strip a marker that still sits above live noncurrent versions. AWS permits expiry of delete markers to begin 1 day after creation. - Verify both rules carry the same filter as the original. A rule scoped to
reports/does not touchReports/on a case-sensitive prefix, does not touchreports-archive/, and does not touch objects taggedclass=archiveif the origin rule filtered on prefix. Copy theFilterblock verbatim into both new rules. Silent filter mismatch is the single most common reason a correctly written rule appears to do nothing, and the console gives you no warning. - Check the rule scope is not narrowed by a storage class filter. If the original rule only matched
STANDARD, objects that transitioned to S3 Intelligent-Tiering or S3 Glacier Flexible Retrieval will sit outside both new rules. Widen the filter, or add a second pair of rules per class. - Apply and read back. In Terraform, use
aws_s3_bucket_lifecycle_configurationwith bothnoncurrent_version_expirationandexpiration { expired_object_delete_marker = true }inside one rule, then runterraform planand confirm it shows a single resource update rather than a destroy-and-recreate of the lifecycle block. A recreate briefly removes all lifecycle rules from the bucket. - Wait a full lifecycle cycle, then verify versions are actually vanishing rather than trusting the console. Run
aws s3api list-object-versions --bucket my-bucket --prefix reports/ --query 'length(Versions)'once a day for three days and watch the number fall. Cross-check with S3 Storage Lens, which reports a free Noncurrent version bytes metric in its default dashboard; advanced metrics cost $0.20 per million objects if you want prefix-level breakdown. - Price the gap while you wait. Anything still sitting as a noncurrent version bills at $0.023 per GB per month in us-east-1 Standard in 2026, identical to current data on that class. A 4 TB backlog of noncurrent versions costs roughly $94 per month until the rules catch up.
The failure mode: you add the noncurrent version rule, wait two days, and storage does not move because the rule was attached to a prefix the original expiration rule never used, or because the bucket has Object Lock in governance or compliance mode holding versions that no lifecycle rule can override. Object Lock wins every time. Remove the lock, wait out the retention period, or accept that the bytes are not going anywhere. If your backlog is measured in tens of millions of versions rather than thousands, do not wait for the daily batch at all: file a manifest into S3 Batch Operations with the Delete object version task, which processes at a far higher rate and gives you a completion report to prove the work.
Transition vs expiration: what the lifecycle rule actually did
A lifecycle rule that only transitions does not delete anything. It rewrites the storage class metadata on a version and stops there — no delete marker, no removal from the listing, no change to the object count in S3 Storage Lens. That is why a bucket full of objects transitioned to Glacier Flexible Retrieval in 2025 still shows them all today, and why the console's "expired" label on the rule means the schedule fired, not that bytes left the bucket.
The four actions differ only in which version they touch and what they leave behind. Versioning is the variable that decides everything: on an unversioned bucket expiration is a real delete, on a versioned bucket the identical rule just writes a marker and bumps the old copy into the noncurrent pile.
| Action | Targets | Effect on version state | Listing after the action runs | Bytes freed |
|---|---|---|---|---|
| Transition | Current versions matching the filter | Storage class changes (e.g. to S3 Glacier Flexible Retrieval); version ID unchanged | Object still visible at the same key | 0 GB |
| NoncurrentVersionTransition | Versions that are already noncurrent | Storage class changes; only valid if the parent rule also sets a noncurrent expiration | Version still in the version list | 0 GB |
| Expiration | Current versions matching the filter | On a versioned bucket, adds one delete marker and makes the previous version noncurrent; on an unversioned bucket, deletes outright | Object may still appear as a noncurrent version beneath the marker | 0 GB until noncurrent expiry removes the underlying data |
| NoncurrentVersionExpiration | Versions noncurrent for at least N days | Permanently deletes those versions; minimum is 1 day after the version becomes noncurrent | Those versions disappear from the version list | Real; billed S3 Standard storage drops at $0.023 per GB per month in us-east-1 |
| Expiration (delete markers) | Delete markers with no noncurrent versions beneath them | Removes the marker itself; minimum 1 day after the marker is created | Key stops appearing entirely | 0 GB (markers carry no data), but clears the ghost listing |
| Transition to Glacier + no expiry rules | Current versions | No version state change at all | Still listed; objects appear archived, so console previews and ranged reads fail with InvalidObjectState |
0 GB |
For anyone whose goal is space and cost, only NoncurrentVersionExpiration wins: it is the one row that actually returns storage and drops the $0.023 per GB per month charge, and it is the row that almost every misconfigured rule omits. That flips in one case — if versioning is suspended or the bucket was never versioned, plain Expiration deletes on its own and the noncurrent rule is dead weight, because no version ever becomes noncurrent. Everything else in the table is either a storage-class move or bookkeeping, and none of it runs faster than once daily in a batch, so plan on up to 24 hours between a version becoming eligible and the deletion landing.
Delete markers: the invisible reason your bucket keeps growing
A delete marker is a zero-byte object version with your key on it and no data inside. When S3 Versioning is enabled and something is deleted, nothing is actually removed—S3 inserts that marker as the new "current" version, and every earlier version becomes noncurrent. The object vanishes from a normal listing, the storage does not. And the marker itself is billable metadata: it has a version ID, a creation timestamp, its own HTTP headers, and it inherits the storage class of the object it replaced. One marker per key is nothing. A bucket with 50 million keys and a lifecycle expiration rule that has been firing daily for a year has 50 million of them, and S3 Storage Lens will show them sitting in your "Noncurrent version bytes" metric long after you assumed the data was gone. That metric is free in the default dashboard; advanced metrics run $0.20 per million objects.
Nothing in your existing expiration rule touches them. An expiration action on a current version creates a delete marker, and that is the whole of its job. To remove a marker you need a second, separate action—configured under "Delete expired object delete markers or incomplete multipart uploads" in the console, or as an expiration { expired_object_delete_marker = true } block in Terraform—and it only sweeps markers that have no noncurrent versions left underneath them. The 1,000-rule-per-bucket quota AWS set in 2025 removed the old excuse for cramming everything into one rule; there is no reason not to split these out. Note the timing: lifecycle actions evaluate once daily in a batch, and nothing is eligible until one full day after the action first applies. Same for noncurrent version expiration, which is why a freshly expired object can sit there for 48 hours and look stuck.
Where the chain comes from
Delete markers are versions too, so they can go noncurrent. Upload a new object under a key that already carries a marker and the marker is superseded, not erased—it drops into noncurrent status alongside the real data versions. Upload and delete in a loop, which is exactly what a retrying application or a sync job does, and you build alternating strata of data and markers, each one billable metadata that your original expiration rule ignores. Cleanup then takes two actions working in sequence: one for noncurrent versions, one for expired delete markers. If the noncurrent rule filters on a storage class that S3 Intelligent-Tiering has quietly moved your markers out of, the chain never gets cut.
S3 Batch Operations can force the issue if you need it gone today rather than within a day or two—a batch job invoking DeleteObject with the version ID, scoped by a manifest from an inventory report. But the standing fix is lifecycle configuration, not a one-off. Test it on a single prefix first, with an IAM policy that permits s3:PutLifecycleConfiguration only on that prefix, and watch the noncurrent bytes figure in Storage Lens for a week before you widen the filter. If Object Lock is in governance or compliance mode on the bucket, none of this will run at all, and no lifecycle rule will tell you why.
Can I force immediate deletion without waiting for lifecycle?
Lifecycle timing is honest but slow: expiration actions are evaluated once daily per bucket, so even a perfectly configured noncurrent version rule will not physically remove anything until the next scheduled batch run — realistically 24 to 48 hours after the rule becomes eligible. When you need the bytes gone now (a compliance deadline, a runaway cost spike, a bucket being torn down), you bypass the lifecycle engine entirely and issue deletes yourself. Here is the working order.
- Enumerate versions before you delete anything. Run
aws s3api list-object-versions --bucket my-bucket --prefix logs/ --query 'Versions[?IsLatest==`false`].{Key:Key,VersionId:VersionId}'and read the output. If you skip this step you will delete the wrong version IDs, and there is no undo. Add--max-itemsbecause large buckets will paginate and a truncated list is a trap. - Delete individual versions with an explicit version ID.
aws s3api delete-object --bucket my-bucket --key path/to/object --version-id 3sL4kqtJlcpXroDTDmJ+rmSpXd3dIbrHY+MTRCxf3vjVBH40Nr8X8gdRQBpUMLUoremoves that version permanently and cannot be reversed. A plaindelete-objectwith no version ID just stacks another delete marker on the pile. - Batch the work with S3 Batch Operations. S3 Inventory gives you a manifest of every version and delete marker, and Batch Operations runs a Delete object job across millions of keys with retry and completion reporting. It is the only practical route past roughly a few hundred thousand objects; a shell loop against `list-object-versions` will take hours and hit API throttling long before it finishes. Jobs are charged per object processed, so price the manifest size before you launch.
- Delete the current version, not just the marker. A delete marker is itself a version with its own version ID. Passing that ID to
delete-object --version-idpermanently removes the marker and re-exposes the previous version as current — which is rarely what people mean. To actually empty a key you must delete both the current version and every noncurrent version beneath it. - Check Storage Lens before you bulk-delete. The default metrics dashboard includes Noncurrent version bytes at no cost, and it will tell you which buckets are worth the effort. Advanced metrics and prefix-level aggregation are billed at $0.20 per million objects, so run a free default report first. Finding one bucket holding 4 TB of noncurrent bytes is worth more than a scripted purge of twenty buckets holding 2 GB each.
- Test on a non-production bucket first. Version deletion is irreversible and IAM does not stop you. Confirm the policy attached to your role actually restricts
s3:DeleteObjectVersionto a test bucket before you point a Batch Operations job at production. A single mis-scoped manifest has emptied real buckets. - Remember what permanent deletion costs you. Object Lock in governance or compliance mode will reject version deletes outright, and S3 Glacier Flexible Retrieval restores are irrelevant here — deleting a version in an archive class is still immediate, no retrieval needed. If legal hold is on, no CLI flag and no Batch Operations job will override it.
The step people get wrong most often is the first one: they reach for a bulk delete without listing versions first, discover the manifest includes the current version of every object they still need, and end up destroying live data while trying to clean up history. List, filter, count, then delete — in that order, on a bucket you can afford to lose.
How to verify the rule will actually work before you wait 24 hours
Preview the filter set before you commit to waiting on it. In the AWS Management Console, edit the rule and use the lifecycle rule preview in the bucket's Management tab; it lists the keys that the current prefix, tag, and size filters match. The API equivalent is ListObjectsV2 with the same prefix you configured, run with --prefix against a small page size first. If that command returns fewer keys than you expect, the console preview will agree, and the rule will behave the same way.
Prefix typos are the most common silent failure, and the trailing slash is the usual culprit. A rule with prefix logs/ will not match a key named logs2026/error.log, and a rule with prefix logs matches both logs/ and logs-archive/. Print the first twenty keys with aws s3api list-objects-v2 --bucket NAME --query 'Contents[].Key' --max-items 20 and compare character by character. Terraform users can hit the same trap when a variable interpolation drops the separator; run terraform plan and read the rendered prefix in the output rather than trusting the source.
Confirming that the rule actually fired
Lifecycle actions run once daily as a batch, and AWS documents up to 48 hours after the expiration date before a queued action completes on a large bucket. Watch for the event rather than the clock: in AWS CloudTrail, filter on the S3 data event source with the lifecycle action name for the bucket, and in S3 Storage Lens turn on the Noncurrent version bytes metric, which is part of the free default metrics set (advanced metrics cost $0.20 per million objects as of the 2026 price list). A flat or rising noncurrent byte count after your expiration date is the signal that the current-version rule created delete markers but nothing is expiring them.
One number worth remembering before you debug a bucket by hand: at $0.023 per GB per month for S3 Standard in us-east-1, a bucket carrying ten million stranded noncurrent versions at 100 KB each is roughly 1 TB and $23 a month in pure waste. Fix the filter, then fix the second rule.
What about objects under Object Lock or Legal Hold?
S3 Object Lock is the one case where a lifecycle rule can be configured correctly, match the right versions, and still do nothing at all. A version placed under Object Lock in compliance mode cannot be deleted by anyone — not the bucket owner, not the root account, not AWS Support — until its retain-until date passes. Lifecycle expiration is a delete request like any other, so it is refused. The object stays, your rule keeps firing, and nothing in the S3 console tells you why. Governance mode behaves the same way, except a principal holding s3:BypassGovernanceRetention can shorten or remove the retention if the bucket owner has granted that permission.
Legal Hold is the sharper trap, because it has no expiry date. It is a boolean flag set with put-object-legal-hold, and it blocks version deletion indefinitely — including after the Object Lock retention period has fully elapsed. If a compliance or legal team applied a hold during an investigation and never lifted it, lifecycle will keep skipping that object forever. Check it directly rather than guessing:
aws s3api get-object-retention --bucket my-bucket --key path/to/object
aws s3api get-object-legal-hold --bucket my-bucket --key path/to/object
The order of operations matters when you are cleaning up. Lift the Legal Hold first (put-object-legal-hold with an empty or OFF status), then deal with retention. If you are in governance mode, an explicit retention removal will work with the bypass header; if you are in compliance mode, there is no bypass and the only option is to wait out the retain-until timestamp. Only after both are cleared does the normal machinery apply: your expiration rule adds the delete marker, the daily batch job runs within 24 hours, and noncurrent version expiration reclaims the underlying data on its own schedule — at which point the delete marker itself needs its own expiration rule to disappear.
One practical note before you start deleting holds: in most organisations, removing a Legal Hold on a production bucket is an IAM-governed action and an audit event. Confirm with whoever owns the compliance obligation that the hold is genuinely stale, and log the change, because s3:PutObjectLegalHold shows up in CloudTrail and will be reviewed.
Cost impact: what you pay for noncurrent versions and delete markers
A bucket where the expiration rule fired but noncurrent version expiration was never configured keeps billing you for every superseded copy at the full rate of whatever storage class those versions inherited. In us-east-1, S3 Standard is $0.023 per GB per month, so 4 TB of stale noncurrent versions is $92 a month for data nobody can name, and 40 TB is $920. Noncurrent versions do not quietly drift to Glacier on their own. Unless a transition action targets them, they sit in Standard indefinitely.
Delete markers are the cheap part and the part people misread. A delete marker is a zero-byte object with no billable storage, but each one is counted as a metadata operation, and Storage Lens reports them alongside object counts. A bucket that churns small files — logs, thumbnails, event payloads — can accumulate tens of millions of markers in a year, which inflates listing costs and makes lifecycle evaluation slower without appearing as a line item you can point at.
| Item | Billable rate (2026, us-east-1) | 10 TB for one month | Who this bites |
|---|---|---|---|
| Noncurrent versions in S3 Standard | $0.023 per GB-month | $235.52 | Buckets with >1 overwrite per key per month |
| Noncurrent versions in Glacier Flexible Retrieval | $0.0036 per GB-month | $36.86 | Archives with transition actions on noncurrent versions |
| Delete markers (storage) | $0.00 per GB-month | $0.00 | Nobody, in storage terms |
| Delete markers (metadata requests on LIST) | $0.005 per 1,000 requests | ~$50 at 10M LISTs against a marker-heavy prefix | High-churn prefixes scanned by tooling |
| S3 Storage Lens noncurrent version bytes | Free (default metrics) | $0.00 | Anyone who has not enabled it yet |
| S3 Storage Lens advanced metrics | $0.20 per million objects monitored | $2.00 at 10M objects | Accounts needing prefix-level breakdowns |
The 6.4x gap between the first two rows is the whole argument: if your noncurrent versions legitimately need to stay retrievable, transition them to Glacier Flexible Retrieval and pay $36.86 per 10 TB instead of $235.52, then let a noncurrent expiration rule delete them after 90 or 180 days. If they do not need to stay, skip the transition and expire them at 7 days — the deletion is free and the saving is the entire $235.52. The one case where the Glacier row loses is data you must be able to read within minutes: Glacier Flexible Retrieval is a minutes-to-hours retrieval with per-request restore fees, so for anything an application reads on demand, expiring noncurrent versions quickly beats transitioning them. S3 Intelligent-Tiering is the honest middle path — configure it and noncurrent versions move to the infrequent access tier ($0.0125 per GB-month) after 30 days without you writing a transition rule, though it charges a $0.0025 per 1,000 objects monitoring fee that makes it a poor fit for buckets measured in billions of tiny keys.
Frequently Asked Questions
Why is my S3 lifecycle rule not deleting objects after 30 days?
Your bucket is versioned, so the rule only placed a delete marker on each object instead of removing the underlying data. The object versions remain in the bucket, still billed, until a separate noncurrent version expiration rule removes them. Add a rule with NoncurrentVersionExpiration set to 30 days, or the versions will sit there indefinitely.
How do I delete all versions of an object in S3?
Run aws s3api list-object-versions to collect every VersionId and DeleteMarker marker for the key, then issue delete-object --version-id for each one. Deleting the key without a version ID only writes another delete marker. For millions of objects, use S3 Batch Operations with a manifest instead of looping through the CLI.
What is the difference between expiration and transition in S3 lifecycle?
Expiration removes data: on an unversioned bucket it deletes the object permanently, and on a versioned bucket it adds a delete marker or clears noncurrent versions. Transition moves an object to another storage class, such as Standard-IA, Glacier Instant Retrieval, or Glacier Deep Archive, while keeping the same key and version intact. Only expiration frees the storage bytes.
Do delete markers cost money in S3?
Yes. A delete marker is a zero-byte object, but S3 bills it as Standard storage for the metadata it occupies, and each PUT that creates one counts as a request. The per-marker charge is tiny, fractions of a cent at $0.023 per GB-month in us-east-1, but a bucket with billions of unexpired markers accumulates real metadata overhead.
Can I expire delete markers with a lifecycle rule?
Yes, since November 2021 you can add an Expiration rule with ExpiredObjectDeleteMarker: true, which removes delete markers left behind after all object versions are gone. The minimum age is 1 day. It only applies when no noncurrent versions remain under the marker, so pair it with a noncurrent version expiration rule to actually clean up.
How long does S3 lifecycle take to delete objects?
S3 runs lifecycle actions once per day in a batch process, and the documentation states expiration can take up to 48 hours after an object passes its expiration age. A 30-day rule therefore typically clears data somewhere between day 31 and day 33. Billing stops when deletion completes, not when the rule's age threshold is crossed.