---
title: soft-delete
description: Turn delete into a recoverable move into a trash prefix, with trashed(), restore(), and purge(). A server-side move under a hidden prefix - body-transparent, no native dependencies, works on any adapter.
---

The built-in `softDelete()` plugin gives you a recycle bin. Instead of destroying an object, [`delete`](/api/delete) server-side **moves** it to a hidden trash prefix; the bytes only ever leave storage when you `purge()`. Three new methods - `trashed()`, `restore()`, and `purge()` - let you list, recover, and permanently remove what's been deleted.

Like [`versioning()`](/plugins/versioning), it's **body-transparent**: it never buffers, transforms, or even reads the body, so streaming, [range downloads](/api/download), [`url()`](/api/url), and [`signedUploadUrl()`](/api/signed-upload-url) all keep working. It has **no native dependencies** and works on any adapter.

```ts lineNumbers
import { createFiles } from "files-sdk";
import { s3 } from "files-sdk/s3";
import { softDelete } from "files-sdk/soft-delete";

const files = createFiles({
  adapter: s3({ bucket: "uploads" }),
  plugins: [softDelete()],
});

await files.upload("notes.txt", "hi");
await files.delete("notes.txt"); // moved to .trash/notes.txt, not destroyed

await files.trashed(); // [{ key: "notes.txt", trashKey: ".trash/notes.txt", … }]
await files.restore("notes.txt"); // back to "notes.txt"
```

<Callout>
  `trashed()`, `restore()`, and `purge()` are contributed by the plugin's
  `extend`, so they only appear on the **type** when you construct with
  [`createFiles`](/plugins/api#createfiles) (identical to `new Files()` at
  runtime).
</Callout>

## How it works

A soft delete is a plain object **move**, not a re-upload:

1. A [`delete`](/api/delete) of a live key `photos/a.jpg` moves the object to `"<prefix>/<key>"` - the default prefix is `.trash`, so it lands at `.trash/photos/a.jpg`.
2. The original key is now gone from listings; the bytes live on under the trash prefix.
3. Deleting a key that doesn't exist stays a **no-op**, exactly like a plain `delete` - the move only runs when there's something to move.

Because a move relocates whatever is **already stored**, the plugin composes cleanly with the transforming plugins: trashing an encrypted object keeps it encrypted, and it restores to readable plaintext.

## Restoring

`restore(key)` moves the trashed copy back over the live key and resolves to the restored [`StoredFile`](/api/stored-file). Restoring also clears the copy from the trash:

```ts lineNumbers
await files.delete("report.pdf");
await files.restore("report.pdf"); // undeletes it
```

If a live object already exists at `key` - say you re-created it after the delete - restoring **overwrites** it. It throws when nothing is trashed for the key.

## Listing the trash

`trashed()` returns everything currently in the trash, each entry carrying the original `key` you'd pass to `restore()` or `purge()`:

```ts lineNumbers
const items = await files.trashed();
// [
//   { key: "report.pdf", trashKey: ".trash/report.pdf", size, lastModified?, etag? },
//   …
// ]
```

The `trashKey` is a real, downloadable object, so you can preview a trashed file without restoring it: `await files.download(items[0].trashKey)`.

## Purging

Soft deletes never reclaim storage on their own - that's the point. `purge()` is the only thing that actually removes bytes. Pass a `key` to empty one item, or omit it to empty the **entire** trash:

```ts lineNumbers
await files.purge("report.pdf"); // one item
await files.purge(); // the whole trash
```

`purge()` is idempotent: purging a key with nothing trashed is a no-op. (Under the hood, a `delete` of a key inside the trash prefix is a **real** delete - that's how purging works, and how any manual trash cleanup behaves.)

## Choosing the prefix

Trashed objects live under `.trash` by default. Override it with `prefix`, and keep your own data out of it:

```ts
softDelete({ prefix: ".bin" });
```

Objects under the trash prefix are **hidden from [`list()`](/api/list)** so deletes don't linger in your listings - unless you explicitly list within the prefix (which is how `trashed()` reads them). Filtering preserves the page `cursor`, so [pagination](/api/list) still resumes correctly; pages may just come back shorter.

## Ordering

Soft delete operates on logical keys and relocates whatever the rest of the pipeline stored, so place it **first** (outermost):

```ts
plugins: [softDelete(), encryption(key)];
```

## Things to keep in mind

- **One trashed copy per key.** A delete moves to `"<prefix>/<key>"`, so re-deleting a key whose trashed copy still exists **replaces** that copy (latest delete wins). Reach for [`versioning()`](/plugins/versioning) if you need every deleted generation kept.
- **A delete costs an extra round-trip.** A soft delete is a server-side `copy` + `delete` rather than a single delete. It's the price of recoverability.
- **Direct presigned writes bypass it.** Only deletes through the instance are trashed. It's a safety net, not a security control, so - unlike [`validation()`](/plugins/validation) - it doesn't fail closed.
- **Trash grows until you `purge()`.** Nothing expires on its own; wire up your own retention if you need one.
- **Don't store your own data under the trash prefix.** A `delete` there is a real delete, and the objects are hidden from `list()`.
