How to restore a Supabase backup — and roll back bad changes

Restores happen on bad days, so this page is organized by bad day. A migration just corrupted data and the project has PITR: use PITR — that is what it is for. The whole database needs to go back to last night: dashboard restore on Pro, or your own dump into a fresh project. One table needs yesterday’s rows and everything else is fine: selective restore — most people don’t know it exists, and it saves the rest of today’s data. Two facts before any button gets pressed: an in-place restore erases everything written after the restore point, and no database restore — Supabase’s or yours — brings back Storage files.

First, always: dump what you have now

Before any restore, take a fresh dump of the current state — a damaged database can usually still be dumped, and it is the only copy of the application data written since your backup. (A --schema=public dump: your tables are in it; Auth users and Storage metadata added since are not.) The restore you are about to run overwrites all of it. Thirty seconds of pg_dump (working command here) buys you the option to recover individual rows from the “bad” state later — and after incidents, someone almost always asks for exactly that.

Path 1 — dashboard restore (Pro and above)

Dashboard → Database → Backups, pick a day, restore. It runs in place: the project is inaccessible while the restore runs, downtime scales with database size, and you should drop active subscriptions and replication slots first (Realtime’s own slot is handled automatically). Granularity is daily — a bad deploy at 5 pm restored from last night costs the day’s writes across every table, which is why the single-table path below exists.

One official option most people miss: on paid plans with physical backups, Supabase can restore a backup — or a PITR point — into a new project instead of in place. Schema, data, roles, and Auth users come across; Storage objects, Edge Functions, and auth configuration do not — those move manually. It is the safer shape when the damaged project needs to stay up as evidence, at the cost of re-pointing your application. On the Free plan this page of the dashboard has nothing to offer either way: there are no backups to restore.

Path 2 — PITR, if you bought it

The $100/month-per-project add-on recovers to any second in its retention window: roll back to 14:03:27, right before the migration ran. For “we broke it minutes ago and every write since matters”, nothing else on this page competes. It is database only, it replaces the daily backups rather than adding to them, and it recovers in place or — like the daily backups — into a new project on physical-backup projects. How it works and what it really costs: the PITR explainer. Where it wins and where it doesn’t: the honest comparison.

Path 3 — your own dump, into a fresh project

If you have your own backups — from pg_dump, a scheduled workflow, or a backup service — the recovery target is a fresh, empty Supabase project, never the damaged one. (No dump yet? The export guide covers producing one, including the roles and data the CLI leaves out by default.) Restoring over a half-broken database leaves you with objects from both eras and no way to tell which is which; with a fresh project, the damaged one stays untouched as evidence and fallback. If your schema uses extensions beyond Supabase’s defaults — pgvector, PostGIS — enable them on the target first (Database → Extensions): tables built on those types cannot restore without them. Then:

read -rs PGPASSWORD && export PGPASSWORD   # hidden prompt — nothing in shell history
pg_restore --no-owner --no-privileges \
  --dbname "postgresql://postgres.<target-ref>@<pooler-host>:5432/postgres" \
  backup-2026-08-08.dump
unset PGPASSWORD

The flags are not decoration:

  • --no-owner --no-privileges — the dump records owners and grants for roles that differ between projects; without the flags the restore drowns in ownership errors.
  • Never --clean against Supabase. Dropping and recreating public silently wipes the default grants for anon and authenticated — the restore looks successful and the API answers 401 on everything.
  • One error is expected and harmless: schema "public" already exists. Errors naming your own tables, indexes, or constraints are real findings.

Honest scope note: a --schema=public dump restores your application schema and data. Auth users, secret values, and Edge Functions are not in it — users get re-invited or migrated separately, secrets get re-entered, functions get redeployed. Budget for that in your recovery time. If your backups are BackupDrill snapshots, the restore & recovery docs cover the console wizard (a read-only preflight before anything executes) and the CLI path, which also puts Storage files back into the target project and reconciles them key by key.

Path 4 — a .backup file you downloaded from the dashboard

Two situations hand you a file with a .backupextension rather than one you made: a project still on the older logical backup process, where each backup has a download option, and a Free project past its resume window, where Project Overview offers the last backup plus the Storage objects. It is a plain Postgres dump with a Supabase-flavoured name, and the only question that matters is which format it is in — that decides the tool, and guessing wrong is the usual reason a restore “does nothing”.

# The download may or may not be gzipped, so look before assuming.
ls -l db_cluster-2026-08-23.backup*

BACKUP_FILE="db_cluster-2026-08-23.backup"
# -k keeps the compressed original, in case the first attempt goes wrong
[ -f "$BACKUP_FILE.gz" ] && gunzip -k "$BACKUP_FILE.gz"

# Then the format question: readable SQL -> psql.  PGDMP -> pg_restore.
file "$BACKUP_FILE"
head -c 8 "$BACKUP_FILE"

If the first bytes are readable SQL, replay it with psql; if it starts with PGDMP, it is a custom-format archive and needs pg_restore with the flags in Path 3. Use a PostgreSQL 17 client either way — an older psql can fail against the Session pooler with a GSSAPI error that looks like a credentials problem and is not.

BACKUP_FILE="db_cluster-2026-08-23.backup"

printf 'Target session pooler string: '
read -rs DB_URL
echo

# Redirect rather than pipe: piping through tee would replace psql's
# exit status with tee's, and tee almost always succeeds.
psql --dbname "$DB_URL" --file "$BACKUP_FILE" > restore.log 2>&1
echo "psql exit: $?"
tail -20 restore.log

Expect errors, and expect most of them to be noise. A dashboard backup is a full dump that recreates auth and storage, which a new project already has, and Supabase documents those duplicate-object messages as expected. That is why this one runs without ON_ERROR_STOP — it would abort on the first harmless conflict. A non-zero exit status is the signal that something beyond those conflicts went wrong — a connection failure or a missing file exits non-zero without printing a single SQL error. The trade is that a genuine SQL failure looks like the harmless ones, so also search restore.log for errors naming your own tables, policies or triggers, and count auth.users — your accounts arrive through the very schema whose errors you were told to ignore.

Putting the Storage files back

Downloaded files do not restore themselves, and no database dump contains them. The Supabase CLI pushes a local directory into a bucket, but ss:/// resolves to whichever project is currently linked — so link first and confirm it took, or the files land somewhere you did not intend:

supabase login
supabase link --project-ref "abcdefghijklmnop"

# confirm the link points where you think before copying anything
supabase projects list

supabase storage cp ./storage-download "ss:///avatars" -r --experimental

Verified against Supabase CLI 2.109; storage cp is still behind --experimental and its flags have moved before, so check supabase storage cp --help if it argues. One last thing the files and rows do not cover: the new project has its own URL, API keys and JWT secret, so update your environment, re-enter the Auth configuration, and expect existing sessions to be signed out.

Rolling back one table, not the whole database

Most “I need to restore” moments are really “one table needs to go back”. Restoring the entire database to yesterday to fix orderscosts you today’s data in every othertable — a trade nobody actually wants. Custom-format dumps can restore selectively. Bring yesterday’s table up in a scratch Postgres next to production:

docker run -d --rm --name rollback-scratch \
  -e POSTGRES_PASSWORD=scratch \
  -p 127.0.0.1:55433:5432 \
  postgres:17-alpine

# Wait until it accepts TCP connections
until docker exec rollback-scratch pg_isready -h 127.0.0.1 -U postgres; do sleep 1; done

pg_restore --no-owner --no-privileges \
  --table=orders \
  --dbname "postgresql://postgres:scratch@127.0.0.1:55433/postgres" \
  backup-2026-08-08.dump

Now you have yesterday’s ordersand production’s, side by side, and the fix becomes a data question instead of a restore question: diff them, decide exactly which rows move, and apply that change to production in a transaction you can inspect first. For a “these rows were deleted, put them back” case that can be as small as a \copy out of the scratch table and a \copy into production. Slower than a big red restore button, and it only endangers the rows you chose. One caveat: --table restores the table alone, not the types it depends on — if the restore errors on a missing enum or extension type, restore the whole archive into the scratch container instead. It is throwaway; a full restore there is never wrong, just slower. When you are done: docker rm -f rollback-scratch.

What if you have no backup at all?

The honest answer is the one nobody wants: without a backup, deleted rows are almost always gone. A committed DELETE in Postgres leaves dead tuples that VACUUM is free to reclaim at any moment, and there is no supported way to read them back through the SQL editor. Recovery attempts that involve raw heap pages are forensic work, not a procedure, and they get less likely to succeed with every write the database accepts afterwards.

So before anything else, if the rows matter: stop writing to that table. Take the application offline if you can. Then check, in this order:

  • Do you have PITR after all? This is the one clean exception — it recovers to the second before the delete. Check Project Settings → Add-ons before assuming you do not have it; on a team, somebody may have enabled it. What PITR covers.
  • Is the project on Pro with daily backups? Pick the newest retained backup taken beforethe delete, which is not always last night’s — if the rows went days ago, the recent snapshots already have them gone. Getting at them without overwriting today’s good writes depends on which backup process the project is on: with a downloadable logical backup, or a dump you took yourself, use the scratch-container method above — a downloaded dashboard backup replays with psql the same way. On physical backups — anything recent — nothing is downloadable, so use Restore to a New Project from the backups page and copy the rows out of the clone.
  • Does the data exist anywhere else? A local dump from development, a CSV export someone took, Stripe or your analytics warehouse holding the same records, application logs with the payloads. Reconstruction from a second system is unglamorous and frequently the only thing that works.
  • Was the project paused rather than damaged? Different problem, much better outcome — see the paused-project guide.

If none of those apply, the loss is real. The only useful thing left is making it the last time: a scheduled dump costs nothing on any plan, and it takes about fifteen minutes to set up — considerably less time than the incident you are currently in.

The restore that works is the one you rehearsed

Everything above assumes the backup restores. That assumption fails quietly: a dump taken with the wrong role, a version-skewed pg_dump, a job that half-failed months ago — all look like good backups in a bucket until the day they matter. Rehearsing the restore is cheap and mechanical: fifteen minutes by hand in Docker, or automated on a schedule. That is the product we run — here is a real drill report showing exactly what gets verified — but the manual drill needs nothing from us, and doing it once before an incident beats reading this page during one.

FAQ

How do I roll back my Supabase database?

Depends how precisely you need to land. PITR (the $100/month add-on) recovers to any second in its window — that is what it exists for. The Pro plan's daily backups restore in place from the dashboard, at last-night granularity, erasing everything written since. Your own dumps restore into a fresh project with pg_restore, and you switch the app over. Whichever path: take a fresh dump of the current state first — even a damaged database can usually still be dumped, and it is the state you are about to overwrite.

Can I restore a Supabase backup to a different project?

Two ways. On paid plans with physical backups, Supabase's restore-to-a-new-project feature clones a backup or PITR point into a fresh project — schema, data, roles, and Auth users included; Storage objects, Edge Functions, and auth configuration still move manually. And your own pg_dump archives restore into any fresh, empty project with pg_restore, on every plan. What no path offers is restoring into an arbitrary existing project with data already in it.

How do I restore a Supabase .backup file?

Check the format first, because it decides the tool. If the file starts with readable SQL, replay it with psql --dbname "$DB_URL" --file the-file.backup; if it starts with PGDMP it is a custom-format archive and needs pg_restore --no-owner --no-privileges instead. It may also arrive gzipped, so run file on it and gunzip if needed. Use a PostgreSQL 17 client — an older psql can fail against the Session pooler with a GSSAPI error that looks like bad credentials. Errors about auth and storage objects already existing are expected when restoring into a new project; errors naming your own tables are not.

Why does my restored project answer 401 on every API request?

Almost always: the restore ran with --clean. Dropping and recreating the public schema silently wipes the default grants Supabase gives anon and authenticated, so PostgREST can no longer see your tables and every API call returns 401. Restore into a fresh project without --clean instead. The expected 'schema "public" already exists' error is harmless; missing grants are not.

I accidentally deleted rows in Supabase — how do I recover them?

Stop writing to that table first, then check whether you have PITR: it is the only mechanism that recovers to the second before the delete, and it is worth confirming under Project Settings then Add-ons rather than assuming. Failing that, a Pro plan's daily backup gets you last night's copy — but on the physical backup process it cannot be downloaded, so the way to see those rows without overwriting production is Restore to a New Project, then copy the rows across. Restoring into a local throwaway container works with a dump you took yourself, and also with a dashboard backup you were able to download — projects still on the older logical backup process can, and so can a project past its resume window, from Project Overview. Without any of those, a committed DELETE is generally unrecoverable — Postgres is free to reclaim the dead tuples, and every subsequent write makes it less likely anything readable remains. The realistic fallback is reconstructing the rows from another system that holds the same data.

Does restoring a backup bring back deleted Storage files?

No. A database restore — dashboard, PITR, or pg_restore — only recovers storage.objects, which is metadata. The actual files live in a separate object store. If they were not backed up separately, the restored database will point at files that no longer exist: every SQL query looks healthy and every download URL 404s.

Sources

Facts and prices last verified August 23, 2026 against the sources above. Written by the team behind BackupDrill.

Want every backup restore-tested on a schedule, so the bad day holds no surprises? Start free — the free plan covers backups for one project; weekly restore drills start on Solo — or run the open-source CLI yourself.