How to export a Supabase database
Two commands cover almost every case: supabase db dump gives you the structure, and pg_dump --format=customgives you structure and rows in one restorable archive. The catch that costs people an afternoon: the Supabase CLI’s default export contains no data at all — schema definitions only.
Below: which export you actually want, the exact flags for each, how to move a whole project, and the part of your project that no export command touches.
First: which of these do you mean by “export”?
It covers four different jobs, and picking the wrong one is why an export “works” and then restores into an empty database.
| You want | Typical reason | Use |
|---|---|---|
| Schema only | Reproduce the structure in another environment; check migrations into git | supabase db dump (this is the default) or pg_dump --schema-only |
| Data only | Move rows between projects — note it carries auth and storage rows, so filter your own schemas before seeding anywhere less trusted | supabase db dump --data-only --use-copy, or pg_dump --data-only --schema=public |
| Your application schema, as one archive | A file pg_restore can replay selectively — see the caveats on schema-filtered dumps | pg_dump --format=custom — excludes Auth users, roles, and large objects |
| The whole project | Clone or migrate to a different Supabase project | Restore to a new project in the dashboard (paid plans), or dump and restore by hand |
True of every row: an export is a point-in-time copy, not a backup — it stops being current the moment the next write lands. If you are after protection rather than a file, skip to the last section.
Get the connection string first
Every command below needs one. Click Connect at the top of the dashboard and copy the Session pooler string — not the Direct one, which resolves over IPv6 and fails on most home networks and CI runners with a confusing network unreachable. What you copy contains a [YOUR-PASSWORD] placeholder, not the password — substitute your database password (reset it under Database settings if you never saved it), percent-encoding any special characters:
postgresql://postgres.PROJECT_REF:PASSWORD@aws-0-REGION.pooler.supabase.com:5432/postgresIf the password contains special characters, percent-encode them or the URL will parse wrong — @ becomes %40, # becomes %23.
Read it in rather than typing it, which would leave your database password in ~/.zsh_history in plain text. Every block below starts this way:
# printf + read works in both bash and zsh —
# zsh's read -p means "read from a coprocess", not "prompt"
printf 'Session pooler connection string: '
read -rs DB_URL
echo
export DB_URL
# when you are done
unset DB_URLOn a shared machine the URL is still visible in the process list while a command runs, since it is an argument. Where that matters, put the password in a mode-0600 ~/.pgpass and use a password-free connection string.
Export with the Supabase CLI
supabase db dump runs pg_dump in a container for you — so it needs Docker installed and running; the pg_dump path in the next section does not.
The detail that matters, and that the flag names hide: the CLI uses different exclusion lists for structure and for data. The schema dump leaves out the Supabase-managed schemas, so you get your schema and not the platform’s. The --data-only dump deliberately keeps auth and storage rows, precisely so a project can be migrated. Two consequences follow.
The good one: your users travel. The new project has an auth schema of its own, so those rows land in it — accounts and password hashes included — which makes this a real migration rather than a schema transplant. The one to plan for follows from it: data.sql contains password hashes, and Storage metadata besides. Treat it as a credential file — not in a repo, not in Slack, not in a syncing Downloads folder. It also carries cron.jobrows, so a restored project starts running the source’s schedules: disable them on the target before it goes anywhere near real traffic. Still absent: the Storage files themselves, and Vault secrets, whose schema is excluded from both dumps.
The official docs’ own recommendation is three separate runs, because roles, schema, and data each need different flags:
# Read the connection string in first — see above for why.
# umask 077 so the files land readable only by you: data.sql will
# contain your users' password hashes.
umask 077
printf 'Session pooler connection string: '; read -rs DB_URL; echo
# 1 — custom roles. Usually near-empty; note that passwords for any
# custom LOGIN roles are not included and must be reset on the target.
supabase db dump --db-url "$DB_URL" -f roles.sql --role-only
# 2 — schema: tables, views, functions, policies. No rows.
supabase db dump --db-url "$DB_URL" -f schema.sql
# 3 — the rows
supabase db dump --db-url "$DB_URL" -f data.sql --use-copy --data-only \
-x "storage.buckets_vectors" -x "storage.vector_indexes"These are three independent snapshots, not one atomic export. If anything changes the schema between the first command and the last, schema.sql and data.sql will not agree. For a migration, freeze deploys while they run; where schema and data must be consistent by construction, take one pg_dump archive instead — the next section.
Read command 2 again: the default dump contains no data and no custom roles. The CLI reference says so plainly, and it is the single most common way a Supabase export goes wrong — the file is thousands of lines long, looks complete, and has zero rows in it. If you only run one command, it should not be that one. The .sql export guide walks through spotting the empty-file case before it costs you.
--use-copy writes rows as COPY blocks rather than individual INSERT statements — much faster on restore for anything past a few thousand rows. The two -x exclusions skip internal Storage vector tables that fail to restore cleanly. One warning on -s: it is a list of schemas to include, not an addition to the default. The CLI already dumps your non-managed schemas, so -s billing silently drops public. If you use it at all, name every application schema: -s public,billing.
Export with pg_dump directly
Skip the CLI when you want a single archive file, selective restores, or a format that is not SQL text. pg_dump against the same connection string does all three:
umask 077 # these files are as sensitive as the database
printf 'Session pooler connection string: '; read -rs DB_URL; echo
# structure + rows for the public schema, in one archive.
# Timestamped, so a re-run cannot overwrite a good export.
pg_dump "$DB_URL" \
--format=custom \
--schema=public \
--file="supabase-$(date +%FT%H%M%S).dump"
# structure only
pg_dump "$DB_URL" --schema-only --schema=public -f schema.sql
# rows only
pg_dump "$DB_URL" --data-only --schema=public -f data.sql“Everything” is the wrong word for that archive, and PostgreSQL says so itself: a schema-filtered dump is not guaranteed to restore into a clean database — cross-schema dependencies are not chased, and large objects are left out unless you ask with --large-objects. --schema=public captures your application schema and nothing else: auth — where your users live — and storage metadata stay out, and dumping them as a non-superuser is restricted anyway. Custom schemas need naming too: --schema=public --schema=billing, one flag each. And pg_dumpnever exports cluster roles, which is what the CLI’s --role-onlyrun above is for. So restore this file into an empty project and your user accounts will not be in it — the dashboard’s restore-to-a-new-project path is the one that carries them.
--format=custom produces a compressed archive that pg_restore can read selectively — one table out of fifty, without touching the rest. Plain SQL cannot do that; it replays top to bottom.
Note what the command does not pass, because the two flags usually added for portability behave differently and only one of them is safe to defer. --no-owner is ignored when pg_dump emits an archive — ownership is recorded either way, and pg_restore --no-owner is where you skip it.--no-privileges is not ignored: pass it here and the GRANT and REVOKE entries are never written, and no restore flag can bring back what the file does not contain.
So dump with privileges and decide at restore time, when you know the target. Restoring into a throwaway container to inspect or drill? Discard both — pg_restore --no-owner --no-privileges is what the restore guide uses, and nothing there needs the source’s access rules. Migrating into a project that will serve real traffic? Keep the privileges and drop only --no-owner.
Discarding them is not neutral: a table the source restricted with explicit grants comes back without the restriction, and the target contributes its own defaults besides. Access is the part of a restore worth checking rather than assuming — a table readable when it should not be raises no error and appears in no log. Verify it against Supabase’s own guidance on what should be reachable before anything points at the new project.
Exporting to clone or migrate a project
This is the one case where the dashboard may beat any command. Paid-plan projects with physical backups have a Restore to a new project tab on the backups page: pick a backup — or a PITR timestamp, with that add-on — and Supabase builds a duplicate from it, carrying schema, data, indexes, roles, and Auth accounts with their password hashes. What it leaves behind is Storage objects and bucket settings, Edge Functions, Auth configuration and API keys, Realtime config, and read replicas. Extensions do come across, which is a hazard for any that reach outside the database — Supabase says to disable pg_net, pg_cronand wrappers on the copy, or your safety clone starts firing production’s jobs.
On the Free plan, or to put the copy outside Supabase entirely, you export and restore by hand. Which export you took decides whether your users come along: the CLI’s --data-only dump carries auth rows, so accounts and hashes travel; pg_dump --schema=public never saw that schema and does not. Migrating an app without its logins is not a migration, so pick deliberately.
The procedure itself — restore order, the transaction flags, the two statements that abort it — is documented by Supabase and maintained against their platform, so follow it there rather than a copy here. What is worth knowing before you start are the three things that turn a long afternoon into permanent loss.
The encryption root key is a manual-path problem. The dashboard clone copies it for you; only a hand-built project gets a key of its own, and that key cannot read ciphertext from the old one. So if you use encrypted columns and are migrating by hand, copy the key across — the Management API returns that key only while the source is active, so do it while the source is up. Pausing is recoverable — resume the project and the key is retrievable again — but deleting is not, and neither is letting a paused project pass its restore window. Note the limit too: the key decrypts ciphertext in your own tables, which travels in the dump. It does not migrate Vault secrets, whose schema is excluded from both export paths; those you read out and re-create deliberately.
Access rules do not survive intact. Dumping with --no-privileges discards every grant, and the target applies its own defaults as objects are created — which on Supabase changed for new projects in May 2026 and reaches existing ones in October. So restored tables can land more open than the source, or closed enough that the API errors. Keep the ACLs in the dump, then run the audit query from the previous section against the target before anything points at it. Hardening beyond that is a subject of its own, in Securing your API.
Some things are never in a dump. Extensions must be enabled on the target first, or a schema using an extension type fails partway through. Customisations inside auth or storage — a trigger, an RLS policy — need supabase db diff --linked --schema auth,storage, since those schema definitions are excluded. The supabase_migrations history is excluded too, so the CLI will think nothing was applied and replay everything on the next db push. And the new project has its own URL, keys and JWT secret: update your environment, re-enter the Auth configuration, and expect existing sessions to be signed out.
What no export command covers: Storage files
Files uploaded through the Storage API live in a separate object store. The database holds only storage.objects metadata about them — names, sizes, MIME types, owners — and no export command on this page reaches the bytes, because they are not in the database to reach.
Whether you get the metadata depends on the path: the CLI’s --data-only dump includes storage rows, while pg_dump --schema=public does not. Metadata without files is the worse of the two outcomes, not the better one: restore it and every row points confidently at an object that is not there.
What that costs you shows up in your own tables. Restore one of these exports into a fresh project and every column holding a file path or a Storage URL comes back intact, pointing at objects that do not exist in the new project’s bucket. Every avatar and PDF renders as a broken link, and the database reports no error, because from its point of view nothing is missing.
Storage files need their own copy, made through the Storage API or the S3-compatible endpoint. The Storage backup guide covers both routes. This is the most expensive assumption in the whole Supabase backup story, and it is worth ten seconds of checking whether it applies to you: if your app has an upload button, it does.
One-off export vs. backups that keep running
Everything above produces a file dated today — right for a migration, a local copy to develop against, or handing data to someone; poor for protection, since it ages from the moment it finishes and the failure it covers arrives on a day nobody planned to run a command.
The free way to fix that is a schedule: the GitHub Actions guide has a complete weekly workflow that runs these same commands and writes the output to a bucket you own. BackupDrill does it as a service — backups into your own S3, R2, or B2 bucket, never our disks, plus the step a schedule alone does not give you: on paid plans the latest snapshot is restored into a throwaway Postgres every week and verified against a checksummed manifest, so you learn the export is replayable before the day you need it to be. The free plan backs up one project weekly and drills the first backup once. A file you have never restored is a guess about the future.
Sources
- Supabase CLI reference — db dump
- Supabase docs — Backup and restore using the CLI
- Supabase docs — Restore to a new project
- Supabase docs — Database Backups
- Supabase docs — Connecting to your database
- PostgreSQL docs — pg_dump
- PostgreSQL docs — session_replication_role
Facts and prices last verified August 23, 2026 against the sources above. Written by the team behind BackupDrill.
Exported once and want it to keep happening? Start free — one project, weekly, into your own bucket — or run the same thing yourself with the open-source CLI.