Measure S3 Storage Used by Noncurrent Object Versions

A stack of hard disk drives on a desk under cool blue light

Photo by Marta Branco on Pexels

S3 noncurrent versions storage cost is the storage charge for every older version kept in a versioned bucket, billed at that version’s storage class rate like any other object. CloudWatch’s BucketSizeBytes includes those versions but can’t separate them, so measure them with ListObjectVersions (or S3 Storage Lens), then expire them with a NoncurrentVersionExpiration lifecycle rule.

Versioning protects you from overwrites and deletes by keeping the old data. The catch is that nothing removes it unless a lifecycle rule does, so a bucket that shows 15 GiB of objects in a listing can bill for 2 TiB. This example is for engineers chasing a storage line that grows faster than the data they can see, and for anyone who wants the S3 noncurrent versions storage cost of each bucket before writing lifecycle rules.

The script finds versioned buckets, walks every object version, adds up current and noncurrent bytes by storage class, counts delete markers, checks for a rule that expires noncurrent versions and estimates the monthly cost. It’s report only: it never deletes a version or changes a rule.

Why can’t you see noncurrent versions storage in the usual metrics?

The Amazon S3 User Guide defines BucketSizeBytes as the sum of all objects and metadata in the bucket, “both current and noncurrent objects”, plus parts of incomplete multipart uploads. NumberOfObjects likewise counts current and noncurrent objects and delete markers. Both are daily metrics, and neither has a dimension that splits versions, so a bucket at 2.4 TiB tells you nothing about how much of that is history.

A normal ListObjectsV2 listing, which is what the console and most scripts use, shows only current versions. The script that calculates the size of each S3 bucket and the metric can therefore disagree wildly for a versioned bucket, and the gap is your noncurrent data.

Two tools do separate them:

  • ListObjectVersions returns up to 1,000 versions per request, each with Size, StorageClass and IsLatest, plus delete markers in a separate list. Exact, but you pay for and wait on every page.
  • S3 Storage Lens includes NonCurrentVersionStorageBytes, NonCurrentVersionObjectCount and DeleteMarkerObjectCount in its free metrics, collected daily and queryable for 14 days. It covers every bucket without a scan, but it’s a dashboard or export, not an answer inside a script.

What does noncurrent version storage cost?

Noncurrent versions are billed per GB-month at their own storage class rate. As of September 2026, the AWS Price List for Amazon S3 (published 26 September 2026) shows these first-tier rates in US East (N. Virginia), which the script uses:

Storage class Per GB-month
S3 Standard (first 50 TB) $0.023
S3 Standard-Infrequent Access $0.0125
S3 One Zone-Infrequent Access $0.01
S3 Glacier Instant Retrieval $0.004
S3 Glacier Flexible Retrieval $0.0036
LIST requests (ListObjectVersions), S3 Standard $0.005 per 1,000

Worked example: a build-artifacts bucket that overwrites the same keys on every commit has 2,412.9 GiB of noncurrent versions in S3 Standard. That’s 2,412.9 × $0.023 = $55.50 a month, for files no deploy will ever read again. Scanning it is cheap by comparison: the sample bucket below took 14 ListObjectVersions pages, and even 10 million versions are 10,000 requests, or 10,000 ÷ 1,000 × $0.005 = $0.05. Time is the real cost of a full scan, which is why the script has a page cap.

What does the script do?

  1. Lists bucketspaginateListBuckets, using each bucket’s BucketRegion to create a client in the right Region. --buckets limits the run.
  2. Checks versioningGetBucketVersioning. Buckets that were never versioned have no Status and are skipped; Enabled and Suspended buckets are measured, because suspending versioning keeps the versions already stored.
  3. Walks every versionListObjectVersions with KeyMarker and VersionIdMarker (the SDK has no paginator for it), splitting Size by IsLatest and storage class and counting DeleteMarkers. It stops at --max-pages (default 2,000 pages, 2 million versions) and labels the bucket partial.
  4. Checks lifecycleGetBucketLifecycleConfiguration, looking for enabled rules with NoncurrentVersionExpiration and showing the days, NewerNoncurrentVersions and any prefix.
  5. Prices and reportsMultiplies noncurrent GiB by the rate table, prints the table sorted by noncurrent GiB and names the storage classes it couldn’t price. --csv writes every column.

Prerequisites

  • Node.js 18 or later with tsx and @aws-sdk/client-s3.
  • A read-only profile. The listing uses the same pagination ideas as listing every object in an S3 bucket with SDK v3.
  • For buckets with hundreds of millions of versions, an S3 Storage Lens dashboard instead of a full scan.

Which IAM permissions does it need?

s3-noncurrent-report-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListBuckets",
      "Effect": "Allow",
      "Action": "s3:ListAllMyBuckets",
      "Resource": "*"
    },
    {
      "Sid": "ReadVersionsAndLifecycle",
      "Effect": "Allow",
      "Action": [
        "s3:GetBucketVersioning",
        "s3:ListBucketVersions",
        "s3:GetLifecycleConfiguration"
      ],
      "Resource": "arn:aws:s3:::*"
    }
  ]
}

ListObjectVersions is authorized by s3:ListBucketVersions and GetBucketLifecycleConfiguration by s3:GetLifecycleConfiguration; neither name matches its API call, which is a common source of access-denied errors. Narrow arn:aws:s3:::* to specific bucket ARNs if you only scan a few. The IAM policy generator for TypeScript code maps SDK calls to actions like these.

The script to measure S3 noncurrent versions storage cost

measure-s3-noncurrent-version-storage.ts

// measure-s3-noncurrent-version-storage.ts
// For every versioned S3 bucket (or the ones you name), walks ListObjectVersions and adds up current
// bytes, noncurrent bytes per storage class and delete markers, checks whether a lifecycle rule expires
// noncurrent versions, and estimates what the noncurrent bytes cost a month. Report only: it never
// deletes a version or changes a lifecycle rule.
// Usage: npx tsx measure-s3-noncurrent-version-storage.ts [--buckets a,b] [--max-pages 2000] [--csv out.csv]
import { writeFileSync } from "node:fs";
import {
  S3Client,
  GetBucketLifecycleConfigurationCommand,
  GetBucketVersioningCommand,
  ListObjectVersionsCommand,
  paginateListBuckets,
  type LifecycleRule,
} 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 onlyBuckets = (flag("--buckets") ?? "").split(",").map((b) => b.trim()).filter(Boolean);
const maxPages = Number(flag("--max-pages") ?? 2000); // 1,000 versions per page
const csvPath = flag("--csv");
const GIB = 1024 ** 3;

// US East (N. Virginia) first-tier storage rates in USD per GB-month, AWS Price List for Amazon S3
// published 26 Sep 2026. Other Regions and larger tiers differ; classes not listed are left unpriced.
const RATE: Record<string, number> = {
  STANDARD: 0.023,
  INTELLIGENT_TIERING: 0.023, // Frequent Access tier: an upper bound for Intelligent-Tiering
  STANDARD_IA: 0.0125,
  ONEZONE_IA: 0.01,
  GLACIER_IR: 0.004,
  GLACIER: 0.0036,
};

interface Row {
  Bucket: string;
  Versioning: string;
  CurrentGiB: number;
  NoncurrentGiB: number;
  NoncurrentPct: number;
  NoncurrentVersions: number;
  DeleteMarkers: number;
  CostPerMonth: number;
  Unpriced: string;
  LifecycleRule: string;
  Scan: string;
}

const errorText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
const gib = (bytes: number): number => Math.round((bytes / GIB) * 100) / 100;

/** Describes the enabled rules that expire noncurrent versions, or "none". */
async function noncurrentRule(s3: S3Client, bucket: string): Promise<string> {
  let rules: LifecycleRule[] = [];
  try {
    rules = (await s3.send(new GetBucketLifecycleConfigurationCommand({ Bucket: bucket }))).Rules ?? [];
  } catch (err) {
    if (err instanceof Error && err.name === "NoSuchLifecycleConfiguration") return "none";
    throw err;
  }
  const expiring = rules.filter((r) => r.Status === "Enabled" && r.NoncurrentVersionExpiration?.NoncurrentDays);
  if (expiring.length === 0) return "none";
  return expiring.map((r) => {
    const e = r.NoncurrentVersionExpiration;
    const scope = r.Filter?.Prefix ? ` on ${r.Filter.Prefix}` : r.Filter?.Tag || r.Filter?.And ? " (filtered)" : "";
    const keep = e?.NewerNoncurrentVersions ? `, keep ${e.NewerNoncurrentVersions}` : "";
    return `${e?.NoncurrentDays}d${keep}${scope}`;
  }).join("; ");
}

async function measure(s3: S3Client, bucket: string, versioning: string): Promise<Row> {
  let current = 0;
  let noncurrentCount = 0;
  let deleteMarkers = 0;
  const noncurrentByClass = new Map<string, number>();
  let KeyMarker: string | undefined;
  let VersionIdMarker: string | undefined;
  let pages = 0;
  let truncated = false;
  do {
    const page = await s3.send(new ListObjectVersionsCommand({ Bucket: bucket, KeyMarker, VersionIdMarker, MaxKeys: 1000 }));
    for (const v of page.Versions ?? []) {
      if (v.IsLatest) {
        current += v.Size ?? 0;
      } else {
        noncurrentCount += 1;
        const cls = v.StorageClass ?? "STANDARD";
        noncurrentByClass.set(cls, (noncurrentByClass.get(cls) ?? 0) + (v.Size ?? 0));
      }
    }
    deleteMarkers += (page.DeleteMarkers ?? []).length;
    pages += 1;
    truncated = page.IsTruncated === true;
    KeyMarker = page.NextKeyMarker;
    VersionIdMarker = page.NextVersionIdMarker;
  } while (truncated && pages < maxPages);

  const noncurrent = [...noncurrentByClass.values()].reduce((s, b) => s + b, 0);
  let cost = 0;
  const unpriced: string[] = [];
  for (const [cls, bytes] of noncurrentByClass) {
    if (RATE[cls] === undefined) unpriced.push(`${cls} ${gib(bytes)} GiB`);
    else cost += (bytes / GIB) * RATE[cls];
  }
  return {
    Bucket: bucket,
    Versioning: versioning,
    CurrentGiB: gib(current),
    NoncurrentGiB: gib(noncurrent),
    NoncurrentPct: current + noncurrent > 0 ? Math.round((noncurrent / (current + noncurrent)) * 100) : 0,
    NoncurrentVersions: noncurrentCount,
    DeleteMarkers: deleteMarkers,
    CostPerMonth: Math.round(cost * 100) / 100,
    Unpriced: unpriced.join(", ") || "-",
    LifecycleRule: await noncurrentRule(s3, bucket),
    Scan: truncated ? `partial (${pages} pages)` : `full (${pages} pages)`,
  };
}

function toCsv(rows: Row[]): string {
  const cols = Object.keys(rows[0] ?? {}) as (keyof Row)[];
  const cell = (v: string | number) => `"${String(v).replace(/"/g, '""')}"`;
  return [cols.join(","), ...rows.map((r) => cols.map((c) => cell(r[c])).join(","))].join("\n") + "\n";
}

async function main(): Promise<void> {
  const home = new S3Client({ region: process.env.AWS_REGION ?? "us-east-1" });
  const buckets: { name: string; region: string }[] = [];
  for await (const page of paginateListBuckets({ client: home }, {})) {
    for (const b of page.Buckets ?? []) {
      if (!b.Name || (onlyBuckets.length > 0 && !onlyBuckets.includes(b.Name))) continue;
      buckets.push({ name: b.Name, region: b.BucketRegion ?? "us-east-1" });
    }
  }

  const rows: Row[] = [];
  let unversioned = 0;
  for (const { name, region } of buckets) {
    const s3 = new S3Client({ region });
    try {
      const status = (await s3.send(new GetBucketVersioningCommand({ Bucket: name }))).Status;
      if (!status) {
        unversioned += 1; // never versioned: no noncurrent versions to measure
        continue;
      }
      rows.push(await measure(s3, name, status));
    } catch (err) {
      console.error(`${name}: ${errorText(err)}`);
    }
  }
  if (rows.length === 0) {
    console.log(`No versioned buckets found (${unversioned} buckets have never had versioning enabled).`);
    return;
  }
  rows.sort((a, b) => b.NoncurrentGiB - a.NoncurrentGiB);
  console.table(rows.map(({ Unpriced, ...shown }) => shown));
  const total = rows.reduce((s, r) => s + r.NoncurrentGiB, 0);
  const cost = rows.reduce((s, r) => s + r.CostPerMonth, 0);
  const noRule = rows.filter((r) => r.LifecycleRule === "none" && r.NoncurrentGiB > 0).length;
  console.log(`${total.toFixed(2)} GiB of noncurrent versions, about $${cost.toFixed(2)} a month at US East list prices. ` +
    `${noRule} of ${rows.length} versioned buckets have noncurrent data and no rule that expires it.`);
  for (const r of rows.filter((x) => x.Unpriced !== "-")) console.log(`${r.Bucket}: not priced: ${r.Unpriced}`);
  if (rows.some((r) => r.Scan.startsWith("partial"))) {
    console.log("Partial scans stopped at --max-pages: their totals are lower bounds. Use S3 Storage Lens for full numbers.");
  }
  if (csvPath) {
    writeFileSync(csvPath, toCsv(rows));
    console.log(`Wrote ${rows.length} rows to ${csvPath}`);
  }
}

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

How do you run it?

Terminal

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

# Every versioned bucket in the account, saved to CSV
AWS_PROFILE=readonly npx tsx measure-s3-noncurrent-version-storage.ts --csv noncurrent.csv

# Two buckets, stopping each scan after 500,000 versions
AWS_PROFILE=readonly npx tsx measure-s3-noncurrent-version-storage.ts --buckets acme-build-artifacts,acme-media-uploads --max-pages 500

Sample output

Output

┌─────────┬────────────────────────┬─────────────┬────────────┬───────────────┬───────────────┬────────────────────┬───────────────┬──────────────┬────────────────┬───────────────────┐
│ (index) │ Bucket                 │ Versioning  │ CurrentGiB │ NoncurrentGiB │ NoncurrentPct │ NoncurrentVersions │ DeleteMarkers │ CostPerMonth │ LifecycleRule  │ Scan              │
├─────────┼────────────────────────┼─────────────┼────────────┼───────────────┼───────────────┼────────────────────┼───────────────┼──────────────┼────────────────┼───────────────────┤
│ 0       │ 'acme-build-artifacts' │ 'Enabled'   │ 15.4       │ 2412.9        │ 99            │ 8400               │ 490           │ 55.5         │ 'none'         │ 'full (14 pages)' │
│ 1       │ 'acme-media-uploads'   │ 'Suspended' │ 120        │ 64.8          │ 35            │ 1800               │ 3             │ 0.81         │ 'none'         │ 'full (3 pages)'  │
│ 2       │ 'acme-archive'         │ 'Enabled'   │ 100        │ 15            │ 13            │ 3500               │ 0             │ 0.04         │ 'none'         │ 'full (5 pages)'  │
│ 3       │ 'acme-terraform-state' │ 'Enabled'   │ 0          │ 0.31          │ 99            │ 780                │ 0             │ 0.01         │ '90d, keep 10' │ 'full (1 pages)'  │
└─────────┴────────────────────────┴─────────────┴────────────┴───────────────┴───────────────┴────────────────────┴───────────────┴──────────────┴────────────────┴───────────────────┘
2493.01 GiB of noncurrent versions, about $56.36 a month at US East list prices. 3 of 4 versioned buckets have noncurrent data and no rule that expires it.
acme-archive: not priced: DEEP_ARCHIVE 5 GiB

The run used mocked S3 responses, so bucket names and sizes are illustrative. acme-build-artifacts is 99% history: 15.4 GiB of current files and 2,412.9 GiB of old versions with no expiration rule. acme-media-uploads has versioning suspended, which stopped new versions but kept 64.8 GiB of old ones in Standard-IA. acme-terraform-state is the healthy case: its history is capped at 10 versions and 90 days. acme-archive transitions old versions to Glacier but never expires them, and the Deep Archive part isn’t priced by the table.

How do you cut the noncurrent versions storage bill?

Add a lifecycle rule that expires noncurrent versions and cleans up after them. This configuration keeps the 3 most recent old versions of each object, deletes the rest 30 days after they become noncurrent, removes delete markers that no longer hide anything, and aborts stale multipart uploads:

lifecycle.json

{
  "Rules": [
    {
      "ID": "expire-old-versions",
      "Status": "Enabled",
      "Filter": { "Prefix": "" },
      "NoncurrentVersionExpiration": { "NoncurrentDays": 30, "NewerNoncurrentVersions": 3 },
      "Expiration": { "ExpiredObjectDeleteMarker": true },
      "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
    }
  ]
}
Terminal

aws s3api put-bucket-lifecycle-configuration --bucket acme-build-artifacts --lifecycle-configuration file://lifecycle.json

Warning: put-bucket-lifecycle-configuration replaces the whole configuration. Read the existing rules first and merge, or you’ll delete transitions someone else set up. Expiring noncurrent versions is permanent, so agree the retention with whoever relies on versioning for recovery, and check S3 Object Lock and replication requirements first.

  • NewerNoncurrentVersions accepts up to 100. It keeps that many recent versions even after NoncurrentDays, which is what you want for Terraform state or config files.
  • Transitions before expiration. If you must keep history for months, a NoncurrentVersionTransition to Glacier Instant Retrieval cuts the rate from $0.023 to $0.004 per GB-month; choosing an S3 storage class for backups compares the options.
  • Expiration is asynchronous. S3 queues expired versions for removal, so the next scan may still show some. Storage Lens also excludes expired objects that haven’t been removed yet.
  • Stop creating history you don’t need. Re-uploading unchanged build output or rewriting objects in place creates a version each time; so does a move, which is a copy and a delete, as the guide to copying and moving S3 objects with SDK v3 explains.

The scripts to find S3 buckets without lifecycle rules and find incomplete S3 multipart uploads cover the other two forms of invisible S3 storage. For the practice of turning findings like these into regular clean-up, the FinOps Foundation’s Usage Optimization capability is a good frame.

Troubleshooting

  • AccessDenied on ListObjectVersions. The action is s3:ListBucketVersions, not s3:ListBucket. A bucket policy can also deny you; see troubleshooting AWS IAM access denied errors.
  • Errors that mention the wrong Region or endpoint. The client Region doesn’t match the bucket’s Region. The script uses BucketRegion from ListBuckets; if you hard-code buckets elsewhere, set the Region per bucket.
  • The scan takes too long. Lower --max-pages to sample, treat the partial totals as a lower bound, and use Storage Lens for the exact figure.
  • The numbers don’t match the bill. The rate table is US East first-tier list prices; other Regions, volume tiers, minimum-duration charges and Intelligent-Tiering’s cheaper tiers all change the result. S3 Standard vs Intelligent-Tiering cost explains the tier maths.

Ask ChatWithCloud instead

For one bucket, ask ChatWithCloud “How much of acme-build-artifacts is noncurrent versions, and is there a lifecycle rule for them?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the JSON; how ChatWithCloud runs AWS SDK code locally covers what’s sent where. It can be wrong and runs changes without a confirmation step, so connect it with a read-only AWS profile and write lifecycle rules yourself. To find which buckets deserve a look first, ask AI which S3 buckets are largest.

Frequently asked questions

Do noncurrent S3 versions cost money?

Yes. Each noncurrent version is stored data billed at its storage class rate, for example $0.023 per GB-month in S3 Standard in US East (N. Virginia) as of September 2026. There’s no separate fee for versioning itself.

Does BucketSizeBytes include old versions?

Yes. The S3 User Guide says it sums current and noncurrent objects, plus incomplete multipart upload parts, so it can’t show the noncurrent share on its own.

Does suspending versioning delete old versions?

No. Suspending stops new versions from being created, but the versions already stored stay and keep billing until a lifecycle rule or a delete removes them.

How many noncurrent versions can a lifecycle rule keep?

NewerNoncurrentVersions accepts up to 100. S3 permanently deletes the older noncurrent versions beyond that number once they pass NoncurrentDays.

How do I clean up S3 delete markers?

Delete markers hold no object data, but they count toward NumberOfObjects and appear in every version listing. Once the versions behind a marker have expired, set ExpiredObjectDeleteMarker to true in a lifecycle rule and S3 removes it.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud