PostgreSQL in Depth Module 1 – How PostgreSQL works and how to run it
psql for daily work: meta-commands and scripts
Use psql 18 every day: describe a database with \d commands, write scripts with variables, \if, \gexec and ON_ERROR_STOP, copy files, and pipeline queries.
What you will learn
- Navigate a database with the \d family of meta-commands and expanded output
- Write repeatable psql scripts with variables, \if, ON_ERROR_STOP and \gexec
- Move data with \copy and explain how it differs from server-side COPY
- Test named prepared statements and pipelines with psql 18's \bind_named and pipeline commands
Before you start
What you’ll learn · Before you start
What you will learn
- Navigate a database with the \d family of meta-commands and expanded output
- Write repeatable psql scripts with variables, \if, ON_ERROR_STOP and \gexec
- Move data with \copy and explain how it differs from server-side COPY
- Test named prepared statements and pipelines with psql 18's \bind_named and pipeline commands
Before you start
On this page
A maintenance script of 40 statements fails at the seventh, and psql runs the other 33 anyway: by default it reports
an error and carries on. psql, the client that ships with PostgreSQL, answers questions at a prompt with its
backslash meta-commands, and the same tool runs files of SQL as scripts, where variables, \if and ON_ERROR_STOP
decide whether a script stops safely or leaves half its work done.
You will find your way around a database with the \d commands, write scripts that use variables, make decisions
with \if and stop at the first error, turn catalog queries into statements with \gexec, move files with \copy,
and send prepared statements and pipelines with the commands new in psql 18.
The \d commands describe a database from its catalogs
Every \d command is a meta-command: A psql command that starts with a backslash, such as \d, \copy or \gexec. psql handles it itself instead of sending it to the server, often by running catalog queries for you., a line starting with a backslash that psql
handles itself, and each one is a query on the system catalogs that psql writes for you. \dt+ lists tables, \di+
indexes, \d name one object in full, and the + adds sizes and descriptions. The run creates the shop database of
this lesson (three customers, 3,000 orders, an audit log) and describes it with psql’s -a option, which echoes
every line of the script before its output:
describe_session.py
"""Finding your way around a database with psql's describe commands and expanded output."""
from pg_session import Server
with Server(settings={"autovacuum": "off"}) as pg:
pg.up()
pg.shell("createdb", "shop")
pg.psql("setup.sql", db="shop", flags=("-q",))
pg.psql("describe.sql", db="shop") Output
$ createdb shop
$ psql -d shop -X -q -f setup.sql
$ psql -d shop -X -a -f describe.sql
\dt+
List of tables
Schema | Name | Type | Owner | Persistence | Access method | Size | Description
--------+-----------+-------+----------+-------------+---------------+------------+-------------
public | audit_log | table | postgres | permanent | heap | 8192 bytes |
public | customers | table | postgres | permanent | heap | 16 kB |
public | orders | table | postgres | permanent | heap | 184 kB |
(3 rows)
\di+
List of indexes
Schema | Name | Type | Owner | Table | Persistence | Access method | Size | Description
--------+---------------------+-------+----------+-----------+-------------+---------------+------------+-------------
public | audit_log_pkey | index | postgres | audit_log | permanent | btree | 8192 bytes |
public | customers_pkey | index | postgres | customers | permanent | btree | 16 kB |
public | orders_customer_idx | index | postgres | orders | permanent | btree | 40 kB |
public | orders_pkey | index | postgres | orders | permanent | btree | 88 kB |
(4 rows)
\d orders
Table "public.orders"
Column | Type | Collation | Nullable | Default
-------------+---------------+-----------+----------+------------------------------
id | bigint | | not null | generated always as identity
customer_id | integer | | not null |
total | numeric(10,2) | | not null |
placed | date | | not null |
Indexes:
"orders_pkey" PRIMARY KEY, btree (id)
"orders_customer_idx" btree (customer_id)
Foreign-key constraints:
"orders_customer_id_fkey" FOREIGN KEY (customer_id) REFERENCES customers(id)
-- \x auto turns a result wider than the screen into one block per row. psql writing to a file has no
-- screen width, so \pset columns sets one: 80 characters.
\pset columns 80
Target width is 80.
\x auto
Expanded display is used automatically.
SELECT name, setting, unit, context, short_desc FROM pg_settings WHERE name IN ('work_mem', 'lock_timeout');
-[ RECORD 1 ]---------------------------------------------------------
name | lock_timeout
setting | 0
unit | ms
context | user
short_desc | Sets the maximum allowed duration of any wait for a lock.
-[ RECORD 2 ]---------------------------------------------------------
name | work_mem
setting | 4096
unit | kB
context | user
short_desc | Sets the maximum memory to be used for query workspaces.
SELECT name, setting, unit FROM pg_settings WHERE name IN ('work_mem', 'lock_timeout');
name | setting | unit
--------------+---------+------
lock_timeout | 0 | ms
work_mem | 4096 | kB
(2 rows)
\x off
Expanded display is off.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 describe_session.py
setup.sql
-- The shop database of this lesson: three customers, 3,000 orders and an empty audit log.
CREATE TABLE customers (id int PRIMARY KEY, name text NOT NULL, city text NOT NULL);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id int NOT NULL REFERENCES customers,
total numeric(10,2) NOT NULL,
placed date NOT NULL
);
CREATE INDEX orders_customer_idx ON orders (customer_id);
CREATE TABLE audit_log (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, note text NOT NULL);
INSERT INTO customers VALUES (1, 'Asha Rao', 'Pune'), (2, 'Imran Khan', 'Lucknow'), (3, 'Meera Iyer', 'Chennai');
INSERT INTO orders (customer_id, total, placed)
SELECT 1 + n % 3, 100 + n % 900, date '2026-01-01' + n % 200 FROM generate_series(1, 3000) AS n;
ANALYZE orders;
CREATE ROLE clerk LOGIN;
GRANT SELECT, INSERT ON customers, orders, audit_log TO clerk; describe.sql
\dt+
\di+
\d orders
-- \x auto turns a result wider than the screen into one block per row. psql writing to a file has no
-- screen width, so \pset columns sets one: 80 characters.
\pset columns 80
\x auto
SELECT name, setting, unit, context, short_desc FROM pg_settings WHERE name IN ('work_mem', 'lock_timeout');
SELECT name, setting, unit FROM pg_settings WHERE name IN ('work_mem', 'lock_timeout');
\x off pg_session.py
"""Throwaway PostgreSQL 18.6 servers for the examples of this lesson.
Every session creates a cluster of its own with initdb in a new folder inside the examples folder, starts it, runs
psql scripts and server programs on it, prints what they printed, and stops and deletes it again, also when something
fails. The server is the pinned PostgreSQL 18.6 from the mise installations next to the Python that runs this file.
It listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system reports as
unused, with no Unix socket, and it asks for a password (scram-sha-256) made up for the run and never printed.
learn:verify's run sandbox cannot read the home folder, where mise keeps PostgreSQL, so these sessions are recorded
with LEARN_VERIFY_NETWORK=allow; they read only this folder and the toolchain and connect to nothing but 127.0.0.1.
The output is exactly what the programs printed, with four substitutions so that a record never shows the computer
it ran on and is the same on every run: the run's folder is shown as ".", the folder of PostgreSQL's programs is left
out of their paths, the operating-system user's name is shown as "learner", and the dots pg_ctl prints while it waits
(one about every tenth of a second) as three. The sessions never print the port. A session stops with status 1 when
a command fails that should have worked, or reports a different number of errors than the session expects, so a run
that went wrong is never recorded as if it had worked.
"""
import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path
PG_VERSION = "18.6"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
BIN = INSTALLS / "postgres" / PG_VERSION / "bin"
OS_USER = pwd.getpwuid(os.geteuid()).pw_name
def free_port():
"""A TCP port on 127.0.0.1 that nothing listens on now."""
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
class Server:
"""One throwaway cluster: initdb, settings, start; stopped and deleted when the with block ends."""
def __init__(self, settings=None, initdb=(), name="data"):
self.settings = dict(settings or {})
self.initdb_args = list(initdb)
self.name = name
self.running = []
def __enter__(self):
if not (BIN / "postgres").exists():
sys.exit(f"PostgreSQL {PG_VERSION} was not found next to this Python")
self.base = Path(tempfile.mkdtemp(prefix="pg", dir=".")).resolve()
self.port = free_port()
self.password = secrets.token_urlsafe(24)
(self.base / "pwfile").write_text(self.password + "\n")
self.env = {**os.environ, "PGHOST": "127.0.0.1", "PGPORT": str(self.port), "PGUSER": "postgres",
"PGPASSWORD": self.password, "PGDATABASE": "postgres", "PGCONNECT_TIMEOUT": "10"}
for key in ("PGSERVICE", "PGSERVICEFILE", "PGOPTIONS", "PSQLRC"):
self.env.pop(key, None)
for script in Path(".").glob("*.sql"):
shutil.copy(script, self.base / script.name)
for data in Path(".").glob("*.csv"):
shutil.copy(data, self.base / data.name)
return self
def __exit__(self, *exc):
for folder, _port in reversed(self.running):
self._call(["pg_ctl", "-D", folder, "-m", "fast", "-w", "-t", "60", "stop"])
self.running = []
shutil.rmtree(self.base, ignore_errors=True)
# ---- the cluster ---------------------------------------------------------------------------------------------
def initdb(self, show=False):
args = ["initdb", "-D", self.name, "-U", "postgres", "--auth=scram-sha-256", "--pwfile=pwfile",
*self.initdb_args]
if not show:
args.append("--no-instructions")
self._expect(args, show=show)
with open(self.base / self.name / "postgresql.conf", "a") as conf:
conf.write("\n# Added by the session: 127.0.0.1 only, no Unix socket.\n")
conf.write(f"listen_addresses = '127.0.0.1'\nport = {self.port}\nunix_socket_directories = ''\n")
for key, value in self.settings.items():
conf.write(f"{key} = {value}\n")
def start(self, show=False, folder=None, port=None):
folder = folder or self.name
self._expect(["pg_ctl", "-D", folder, "-l", f"{folder}.log", "-w", "-t", "60", "start"], show=show)
self.running.append((folder, port or self.port))
def up(self):
"""initdb and start, quietly."""
self.initdb()
self.start()
return self
def standby(self, folder="standby"):
"""A streaming standby of this server, made with pg_basebackup -R, started on a port of its own."""
self._expect(["pg_basebackup", "-D", folder, "-R", "-X", "stream", "-c", "fast"])
port = free_port()
with open(self.base / folder / "postgresql.conf", "a") as conf:
conf.write(f"port = {port}\n")
self.start(folder=folder, port=port)
for _ in range(300):
if self.query("SELECT count(*) FROM pg_stat_replication WHERE state = 'streaming'") == "1":
return port
time.sleep(0.1)
sys.exit("the standby did not start streaming")
# ---- running things ------------------------------------------------------------------------------------------
def _call(self, args, env=None):
args = [str(a) for a in args]
done = subprocess.run([str(BIN / args[0]), *args[1:]], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, cwd=self.base, env=env or self.env)
return done.returncode, self.clean(done.stdout)
def _expect(self, args, show=False, status=0):
code, out = self._call(args)
if show:
_show(args, out)
if code != status:
if not show:
print(out)
sys.exit(f"{args[0]} ended with status {code}, not {status}")
return out
def clean(self, text):
"""The run's folder as ".", the user as learner, pg_ctl's waiting dots as three (see the docstring)."""
text = text.replace(str(BIN) + "/", "").replace(str(self.base) + "/", "").replace(str(self.base), ".")
text = re.sub(r"(waiting for server to (?:start|shut down))\.+", r"\1...", text)
return text.replace(f'"{OS_USER}"', '"learner"')
def wait_for(self, sql, value):
"""Waits (unprinted) until a query returns the value, for example after a reload."""
for _ in range(300):
if self.query(sql) == value:
return
time.sleep(0.1)
sys.exit(f"{sql} never returned {value}")
def show_file(self, path):
"""Prints a text file of the cluster, as `cat` would."""
print(f"$ cat {path}")
print((self.base / path).read_text().rstrip("\n"))
print()
def query(self, sql, port=None):
"""One value from one query, unprinted (the session uses it to wait or to decide)."""
env = {**self.env, "PGPORT": str(port or self.port)}
code, out = self._call(["psql", "-X", "-At", "-c", sql], env=env)
if code != 0:
sys.exit(f"the query failed: {out}")
return out.strip()
def silent(self, sql):
"""Runs a statement without printing it (used only to give a role the run's password)."""
self.query(sql)
def psql(self, script, errors=0, status=None, user=None, db=None, port=None, flags=("-a",), note=None):
"""Runs a psql script with -X (no ~/.psqlrc) and the given flags, and prints the command and its output.
`errors` is how many ERROR lines the script must print; `status` the exit status it must end with."""
env = {**self.env, "PGPORT": str(port or self.port)}
if user:
env["PGUSER"] = user
if db:
env["PGDATABASE"] = db
args = ["psql", "-X", *flags, "-f", script]
code, out = self._call(args, env=env)
shown = ["psql", "-X", *flags, "-f", script]
if user:
shown[1:1] = ["-U", user]
if db:
shown[1:1] = ["-d", db]
if note:
print(f"-- {note}")
_show(shown, out)
found = sum(1 for line in out.splitlines() if "ERROR:" in line or ": error:" in line)
expected_status = 0 if status is None else status
if found != errors or code != expected_status:
sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")
def sessions(self, count):
"""Opens `count` idle psql connections (each one a client backend) until the with block ends."""
return _Idle(self, count)
def shell(self, *args, status=0):
"""Runs one of PostgreSQL's programs and prints the command and its output."""
return self._expect(list(args), show=True, status=status)
def listing(self, folder, prefix=""):
"""Prints the names in a folder of the cluster (sorted, folders with a slash), as `ls -p` would list them."""
path = self.base / folder
names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
print(f"-- in {folder}" + (f", the names that start with {prefix}" if prefix else "") + ":")
print(textwrap.fill(" ".join(names), width=78, initial_indent=" ", subsequent_indent=" ",
break_on_hyphens=False))
print()
return names
def _arg(a):
"""A command-line argument as a reader would type it: double quotes around text with single quotes in it."""
a = str(a)
if re.fullmatch(r"[\w@%+=:,./-]+", a):
return a
if not re.search(r"[\"$`\\!]", a):
return f'"{a}"'
return shlex.quote(a)
def _show(args, out):
print("$ " + " ".join(_arg(a) for a in args))
if out.strip():
print(out.rstrip("\n"))
print()
class _Idle:
"""Idle psql connections, closed again when the with block ends."""
def __init__(self, server, count):
self.server, self.count, self.procs = server, count, []
def __enter__(self):
before = int(self.server.query("SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'"))
for _ in range(self.count):
self.procs.append(subprocess.Popen([str(BIN / "psql"), "-X", "-q"], stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env=self.server.env, cwd=self.server.base))
sql = "SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'"
for _ in range(300):
# the query's own connection is one more client backend
if int(self.server.query(sql)) == before + self.count:
return self
time.sleep(0.1)
sys.exit("the idle connections did not open")
def __exit__(self, *exc):
for p in self.procs:
p.stdin.close()
p.wait(timeout=30) \dt+ shows each table’s owner, persistence, access method and size: 184 kB for the 3,000 orders. \di+ lists four
indexes, among them orders_pkey at 88 kB, more than the 40 kB of orders_customer_idx beside it. \d orders gives
the columns with their types, nullability and defaults, then the indexes and the foreign key, which is what you check
before you change a table.
The last two queries show \x auto. A result wider than the screen comes out as one block per row, column name on
the left and value on the right, and a narrow one stays a table. psql writing to a file has no screen to measure, so
the script set the width with \pset columns 80.
A few more of the family, all on the psql page of the manual: \df+ for functions, \dx for extensions, \dp for
privileges and \du for roles. psql -E prints the catalog query behind each one, which is a quick way to learn the
catalogs. During an incident, \timing on prints how long each statement took and \watch 5 repeats the last query
every 5 seconds; psql 18 added the variable WATCH_INTERVAL to change the default wait of 2 seconds.
Checkpoint
Check: The \d commands and expanded output
Variables and \if let a script decide
psql variables live in psql, not in the server. \set name value sets one, :name puts it into the next line,
:'name' puts it in as a quoted SQL literal and :"name" as a quoted identifier. \gset, in place of the semicolon,
runs a query and stores its single row in variables named after its columns. The run contains a common mistake first,
then a script that makes it work, and stops at an error on purpose:
scripts_session.py
"""psql scripts with variables, \\if, \\gexec and ON_ERROR_STOP, run with -a so every line is echoed."""
from pg_session import Server
with Server(settings={"autovacuum": "off"}) as pg:
pg.up()
pg.shell("createdb", "shop")
pg.psql("setup.sql", db="shop", flags=("-q",))
pg.psql("if_mistake.sql", db="shop", errors=1)
pg.psql("report.sql", db="shop", errors=1, status=3)
pg.psql("gexec.sql", db="shop") Output
$ createdb shop
$ psql -d shop -X -q -f setup.sql
$ psql -d shop -X -a -f if_mistake.sql
-- \if takes a boolean, not an expression: psql does no arithmetic and no comparison.
SELECT count(*) AS n FROM orders \gset
\echo :n orders
3000 orders
\if :n > 1000
psql:if_mistake.sql:4: error: unrecognized value "3000 > 1000" for "\if expression": Boolean expected
\echo a busy shop
\endif
$ psql -d shop -X -a -f report.sql
-- A report script: stop at the first error, let the server compare, quote every value it splices in.
\set ON_ERROR_STOP on
SELECT count(*) AS n, count(*) > 1000 AS busy FROM orders \gset
\if :busy
\echo :n orders: a busy shop
3000 orders: a busy shop
\else
\echo :n orders: a quiet shop
\endif
\set city 'Pune'
SELECT c.name, count(*) AS orders, sum(o.total) AS spent
FROM customers AS c JOIN orders AS o ON o.customer_id = c.id
WHERE c.city = :'city'
GROUP BY c.name;
name | orders | spent
----------+--------+-----------
Asha Rao | 1000 | 518800.00
(1 row)
SELECT * FROM order_totals;
psql:report.sql:14: ERROR: relation "order_totals" does not exist
LINE 1: SELECT * FROM order_totals;
^
$ psql -d shop -X -a -f gexec.sql
-- Turn a catalog query into a maintenance script: one ANALYZE for every table never analysed.
SELECT format('ANALYZE %I.%I', schemaname, relname)
FROM pg_stat_user_tables
WHERE last_analyze IS NULL AND last_autoanalyze IS NULL
ORDER BY relname \gexec
ANALYZE public.audit_log
ANALYZE
ANALYZE public.customers
ANALYZE
SELECT relname, last_analyze IS NOT NULL AS analysed FROM pg_stat_user_tables ORDER BY relname;
relname | analysed
-----------+----------
audit_log | t
customers | t
orders | t
(3 rows)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 scripts_session.py
if_mistake.sql
-- \if takes a boolean, not an expression: psql does no arithmetic and no comparison.
SELECT count(*) AS n FROM orders \gset
\echo :n orders
\if :n > 1000
\echo a busy shop
\endif report.sql
-- A report script: stop at the first error, let the server compare, quote every value it splices in.
\set ON_ERROR_STOP on
SELECT count(*) AS n, count(*) > 1000 AS busy FROM orders \gset
\if :busy
\echo :n orders: a busy shop
\else
\echo :n orders: a quiet shop
\endif
\set city 'Pune'
SELECT c.name, count(*) AS orders, sum(o.total) AS spent
FROM customers AS c JOIN orders AS o ON o.customer_id = c.id
WHERE c.city = :'city'
GROUP BY c.name;
SELECT * FROM order_totals;
\echo this line never runs gexec.sql
-- Turn a catalog query into a maintenance script: one ANALYZE for every table never analysed.
SELECT format('ANALYZE %I.%I', schemaname, relname)
FROM pg_stat_user_tables
WHERE last_analyze IS NULL AND last_autoanalyze IS NULL
ORDER BY relname \gexec
SELECT relname, last_analyze IS NOT NULL AS analysed FROM pg_stat_user_tables ORDER BY relname; In if_mistake.sql, \gset stored n = 3000, and \if :n > 1000 failed with ‘unrecognized value “3000 > 1000” …
Boolean expected’. psql does no arithmetic and no comparisons: after it substitutes the variable, \if needs a word it
reads as true or false, such as t, on or yes, and the manual says anything else is treated as false, so the
block was skipped. report.sql lets the server compare instead, count(*) > 1000 AS busy, and \if :busy read t.
report.sql also put the city into its query as :'city', a quoted literal, which stays correct for a value such as
O'Fallon and cannot end the string early. Then it ran SELECT * FROM order_totals, a table that does not exist.
Because the script began with \set ON_ERROR_STOP on, psql stopped there with exit status 3, and the last \echo
never ran. Without it, psql would have reported the error and gone on with the next line, exactly what went wrong
with the 40-statement script.
One transaction for a script that must all run or not at all
psql -X --single-transaction -v ON_ERROR_STOP=on -f migrate.sql wraps the whole file in BEGIN and COMMIT, and with
ON_ERROR_STOP it sends a ROLLBACK at the first error, so a half-applied script cannot happen. -X skips your
personal ~/.psqlrc, so a script behaves the same for everyone.
Checkpoint
Check: Variables, \if and ON_ERROR_STOP
\gexec turns the rows of a query into statements
\gexec runs a query and then sends every field of every row it returns to the server as a statement of its own.
Combined with the catalogs, it writes maintenance scripts for you. gexec.sql, the last part of the run above, builds
one ANALYZE for each table that was never analysed:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The query goes out. psql sends the SELECT, which asks pg_stat_user_tables for every table that was never analysed and builds a statement for each with format('ANALYZE %I.%I', …).
- Rows of text come back. The server answers with two rows, each one a piece of text, ANALYZE public.audit_log and ANALYZE public.customers. orders was analysed by the setup script, so it is not there.
- Each field becomes a query. Because the query ended with \gexec instead of a semicolon, psql does not print the rows. It sends each field to the server as a statement of its own, in the order the rows came.
- The second statement. The server analyses audit_log and reports ANALYZE; psql sends the second statement. Had one failed, the rest would still run unless ON_ERROR_STOP was on.
- The check. The script's last query reads pg_stat_user_tables again, and all three tables are now analysed. The catalog said what needed doing, and the same query did it.
The query returned ANALYZE public.audit_log and ANALYZE public.customers and psql ran both; orders had been
analysed by the setup and was left out. format('ANALYZE %I.%I', schemaname, relname) matters: %I quotes an
identifier when it needs quotes, so a table called Order Items becomes "Order Items" instead of two words that
fail, or a name that changes the meaning of the statement.
Building statements by gluing names together
'ANALYZE ' || relname works until a table name has a capital letter, a space or a quote in it. Use
format('… %I', name) for identifiers and %L for literals, or quote_ident and quote_literal, and look at the
generated statements with a plain semicolon before you run them with \gexec.
Checkpoint
Check: Statements generated with \gexec
\copy reads files where psql runs, COPY where the server runs
SQL’s COPY moves rows between a table and a file, and the file is opened by the server, on the server’s machine, as
the operating-system user the server runs as. psql’s \copy runs the same command but reads or writes the file
itself, on the machine where psql runs, with your own permissions. The run logs in as clerk, a role that may read
and insert rows and nothing more:
copy_session.py
"""\\copy against server-side COPY, run by the clerk role, which may read and insert rows but not server files."""
from pg_session import Server
with Server(settings={"autovacuum": "off"}) as pg:
pg.up()
pg.shell("createdb", "shop")
pg.psql("setup.sql", db="shop", flags=("-q",))
pg.silent(f"ALTER ROLE clerk PASSWORD '{pg.password}'")
pg.psql("copy.sql", db="shop", user="clerk", errors=2)
pg.show_file("customers_out.csv") Output
$ createdb shop
$ psql -d shop -X -q -f setup.sql
$ psql -d shop -U clerk -X -a -f copy.sql
-- \copy: psql reads and writes the files, on the computer where psql runs.
\copy customers FROM 'new_customers.csv' WITH (FORMAT csv, HEADER)
COPY 2
\copy (SELECT id, name, city FROM customers ORDER BY id) TO 'customers_out.csv' WITH (FORMAT csv, HEADER)
COPY 5
-- COPY with a file name: the server opens the file, so the role needs the right to read server files.
COPY customers FROM 'new_customers.csv' WITH (FORMAT csv, HEADER);
psql:copy.sql:5: ERROR: permission denied to COPY from a file
DETAIL: Only roles with privileges of the "pg_read_server_files" role may COPY from a file.
HINT: Anyone can COPY to stdout or from stdin. psql's \copy command also works for anyone.
-- A COPY FROM STDIN that fails at once: psql 18.6 skips its in-line data up to the \. line.
COPY customer FROM STDIN WITH (FORMAT csv);
psql:copy.sql:7: ERROR: relation "customer" does not exist
SELECT count(*) AS customers FROM customers;
customers
-----------
5
(1 row)
$ cat customers_out.csv
id,name,city
1,Asha Rao,Pune
2,Imran Khan,Lucknow
3,Meera Iyer,Chennai
4,Kabir Das,Varanasi
5,Leela Nair,Kochi
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 copy_session.py
copy.sql
-- \copy: psql reads and writes the files, on the computer where psql runs.
\copy customers FROM 'new_customers.csv' WITH (FORMAT csv, HEADER)
\copy (SELECT id, name, city FROM customers ORDER BY id) TO 'customers_out.csv' WITH (FORMAT csv, HEADER)
-- COPY with a file name: the server opens the file, so the role needs the right to read server files.
COPY customers FROM 'new_customers.csv' WITH (FORMAT csv, HEADER);
-- A COPY FROM STDIN that fails at once: psql 18.6 skips its in-line data up to the \. line.
COPY customer FROM STDIN WITH (FORMAT csv);
6,Ravi Kumar,Indore
\.
SELECT count(*) AS customers FROM customers; new_customers.csv
id,name,city
4,Kabir Das,Varanasi
5,Leela Nair,Kochi \copy customers FROM 'new_customers.csv' loaded 2 rows from the file beside psql, and \copy (SELECT …) TO 'customers_out.csv' wrote all 5 rows back out, header first. The same load as SQL’s COPY … FROM 'new_customers.csv' failed: “permission denied to COPY from a file”, with the detail that only roles with the
privileges of pg_read_server_files may do it. That is deliberate. A role that can make the server open any file
could read the server’s own configuration and data, so the manual reserves it for superusers and those predefined
roles, and anyone can still COPY to standard output or from standard input, which is what \copy uses.
| Aspect | COPY … FROM 'file' |
\copy … FROM 'file' |
|---|---|---|
| Who opens the file | The server, as its operating-system user | psql, as you |
| Where the file is | On the database server’s machine | On the machine where psql runs |
| Who may use it | Superusers and members of pg_read_server_files |
Any role with INSERT on the table |
| Relative paths | Relative to the server’s data directory | Relative to psql’s current folder |
psql 18.6 skips the data of a failed COPY FROM STDIN
The script’s last COPY named a table that does not exist, customer, and its in-line data followed. Since 18.6,
psql skips that data up to the \. line instead of running it as SQL, a fix the release notes list as
CVE-2026-6464; the run printed one error and went on to count 5 customers. Test scripts that fail a COPY on purpose
must end its data with a \. line.
Checkpoint
Check: \copy against COPY
psql 18 prepares, binds and pipelines statements
Two groups of meta-commands are new in psql 18. \parse name prepares the query buffer as a named
prepared statement: A statement the server has parsed and stored under a name for one session, so that it can be run many times with different parameter values ($1, $2 …) without being parsed again., \bind_named name value … followed by \g runs it with
parameters, and \close_prepared name drops it, which lets you test from psql what an application driver does. \startpipeline … \endpipeline sends statements
without waiting for each answer, and \syncpipeline marks a sync point inside:
pipeline_session.py
"""psql 18's commands for named prepared statements and pipelines."""
from pg_session import Server
with Server(settings={"autovacuum": "off"}) as pg:
pg.up()
pg.shell("createdb", "shop")
pg.psql("setup.sql", db="shop", flags=("-q",))
pg.psql("pipeline.sql", db="shop", errors=1) Output
$ createdb shop
$ psql -d shop -X -q -f setup.sql
$ psql -d shop -X -a -f pipeline.sql
-- A named prepared statement: parse once, bind and run it twice, close it.
SELECT count(*) AS orders, sum(total) AS spent FROM orders WHERE customer_id = $1 \parse orders_of
\bind_named orders_of 1 \g
orders | spent
--------+-----------
1000 | 518800.00
(1 row)
\bind_named orders_of 2 \g
orders | spent
--------+-----------
1000 | 519500.00
(1 row)
SELECT name, statement FROM pg_prepared_statements;
name | statement
-----------+------------------------------------------------------------------------------------
orders_of | SELECT count(*) AS orders, sum(total) AS spent FROM orders WHERE customer_id = $1
(1 row)
\close_prepared orders_of
SELECT count(*) AS prepared FROM pg_prepared_statements;
prepared
----------
0
(1 row)
-- A pipeline: statements go out without waiting for answers; \syncpipeline ends one implicit transaction.
\startpipeline
INSERT INTO audit_log (note) VALUES ('one');
INSERT INTO audit_log (note) VALUES ('two');
\syncpipeline
INSERT INTO audit_log (note) VALUES ('three') RETURNING id;
\endpipeline
INSERT 0 1
INSERT 0 1
id
----
3
(1 row)
INSERT 0 1
-- An error inside a pipeline rolls back its part of the pipeline and skips the rest of it.
\startpipeline
INSERT INTO audit_log (note) VALUES ('four');
SELECT 1 / 0;
INSERT INTO audit_log (note) VALUES ('five');
\endpipeline
INSERT 0 1
psql:pipeline.sql:20: ERROR: division by zero
SELECT id, note FROM audit_log ORDER BY id;
id | note
----+-------
1 | one
2 | two
3 | three
(3 rows)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pipeline_session.py
pipeline.sql
-- A named prepared statement: parse once, bind and run it twice, close it.
SELECT count(*) AS orders, sum(total) AS spent FROM orders WHERE customer_id = $1 \parse orders_of
\bind_named orders_of 1 \g
\bind_named orders_of 2 \g
SELECT name, statement FROM pg_prepared_statements;
\close_prepared orders_of
SELECT count(*) AS prepared FROM pg_prepared_statements;
-- A pipeline: statements go out without waiting for answers; \syncpipeline ends one implicit transaction.
\startpipeline
INSERT INTO audit_log (note) VALUES ('one');
INSERT INTO audit_log (note) VALUES ('two');
\syncpipeline
INSERT INTO audit_log (note) VALUES ('three') RETURNING id;
\endpipeline
-- An error inside a pipeline rolls back its part of the pipeline and skips the rest of it.
\startpipeline
INSERT INTO audit_log (note) VALUES ('four');
SELECT 1 / 0;
INSERT INTO audit_log (note) VALUES ('five');
\endpipeline
SELECT id, note FROM audit_log ORDER BY id; orders_of was parsed once and run twice, for customers 1 and 2, with 1,000 orders each; pg_prepared_statements
listed it until \close_prepared dropped it. The pipelines need a closer look, because the second one lost a row that
had been inserted. Step through both:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Sent without waiting. After \startpipeline, psql sends INSERT one and INSERT two straight after each other. It does not wait for the first answer before sending the second, which saves a round trip per statement.
- A sync point. \syncpipeline sends a sync. The two inserts since the start form one implicit transaction, which the server commits at the sync.
- The third insert. INSERT three … RETURNING id goes out after the sync, in a new implicit transaction, and \endpipeline sends the final sync and reads every result.
- All the answers. psql prints the results in order, INSERT 0 1 twice, then the returned id 3 and its INSERT 0 1. The table holds one, two and three.
- A pipeline with an error. The second pipeline sends INSERT four, SELECT 1 / 0 and INSERT five with no sync between them, so all three belong to one implicit transaction.
- The error aborts its part. four is inserted, then 1 / 0 fails with division by zero. The server rolls back that implicit transaction, four included, and skips five until the sync.
- What stayed. The final SELECT shows one, two and three. Put a sync after every unit of work that must stand on its own, because an error takes back everything since the last one.
A pipeline’s statements between two syncs run as one implicit transaction: A transaction the server opens by itself for statements sent without BEGIN: one statement on its own, or everything between two sync points of a pipeline. An error in it rolls back all of it.. In
the second pipeline four was inserted, SELECT 1 / 0 failed, the server rolled back that implicit transaction and
skipped five; the final SELECT showed rows 1 to 3 only. libpq’s chapter on pipeline mode describes exactly this, and it is the behaviour
your application’s driver has too when it pipelines.
Changed in psql 18, and coming in 19
psql 18 added \parse, \bind_named, \close_prepared and the pipeline commands, and the prompt escape %P
that shows the pipeline’s state. The release notes of PostgreSQL 19, still in preparation, add the prompt escapes
%S for the search path and %i for whether the server is a hot standby.
Checkpoint
Check: Prepared statements and pipelines
Practise: write the script you will need at three in the morning
Before the self-check, write down how you would make a script of your own stop at its first error, run every statement in one transaction, take a table name from a variable safely, and generate one statement per table from the catalogs. Every question below comes back to one of those habits:
Practice that adapts to you
Practise this lesson
10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): What is the difference between COPY and \copy, and when do you use each?
Both move rows between a table and a file, in the same formats. SQL’s COPY runs on the server, so a file name in it is opened by the server process, on the database server’s machine, with the server’s operating-system permissions; that is why COPY to or from a file needs superuser or membership in pg_read_server_files or pg_write_server_files. psql’s \copy runs COPY … FROM STDIN or TO STDOUT and reads or writes the file itself, on the machine where psql runs, with your own permissions, so any role with the right table privileges can use it. Use \copy for files on your own machine or a jump host, and COPY with a file name only for files that are already on the database server, in an administrative job.
Two questions go further: a nightly script that ran past its first error, and a \gexec script that broke on an unusual table name. This module’s question page gives the first move for each and the follow-ups to expect:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A nightly psql script of 40 statements failed at statement 7, ran the other 33, exited with status 0 and left the data half migrated. The scheduler reported success. How do you make the script safe, and how does the scheduler learn that it failed?
-
Mid to senior
A \gexec script grants SELECT on every table of a schema with 'GRANT SELECT ON ' || schemaname || '.' || relname || ' TO reporting'. It broke on a table called Order Items, and a colleague warns it is unsafe as well. Explain both problems and rewrite the query.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What are psql's \d commands, really?
Catalog queries psql writes for you: \dt+ and \di+ add sizes, \d name shows an object in full, \x auto switches wide results to one block per row, and psql -E prints the query behind each command.
-
How do psql variables behave in a script?
They are text psql substitutes: :'name' inserts a safely quoted literal, \gset stores a query's single row in variables, and \if needs a true/false word because psql does no arithmetic — let the server compare.
-
How do you make a psql script stop at its first error or run all-or-nothing?
\set ON_ERROR_STOP on stops at the first error with exit status 3. --single-transaction wraps the whole file in BEGIN/COMMIT and rolls back on the first error.
-
What does \gexec do, and how do you keep it safe?
It runs each field of a query's result as its own statement — handy with the catalogs for maintenance scripts. Use format('%I') for identifiers and %L for literals so unusual names stay correct.
-
What is the difference between \copy and COPY with a file name?
\copy reads/writes the file where psql runs, with your permissions. COPY with a file name opens it on the server as the server's OS user and needs superuser or pg_read_server_files / pg_write_server_files.
-
In a psql 18 pipeline, how are statements grouped into transactions?
Statements between two syncs run as one implicit transaction, so an error rolls back everything since the last sync. psql 18 added \parse, \bind_named, \close_prepared and the pipeline commands to mimic drivers.
Key takeaways
- The
\dcommands are catalog queries psql writes for you:\dt+and\di+add sizes,\d nameshows an object in full,\x autoswitches wide results to one block per row, andpsql -Eshows the query behind each command. - psql variables are text that psql substitutes:
:'name'quotes a literal safely,\gsetstores a row, and\ifneeds a true or false value, so let the server do comparisons. \set ON_ERROR_STOP onstops a script at its first error with exit status 3, and--single-transactionmakes the whole file one transaction that rolls back on an error.\gexecruns each field of a query’s result as a statement, andformat('%I')keeps generated identifiers safe.\copyreads and writes files where psql runs, with your permissions, whileCOPYwith a file name runs on the server and needspg_read_server_filesor superuser.- psql 18’s
\parse,\bind_namedand pipeline commands test what drivers do; statements between two syncs form one implicit transaction, so an error rolls back everything since the last sync.
Terms in this lesson
- implicit transaction
- A transaction the server opens by itself for statements sent without BEGIN: one statement on its own, or everything between two sync points of a pipeline. An error in it rolls back all of it.
- meta-command
- A psql command that starts with a backslash, such as \d, \copy or \gexec. psql handles it itself instead of sending it to the server, often by running catalog queries for you.
- prepared statement
- A statement the server has parsed and stored under a name for one session, so that it can be run many times with different parameter values ($1, $2 …) without being parsed again.
References
- PostgreSQL 18 documentation: psql (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: COPY (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 32.5 Pipeline Mode (The PostgreSQL Global Development Group)
- PostgreSQL 18 release notes (The PostgreSQL Global Development Group)
- PostgreSQL 18.6 release notes (The PostgreSQL Global Development Group)
- PostgreSQL 19 release notes (in preparation) (The PostgreSQL Global Development Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress