Skip to content

S3 cost optimization: storage classes, lifecycle rules, and the requests line nobody reads

S3 cost optimization beyond storage classes: lifecycle rules that actually pay off, the request charges nobody reads, versioning bloat, and abandoned multipart uploads.

S3 bills four things — storage by class, requests by the thousand, retrievals from the colder classes, and data transfer out — and most cost work fixates on the first while the others quietly compound. The effective sequence: measure what each bucket holds and how it is accessed (Storage Lens, not guesswork), add the two lifecycle rules every bucket should have (abort incomplete multipart uploads, expire noncurrent versions), move genuinely cold data down-class with attention to minimum-duration and per-object costs, and only then tune the requests line.

What’s happening

The storage classes trade per-GB price against access cost and commitment. Standard costs the most per GB with no strings. Standard-IA and One Zone-IA cost less per GB but add a per-GB retrieval fee, a 30-day minimum storage duration, and a 128 KB minimum billable object size. The Glacier classes (Instant Retrieval, Flexible Retrieval, Deep Archive) go further down in storage price and further up in retrieval cost and delay, with 90- and 180-day minimums. Intelligent-Tiering watches each object’s access pattern and moves it automatically, for a small monthly monitoring fee per object — which makes it excellent for unpredictable access and a bad deal for millions of tiny objects, where the monitoring fee rivals the storage.

Three multipliers hide off the storage line. Versioning keeps every overwritten and deleted object as a noncurrent version, invisible in the console’s default view, billing full price forever without a lifecycle rule. Incomplete multipart uploads — failed or interrupted large uploads — hold their uploaded parts as billable storage until explicitly aborted. And requests: PUTs and LISTs cost several times more per thousand than GETs, so a job that writes millions of small files, or polls a prefix with LIST every few seconds, can spend more on requests than on the bytes themselves. Even lifecycle transitions bill per 1,000 objects moved — transitioning fifty million small objects to Glacier can cost more than a year of the storage it saves. Full mechanics live in the S3 documentation.

What to check first

  • Storage Lens (the default dashboard is free): total bytes by class, noncurrent-version bytes, and incomplete-multipart bytes per bucket. This one screen usually names the problem.
  • Cost Explorer grouped by usage type for S3: the split between TimedStorage-*, Requests-Tier1 (PUT/LIST), Requests-Tier2 (GET), and retrieval lines tells you which article section to jump to.
  • Which buckets have versioning on — and whether any of them have a noncurrent-version expiration rule.
  • Object count vs total size per bucket. Millions of objects averaging a few KB change every recommendation below.
  • Who reads the bucket, and how often. Access patterns decide class; nothing else does.

How to verify

# Size and object count per bucket without listing objects (free, daily metrics)
aws cloudwatch get-metric-statistics --namespace AWS/S3 \
  --metric-name BucketSizeBytes \
  --dimensions Name=BucketName,Value=my-bucket Name=StorageType,Value=StandardStorage \
  --start-time 2026-07-01T00:00:00Z --end-time 2026-07-04T00:00:00Z \
  --period 86400 --statistics Average

# Lifecycle rules currently on a bucket (NoSuchLifecycleConfiguration = none)
aws s3api get-bucket-lifecycle-configuration --bucket my-bucket

# Versioning status
aws s3api get-bucket-versioning --bucket my-bucket

# Incomplete multipart uploads sitting in a bucket
aws s3api list-multipart-uploads --bucket my-bucket \
  --query 'Uploads[].[Key,Initiated]' --output table

Avoid aws s3 ls --recursive --summarize on large buckets — it LISTs every object, which is slow and itself billable. For per-prefix breakdowns on big buckets, enable S3 Inventory once and analyze the report. Console: S3 > (bucket) > Management for lifecycle rules, S3 > Storage Lens > Dashboards for the fleet view.

The runbook

  1. Add the two universal rules to every bucket. Abort incomplete multipart uploads after 7 days; expire noncurrent versions after a deliberate window (30-90 days is common) and clean up expired object delete markers. These have no downside beyond losing undelete-forever, and they are where the surprise bytes live.
  2. Expire what has an expiry date in real life. Logs, temporary exports, build artifacts, old reports: a lifecycle expiration rule per prefix, matched to the retention the business actually needs. Deleted is cheaper than any storage class.
  3. Transition cold, large objects down-class. Backups and archives older than 30-90 days to Glacier Flexible or Deep Archive — after checking the per-object transition cost against object count. Rule of thumb: great for GB-scale objects, marginal for MB-scale, wrong for KB-scale (aggregate small files into archives first if you can).
  4. Use Intelligent-Tiering for the genuinely unpredictable. User-generated content and datasets with unknowable access patterns, where objects average well above 128 KB. Do not use it as a default for everything — the monitoring fee is a real line at high object counts.
  5. Attack the requests line at the source. Batch small writes into larger objects, replace LIST-polling with S3 event notifications (EventBridge or SQS), and cache hot GETs behind CloudFront — which also moves egress to cheaper rates.
  6. Confirm transfer is not the real story. If DataTransfer-Out dominates the S3 bill, the fix is CDN and endpoint work — see the data transfer costs breakdown — not storage classes.

When this is the wrong move

Do not push data you restore under pressure into Flexible Retrieval or Deep Archive — retrieval delays of hours are incompatible with incident response, and bulk restores of large archives carry real retrieval charges precisely when you are least positioned to debate them. Skip class transitions entirely on buckets dominated by sub-128 KB objects; minimums and transition requests eat the savings. And leave compliance-scoped buckets (Object Lock, litigation holds) out of expiration rules until someone with authority signs the retention schedule.

Common mistakes

  • Enabling versioning for safety and never adding a noncurrent-version expiration — the bucket silently doubles and keeps going.
  • Transitioning millions of small objects to Glacier and paying more in transition requests than a year of storage savings.
  • Reading only the storage line while a LIST-every-second poller dominates the actual bill.
  • Testing lifecycle rules on production prefixes with a too-broad filter and expiring current data — scope rules tightly and dry-run on a copy.

Keeping it under control

  • Storage Lens review monthly: bytes by class, noncurrent versions, incomplete multipart — three numbers, five minutes.
  • Bucket creation standards (IaC module) that include the two universal lifecycle rules by default.
  • Per-prefix retention documented next to the code that writes there, so expiration rules have a source of truth.
  • An alert on request-line cost growth, which catches runaway pollers and misbehaving jobs early.
  • Quarterly check that new buckets did not appear outside the standards — they always do.

S3 rewards the boring discipline of matching each byte to how it will actually be read — and punishes defaults left alone for three years. 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.