The Problem: Delete Doesn't Mean DeleteWhen versioning is enabled on an S3 bucket, a DELETE request does not remove the object. Instead, it inserts a
delete marker - a zero-byte placeholder that tells S3 to behave as though the object no longer exists. The actual object data, across all of its versions, remains in the bucket. It is still stored, and it is still billed.
This is by design. Versioning exists to support recovery of accidentally deleted data, which is valuable for compliance, audit trails, and disaster recovery. The problem is that many teams enable versioning because a Terraform module or an AWS best-practice guide recommended it, without fully understanding the billing implications.
Here is where the math gets uncomfortable:
Consider 1,000,000 objects averaging 5 MB each - roughly 5 TB of storage. At S3 Standard pricing in eu-central-1 (0.0245$/GB/month), that's approximately 122.50$/month. After "deleting" all of them, storage costs remain $122.50/month. The deletion only added 1,000,000 delete markers on top - negligible in size, but they still count as objects for request pricing purposes.
There is a secondary cost here that is easy to miss: those delete markers are not inert. Every delete marker creation is a write request billed at the standard PUT rate. One million "deletes" generating one million delete markers means one million write requests — roughly
5$ at the standard 0.0054$ per 1,000 PUT requests in eu-central-1. More importantly, accumulated delete markers also count against the 5,500 GET requests-per-second per-prefix limit when listing objects. Buckets with millions of stale delete markers can experience dramatically slower listing operations, creating a compounding operational tax on top of the storage cost.
At production scale, this compounds fast. A 12 TB accumulation of noncurrent versions across several buckets - not uncommon in organizations with active data pipelines - amounts to roughly
294$/month, or over
3,500$/year, for data that everyone assumes is gone.
Why Most Teams Don't NoticeThe AWS Console shares some blame here. When listing objects in a versioned bucket, the default view hides noncurrent versions and delete markers. The "Show versions" toggle must be explicitly enabled to reveal the full picture. Most operators never flip it.
The CLI behaves the same way. Running
aws s3 ls shows the current view, which after deletion looks clean - empty, as expected. Seeing the underlying reality requires the more specific
aws s3api list-object-versions command.
CloudWatch storage metrics report
total bucket size, including all noncurrent versions. But unless someone is cross-referencing that number against what the bucket
should contain, the discrepancy goes unnoticed. It just reads as "yes, that's our storage footprint."
The realization typically arrives during a cost optimization sprint, or when someone enables S3 Storage Lens and sees a bucket that should be nearly empty sitting at multiple terabytes.
Why Lifecycle Rules Don't Fully Solve ThisLifecycle rules are the first thing every team reaches for. They help, but they do not do what most people expect.
NoncurrentVersionExpirationA lifecycle rule with
NoncurrentVersionExpiration automatically removes noncurrent versions after a specified number of days. On the surface, this sounds like the answer.
The limitation: it applies to
all noncurrent versions within the rule's scope (bucket or prefix). There is no way to restrict it to "only clean up versions for objects that have delete markers." It treats every noncurrent version identically — whether it became noncurrent because a newer version replaced it, or because someone issued a delete. For workloads that need version history on some objects (configuration files, state files, critical documents) while wanting true deletion for bulk data, this rule is too blunt an instrument.
ExpiredObjectDeleteMarkerThere is a lifecycle option called
ExpiredObjectDeleteMarker that sounds promising. It removes delete markers that have become "expired," meaning all underlying versions of that object have already been removed.
The critical distinction: this rule only cleans up
orphaned delete markers. It does not trigger deletion of the versions underneath them. It is useful for tidying up
after all versions have been removed through other means, but it does nothing about the core problem.
Multiple blog posts and even portions of AWS documentation imply that lifecycle rules represent a complete solution here. For workloads requiring any selectivity, they are not.
The Actual Fix: Scripted PurgeTo truly delete objects that carry delete markers - removing every version and the markers themselves — a scripted approach is necessary.
The Naive Approach and Why It Doesn't ScaleThe intuitive workflow looks like this: list all delete markers, then for each key, issue a separate
ListObjectVersions call to find all its versions, then batch-delete them. This works for small buckets, but it hides a serious cost problem.
Each per-key
ListObjectVersions call is billed as a LIST request at 0.0054$ per 1,000 requests in eu-central-1. For 1,000,000 keys, that is
1,000,000 API calls costing roughly 5,400$ in listing fees alone - before a single object is actually deleted. At that point, the cleanup costs more than several months of the storage it is trying to reclaim.
The Prefix-Scan ApproachThe fix is to avoid per-key listing entirely. Instead, scan all versions under a shared prefix in a single paginated pass and group them in memory by key. For workloads where keys share common prefixes (which covers most real-world S3 usage - data/raw/2024/,events/,uploads/), this collapses millions of per-key calls into a few thousand paginated scans.
The workflow becomes:
- Scan all versions and delete markers under the target prefix in a single paginated pass
- Identify which keys have a current delete marker (i.e., were "deleted")
- Collect all versions belonging to those keys
- Batch-delete everything in chunks of 1,000
- Handle throttling and retries
Always run the dry run first.
Regardless of confidence level, verify what the script intends to do before letting it execute.
A few implementation notes worth calling out:
Memory is the tradeoff. The prefix-scan approach loads version metadata into memory. For 1,000,000 keys with an average of three versions each, expect roughly 300-500 MB of memory usage. For buckets with tens of millions of versions under a single prefix, break the work into sub-prefixes (e.g., scan data/raw/2024-01/, then data/raw/2024-02/, etc.).
Throttling is real. S3 enforces per-prefix rate limits of 3,500 PUT/DELETE and 5,500 GET requests per second. At 1M+ objects, expect to hit these limits. The exponential backoff in the script is not optional.
For very large buckets (tens of millions of objects), consider using S3 Inventory as the source of truth instead of live listing. It is both cheaper and faster. More on this below.
The Cost of CleanupThe cleanup itself is not free, and this is the part that rarely gets discussed.
In eu-central-1:
ListObjectVersions: 0.0054$ per 1,000 requests
DeleteObjects: 0.0054$ per 1,000 requests
For 1,000,000 deleted objects with an average of three versions each, here is how the two approaches compare:
Naive per-key listingOperation | Approximate Calls | Cost |
List delete markers | ~1,000 | ~5,40$ |
List versions per key | ~1000,000 | ~5,400$ |
Batch Delete | ~3000 | ~16,20$ |
Total |
| ~5,421$ |
Prefix-scan groupingOperation | Approximate Calls | Cost |
Single prefix scan (all versions | ~3,000 | ~16,20$ |
Batch Delete | ~3,000 | ~16,20$ |
Total |
| ~32,40$ |
The difference is staggering:
5,400$ versus
32$ for the same cleanup job. The naive approach costs more than the annual storage bill for the data it is removing. The prefix-scan approach costs less than a single month of storing those objects.
This is why the script above uses a single-pass scan. At small scale (a few thousand keys), the difference barely matters. At 1,000,000 keys, it is the difference between a reasonable cleanup and a cleanup that requires its own cost approval.
For truly large-scale cleanups involving tens of millions of objects, S3 Batch Operations combined with S3 Inventory is the better path. The inventory report costs a fraction of live listing, and Batch Operations handles deletions efficiently at scale.
Prevention: Design Your Buckets SmarterThe best cleanup is the one that never needs to run.
Separate buckets by retention requirements. Ephemeral processing data and long-term compliance archives should not coexist in the same versioned bucket. Processing data belongs in a bucket with aggressive lifecycle rules or no versioning at all. Compliance data gets versioning with carefully scoped retention policies.
Use prefix-based lifecycle rules. When a single bucket is unavoidable, organize objects by prefix and apply different lifecycle rules per prefix. A raw/prefix might get 7-day noncurrent expiration, while archive/ gets 365 days. This is far more manageable than a single policy applied to everything.
Disable versioning where it adds no value. Teams routinely leave versioning enabled on buckets storing temporary processing artifacts, CI/CD caches, or log exports. The question to ask: if this data were lost, would anyone notice? If the answer is no, versioning is just burning money.
Enable S3 Storage Lens. Configure it once, review it monthly. It surfaces noncurrent version bytes per bucket, which is the fastest way to identify the problem before it compounds into real cost.
The Missing Lifecycle ActionAWS should offer a native lifecycle action - something like
PurgeDeletedObjects - that automatically removes all versions of objects carrying delete markers older than N days. The semantics are clean: "if I deleted it more than 30 days ago, I meant it. Remove everything."
This would fill the gap between
NoncurrentVersionExpiration (too broad - hits all noncurrent versions regardless of cause) and
ExpiredObjectDeleteMarker (too narrow - only removes orphaned markers after the real work is already done). The current situation forces every team to build and maintain custom cleanup scripts, resulting in thousands of slightly different - and sometimes buggy - purge jobs running across AWS infrastructure.
The absence of this feature creates what is effectively a
versioning tax: teams that want the safety benefits of versioning must either accept unbounded storage growth for "deleted" data, implement blunt lifecycle rules that sacrifice version history across the board, or build and maintain custom purge tooling. This tax is significant enough that some teams disable versioning entirely to avoid it - which means giving up a genuinely important safety feature because the deletion semantics are incomplete.
This has been a recurring request in AWS forums for years. It remains the obvious missing middle ground.
Go Check Your BucketsIf versioned S3 buckets are part of the infrastructure and objects have been "deleted" through normal means - application code, the console, aws s3 rm - there is a good chance noncurrent versions are accumulating right now.
Run this and check:
aws s3api list-object-versions \ --bucket TEST_BUCKET \ --query 'DeleteMarkers[?IsLatest==`true`] | length(@)' \ --output textIf that number is a surprise, there is work to do.
[ Sources ]
S3 Versioning & Deletion: Using versioning in S3 buckets ·Working with delete markers·Deleting object versionsLifecycle Management: Managing your storage lifecycle·NoncurrentVersionExpiration & ExpiredObjectDeleteMarkerCost & Performance: Amazon S3 pricing·Request rate and performance optimizationTooling: S3 Batch Operations·S3 Inventory·S3 Storage Lens·Boto3 — delete_objects