When a multipart upload starts but never completes, every part already uploaded stays in the bucket and bills as storage — indefinitely — while remaining invisible to aws s3 ls and to the objects tab in the console. If your S3 bill says one number and your object listing adds up to a smaller one, incomplete multipart uploads are the first suspect. The fix is two commands to find and abort them, plus one lifecycle rule so they can never accumulate again.
What’s happening
A multipart upload is a three-step protocol: CreateMultipartUpload opens a session, UploadPart sends the pieces, and CompleteMultipartUpload stitches them into a real object. The CLI and SDKs use it automatically for anything over their threshold (8 MB by default in the CLI). If the process dies between step one and step three — a cancelled deploy, a crashed backup agent, a spot instance reclaimed mid-upload, a laptop closed on an aws s3 cp — S3 keeps the parts and waits for the completion that will never come.
Those orphaned parts are billed at the storage class’s normal per-GB rate, but they are not objects yet, so nothing that lists objects will ever show them: not list-objects-v2, not the console object view, not most third-party S3 browsers. They surface in exactly three places: the ListMultipartUploads API, the CloudWatch bucket-size metrics (which include part bytes), and the Storage Lens incomplete-multipart-upload metric. Buckets that receive large uploads from unreliable clients — backup targets, video ingestion, data pipeline landing zones — accumulate them for years. The mechanics are documented in the S3 documentation.
What to check first
- The gap. Compare CloudWatch
BucketSizeBytesagainst a--summarizelisting total; a persistent difference is parts, noncurrent versions, or delete markers. - Storage Lens. The free dashboard already carries an incomplete-multipart-upload bytes metric per bucket — fastest fleet-wide view.
- Which buckets take big uploads. Backup destinations and ingestion buckets are where the money is.
- Whether a lifecycle abort rule already exists. Someone may have added one that covers only a prefix.
How to verify
List the open multipart uploads in a bucket:
aws s3api list-multipart-uploads --bucket my-bucket \
--query 'Uploads[].[Key,UploadId,Initiated]' --output table
Anything initiated weeks or months ago is dead weight. To see how much a single stalled upload holds:
aws s3api list-parts --bucket my-bucket \
--key backups/full-2026-03-14.tar.gz \
--upload-id "$UPLOAD_ID" \
--query 'Parts[].Size' --output text | tr '\t' '\n' | paste -sd+ - | bc
For the account-wide picture, open S3 > Storage Lens > Dashboards > default-account-dashboard and look at the incomplete multipart upload bytes metric — it exists in the free tier and breaks down per bucket. Compare against total bucket bytes to see which buckets are worst.
The fix
-
Abort the stale uploads. Each abort deletes all parts for that upload session and stops the billing immediately:
aws s3api abort-multipart-upload \ --bucket my-bucket \ --key backups/full-2026-03-14.tar.gz \ --upload-id "$UPLOAD_ID"For many stale sessions, loop over the listing — but check
Initiateddates and skip anything from the last few days, because an upload that is legitimately in progress dies the same way when aborted. -
Add the lifecycle rule that makes this permanent. One rule, empty filter, applies to the whole bucket:
{ "Rules": [ { "ID": "abort-incomplete-mpu", "Status": "Enabled", "Filter": {}, "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 } } ] }Merge it into the existing configuration (the put call replaces everything — see S3 lifecycle rules) and apply with
aws s3api put-bucket-lifecycle-configuration. Seven days tolerates slow resumable uploads while capping the waste. -
Fix the producer that keeps failing. Recurring incomplete uploads mean a client is dying mid-transfer: an undersized backup window, an agent without retry logic, spot instances doing uploads without checkpointing. The lifecycle rule mops the floor; this step turns off the tap.
-
Verify the bill actually moves. CloudWatch
BucketSizeBytesshould drop within a day or two of the aborts; the invoice line follows on the next cycle.
When this is the wrong move
Do not abort uploads initiated in the last day or two on buckets with active large transfers — genuinely in-progress sessions look identical to dead ones in the listing, and aborting one forces the client to start over. If the bill gap persists after cleaning parts, the remaining bytes are usually noncurrent versions on a versioned bucket — a different rule fixes that. And if a bucket legitimately hosts multi-week resumable uploads (rare, but real in media workflows), set DaysAfterInitiation above the longest legitimate session rather than skipping the rule.
Common mistakes
- Trusting
aws s3 ls --summarizeas the bucket’s true size — it counts completed current objects only, never parts. - Running the cleanup once and skipping the lifecycle rule, so the same money leak reopens within months.
- Aborting everything in the listing, including last night’s still-running backup upload.
- Adding the abort rule with
put-bucket-lifecycle-configurationand wiping the bucket’s existing rules, because the call replaces rather than merges.
Prevention
- Bake the abort-incomplete-MPU rule into your Terraform or CloudFormation bucket module so every bucket ships with it.
- Watch the Storage Lens incomplete-MPU metric monthly; a rising line means a client is failing repeatedly.
- Give upload clients sane retry and resume behaviour — the CLI’s transfer settings (tuning patterns here) or SDK transfer managers.
- After any interrupted manual transfer, spend the thirty seconds on
list-multipart-uploadsrather than leaving a souvenir.
This is the cheapest S3 fix that exists: one listing, a handful of aborts, one lifecycle rule, and a permanent hole in the bill closes. 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.