Per-Tenant Backup and Restore Across Isolation Tiers

"Can you restore just our data?" is a question every enterprise customer asks, and the honest answer depends entirely on which isolation tier they are on. This page is a focused part of Hybrid & Tiered Isolation Models: what each tier makes possible, how to build a per-tenant restore where the tier does not give you one, and how to prove the recovery time you promise.

Problem Framing

Physical backups are cluster-shaped. A point-in-time recovery restores an entire database to an instant, which is exactly right for a siloed tenant and useless for a pooled one β€” restoring the shared cluster to yesterday would roll back every other tenant along with the one that needs it.

So the achievable restore granularity is a property of the tier. Siloed: restore the database, done. Bridged: restore a schema from a logical dump, or restore everything into a staging instance and copy one schema back. Pooled: there is no physical path at all β€” the only option is a logical per-tenant export taken on a schedule, restored into a staging schema and copied back row by row, in referential order.

That last path is slow, and its cost is what should be stated in the contract. A pooled tenant with a four-hour recovery objective is a promise the architecture cannot keep; the same tenant on a dedicated database can be restored in minutes. Recovery granularity is one of the strongest arguments for tier promotion, and it is the one customers understand immediately.

Step-by-Step Guide

1. Give every tenant a recovery objective, and record which tier can meet it

CREATE TABLE tenant_recovery_objective (
  tenant_id      uuid PRIMARY KEY,
  rpo_minutes    integer NOT NULL,          -- how much data may be lost
  rto_minutes    integer NOT NULL,          -- how long a restore may take
  source         text NOT NULL,             -- 'default' | 'contract'
  CHECK (rpo_minutes > 0 AND rto_minutes > 0)
);

-- Objectives the current placement cannot meet.
SELECT o.tenant_id, p.tier, o.rto_minutes
FROM   tenant_recovery_objective o
JOIN   tenant_placement p USING (tenant_id)
WHERE  (p.tier = 'pooled'  AND o.rto_minutes < 240)
   OR  (p.tier = 'bridged' AND o.rto_minutes < 30);
-- every row is either a promotion candidate or a contract to renegotiate

2. Take logical per-tenant exports for pooled tenants

# Nightly, per tenant, in dependency order. Binary format for speed and fidelity.
for TABLE in tenant organisation membership document document_version invoice; do
  psql "$SRC" -c "\copy (SELECT * FROM $TABLE WHERE tenant_id = '$TENANT') \
    TO PROGRAM 'zstd -q -o /backup/$TENANT/$(date +%F)/$TABLE.bin.zst' (FORMAT binary)"
done

Export from one repeatable-read snapshot across all tables, exactly as in a tier promotion, so the export is internally consistent. A per-table export taken over twenty minutes produces child rows whose parents were deleted midway, and the restore will fail on a foreign key at the least convenient moment.

3. Restore into staging first, never directly over live data

-- Restore lands in an isolated schema so nothing can overwrite production by accident.
CREATE SCHEMA restore_acme_20260730;
SET search_path TO restore_acme_20260730, public;
-- \copy each table back into schema-local tables created from the same migration set
// verify-restore.ts β€” check before anything is copied into production
export async function verifyStagedRestore(schema: string, tenantId: string) {
  const checks = await Promise.all(TABLES.map(async (t) => ({
    table: t,
    staged: await count(`${schema}.${t}`, tenantId),
    manifest: await manifestCount(tenantId, t),
  })));
  const bad = checks.filter((c) => c.staged !== c.manifest);
  if (bad.length) throw new Error(`row count mismatch: ${JSON.stringify(bad)}`);
  await assertNoForeignTenantRows(schema, tenantId);      // the restore must contain one tenant only
  return checks;
}

assertNoForeignTenantRows is the check that turns a restore into a safe operation: a staged dataset containing a second tenant's rows would inject them into production the moment it is copied across.

4. Copy back in referential order, inside one transaction

BEGIN;
SET LOCAL app.current_tenant_id = '…acme…';

-- Remove the tenant's current rows, children first.
DELETE FROM document_version WHERE tenant_id = current_setting('app.current_tenant_id')::uuid;
DELETE FROM document         WHERE tenant_id = current_setting('app.current_tenant_id')::uuid;
DELETE FROM membership       WHERE tenant_id = current_setting('app.current_tenant_id')::uuid;

-- Insert the restored rows, parents first.
INSERT INTO membership       SELECT * FROM restore_acme_20260730.membership;
INSERT INTO document         SELECT * FROM restore_acme_20260730.document;
INSERT INTO document_version SELECT * FROM restore_acme_20260730.document_version;
COMMIT;

One transaction means the tenant is never half-restored. On a large tenant this transaction can be long, which is the honest cost of pooled recovery and another reason the recovery objective belongs in the tier decision.

5. Rehearse, and publish the measured time rather than the hoped one

// drill.ts β€” scheduled monthly against a real tenant-sized dataset
export async function restoreDrill(tier: Tier) {
  const t0 = Date.now();
  const tenant = await pickRepresentativeTenant(tier);
  const staged = await restoreToStaging(tenant, latestBackup(tenant));
  await verifyStagedRestore(staged.schema, tenant.id);
  const elapsedMin = (Date.now() - t0) / 60_000;

  await recordDrill({ tier, tenantId: tenant.id, elapsedMin, bytes: staged.bytes });
  if (elapsedMin > objectiveFor(tier)) await alert('restore_drill_exceeded_rto', { tier, elapsedMin });
}

An untested restore path is a plan, not a capability. The drill also produces the number you are allowed to put in a contract, which is otherwise an estimate someone made once.

Verification

-- 1. Every tenant has a recent backup appropriate to its objective.
SELECT t.tenant_id, o.rpo_minutes,
       extract(epoch FROM (now() - max(b.taken_at))) / 60 AS minutes_since_backup
FROM   tenant_recovery_objective o
JOIN   tenant_placement t USING (tenant_id)
LEFT   JOIN tenant_backup b USING (tenant_id)
GROUP  BY t.tenant_id, o.rpo_minutes
HAVING max(b.taken_at) IS NULL
    OR extract(epoch FROM (now() - max(b.taken_at))) / 60 > o.rpo_minutes;
-- expected: no rows

-- 2. Drill results are within the promised recovery time.
SELECT tier, max(elapsed_min) AS worst, count(*) AS drills
FROM   restore_drill
WHERE  ran_at > now() - interval '90 days'
GROUP  BY tier;

-- 3. No staged restore schema has been left behind.
SELECT nspname FROM pg_namespace WHERE nspname LIKE 'restore\_%'
  AND  nspname NOT IN (SELECT schema_name FROM active_restore);

The third query matters more than it looks: an abandoned staging schema holds a full copy of a tenant's data outside every access control built for the live tables, which is a quiet data-exposure problem sitting in plain sight.

Failure Modes & Gotchas

FAQ

How often should per-tenant logical exports run for pooled tenants? Match the recovery-point objective you have actually promised: nightly for a twenty-four-hour objective, every few hours for something tighter. The cost is real, since each export is a scan over that tenant's rows, so run them off-peak and stagger tenants across the window. If a pooled tenant needs an objective measured in minutes, no export cadence will get you there and the answer is promotion.

Can a single tenant be restored from a physical cluster backup? Indirectly. Restore that whole snapshot into a temporary instance, extract that tenant's rows from it, and copy them back β€” which works but costs a full-cluster restore in time and money, and needs enough capacity to stand up a second copy of the whole database. It is the right emergency path for a pooled tenant with no recent export, and a poor routine one.

Does restoring one tenant risk the others? It should not, and the controls that guarantee it are the same ones used everywhere else: stage in an isolated schema, verify the staged data contains exactly one tenant, scope every statement in the copy-back, and run the whole cutover in one transaction. The genuine residual risk is duration β€” a long transaction on a shared cluster holds locks and delays vacuum β€” which is why large pooled restores are scheduled rather than performed on demand.

Should customers be able to trigger their own restore? Not directly, and the reason is blast radius rather than trust. A restore overwrites live data, and a customer who selects the wrong backup has destroyed work with no way back β€” so the operation needs a confirmation path, a staged verification and a human who has done it before. What customers can reasonably self-serve is the read-only half: browsing available restore points, seeing what a backup contains, and requesting a restore that a human then executes with the safeguards in place.