List All Objects in an S3 Bucket With AWS SDK v3

Open metal filing cabinet drawers full of neatly arranged paper folders

Photo by Diego Marín on Unsplash

To list all objects in an S3 bucket with AWS SDK v3, loop over paginateListObjectsV2({ client }, { Bucket }) with for await and read Contents from each page. Each ListObjectsV2 call returns at most 1,000 keys, and the paginator follows NextContinuationToken until the listing ends. Add Prefix and Delimiter: "/" to list one “folder”.

This guide is for Node.js and TypeScript developers who need every key in a bucket, or every key under a prefix, without silently stopping at the first 1,000. You’ll finish with a small typed module that can list all objects in an S3 bucket with AWS SDK v3, list folder-style, resume a listing, and walk object versions, plus the IAM policy and the cost of doing it on a big bucket.

If you’re moving from v2’s s3.listObjectsV2().promise(), the client swap is covered in the guide to migrate a Node.js app from AWS SDK v2 to v3, and the AWS SDK v2 to v3 code converter drafts the change for you. This page is about what goes inside the listing loop.

How does ListObjectsV2 pagination work?

One ListObjectsV2 request returns up to 1,000 keys (MaxKeys can lower that, never raise it). If there are more, the response has IsTruncated: true and a NextContinuationToken, which you pass back as ContinuationToken. The paginator in @aws-sdk/client-s3 does exactly that loop and yields each page. Its pageSize option sets MaxKeys. Every v3 client ships paginators that work the same way; the guide to paginate any AWS API with AWS SDK v3 paginators covers the rest, plus the manual token loop for operations that have none.

A few response details matter in practice:

  • Order. In general purpose buckets, keys come back in lexicographical order by key name, so logs/2026-09-27 sorts after logs/2026-09-26. Directory buckets don’t return keys in order.
  • KeyCount is the number of keys in that response, not in the bucket.
  • Owners aren’t included unless you set FetchOwner: true.
  • Version 1. The older ListObjects call still works for backward compatibility, but AWS recommends ListObjectsV2 for new code.

The paginator returns an async iterable, so it’s consumed with JavaScript’s for await...of statement. That also lets you stop early with break, and no further pages are requested.

Prerequisites

  • Node.js 18 or later, TypeScript and tsx.
  • @aws-sdk/client-s3. The paginators ship inside the client package; you don’t need a separate install.
  • Credentials the SDK can find. If the listing works locally but not in CI, the guide to AWS SDK v3 credential providers such as fromIni and fromSSO explains which source wins.

How to list all objects in an S3 bucket with AWS SDK v3, step by step

  1. Create one S3Client per processSet its Region to the bucket’s Region. A mismatch fails with a redirect error rather than listing slowly.
  2. Choose the scopeLeave Prefix empty for the whole bucket, or set it to narrow the listing. Prefix is a plain string match on the start of the key.
  3. Choose flat or folder-styleWithout Delimiter, you get every key under the prefix. With Delimiter: "/", deeper keys are rolled up into CommonPrefixes, one entry per “subfolder”.
  4. Loop over pagesUse paginateListObjectsV2 and read Contents (and CommonPrefixes) from each page. Don’t stop after the first response.
  5. Stream, don’t collect, for big bucketsProcess each object as it arrives instead of building an array of millions of keys in memory. The guide to copy and move S3 objects with AWS SDK v3 uses the same paginator to copy a whole prefix.

Example: a typed listing module

list-s3-objects.ts

// list-s3-objects.ts
// Listing patterns for S3 with AWS SDK for JavaScript v3: every key, "folders", resume points and versions.
import {
  S3Client,
  ListObjectVersionsCommand,
  paginateListObjectsV2,
  type _Object,
  type ListObjectsV2CommandInput,
} from "@aws-sdk/client-s3";

export const s3 = new S3Client({}); // region from AWS_REGION or your profile

/** 1. Stream every object under a prefix, one page (up to 1,000 keys) at a time. */
export async function* allObjects(
  Bucket: string,
  Prefix = "",
  extra: Partial<ListObjectsV2CommandInput> = {},
): AsyncGenerator<_Object> {
  for await (const page of paginateListObjectsV2({ client: s3, pageSize: 1000 }, { Bucket, Prefix, ...extra })) {
    for (const obj of page.Contents ?? []) yield obj;
  }
}

/** 2. Collect keys into an array. Fine for thousands of keys; use allObjects() for millions. */
export async function listAllKeys(Bucket: string, Prefix = ""): Promise<string[]> {
  const keys: string[] = [];
  for await (const obj of allObjects(Bucket, Prefix)) if (obj.Key) keys.push(obj.Key);
  return keys;
}

/** 3. One level of "folders" and files, like `ls`: Delimiter "/" rolls deeper keys into CommonPrefixes. */
export async function listFolder(Bucket: string, Prefix = ""): Promise<{ folders: string[]; files: _Object[] }> {
  const folders: string[] = [];
  const files: _Object[] = [];
  for await (const page of paginateListObjectsV2({ client: s3 }, { Bucket, Prefix, Delimiter: "/" })) {
    for (const p of page.CommonPrefixes ?? []) if (p.Prefix) folders.push(p.Prefix);
    files.push(...(page.Contents ?? []).filter((o) => o.Key !== Prefix)); // skip the console's 0-byte folder marker
  }
  return { folders, files };
}

/** 4. Resume a long listing after the last key you processed (general purpose buckets only). */
export async function* objectsAfter(Bucket: string, StartAfter: string): AsyncGenerator<_Object> {
  yield* allObjects(Bucket, "", { StartAfter });
}

/** 5. Every version and delete marker. client-s3 has no paginator for this, so loop on the markers. */
export async function listVersions(Bucket: string, Prefix = ""): Promise<{ key: string; versionId: string; latest: boolean; deleteMarker: boolean; size: number }[]> {
  const out: { key: string; versionId: string; latest: boolean; deleteMarker: boolean; size: number }[] = [];
  let KeyMarker: string | undefined;
  let VersionIdMarker: string | undefined;
  do {
    const page = await s3.send(new ListObjectVersionsCommand({ Bucket, Prefix, KeyMarker, VersionIdMarker }));
    for (const v of page.Versions ?? []) {
      out.push({ key: v.Key ?? "", versionId: v.VersionId ?? "null", latest: v.IsLatest ?? false, deleteMarker: false, size: v.Size ?? 0 });
    }
    for (const d of page.DeleteMarkers ?? []) {
      out.push({ key: d.Key ?? "", versionId: d.VersionId ?? "null", latest: d.IsLatest ?? false, deleteMarker: true, size: 0 });
    }
    if (!page.IsTruncated) break;
    KeyMarker = page.NextKeyMarker;
    VersionIdMarker = page.NextVersionIdMarker;
  } while (KeyMarker);
  return out;
}

Notes on the choices in this module:

  • allObjects is a generator. Memory stays flat no matter how many keys the bucket holds, and callers can break as soon as they find what they need.
  • Folders aren’t real. S3 has keys, not directories. A “folder” in CommonPrefixes is just the shared text up to the next /, and each one counts as one item against MaxKeys. Folders created in the S3 console are 0-byte objects whose key ends in /, which is why listFolder skips a key equal to the prefix.
  • StartAfter is a resume point. S3 starts listing after that key, which is useful when a long job crashes and you logged the last key you finished. Directory buckets don’t support it.
  • Versions need their own loop. @aws-sdk/client-s3 (checked at version 3.1141.0) doesn’t export a paginator for ListObjectVersions, so listVersions follows NextKeyMarker and NextVersionIdMarker by hand. Delete markers come back in a separate DeleteMarkers array.

Calling it from a script:

run-list.ts

// run-list.ts
// Usage: npx tsx run-list.ts <bucket> [prefix]
import { allObjects, listFolder } from "./list-s3-objects.js";

const [bucket, prefix = ""] = process.argv.slice(2);
if (!bucket) {
  console.error("Usage: npx tsx run-list.ts <bucket> [prefix]");
  process.exit(1);
}

const { folders, files } = await listFolder(bucket, prefix);
console.log("folders:", folders);
console.log("files at this level:", files.map((f) => f.Key));

let count = 0;
let bytes = 0;
const started = Date.now();
for await (const obj of allObjects(bucket, prefix)) {
  count += 1;
  bytes += obj.Size ?? 0;
}
console.log(`${count} objects, ${(bytes / 1024 ** 3).toFixed(2)} GiB under "${prefix}" in ${Date.now() - started} ms`);
console.log(`about ${Math.max(1, Math.ceil(count / 1000))} LIST requests`);
Terminal

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

AWS_PROFILE=dev AWS_REGION=eu-west-1 npx tsx run-list.ts amzn-s3-demo-bucket reports/
# folders: [ 'reports/2026/', 'reports/archive/' ]
# files at this level: [ 'reports/README.txt' ]
# 48213 objects, 12.47 GiB under "reports/" in 6120 ms
# about 49 LIST requests

The paginator source and the rest of the S3 client live in the client-s3 package in the AWS SDK for JavaScript v3 repository, which is the quickest place to confirm which operations have paginators in the version you installed.

What does it cost to list a large bucket?

LIST requests are billed like PUT requests. As of September 2026, the AWS Price List gives $0.005 per 1,000 PUT, COPY, POST or LIST requests for S3 Standard in us-east-1. A full listing takes one request per 1,000 keys:

  • 10 million objects ÷ 1,000 = 10,000 LIST requests × $0.005 / 1,000 = $0.05 per full listing.
  • Run it every 10 minutes and it’s 4,320 listings a month, or about $216.

The request cost is rarely the problem for one-off jobs; time is. Each page is a round trip, so millions of keys take minutes. For recurring reports on huge buckets, S3 Inventory is the better tool: it delivers a CSV, ORC or Parquet file of the bucket’s objects daily or weekly, costs $0.0025 per million objects listed in us-east-1 (Price List, September 2026), and doesn’t touch your bucket’s request rate. The trade-off is freshness: the first report can take up to 48 hours, and each list is an eventually consistent snapshot. The guide to calculate S3 GET and PUT request costs covers the other request types.

If you only need total size per bucket, don’t list at all: the example to find the size of each S3 bucket and the largest one reads CloudWatch storage metrics instead. And to check one key, use HeadObject as in the example to check if an S3 object exists in TypeScript.

Which IAM permissions does listing need?

ListObjectsV2 needs s3:ListBucket on the bucket ARN, not on bucket/*. ListObjectVersions needs s3:ListBucketVersions. The s3:prefix condition key limits which prefixes a caller can list:

list-bucket-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListReportsPrefix",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::amzn-s3-demo-bucket",
      "Condition": {
        "StringLike": { "s3:prefix": ["reports/", "reports/*"] }
      }
    },
    {
      "Sid": "ListVersionsToo",
      "Effect": "Allow",
      "Action": "s3:ListBucketVersions",
      "Resource": "arn:aws:s3:::amzn-s3-demo-bucket"
    }
  ]
}

With this policy, a listing without Prefix: "reports/" is denied, which is the point. To derive the actions from your own code, see how to find the IAM actions your AWS SDK for JavaScript code needs.

Troubleshooting and common mistakes

  • Exactly 1,000 keys, every time. The code sends one ListObjectsV2Command and ignores IsTruncated. Switch to the paginator.
  • AccessDenied with a policy that “allows S3”. s3:ListBucket was granted on arn:aws:s3:::bucket/*. It has to be on arn:aws:s3:::bucket. If a s3:prefix condition is in place, the request’s Prefix must match it.
  • PermanentRedirect or an error about addressing the bucket with a specific endpoint. The client’s Region isn’t the bucket’s Region. Set it, or use followRegionRedirects: true in the S3Client config as a last resort when you can’t know the Region ahead of time.
  • Empty “folders” in the output. Those are 0-byte folder markers from the console. Filter keys that end in / with Size 0.
  • NoSuchBucket. A typo, or the bucket was deleted.
  • Deleted files still show up in versions. In a versioned bucket, a delete adds a delete marker; the old versions stay until a lifecycle rule or an explicit delete removes them. The example to find S3 buckets without lifecycle rules shows which buckets keep them forever.

Limits of listing

  • 1,000 keys per request, in key order; there’s no server-side sort by date or size. Sort in your code, or use S3 Inventory with Athena. The guide to run an Athena query with AWS SDK v3 shows the query side.
  • Prefix is the only server-side filter on keys. Filtering by suffix, date or size happens after the listing, so you still pay for every page.
  • Directory buckets support only / as a delimiter, only prefixes that end in /, and no StartAfter.

For a quick look without code, ChatWithCloud can answer “How many objects are under reports/ in amzn-s3-demo-bucket?” from a read-only profile. It runs the AWS calls on your machine and sends the results to the AI model to write the answer, and it can also tell you which S3 buckets are largest and which are public.

Frequently asked questions

How do I get more than 1,000 objects from S3 in Node.js?

Use paginateListObjectsV2 from @aws-sdk/client-s3 with for await, or repeat ListObjectsV2Command with ContinuationToken set to the previous NextContinuationToken until IsTruncated is false.

How do I list only the folders in an S3 bucket?

Send Delimiter: "/" with the parent Prefix and read CommonPrefixes. Each entry is one “subfolder” at that level.

Can I list S3 objects sorted by last modified date?

Not on the server. Listings are in key order, so collect the objects you need and sort by LastModified in your code, or use date-based key prefixes.

What’s the difference between ListObjects and ListObjectsV2?

V2 uses continuation tokens and supports StartAfter and FetchOwner. AWS keeps V1 for backward compatibility and recommends V2 for new code.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud