Find and Abort Incomplete S3 Multipart Uploads

Scattered jigsaw puzzle pieces next to a partly assembled puzzle on a table

Photo by Tanja Tepavac on Unsplash

S3 incomplete multipart uploads are uploads that were started but never completed or aborted. Their parts stay in the bucket, invisible to normal object listings, and you pay storage for them until you abort. Find them with ListMultipartUploads per bucket, size them with ListParts, and remove old ones with AbortMultipartUpload, which the script below does behind --apply.

A multipart upload has no expiry. If a backup job crashes halfway through a 200 GB file, or a browser upload is closed after 40 parts, those parts sit in the bucket indefinitely. They don’t show up in ListObjectsV2 or in the console’s object list, which is why bucket sizes sometimes don’t add up.

This example is for engineers chasing S3 storage they can’t explain. It lists S3 incomplete multipart uploads in every bucket you own, adds up what their parts hold, estimates the monthly cost, and, only when you ask, aborts the uploads older than a threshold. To stop new ones building up, add a lifecycle rule afterwards; the script to find S3 buckets without lifecycle rules covers the AbortIncompleteMultipartUpload rule, so this page doesn’t repeat it.

Why do S3 incomplete multipart uploads cost money?

Amazon S3 keeps every uploaded part until you complete or abort the upload, and bills storage, requests and bandwidth for them in the meantime. Parts are billed in the storage class named when the upload started. Uploads aimed at S3 Glacier Flexible Retrieval or Glacier Deep Archive are billed as staging storage at S3 Standard rates until they complete, so for most leftover uploads the Standard price is the right one to use. Aborting frees the parts, and there are no early delete charges for incomplete uploads in any storage class.

S3 storage price, us-east-1 (as of September 2026) Per GB-month
S3 Standard, first 50 TB $0.023
S3 Standard, next 450 TB $0.022
S3 Standard, over 500 TB $0.021
S3 Standard-Infrequent Access $0.0125

Prices vary by Region and change over time; check the official Amazon S3 pricing page for yours and pass it with --price.

A worked example

Say the script finds 3 abandoned uploads older than 7 days: a 310 GB database dump, a 96 GB video and 6 GB of assorted parts, all started as S3 Standard.

  • Total: 310 + 96 + 6 = 412 GB
  • Monthly cost: 412 × $0.023 = $9.48 per month
  • Per year if nobody notices: $9.48 × 12 = $113.76

Small per bucket, but these uploads repeat. A nightly job that fails once a week leaves a new dump behind every time, and the total only grows.

Can Storage Lens show incomplete uploads across the account?

Yes. The free S3 Storage Lens metrics include incomplete multipart upload bytes and object counts, plus versions limited to uploads more than 7 days old (IncompleteMPUStorageBytesOlderThan7Days). Use the default dashboard to see which buckets are worth scanning, then run the script on those to get the individual uploads.

What does the script do?

  1. Lists buckets with their RegionspaginateListBuckets with MaxBuckets set, which makes S3 return each bucket’s BucketRegion, so every call goes to the right Regional endpoint.
  2. Lists uploadsListMultipartUploads returns up to 1,000 uploads per call. SDK v3 has no paginator for it, so the script follows NextKeyMarker and NextUploadIdMarker by hand.
  3. Sizes each uploadpaginateListParts sums Size across every finished part.
  4. Estimates costConverts bytes to GB and multiplies by --price (default $0.023).
  5. Aborts only with –applyAbortMultipartUpload for uploads at least --older-than days old (default 7). Newer uploads are marked “keep”.

Prerequisites

  • Node.js 18 or later, npm and tsx, plus @aws-sdk/client-s3.
  • An AWS profile set up as in the guide to AWS SDK v3 credential providers.
  • A list of jobs that legitimately keep uploads open for days, such as long-running backups, so you pick an --older-than value that doesn’t cut them off.

Which IAM permissions does it need?

s3-multipart-cleanup-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListBuckets",
      "Effect": "Allow",
      "Action": "s3:ListAllMyBuckets",
      "Resource": "*"
    },
    {
      "Sid": "ListUploads",
      "Effect": "Allow",
      "Action": "s3:ListBucketMultipartUploads",
      "Resource": "arn:aws:s3:::*"
    },
    {
      "Sid": "ListParts",
      "Effect": "Allow",
      "Action": "s3:ListMultipartUploadParts",
      "Resource": "arn:aws:s3:::*/*"
    },
    {
      "Sid": "AbortOnlyWithApply",
      "Effect": "Allow",
      "Action": "s3:AbortMultipartUpload",
      "Resource": "arn:aws:s3:::*/*"
    }
  ]
}

s3:ListBucketMultipartUploads applies to the bucket ARN, while listing parts and aborting apply to object ARNs (bucket/*). Replace the * with named buckets if you only clean a few, and drop the last statement for report-only runs. The IAM policy generator for TypeScript code produces the same list from the script.

The script to find incomplete S3 multipart uploads

find-incomplete-s3-multipart-uploads.ts

// find-incomplete-s3-multipart-uploads.ts
// Lists incomplete multipart uploads in every bucket you own, sums the bytes their parts hold and
// estimates the monthly storage cost. Report only by default. With --apply, aborts the uploads that
// are older than --older-than days (default 7), which deletes their parts.
// Usage:
//   npx tsx find-incomplete-s3-multipart-uploads.ts [--older-than 7] [--bucket my-bucket] [--price 0.023] [--apply]
import {
  AbortMultipartUploadCommand,
  ListMultipartUploadsCommand,
  S3Client,
  paginateListBuckets,
  paginateListParts,
  type MultipartUpload,
} from "@aws-sdk/client-s3";

const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};
const apply = args.includes("--apply");
const olderThanDays = Number(flag("--older-than") ?? "7");
const onlyBucket = flag("--bucket");
// S3 Standard, first 50 TB, us-east-1, USD per GB-month (September 2026). Pass --price for your Region.
const pricePerGb = Number(flag("--price") ?? "0.023");
const GB = 1024 ** 3;

interface Row {
  Bucket: string;
  Key: string;
  Initiated: string;
  AgeDays: number;
  StorageClass: string;
  Parts: number;
  SizeGB: string;
  Action: string;
}

const clients = new Map<string, S3Client>();
const clientFor = (region: string): S3Client => {
  let c = clients.get(region);
  if (!c) {
    c = new S3Client({ region });
    clients.set(region, c);
  }
  return c;
};

// ListMultipartUploads has no paginator in SDK v3, so follow the key and upload ID markers by hand.
async function listUploads(s3: S3Client, bucket: string): Promise<MultipartUpload[]> {
  const uploads: MultipartUpload[] = [];
  let KeyMarker: string | undefined;
  let UploadIdMarker: string | undefined;
  for (;;) {
    const page = await s3.send(new ListMultipartUploadsCommand({ Bucket: bucket, KeyMarker, UploadIdMarker }));
    uploads.push(...(page.Uploads ?? []));
    if (!page.IsTruncated) return uploads;
    KeyMarker = page.NextKeyMarker;
    UploadIdMarker = page.NextUploadIdMarker;
  }
}

async function partsOf(s3: S3Client, bucket: string, u: MultipartUpload): Promise<{ count: number; bytes: number }> {
  let count = 0;
  let bytes = 0;
  for await (const page of paginateListParts({ client: s3 }, { Bucket: bucket, Key: u.Key, UploadId: u.UploadId })) {
    for (const p of page.Parts ?? []) {
      count++;
      bytes += p.Size ?? 0;
    }
  }
  return { count, bytes };
}

async function main(): Promise<void> {
  const home = clientFor(process.env.AWS_REGION ?? "us-east-1");
  const buckets: { name: string; region: string }[] = [];
  // Sending MaxBuckets makes ListBuckets return each bucket's Region (BucketRegion).
  for await (const page of paginateListBuckets({ client: home }, { MaxBuckets: 1000 })) {
    for (const b of page.Buckets ?? []) {
      if (!b.Name || (onlyBucket && b.Name !== onlyBucket)) continue;
      buckets.push({ name: b.Name, region: b.BucketRegion ?? "us-east-1" });
    }
  }

  const rows: Row[] = [];
  let totalBytes = 0;
  let staleBytes = 0;
  for (const { name, region } of buckets) {
    const s3 = clientFor(region);
    try {
      for (const u of await listUploads(s3, name)) {
        const ageDays = u.Initiated ? Math.floor((Date.now() - u.Initiated.getTime()) / 86_400_000) : 0;
        const { count, bytes } = await partsOf(s3, name, u);
        const stale = ageDays >= olderThanDays;
        totalBytes += bytes;
        if (stale) staleBytes += bytes;
        let action = stale ? "would abort" : "keep (recent)";
        if (stale && apply) {
          await s3.send(new AbortMultipartUploadCommand({ Bucket: name, Key: u.Key, UploadId: u.UploadId }));
          action = "ABORTED";
        }
        rows.push({
          Bucket: name,
          Key: (u.Key ?? "").length > 40 ? `...${(u.Key ?? "").slice(-37)}` : u.Key ?? "",
          Initiated: u.Initiated?.toISOString().slice(0, 10) ?? "",
          AgeDays: ageDays,
          StorageClass: u.StorageClass ?? "STANDARD",
          Parts: count,
          SizeGB: (bytes / GB).toFixed(2),
          Action: action,
        });
      }
    } catch (err) {
      console.error(`${name}: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
    }
  }

  rows.sort((a, b) => Number(b.SizeGB) - Number(a.SizeGB));
  if (rows.length) console.table(rows);
  const cost = (bytes: number) => ((bytes / GB) * pricePerGb).toFixed(2);
  console.log(`${buckets.length} buckets, ${rows.length} incomplete uploads, ${(totalBytes / GB).toFixed(2)} GB in parts`);
  console.log(`Older than ${olderThanDays} days: ${(staleBytes / GB).toFixed(2)} GB, about $${cost(staleBytes)} per month at $${pricePerGb}/GB`);
  console.log(apply ? "Aborted the uploads marked ABORTED." : "Report only: nothing was modified. Add --apply to abort the old uploads.");
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

How do you run it?

Terminal

npm install @aws-sdk/client-s3
npm install --save-dev tsx typescript @types/node

# Report on every bucket
AWS_PROFILE=readonly npx tsx find-incomplete-s3-multipart-uploads.ts

# Abort uploads older than 14 days in one bucket
AWS_PROFILE=ops npx tsx find-incomplete-s3-multipart-uploads.ts --bucket db-backups-prod --older-than 14 --apply

Sample output

Output

┌─────────┬───────────────────┬──────────────────────────────┬──────────────┬─────────┬──────────────┬───────┬──────────┬─────────────────┐
│ (index) │ Bucket            │ Key                          │ Initiated    │ AgeDays │ StorageClass │ Parts │ SizeGB   │ Action          │
├─────────┼───────────────────┼──────────────────────────────┼──────────────┼─────────┼──────────────┼───────┼──────────┼─────────────────┤
│ 0       │ 'db-backups-prod' │ 'nightly/2027-03-02/pg.dump' │ '2027-03-02' │ 76      │ 'STANDARD'   │ 3100  │ '310.00' │ 'would abort'   │
│ 1       │ 'media-uploads'   │ 'raw/launch-video-final.mov' │ '2027-04-11' │ 36      │ 'STANDARD'   │ 12    │ '96.00'  │ 'would abort'   │
│ 2       │ 'media-uploads'   │ 'raw/tmp-4f1c.bin'           │ '2027-05-01' │ 16      │ 'STANDARD'   │ 3     │ '6.00'   │ 'would abort'   │
│ 3       │ 'db-backups-prod' │ 'nightly/2027-05-17/pg.dump' │ '2027-05-17' │ 0       │ 'STANDARD'   │ 840   │ '84.00'  │ 'keep (recent)' │
└─────────┴───────────────────┴──────────────────────────────┴──────────────┴─────────┴──────────────┴───────┴──────────┴─────────────────┘
41 buckets, 4 incomplete uploads, 496.00 GB in parts
Older than 7 days: 412.00 GB, about $9.48 per month at $0.023/GB
Report only: nothing was modified. Add --apply to abort the old uploads.

Names are illustrative. The last row is tonight’s backup still running, which is exactly why the script only aborts uploads past the threshold. The 76-day-old dump is a failed run that nobody retried.

Warning: aborting is permanent. The parts are deleted and the upload ID can’t be reused. If a part upload is still in flight when you abort, it can still succeed and leave storage behind, so only abort uploads that are clearly dead.

How do you stop incomplete uploads coming back?

Three changes, in order of impact:

  • Add a lifecycle rule to every bucket that takes multipart uploads. The rule deletes incomplete uploads a set number of days after they start, with no script to schedule. See which S3 buckets are missing lifecycle rules.
  • Abort in your upload code on failure. The Upload class in @aws-sdk/lib-storage manages the parts for you, as the guide to upload large files and streams to S3 with AWS SDK v3 shows. If you call CreateMultipartUpload yourself, call AbortMultipartUpload in the error path.
  • Watch the Storage Lens metric for incomplete upload bytes older than 7 days, so a new source shows up before the bill does.

Incomplete uploads are one of several reasons a bucket costs more than its objects suggest. Noncurrent versions are another: the script to find S3 buckets without versioning helps you see which buckets keep them, and the script to measure S3 storage used by noncurrent object versions shows how much storage they take. To see where S3 spend sits overall, find the size of each S3 bucket and the largest one, and read S3 Standard vs Intelligent-Tiering costs for the objects you keep.

Troubleshooting

  • Storage Lens shows more storage than your objects add up to. Its total storage metric includes incomplete multipart uploads, which never appear in object listings. Run this script on that bucket.
  • An upload shows 0 parts but still exists. ListParts doesn’t return parts that haven’t finished uploading. Parts may still be in flight, or the upload was started and never used; abort it if it’s old.
  • AccessDenied on one bucket. A bucket policy may deny listing to everyone except its owner role. The script logs the bucket and moves on; troubleshooting IAM access denied errors step by step helps you find which policy denied it.
  • The cost looks wrong for your Region. The default is the us-east-1 Standard price. Pass --price with your Region’s rate.

Ask ChatWithCloud instead

For a quick look at one bucket, ask ChatWithCloud “Are there incomplete multipart uploads in my db-backups-prod bucket, and how big are they?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and explains the answer, one profile and Region per session; how ChatWithCloud runs AWS SDK code on your machine covers the details. It runs changes without asking first, so use a read-only profile, as in connecting ChatWithCloud to your AWS account, and abort uploads yourself.

Frequently asked questions

Do incomplete multipart uploads cost money in S3?

Yes. You pay storage for every uploaded part until the upload is completed or aborted, billed in the storage class chosen when the upload started.

How do I list incomplete multipart uploads with the AWS CLI?

Run aws s3api list-multipart-uploads --bucket my-bucket. Each result has a Key, UploadId and Initiated date; use aws s3api list-parts to see the parts of one upload.

Do incomplete multipart uploads expire automatically?

No. An upload stays until you complete or abort it. A lifecycle rule with AbortIncompleteMultipartUpload is how you make them expire.

Is it safe to abort an incomplete multipart upload?

Only if nothing is still uploading to it. Aborting deletes all its parts and the upload ID can’t be used again, so skip recent uploads that may belong to a running job.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud