PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 1: PostgreSQL and psql

psql for DBAs: variables, \gset, \gexec, \if and \watch

Level 1: Foundations25 min

psql is more than a prompt: it has variables, conditionals, result capture and statement generation, which turn it into a scripting tool for maintenance jobs and runbooks. This lesson covers the features DBAs use in scripts and the settings that make psql safe for migrations.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Use psql variables with the correct quoting forms
  • Capture query results with \gset and generate statements with \gexec
  • Write scripts that stop on the first error and branch with \if

conceptThe concept

FeatureSyntaxUse
Variables\set name value, then :name, :'name', :"name"Plain text, a quoted literal, or a quoted identifier. Safe quoting matters when values come from outside.
CaptureSELECT ... \gset [prefix]Store each column of a one-row result in a variable.
GenerateSELECT format(...) ... \gexecRun every value of the result as a statement: per-table ANALYZE, grants, reindexing.
Branch\if :var / \elif / \else / \endifVersion- or environment-specific script sections.
RepeatSELECT ... \watch i=5 c=10Re-run a query on an interval: progress views, replication lag, locks.
Errors\set ON_ERROR_STOP on, \errverboseStop a script on the first error; show the last error with SQLSTATE and source location.

Automatic variables include ERROR, SQLSTATE, ROW_COUNT, LAST_ERROR_MESSAGE, DBNAME, USER and SERVER_VERSION_NUM. Real psql starts with ON_ERROR_STOP off; this course runs with it on, as scripts should.

whyWhy it matters

Most production changes are run through psql: migrations, runbooks, cron jobs. Without ON_ERROR_STOP a failed statement is printed and the script continues, applying half a change. With variables, \gset and \gexec you can write one script that adapts to the server instead of copy-pasting SQL per table.

Use caseReal-world scenario

A runbook needs to ANALYZE every table that changed more than 10% since its last analyze, but only on servers older than 17, and print how many tables it touched. One psql script does it: \gset captures the server version, \if branches, a SELECT with format('ANALYZE %I.%I', ...) and \gexec generates and runs the statements, and ON_ERROR_STOP plus --single-transaction make a failure stop everything cleanly.

Try itExamples

Run and edit these in the interactive lesson.

Variables and safe quoting
\set status 'shipped'
\set tbl orders
\echo counting :tbl with status :'status'
SELECT count(*) AS n FROM :"tbl" WHERE status = :'status';
:'status' becomes a correctly quoted literal and :"tbl" a quoted identifier. Use these forms for any value you did not type yourself.
Capture a result with \gset
SELECT count(*) AS orders, max(created_at)::date AS latest FROM orders \gset stat_
\echo :stat_orders orders, the latest on :stat_latest
The prefix keeps captured names from clashing with your own variables. \gset errors if the query returns zero rows or more than one.
Generate and run statements with \gexec
SELECT format('SELECT %L AS table_name, count(*) AS row_count FROM %I', relname, relname)
FROM pg_class
WHERE relnamespace = 'public'::regnamespace AND relname IN ('customers', 'products', 'employees')
ORDER BY relname \gexec
Run the SELECT without \gexec first to review exactly what will execute. format() with %I and %L quotes names and values correctly.
Branch on the server version
SELECT current_setting('server_version_num')::int >= 170000 AS has_17 \gset
\if :has_17
  \echo 17 or later: checkpoint counters live in pg_stat_checkpointer
  SELECT num_timed >= 0 AS ok FROM pg_stat_checkpointer;
\else
  \echo before 17: use pg_stat_bgwriter
\endif
\if takes a boolean: true/false, on/off, yes/no or 1/0. Only the active branch is executed.
Errors: ON_ERROR_STOP and \errverbose
\set ON_ERROR_STOP off
SELECT 1 / 0 AS boom;
\echo after the error: ERROR=:ERROR SQLSTATE=:SQLSTATE
\errverbose
With ON_ERROR_STOP off the script continues and the error is only visible in the variables. \errverbose shows the SQLSTATE and the C function that raised it, useful in bug reports.
Repeat a query with \watch
SELECT count(*) AS sessions FROM pg_stat_activity \watch i=1 c=2
Without c= real psql repeats until you press Ctrl-C. Typical uses: pg_stat_progress_create_index during a build, replication lag during a resync.
.psqlrc and running scripts safely
-- ~/.psqlrc
\set QUIET 1
\pset null '(null)'
\x auto
\timing on
\set PROMPT1 '%n@%/%R%# '
\set HISTFILE ~/.psql_history- :DBNAME
\set ON_ERROR_ROLLBACK interactive
\set QUIET 0

-- run a migration: no personal psqlrc, stop at the first error, one transaction
psql -X -v ON_ERROR_STOP=1 --single-transaction -f migrate.sql "service=prod"

-- pass values in from the shell; use them as :'run_date' inside the script
psql -X -v ON_ERROR_STOP=1 -v run_date=2026-10-08 -f report.sql

-- client-side copy: the file is on your machine, no superuser needed
\copy (SELECT * FROM orders WHERE created_at > now() - interval '1 day') TO 'orders.csv' WITH (FORMAT csv, HEADER)

-- extended protocol from psql (16+): parameters sent separately from the SQL text
SELECT * FROM orders WHERE id = $1 \bind 42 \g

internalsUnder the hood

Variables are interpolated by psql before the query is sent, so the server never sees :name. :'name' is quoted with the same rules as quote_literal and :"name" like quote_ident, so they are safe; plain :name is pasted as-is. Interpolation does not happen inside quoted strings or dollar-quoted bodies. \gexec sends each generated statement separately, so statements that cannot run inside a transaction block (VACUUM, CREATE INDEX CONCURRENTLY) still work when the script is not wrapped in one.

Avoid / preferDo this, not that

Avoid
psql -f migrate.sql
Prefer
psql -X -v ON_ERROR_STOP=1 --single-transaction -f migrate.sql

By default psql prints an error and keeps going, so a migration can half-apply. ON_ERROR_STOP exits with status 3 on the first error, --single-transaction rolls everything back, and -X ignores a personal .psqlrc that might change behaviour.

Avoid
SELECT 'DROP TABLE ' || relname || ';' FROM pg_class WHERE relname LIKE 'tmp_%' \gexec
Prefer
-- review first, then run with \gexec
SELECT format('DROP TABLE %I.%I', relnamespace::regnamespace, relname)
FROM pg_class
WHERE relkind = 'r' AND relname LIKE 'tmp\_%' AND relnamespace = 'scratch'::regnamespace;

Concatenation breaks on names that need quoting and is injectable. _ is a LIKE wildcard, so tmp_% also matches tmpl_audit. Restrict the schema and kind, and always look at the generated list before executing it.

Avoid
SELECT * FROM orders WHERE status = ':status';
Prefer
SELECT * FROM orders WHERE status = :'status';

psql does not interpolate inside quotes, so the first query searches for the literal text :status. :'status' produces a properly quoted literal.

PitfallsCommon mistakes

  • Using plain :name for a value that came from outside the script, instead of :'name' or :"name".
  • Forgetting that \gset needs exactly one row; zero rows leaves the old variable values in place.
  • Running \gexec on a generated list of destructive statements without reviewing it first.
  • Putting VACUUM or CREATE INDEX CONCURRENTLY in a script run with --single-transaction; they cannot run in a transaction block.

LimitsWhen not to use it

When a script grows loops, retries and real error handling, move it to a proper language (Python, Go) with a driver, or to a PL/pgSQL procedure. psql scripting is for linear runbooks and generation of statements, not for programs.

SecuritySecurity notes

Never interpolate untrusted input with plain :var; use :'var' or :"var". Keep passwords out of scripts and command lines (they show in ps and shell history); use ~/.pgpass (mode 0600) or a service file. \! and \copy ... PROGRAM run commands as your OS user, so review scripts from others before running them.

ChallengeTry it yourself

Store the id of the customer with the most orders in a variable with \gset (break ties by the lower id), then use the variable to list that customer's three most recent orders: id, order date and total.

Show one solution
One solution
SELECT customer_id AS top_customer
FROM orders
GROUP BY customer_id
ORDER BY count(*) DESC, customer_id
LIMIT 1 \gset
SELECT id, created_at::date AS ordered_on, total
FROM orders
WHERE customer_id = :top_customer
ORDER BY created_at DESC
LIMIT 3;

InterviewQuestions you should be able to answer

How do you make a psql migration script fail safely?
Run it with -X (ignore .psqlrc), -v ON_ERROR_STOP=1 so the first error aborts with exit status 3, and --single-transaction (or explicit BEGIN/COMMIT) so nothing is half-applied. Statements that cannot run in a transaction (CREATE INDEX CONCURRENTLY, VACUUM) go in a separate script. Check the exit code in CI or the deploy tool.
What do \gset and \gexec do?
\gset runs the current query and stores each column of the single result row in a psql variable (optionally prefixed), so later statements or \if can use it. \gexec runs the current query and then executes every non-null value of the result as its own SQL statement, which is the standard way to generate per-table maintenance or grant statements with format().

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation