VPS Snaps

How to restore a PostgreSQL dump with pg_restore or psql

A dump whose first five bytes are PGDMP is a pg_dump archive: restore it with pg_restore -d mydb mydb.dump. Anything else is usually plain SQL, which only psql can load: psql -X -v ON_ERROR_STOP=1 --single-transaction -d mydb -f mydb.sql. Either way, create the roles and an empty database first, and afterwards refresh the statistics and compare row counts with the source.

10 min readUpdated Tested on Ubuntu 24.04 LTS, PostgreSQL 16 (pg_dump 18 for the version error)

Find out which format you have

Choosing a format is covered in the pg_dump guide. To tell them apart, run file in the directory holding the dump:

Terminal
file shop.sql shop.sql.gz shop.dump shop.tar shop_dir
Output
shop.sql: ASCII text
shop.sql.gz: gzip compressed data, from Unix, original size modulo 2^32 2161974
shop.dump: PostgreSQL custom database dump - v1.15-0
shop.tar: POSIX tar archive
shop_dir: directory
FormatHow to recognize itRestore with
Plain SQL (-Fp)Text starting -- PostgreSQL database dump, often gzipped as .sql.gzpsql
Custom (-Fc)First five bytes are PGDMPpg_restore
Directory (-Fd)A toc.dat (also starting PGDMP) plus one .dat.gz file per tablepg_restore
Tar (-Ft)A tar file holding toc.dat, restore.sql and the .dat filespg_restore

v1.15-0 is the archive format, not the PostgreSQL version: pg_dump 16 wrote 1.15, pg_dump 17 and 18 wrote 1.16. The exact versions are in a plain dump's header:

Terminal
gunzip -c shop.sql.gz | head -n 20 | grep Dumped
Output
-- Dumped from database version 16.15 (Ubuntu 16.15-0ubuntu0.24.04.1)
-- Dumped by pg_dump version 16.15 (Ubuntu 16.15-0ubuntu0.24.04.1)

pg_restore -l shop.dump | head -12 shows the same for an archive. A plain dump made with pg_dump -C has its own CREATE DATABASE and \connect lines, so psql fills that database whatever -d says; point -d at postgres.

Prepare the target

  • Restore the roles first: a dump names owners and grantees but does not create them. See backing up roles.
  • Create an empty database from template0. Dumps are relative to it, the docs explain, so anything added to template1 would clash.
  • Make the right role its owner (-O). Since PostgreSQL 15, other ordinary users cannot create objects in a new database's public schema.
  • Install every extension the dump uses. It runs CREATE EXTENSION IF NOT EXISTS citext WITH SCHEMA public; without a version, so the server creates its default version from files that must already be installed.
Terminal
sudo -u postgres createdb -T template0 -O app_owner shop

Restore a plain SQL dump with psql

Terminal
sudo -u postgres psql -X -v ON_ERROR_STOP=1 --single-transaction -d shop -f /var/backups/postgresql/shop.sql
  • -X skips your ~/.psqlrc, as the pg_dump docs recommend, so personal settings cannot change the restore.
  • -v ON_ERROR_STOP=1 stops at the first error and exits with status 3. Without it psql prints the error and carries on.
  • --single-transaction (-1) runs the file as one transaction: an error rolls everything back instead of leaving half a database, even when that discards hours of work.
  • -d is the database to load into; -f is the file.

Output such as CREATE TABLE, COPY 40000 and small setval results is normal. For a gzipped dump, psql reads the pipe as if given -f -, so --single-transaction still applies:

Terminal
gzip -t shop.sql.gz && gunzip -c shop.sql.gz | sudo -u postgres psql -X -v ON_ERROR_STOP=1 --single-transaction -d shop

Keep the gzip -t. We cut 700 bytes off a .sql.gz and piped it in without it: gunzip printed unexpected end of file, yet psql committed what it had received and exited 0, leaving one table 9 rows short and no primary keys or indexes. set -o pipefail reports the failure, but only after the commit.

A plain dump fixes ownership when it is made: with the roles missing, psql stops at the first ALTER ... OWNER TO with role "app_owner" does not exist. Create the roles, or dump again with pg_dump --no-owner --no-privileges.

Restore a custom, directory or tar archive with pg_restore

Terminal
sudo -u postgres pg_restore -d shop -j 4 --exit-on-error /var/backups/postgresql/shop.dump
  • -d is the database to restore into.
  • -j 4 loads data and builds indexes and constraints in four sessions, each its own connection. Custom and directory archives only, read from disk rather than a pipe, and not with --single-transaction. The docs suggest starting at the server's CPU core count.
  • --exit-on-error (-e) stops at the first error. By default pg_restore carries on, ends with errors ignored on restore: N and exits 1.
  • --single-transaction (-1) is the all-or-nothing alternative to -j.

For a directory archive, pass the directory. --create (-C) also creates the database, with its archived name, owner, settings and grants; -d then only names the database to connect to first:

Terminal
sudo -u postgres pg_restore -C --exit-on-error -d postgres /var/backups/postgresql/shop.dump

Always add --exit-on-error to --create. When shop already existed, pg_restore printed database "shop" already exists, then connected to the existing database and kept restoring into it. Tables with a primary key rejected the duplicate rows; reporting.daily_totals, which has none, went from 29 rows to 58.

To overwrite a database in place, use --clean --if-exists instead (pg_dump guide). --clean --create drops the whole database and fails with database "shop" is being accessed by other users while anything is connected; without -e it then restored into the old one, as above.

pg_restore -f - shop.dump | less shows the SQL without running it. A restore executes whatever the source's superusers put in the dump, the docs warn, so read untrusted dumps first.

Restore without superuser rights

On a managed database, or where logging in as postgres is not allowed, the restoring user has no rights in a new database. Our restorer, restoring into one owned by postgres, got permission denied for database shop, permission denied to create extension "citext" and this:

Output
pg_restore: error: could not execute query: ERROR:  permission denied for schema public
LINE 1: CREATE TABLE public.customers (
                     ^

Make the restoring user the database owner: it then owns public (via pg_database_owner) and may create schemas and trusted extensions. The restore then ran clean:

Terminal
sudo -u postgres createdb -T template0 -O restorer shop
Terminal
pg_restore -h localhost -U restorer --no-owner --no-privileges -d shop shop.dump
  • --no-owner (-O) skips ALTER ... OWNER TO, so restorer owns everything.
  • --no-privileges (-x) skips GRANT and REVOKE; grant access yourself afterwards.
  • --role=app_owner keeps the original owners: pg_restore logs in as one user, then runs SET ROLE app_owner. Our bob (a member of app_owner with INHERIT FALSE), restoring into a database owned by app_owner, hit the same permission errors without it and restored cleanly with it.
Terminal
pg_restore -h localhost -U bob --role=app_owner -d shop shop.dump

Untrusted extensions such as postgres_fdw fail with permission denied to create extension "postgres_fdw" (HINT: Must be superuser to create this extension.). Have a superuser or your provider create it in the empty database first; the restore then only trips on COMMENT ON EXTENSION, which --no-comments skips. Provider limits are in managed database backups.

Restore part of an archive

-l prints the archive's table of contents, one line per object:

Terminal
pg_restore -l shop.dump | grep 'public orders'
Output
220; 1259 16504 TABLE public orders app_owner
219; 1259 16503 SEQUENCE public orders_id_seq app_owner
3511; 0 16504 TABLE DATA public orders app_owner
3527; 0 0 SEQUENCE SET public orders_id_seq app_owner
3362; 2606 16509 CONSTRAINT public orders orders_pkey app_owner
3360; 1259 16515 INDEX public orders_customer_idx app_owner
3363; 2606 16510 FK CONSTRAINT public orders orders_customer_id_fkey app_owner

Save the whole list, comment out lines with a leading ;, and restore with -L, which runs the remaining items in the file's order. This leaves out the rows of orders, a common way to skip a huge log table:

Terminal
pg_restore -l shop.dump > shop.list
Terminal
sed -i '/TABLE DATA public orders /s/^/;/' shop.list
Terminal
sudo -u postgres pg_restore -L shop.list -d shop_nodata shop.dump

In the empty shop_nodata, orders came back with no rows but with its primary key and index. The shortcuts leave more out:

  • -n reporting restores the objects in that schema but not the schema itself. pg_restore 16 failed with schema "reporting" does not exist until we ran CREATE SCHEMA reporting first, and the SQL that pg_restore 17 and 18 generate has no CREATE SCHEMA either.
  • -t customers restores one table and its rows, but not its indexes or constraints, nor anything it depends on: ours failed with type "public.citext" does not exist. -t takes no wildcards or schema names; add -n for the schema.

Version rules

SituationWhat happened
pg_restore older than the pg_dump that wrote the archiveRefused: pg_restore 16 printed unsupported version (1.16) in file header for a pg_dump 18 archive.
pg_restore 17 or 18 into a PostgreSQL 16 serverBoth send SET transaction_timeout = 0 first, which 16 rejects: one ignored error, or with -e or -1, a restore that stops before creating anything.
A plain dump from a newer pg_dump into an older serverpsql fails on that line: unrecognized configuration parameter "transaction_timeout". Older targets are unsupported; dump with the target's pg_dump.
Into a newer major versionSupported; it is how major upgrades by dump work.

On Debian and Ubuntu, /usr/bin/pg_restore runs the local cluster's version but /usr/bin/psql the newest installed one: on our server, 16 and 18. Run another version by full path, such as /usr/lib/postgresql/18/bin/pg_restore.

Refresh statistics and count the rows

A dump carries no planner statistics by default, so the first queries may pick poor plans. --analyze-in-stages produces rough statistics first, then full ones:

Terminal
sudo -u postgres vacuumdb --analyze-in-stages -d shop
Output
vacuumdb: processing database "shop": Generating minimal optimizer statistics (1 target)
vacuumdb: processing database "shop": Generating medium optimizer statistics (10 targets)
vacuumdb: processing database "shop": Generating default (full) optimizer statistics

Then check that every row arrived. This query counts the rows in each table:

rowcounts.sql
SELECT n.nspname || '.' || c.relname AS table_name,
       (xpath('/row/n/text()',
              query_to_xml(format('SELECT count(*) AS n FROM %I.%I', n.nspname, c.relname), false, true, '')))[1]::text::bigint AS row_count
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'p')
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
  AND n.nspname NOT LIKE 'pg_toast%'
ORDER BY 1;

Run it on the source when you take the dump and keep the result next to the file. After the restore, run it on the restored database and compare:

Terminal (source, at dump time)
sudo -u postgres psql -X -At -F ' ' -f rowcounts.sql -d shop > shop-rowcounts.txt
Terminal (restored server)
sudo -u postgres psql -X -At -F ' ' -f rowcounts.sql -d shop | diff shop-rowcounts.txt - && echo "row counts match"

On the truncated restore above, where psql had exited 0, the diff caught the gap:

Output
3c3
< reporting.daily_totals 29
---
> reporting.daily_totals 20

Counts prove the rows arrived, not that the application works; run its checks too, and make it a routine with scheduled test restores.

Common errors

ErrorCause and fix
input file appears to be a text format dump. Please use psql.A plain SQL dump. Restore it with psql as above.
input file does not appear to be a valid archiveNot an archive, for example a .sql.gz. Check with file.
unsupported version (1.16) in file headerpg_restore is older than the pg_dump that wrote the file. Run a newer one by full path.
role "app_owner" does not existRestore the roles first, or use --no-owner --no-privileges (pg_restore only).
database "shop" already exists--create on an existing database. Drop it or restore without -C; add -e so pg_restore stops here.
relation "customers" already existsThe target is not empty. Use a new database, or pg_restore's --clean --if-exists.
permission denied for schema publicPostgreSQL 15+: make the restoring user the database owner (createdb -O).
extension "postgis" is not availableInstall the extension's package for that PostgreSQL version on the new server.
CREATE DATABASE cannot run inside a transaction blockA pg_dump -C plain dump run with --single-transaction. Leave out -1.
one of -d/--dbname and -f/--file must be specifiedAdd -d to restore into a database; -f writes SQL instead.
parallel restore from standard input is not supported-j needs a file or directory path, not < or a pipe.
database "shop" is being accessed by other users--clean --create with sessions open. Stop the application first.

Frequently asked questions

How do I restore a .sql file in PostgreSQL?
With psql, into an existing empty database: psql -X -v ON_ERROR_STOP=1 --single-transaction -d mydb -f file.sql. pg_restore cannot read plain SQL.
How do I restore a .dump file?
If it starts with PGDMP it is a custom-format archive: pg_restore -d mydb file.dump into an empty database, or pg_restore -C -e -d postgres file.dump to create it too.
Can I restore a dump into a database with a different name?
Yes. Without -C, pg_restore and psql load into whatever database -d names. Only pg_restore -C, or a plain dump made with pg_dump -C, uses the original name.
Why does pg_restore exit with status 1 when the data looks fine?
It ignored errors and printed how many. Ownership and comment errors are often harmless, but read each one and compare row counts before trusting the result.

How this was checked

The commands were run on Ubuntu 24.04 LTS, PostgreSQL 16 (pg_dump 18 for the version error) on October 4, 2026. Any that need something this test server does not have, such as a second server, a cloud account or another database engine, were checked against the official pages below instead.

Sources, on October 4, 2026: