---
title: Bulk actions
description: Pass an array instead of a key to act on many objects at once - a bounded fan-out that keeps successes and failures separate, never failing the whole batch.
---

`upload`, `download`, `head`, and `exists` each take a single key or an array; `delete` takes one key or many. The array form fans out with bounded concurrency (8 by default) and returns a structured result that keeps successes and failures separate, in input order — so one bad key never sinks the whole batch.

```ts lineNumbers
// Upload several objects in one call
const { results, errors } = await files.upload([
  { key: "a.txt", body: "alpha" },
  { key: "b.txt", body: "beta", contentType: "text/plain" },
]);

// exists() splits the keys into present and absent
const { existing, missing } = await files.exists(["a.txt", "b.txt", "c.txt"]);

// delete() reports the keys it removed
const { results: deleted } = await files.delete(["a.txt", "b.txt"]);
```

Every method but `exists` returns the same shape, a `BulkResult`: the successes in `results` and an optional `errors` array, omitted entirely when every item succeeded. `exists` splits the keys into `existing` and `missing` instead:

| Method     | Array form returns                          |
| ---------- | ------------------------------------------- |
| `upload`   | `{ results: FileInfo[], errors? }`          |
| `download` | `{ results: StoredFile[], errors? }`        |
| `head`     | `{ results: FileInfo[], errors? }`          |
| `delete`   | `{ results: string[], errors? }` (the keys) |
| `exists`   | `{ existing, missing, errors? }`            |

The success arrays come back in the order you supplied the keys. Each entry in `errors` is `{ key, error }`, where `error` is a normalized [`FilesError`](/docs/api/errors). Invalid keys (empty, or containing null bytes) are reported there too, never thrown.

## Partial failure

By default the array forms don't throw on a partial failure — every item is attempted and per-key failures collect in `errors`. Pass `stopOnError: true` to bail at the first failure and return the results gathered so far plus that error, or `concurrency` to tune the fan-out. `stopOnError` always runs the items one at a time, in order, and a bulk `delete` with it skips the adapter's native batch (a batch request can't stop partway), so it behaves the same with or without plugins.

```ts lineNumbers
const result = await files.upload(items, {
  concurrency: 16,
  stopOnError: false,
});

if (result.errors) {
  for (const { key, error } of result.errors) {
    logger.warn("upload failed", key, error.code);
  }
}
```

<AutoTypeTable
  path="../../packages/files-sdk/src/index.ts"
  name="BulkOptions"
/>

## Native bulk vs. fan-out

`upload`, `download`, `head`, and `exists` have no provider batch primitive, so the SDK always fans out to per-key calls under the concurrency limit. `delete` is the exception: adapters with a native bulk primitive (S3 `DeleteObjects`, chunked into batches of 1000; Azure's Blob Batch API, chunked into batches of 256; Supabase; UploadThing) remove everything in a single request per batch and ignore `concurrency`, FTP and SFTP delete sequentially over one reused connection, and the rest fall back to bounded fan-out. When a native bulk provider only reports that the whole request failed, that error is mapped onto each affected key. An instance with a [plugin](/docs/plugins) that wraps operations skips the native primitive and fans out per key, so the plugin can intercept each delete.

## Retries and hooks

Bulk calls are **not** retried — `retries` applies to single-operation calls only — so [`onRetry`](/docs/api/onretry) never fires for their items. The one exception is a sub-operation a plugin re-routes an item to (such as [`softDelete()`](/docs/plugins/soft-delete) turning a bulk `delete` into a `move`), which runs the full single-operation path and does retry. [`onAction`](/docs/api/onaction) emits one event for the whole call, carrying the caller's `keys` and the aggregated result; the per-item failures inside `errors` are not rejections, so they don't fire [`onError`](/docs/api/onerror).

The client's [`prefix`](/docs/prefixes) is honored throughout: keys are resolved under it on the way in and stripped back off on the way out, just as in the single-key forms.
