Skip to content
downpipes docs

Granular and targeted restores: one record, a prefix, a sample, or a redirect

A restore does not have to be all-or-nothing. You can bring back exactly one record, a prefix of records, a capped sample, or send the restored data to a different binding. This page is for the approver and the operator who narrow a restore deliberately. It explains how each choice changes the blast radius, the friction at the confirm step, and the approval the apply requires.

The thread running through every option is that the approval binds to the exact scope you apply. Narrowing or widening the scope changes the plan hash, which re-arms the dual-control approval, so an approval can never be reused for a different scope than the one it was granted for. For the request, approve and apply lifecycle itself, see dual control.

One scope is deliberately absent. There is no point-in-time or as-of-timestamp picker in the console, so this page never offers “restore as of a time” as a narrowing option. Recovery point is expressed as the newest good run, covered in recovery objectives.

The ways to narrow a restore

Each narrowing is a field on the restore request. They compose with the same dry-run-by-default, verify-before-write and dual-control safety as a full restore, and each only ever reduces what gets written.

FieldScopes byNotes
recordNameOne exact record nameThe first-class single-record path. An exact match, never a prefix. Supersedes the prefix selectors when set.
includeKey prefixes admittedEmpty means all. A prefix, so include: ["user:42"] would also admit user:42x.
excludeKey prefixes removedExclude wins over include.
maxRecordsA count capCaps both the dry-run preview and a partial apply.
target.bindingA redirect destinationSends every restored record to one binding instead of its original. High impact.
d1TablesChosen tables of one D1 databaseRestores those tables into a fresh database. Supersedes the prefixes; not combinable with recordName.

Single-record restore by exact name

recordName is the first-class way to restore exactly one record. When it is set, the whole restore is scoped to the single record whose source name equals it. Every other record in the run is reported “not the selected record”. The match is exact, never a prefix: where a prefix selector is fuzzy at the edges, recordName resolves to the named record or to nothing at all. It can only ever narrow the scope to one record, never widen it. When it is set, the include and exclude prefixes are ignored because the single exact name is the more specific intent.

If the run holds no record with that exact name, the plan is empty by construction and the engine returns “record not found in run” rather than a success or a silent whole-run restore. So a typo in the name fails loudly with nothing written, instead of quietly doing the wrong thing.

In the console you reach a single-record restore two ways. There is a “Restore one record” field on the plan form whose hint reads that an empty value restores the whole run or prefixes above. There is also a per-row “Restore just this” affordance on each resolved-destination row of a dry-run plan. Choosing “Restore just this” re-enters the same flow scoped to that one record, running a fresh dry-run, so it flows through the identical dry-run, dual-control and apply path with its own distinct plan hash and approval. A plan that is already a single-record plan does not offer the affordance again, because there is nothing left to narrow.

{
  "runId": "01J...",
  "recordName": "user:42",
  "confirm": false
}
FieldTypeMeaning
runIdstringThe run to restore from.
recordNamestringThe exact source name of the single record to restore.
confirmbooleanAbsent or false is a dry run that writes nothing; true applies.

Because recordName is folded into the plan hash, a single-record apply carries its own distinct approval and can never reuse a whole-run approval. Changing which record you name re-arms the approval.

Include and exclude prefix selectors

The include and exclude arrays scope a restore by key prefix, with the same semantics as a source selector: an empty include means every record, and exclude wins where the two overlap. They are prefix matches, so they are the right tool for “everything under this namespace” rather than “this one key”. When recordName is set it supersedes them entirely, because the single exact name is the narrower, more specific intent and exclude cannot re-admit anything once the scope is already one name.

{
  "runId": "01J...",
  "include": ["session:", "cart:"],
  "exclude": ["session:debug-"]
}
FieldTypeMeaning
includestring[]Key prefixes to admit. Empty admits everything.
excludestring[]Key prefixes to remove. Exclude wins over include.

The selectors are part of the plan hash, so changing either one re-arms the approval. An apply must match the exact selectors that were approved.

An entry that is the empty string is refused with a 400, on the dry run and apply (POST /admin/restore), on raising a request (POST /admin/restore/request) and on a restorability proof (POST /admin/restore/verify). On the first two routes the check runs before your role is consulted; on POST /admin/restore/verify the role check runs first (engine/src/admin/router-restore.ts). Every name starts with the empty string, so an empty entry in exclude would put the whole run out of scope and the restore would write nothing and still report success. An entry that is not a string is refused the same way. Send the prefix you meant, or send an empty list. Selectors and scope sets out the rule and the exact message.

The maxRecords cap

maxRecords is a count cap that applies in two places. In a dry run it bounds the preview, and in an apply it bounds the write: the apply window is the in-scope plan truncated to maxRecords. So a partial apply writes only the capped window, and the same cap governs what you previewed and what lands.

Separately from the cap, a dry run always returns a bounded sample of resolved destinations so a huge run never returns a huge plan. When the caller gives no maxRecords, the sample is capped at 50 rows. So the preview you read is always a manageable size, whether or not you set a cap.

FieldTypeMeaning
maxRecordsintegerCaps the dry-run preview and the apply window to this many records.

maxRecords is bound into the plan hash. Changing the cap re-arms the approval, so an approval granted for a 100-record window does not authorise a larger one.

The 200-record in-account ceiling

The in-account restore, the one the console drives, runs the whole in-scope window inside a single engine invocation, so it is bounded to 200 records. A restore whose in-scope window is larger than that is refused before any byte is written, by both the dry run and the apply, with a message that names the limit and points you at the two ways forward: recover the full run offline with the downpipe CLI, which runs on your own machine with no platform limits, or set maxRecords to 200 or fewer for a bounded in-account restore. This is why a whole-run restore of a large run is refused rather than half-applied. This is also why a big downpipe’s row shows as blocked in a batch restore. From engine 0.3.6, a restore that keeps live keys has a limit of 160 records, because it checks each key before the write.

The 200 is a single budget over everything a restore writes, spent in apply order. Data records come first, then any Cloudflare-config surfaces, then any media re-uploads, all counted together against the one ceiling. So a restore with only a handful of data records but many config or media records is bounded by the same 200. The ceiling is measured on the combined count.

A D1 database is all-or-nothing inside the window. A D1 restore replays as a sequence that must travel together and is not resumable. Because of that, a database that does not fully fit the window is never partially applied. It is dropped from the applied window and steered offline instead, so a re-run can never collide with a half-loaded database.

Continuing a windowed restore

When a cap leaves records beyond the window unrestored, the receipt reports them as an intentional cap rather than a failure. The window is always the first records of the in-scope plan, not a resume from where a previous window stopped, so you bring back more by re-running with a higher maxRecords, not by paging from an offset. KV, R2, Cloudflare-config and Images writes are idempotent, so the records an earlier window already wrote are simply re-applied and the overlap is safe. A Stream video is the exception: each re-upload is assigned a new id, so re-running a window that already restored a video leaves the earlier copy behind as an orphan. If a window included videos, start the next window past them rather than overlapping, or restore the videos once on their own.

A D1 database left unrestored by the window is the one exception to that advice. An in-account D1 restore cannot resume, so the guidance for a D1 left outside the window is to recover the full run offline with the downpipe CLI, never to re-run with a higher cap.

Redirecting a restore to one binding

A redirect sends every restored record to a single target binding instead of its original source binding. This is a high-impact change, and the console treats it as one. Every sampled record resolves to the redirect target, which is the whole point of a redirect. Because of that, the dry-run preview shows the redirect destination on every row, and the blast-radius cues reflect it. On the plan itself, the impact banner names the redirect target directly, in a sentence that every record is written to that binding rather than its original source binding, so the impact is explicit before you confirm.

A danger-toned “Redirect” chip naming the exact binding appears later. It shows as the value of the Target cue on the pending request in the approver’s approvals inbox, not on this pre-confirm plan.

The confirm friction escalates with the blast radius. A small, same-binding restore uses a single confirm modal, but a redirect always escalates to type-to-confirm regardless of how few records it touches, and so does restoring a run that is not the latest. When a redirect escalates, the value you type to confirm is the target binding name, so the confirmation is bound to the exact destination you are redirecting into.

{
  "runId": "01J...",
  "target": { "binding": "KV_RESTORE_STAGING" },
  "confirm": true
}
FieldTypeMeaning
target.bindingstringThe single binding every restored record is written to.
target.namespaceIdstringOverrides the recovered KV namespace name (optional).
target.bucketNamestringOverrides the recovered R2 bucket name (optional).

The target binding, namespace and bucket names are all part of the plan hash. Redirecting, or changing the redirect, re-arms the approval, so a redirect apply carries an approval granted for that exact redirect and no other.

Which binding names are valid, and what is refused

The target must be a binding your engine actually holds. A redirect target you type is used directly as the binding to write through, so it has to be present on the engine already: a source binding your downpipes attached (whose name you chose when you attached the source), or one following the KV_<namespace>, R2_<bucket> or D1_<name> convention. A name the engine does not hold is not quietly accepted: the plan refuses with a “target binding not present” reason and nothing is written, so a typo in the redirect target fails visibly rather than landing your data somewhere unexpected.

The engine’s own bindings, its signer key, its destination credentials and its operational keys, are reserved. A redirect onto a reserved binding refuses the whole restore before a single byte is written. This mirrors the read side, which refuses to back those same bindings up. This is what makes a redirect safe to point anywhere: a confused-deputy attempt to write archived data over an engine secret is refused, not honoured.

A redirect writes every record through the sink for its own source type, and the engine does not check that the target binding is that type, so you must point a redirect only at a binding of the same type as the records you are moving. A type mismatch is not caught and not skipped: the record is written through its own sink to whatever resource the binding names, so it lands in the wrong place, and depending on the direction it then either fails its post-write verification or is still counted as restored. To move a run that mixes source types, redirect one type at a time, each to a matching binding. Do not send them all to a single binding.

A cancel does not undo what is already written

At the type-to-confirm gate a cancel stops further writes, but it does not roll back records already written; the engine does not roll back. A non-latest restore writes older data over current data, which is why it escalates the confirmation. Review the plan and the blast-radius cues before you apply.

D1 table-subset restore

A D1 database backs up as a per-table sequence, so you can restore a chosen set of its tables rather than the whole database. d1Tables names the database and the tables to bring back. The restore goes into a fresh, empty D1 database: the header re-creates every table so the schema is intact, and only the tables you named are populated with rows. A D1 restore is always into a fresh target, because its rows load over several non-atomic batches and a non-empty target is refused, so you restore the subset into a new database and repoint or copy from there rather than editing a live database in place.

Table names are matched to the backup case-insensitively, and a name that is not in the backup is refused with “table not found”, so a typo fails loudly with nothing written. d1Tables replaces the include and exclude prefixes, so the restore holds only the chosen tables of that database. You cannot combine it with recordName, because the two are different and non-composable intents.

{
  "runId": "01J...",
  "d1Tables": { "database": "app_db", "tables": ["users", "orders"] },
  "confirm": false
}
FieldTypeMeaning
d1Tables.databasestringThe D1 database to restore a subset of.
d1Tables.tablesstring[]The table names to restore, matched case-insensitively to the backup.
d1Tables.createOnlybooleanOptional. When true, the fresh database is created with only these tables (a minimal extract) instead of the full schema.

d1Tables is folded into the plan hash, so a table-subset apply carries its own distinct approval and changing the database, the tables or createOnly re-arms it.

Create only the selected tables

By default the fresh database keeps the full schema: every table is created, the tables you named hold rows, and the rest are empty. Set createOnly to true for a minimal extract instead. Only the selected tables are created, along with the indexes and triggers defined on them.

A selected table’s foreign key to a table you did not select stays as a dangling reference. SQLite accepts this because a restore runs with foreign-key enforcement off. Because createOnly changes what the restore creates, it is bound into the plan hash and carries its own approval. This approval is distinct from a full-schema restore of the same tables.

The foreign-key dependency lint

When you select a child table but not the parent it references, and that parent holds rows in the backup, the dry-run plan flags it. Each dependencyWarnings entry names the selected child table and that parent, so you can widen the selection before you confirm. The warning is advisory, not a gate: a D1 restore inserts rows with foreign-key enforcement off, so the child’s rows still load, but its references point at rows that were not restored. In the console this reads as a calm caution on the plan, above the confirm step.

In the console the D1 table-subset controls live in a “D1 table-subset restore” section on the plan form: a database field, a comma-separated tables field, and a “create only these tables” toggle. Filling the database and tables scopes the restore to those tables. The selection re-arms the plan the same way the other scope fields do. The selection is also bound into the plan hash so the approval matches the apply.

Every narrowing re-arms the approval

The single rule that ties the page together is that the approval binds to the exact scope you apply. The plan hash is computed over the run, the destination it is read back from, the target binding, namespace and bucket names, and the selectors. The plan hash also covers the record cap, the record name and the D1 table-subset selection. It further covers the Cloudflare-config account, zone and resolved surface list, and the media re-upload account. From engine 0.3.6 it also covers the choice to keep live keys (onExisting: "skip"), so that choice carries its own approval. Change any one of those decision fields and you get a different hash, which simply has no matching approval, so the apply is refused until a fresh request and a fresh approval are raised for the new scope.

This is why widening or changing scope voids a prior approval. An approval is single-use and bound to one plan, so a granular restore you approved cannot be silently broadened into a whole-run restore at apply time, and a whole-run approval cannot be narrowed and reused either. The console recomputes and shows the plan hash from the request so you can see the exact value an approval binds to. The engine recomputes and gates on it server-side so the binding is the engine’s, not a value a client could choose.

Media restore (Stream and Images)

Media re-upload is not a narrowing but an additional restore context, alongside the Cloudflare-config context. A Stream video or an Images file is captured as real bytes in the archive. A restore can re-upload it back into your live account rather than leaving it for out-of-band handling. It is opt-in and safe by default: nothing is uploaded unless you supply an edit-scoped Cloudflare token with access to Stream and Images together with the account id, and confirm the apply. A dry run reports which files would upload and writes nothing. The re-upload only ever creates, it never deletes, so it cannot brick a live asset.

In the console the controls are a “Media restore (optional)” section on the plan form, a media edit token and the account id. The account id is the 32-character hexadecimal identifier the media was backed up from. It is shown as the Account ID in the right-hand column of your Cloudflare dashboard’s account overview. Stream and Images are account-scoped, so there is no zone id here. Both parts are required together: supply only one and the plan is built without the media context rather than half-applying it.

The identity a file keeps on re-upload depends on the service.

RecordOn re-uploadIdentity
Images fileRe-uploaded to its original id when that id is free. If a different image already holds the id, the re-upload is refused for that record rather than overwriting it; a byte-identical image is treated as an idempotent re-restore.Keeps its original id
Stream videoRe-uploaded through Stream’s direct upload. Stream does not let a caller choose the video id, so the service assigns a fresh uid. The receipt reports an id map from the old uid to the new one so you can update references.Gets a new uid
Metadata or inventory recordInformational; re-create from the verified snapshot.Not a file upload

The id map is on the receipt itself, not only on the screen that reports the restore. Each remapped record’s receipt row carries the live restoredId beside the archived name, inside the signed and hashed part of the receipt, so the file you download from the restore screen is enough on its own to repoint your references, and a receipt that has been altered to remove the map no longer verifies. A record that did not remap, which is every image and every non-media record, carries no such field.

One size bound and one precondition are worth knowing before you rely on it. An in-account re-upload is held whole in memory, so a media file larger than 25 MiB, image or video alike, is surfaced out of band for manual re-upload from the verified bytes rather than failing the restore. And only a record that captured the file’s actual bytes can be re-uploaded: a record whose stored value is a capture marker, meaning the file was not captured as bytes, is reported as captured but not re-uploaded. Each re-upload is then proven. An image is proven by reading the live file back and re-hashing it against the archived bytes. A transcoded video is proven by confirming the new uid resolves.

Where this fits

Last updated .