On this page
- Connecting and psql essentials
- Inspecting a database
- Roles, privileges and access
- Schema changes that don’t take the site down
- Diagnosing slow queries
- Connections, locks and blocked sessions
- Vacuum, bloat and transaction wraparound
- Backups and recovery
- Replication and read replicas
- Configuration that actually matters
- Troubleshooting specific errors
- Production checklist
- Frequently asked questions
- Related resources
Most engineers meet PostgreSQL through an ORM and only ever open psql when something is broken. That is exactly the wrong moment to be learning the tooling. This reference is organized by the operational task you’re doing — connect, inspect, grant, migrate, diagnose, unblock, reclaim, back up, replicate — with the version caveats and lock behaviour that decide whether a command is safe to run on a live database at 3am.
Connecting and psql essentials
psql is not just a SQL prompt — its backslash meta-commands are the fastest way to inspect a database, and they are the thing most people never learn.
psql connection & meta-commands
| Command | What it does | Risk |
|---|---|---|
psql -h host -U user -d dbname | Connect. Omit -h for a local Unix socket connection. | Safe |
psql 'postgresql://user@host:5432/db?sslmode=require' | Connect with a URI, forcing TLS. | Safe |
\l | List databases, with owner, encoding and size. | Safe |
\c dbname | Connect to a different database in the same session. | Safe |
\dt | List tables in the current search_path. | Safe |
\dt *.* | List tables in EVERY schema — including ones search_path hides. | Safe |
\d+ tablename | Describe a table: columns, indexes, constraints, size, storage. | Safe |
\du+ | List roles, their attributes and memberships. | Safe |
\dn+ | List schemas with their access privileges. | Safe |
\df+ funcname | Describe functions, including the body and volatility. | Safe |
\x auto | Expanded output — makes wide rows readable. 'auto' switches only when needed. | Safe |
\timing on | Print execution time for every statement. | Safe |
\watch 2 | Re-run the previous query every 2 seconds — a poor man's monitor. | Safe |
\e | Open the last query in $EDITOR. | Safe |
\copy tbl FROM 'f.csv' CSV HEADER | Client-side bulk load. Unlike COPY, needs no server file access. | Caution |
\set ON_ERROR_STOP on | Abort a script on the first error instead of ploughing on. | Safe |
\conninfo | Show who you are connected as, to what, and over which host/port. | Safe |
No commands match that filter.
Three habits worth forming immediately:
# 1. Never put a password in the command line or a shell variable that gets logged.
# ~/.pgpass, mode 0600, is read automatically:
echo "db.internal:5432:appdb:appuser:secret" >> ~/.pgpass && chmod 600 ~/.pgpass
# 2. ON_ERROR_STOP for every non-interactive script. Without it, psql runs the
# remaining statements after a failure — which is how half-applied migrations happen.
psql -v ON_ERROR_STOP=1 -f migration.sql
# 3. Make destructive sessions obvious to yourself.
export PSQL_EDITOR=vim
psql -h prod-db -d appdb # then: \set PROMPT1 '%[%033[1;31m%]PROD%[%033[0m%]=# '
Inspecting a database
Before you tune anything, find out what is actually big and what is actually busy.
-- Database sizes, largest first
SELECT datname, pg_size_pretty(pg_database_size(datname)) AS size
FROM pg_database ORDER BY pg_database_size(datname) DESC;
-- Table sizes including indexes and TOAST
SELECT relname AS table,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total,
pg_size_pretty(pg_relation_size(c.oid)) AS heap,
pg_size_pretty(pg_indexes_size(c.oid)) AS indexes
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r' AND n.nspname NOT IN ('pg_catalog','information_schema')
ORDER BY pg_total_relation_size(c.oid) DESC
LIMIT 20;
-- Indexes nobody uses (candidates for removal — check on a replica first)
SELECT relname AS table, indexrelname AS index,
pg_size_pretty(pg_relation_size(indexrelid)) AS size, idx_scan AS scans
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
idx_scan = 0 means “not used since the stats were last reset” — not “never used”. Check pg_stat_get_db_stat_reset_time() before you drop anything, and remember that an index backing a unique or foreign-key constraint is doing a job even at zero scans.
Roles, privileges and access
Postgres permissions trip people up because access needs two grants that most people only remember one of: USAGE on the schema, and a privilege on the object inside it.
Roles & privileges
| Command | What it does | Risk |
|---|---|---|
CREATE ROLE app LOGIN PASSWORD 'x' | A role with LOGIN is what older docs call a 'user'. | Caution |
CREATE ROLE readonly NOLOGIN | A group role — grant it to humans rather than granting each person directly. | Safe |
GRANT readonly TO alice | Add a role to a group role. | Caution |
GRANT USAGE ON SCHEMA public TO readonly | Required before ANY table privilege in that schema works. | Caution |
GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly | Applies to tables that exist RIGHT NOW — not future ones. | Caution |
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO readonly | The fix for 'new tables lose their grants'. | Caution |
REVOKE ALL ON TABLE t FROM PUBLIC | PUBLIC is an implicit grant to everyone — revoke it deliberately. | Caution |
\dp tablename | Show the actual privileges on a table. The truth, not what you think you granted. | Safe |
ALTER ROLE app SET statement_timeout = '30s' | Per-role defaults — a very effective production guardrail. | Caution |
ALTER ROLE app WITH NOLOGIN | Disable a compromised account without dropping it or losing its grants. | Caution |
DROP OWNED BY olduser | Drops every object the role owns. Required before DROP ROLE. No undo. | Destructive |
REASSIGN OWNED BY olduser TO newuser | Transfer ownership instead of dropping — usually what you actually want. | Caution |
No commands match that filter.
The single most common permission failure looks like this:
-- This alone does NOT work:
GRANT SELECT ON ALL TABLES IN SCHEMA reporting TO analyst;
-- => still: ERROR: permission denied for schema reporting
-- Both are needed:
GRANT USAGE ON SCHEMA reporting TO analyst;
GRANT SELECT ON ALL TABLES IN SCHEMA reporting TO analyst;
-- And for tables created LATER by the migration user:
ALTER DEFAULT PRIVILEGES FOR ROLE migrator IN SCHEMA reporting
GRANT SELECT ON TABLES TO analyst;
Authentication happens before any of this, in pg_hba.conf — evaluated top to bottom, first match wins:
# TYPE DATABASE USER ADDRESS METHOD
host appdb app 10.0.0.0/8 scram-sha-256
hostssl all all 0.0.0.0/0 scram-sha-256
local all postgres peer
sudo -u postgres psql -c 'SELECT pg_reload_conf();' # no restart needed for pg_hba
A rule that never matches produces no pg_hba.conf entry for host; a matching rule with the wrong method produces password authentication failed.
Schema changes that don’t take the site down
Every ALTER TABLE takes a lock. The question is only which lock and for how long — and an ACCESS EXCLUSIVE lock held behind a long-running query will queue every subsequent read.
DDL and its lock behaviour
| Command | What it does | Risk |
|---|---|---|
ALTER TABLE t ADD COLUMN c int | Fast. Metadata-only since PG 11, even with a non-volatile DEFAULT. | Caution |
ALTER TABLE t ADD COLUMN c int DEFAULT now() | VOLATILE default = full table rewrite. Very different cost. | Destructive |
ALTER TABLE t ALTER COLUMN c SET NOT NULL | Full scan under ACCESS EXCLUSIVE — unless a validated CHECK exists (PG 12+). | Destructive |
ALTER TABLE t DROP COLUMN c | Metadata-only and fast, but irreversible. | Destructive |
CREATE INDEX CONCURRENTLY idx ON t (c) | No write lock. Cannot run inside a transaction block. | Caution |
DROP INDEX CONCURRENTLY idx | The safe counterpart for removing an index on a live table. | Caution |
REINDEX INDEX CONCURRENTLY idx | Rebuild a bloated index without blocking writes (PG 12+). | Caution |
ALTER TABLE t ADD CONSTRAINT fk ... NOT VALID | Add the constraint instantly; enforce it for new rows only. | Caution |
ALTER TABLE t VALIDATE CONSTRAINT fk | Then validate existing rows under a much weaker lock. | Caution |
SET lock_timeout = '3s' | Fail the DDL instead of queueing behind a long query. Set this FIRST. | Safe |
ALTER TABLE t RENAME TO t_old | Instant, but every cached plan and application reference breaks. | Destructive |
No commands match that filter.
The two-line habit that prevents most migration outages:
SET lock_timeout = '3s';
SET statement_timeout = '30s';
ALTER TABLE orders ADD COLUMN shipped_at timestamptz;
Without lock_timeout, an ALTER TABLE that cannot get its lock waits — and while it waits, it holds a place in the lock queue that blocks every read behind it. A three-second failure you retry is enormously better than a five-minute stall you have to diagnose live.
CREATE INDEX CONCURRENTLY deserves its own note: it cannot run inside a transaction block, it takes roughly twice as long, and if it fails it leaves an invalid index behind that still costs write overhead while never being used for reads:
-- Find the wreckage of a failed concurrent build
SELECT indexrelid::regclass AS index FROM pg_index WHERE NOT indisvalid;
DROP INDEX CONCURRENTLY idx_orders_customer; -- then rebuild
Diagnosing slow queries
-- What is slow, cumulatively? Requires pg_stat_statements in shared_preload_libraries.
SELECT calls,
round(mean_exec_time::numeric, 2) AS avg_ms,
round(total_exec_time::numeric) AS total_ms,
rows,
left(query, 90) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
Sort by total_exec_time, not mean_exec_time. A 40ms query called two million times costs far more than a 9-second report someone runs at midnight, and it is usually much easier to fix.
Then read the plan for the real thing:
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT * FROM orders WHERE customer_id = 42 AND status = 'open';
Reading a plan — what to look for
| Command | What it does | Risk |
|---|---|---|
Seq Scan on a large table | Missing index, or the planner decided the index wasn't worth it. Check the row estimate. | Safe |
rows=1 ... actual rows=94000 | The estimate is wrong. Usually stale stats (ANALYZE) or a correlation the planner can't see. | Safe |
Nested Loop with a big outer row count | Fine for a few rows, catastrophic for many — often the symptom of the bad estimate above. | Safe |
Rows Removed by Filter: 2000000 | The index got you to the wrong place; the filter did the real work. Consider a composite index. | Safe |
Buffers: read=... vs hit=... | 'read' is disk. A high read/hit ratio on a hot query means it isn't fitting in cache. | Safe |
external merge Disk: 240000kB | The sort spilled to disk. work_mem is too low FOR THIS QUERY. | Safe |
No commands match that filter.
ANALYZE in EXPLAIN (ANALYZE) actually executes the query. On an UPDATE or DELETE, wrap it:
BEGIN;
EXPLAIN (ANALYZE) DELETE FROM sessions WHERE expires_at < now();
ROLLBACK;
Index choice, briefly: B-tree for equality and ranges (the default, and right almost always); GIN for jsonb containment and full-text; BRIN for naturally ordered huge tables like append-only time series; partial indexes (WHERE status = 'open') when queries only ever touch a slice; and column order in a composite index matters — equality columns first, then the range column.
Connections, locks and blocked sessions
This is the section you will actually open during an incident.
-- Who is doing what, longest transaction first
SELECT pid, usename, state,
now() - xact_start AS xact_age,
now() - query_start AS query_age,
wait_event_type, wait_event,
left(query, 80) AS query
FROM pg_stat_activity
WHERE state <> 'idle' AND pid <> pg_backend_pid()
ORDER BY xact_age DESC NULLS LAST;
-- Who is blocking whom (PG 9.6+)
SELECT blocked.pid AS blocked_pid,
blocked.usename AS blocked_user,
blocking.pid AS blocking_pid,
blocking.usename AS blocking_user,
left(blocked.query, 60) AS blocked_query,
left(blocking.query, 60) AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
ON blocking.pid = ANY(pg_blocking_pids(blocked.pid));
Ending a session — know which one you want
| Command | What it does | Risk |
|---|---|---|
SELECT pg_cancel_backend(pid) | Cancels the running QUERY. The connection and its transaction survive. Try this first. | Caution |
SELECT pg_terminate_backend(pid) | Kills the whole CONNECTION and rolls back its transaction. | Destructive |
No commands match that filter.
Reach for pg_cancel_backend first: it is the gentler tool, and for a runaway SELECT it is all you need. pg_terminate_backend is what you want for a session stuck idle in transaction, because there is no query to cancel — the session is holding locks while doing nothing at all.
Connection exhaustion is the other half of this section. Postgres uses a process per connection, so “just raise max_connections” trades one problem for memory pressure and context-switching. The real answer is a pooler — PgBouncer in transaction mode — in front of the database:
SELECT count(*) AS used,
(SELECT setting::int FROM pg_settings WHERE name = 'max_connections') AS max
FROM pg_stat_activity;
Hitting the ceiling produces too many clients already and, once the reserved slots go, remaining connection slots are reserved.
Vacuum, bloat and transaction wraparound
Postgres never overwrites a row in place — an UPDATE writes a new version and leaves the old one dead. Vacuum reclaims those. When vacuum cannot keep up, you get bloat; when it cannot run at all, you eventually get a database that refuses writes.
Vacuum and maintenance
| Command | What it does | Risk |
|---|---|---|
VACUUM (VERBOSE, ANALYZE) tablename | Reclaim dead rows and refresh statistics. Does not block reads or writes. | Caution |
VACUUM FULL tablename | Rewrites the table, returning space to the OS. ACCESS EXCLUSIVE lock + 2x disk. | Destructive |
ANALYZE tablename | Refresh planner statistics only. Cheap — run it after a big data change. | Safe |
SELECT * FROM pg_stat_user_tables | n_dead_tup, last_autovacuum, last_autoanalyze — the bloat dashboard. | Safe |
ALTER TABLE t SET (autovacuum_vacuum_scale_factor = 0.02) | Vacuum a hot table more aggressively than the global default. | Caution |
No commands match that filter.
-- Tables most in need of vacuum
SELECT relname,
n_live_tup, n_dead_tup,
round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY dead_pct DESC NULLS LAST;
-- Transaction age — the wraparound clock. Watch this.
SELECT datname, age(datfrozenxid) AS xid_age
FROM pg_database ORDER BY xid_age DESC;
VACUUM FULL is not “a better VACUUM”. It takes an ACCESS EXCLUSIVE lock for the whole rewrite and needs enough free disk for a second copy of the table. On a live system, pg_repack does the same job without the outage.
Backups and recovery
Backup and restore
| Command | What it does | Risk |
|---|---|---|
pg_dump -Fc -d appdb -f app.dump | Custom-format logical dump — compressed, and restorable selectively. | Safe |
pg_dump -Fd -j 8 -d appdb -f dumpdir | Directory format with 8 parallel workers. Much faster on big databases. | Safe |
pg_dumpall --globals-only -f globals.sql | Roles and tablespaces. pg_dump does NOT include these — you need both. | Safe |
pg_restore -d appdb -j 8 app.dump | Restore in parallel. The database must already exist. | Destructive |
pg_restore -l app.dump | List the contents so you can restore one table instead of everything. | Safe |
pg_basebackup -D /var/lib/pg/backup -Fp -Xs -P | Physical base backup — the starting point for PITR and replicas. | Safe |
SELECT pg_size_pretty(pg_database_size('appdb')) | Know the size before you plan the restore window. | Safe |
No commands match that filter.
The distinction that matters operationally: a logical dump (pg_dump) is portable across versions and lets you restore a single table, but restoring a large one is slow because every index is rebuilt. A physical backup (pg_basebackup plus archived WAL) restores fast and supports point-in-time recovery, but it is tied to the same major version and architecture.
Replication and read replicas
-- On the primary: who is replicating, and how far behind?
SELECT client_addr, state, sync_state,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS replay_lag_bytes,
write_lag, flush_lag, replay_lag
FROM pg_stat_replication;
-- On a replica: how stale is the data I'm reading?
SELECT now() - pg_last_xact_replay_timestamp() AS replica_lag;
-- Replication slots — an inactive slot will fill your disk with retained WAL
SELECT slot_name, active,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots;
Long queries on a hot standby get cancelled when replay needs to remove rows they are reading — that is canceling statement due to conflict with recovery. The trade-off is hot_standby_feedback = on (the replica tells the primary what it still needs, at the cost of bloat on the primary) or a raised max_standby_streaming_delay (lets the replica fall further behind instead).
Configuration that actually matters
Ignore the hundreds of settings; a handful decide almost everything.
Settings worth knowing
| Command | What it does | Risk |
|---|---|---|
shared_buffers | Postgres's own cache. ~25% of RAM is the standard starting point. | Caution |
effective_cache_size | A HINT to the planner about total cache (~50-75% of RAM). Allocates nothing. | Safe |
work_mem | Per sort/hash NODE, not per query. A complex query can use many multiples. | Caution |
maintenance_work_mem | Used by VACUUM and index builds. Raising it makes both markedly faster. | Caution |
max_connections | Raise this last. Use a pooler instead — every connection is a process. | Caution |
statement_timeout | Cap runaway queries. Set per-role, not globally, so migrations aren't killed. | Caution |
idle_in_transaction_session_timeout | Reap sessions holding locks while doing nothing. | Caution |
log_min_duration_statement = 1000 | Log anything over 1s. The cheapest observability you can enable. | Safe |
SHOW ALL | Every current setting and its value. | Safe |
SELECT * FROM pg_settings WHERE pending_restart | Settings you changed that have NOT taken effect yet. | Safe |
No commands match that filter.
ALTER SYSTEM SET log_min_duration_statement = '1s';
SELECT pg_reload_conf(); -- enough for most settings
-- Did it actually take effect, or does it need a restart?
SELECT name, setting, pending_restart FROM pg_settings WHERE pending_restart;
work_mem is the one people get wrong most often. It is allocated per sort or hash node, per parallel worker — so a query with four sorts running three workers can consume many times the configured value. Set it modestly at the global level and raise it per-session for the specific reporting query that needs it.
Troubleshooting specific errors
The Postgres failures engineers hit most often, each with a dedicated guide:
- duplicate key value violates unique constraint — an insert collided with an existing key; find the row, then use
ON CONFLICT. - duplicate key from an out-of-sync sequence — a serial fell behind the table; resync with
setval. - relation does not exist —
search_path, a quoted case-sensitive name, or the wrong schema. - permission denied for table — the schema
USAGEplusGRANTpair, and default privileges. - column does not exist — usually quoting, or a stale view definition.
- deadlock detected — two transactions taking the same locks in different orders.
- too many clients already — connection exhaustion; the answer is pooling.
- could not serialize access — expected under
REPEATABLE READ/SERIALIZABLE; retry the transaction. - canceling statement due to conflict with recovery — replica replay versus a long read.
- no pg_hba.conf entry for host — no authentication rule matched the connection.
For anything else, search the DevOps error library or paste the message into the Incident Assistant.
Production checklist
lock_timeoutbefore every DDL. A migration that waits blocks everything queued behind it.statement_timeoutandidle_in_transaction_session_timeoutper role. Set them on the application role, not globally, so migrations and backups aren’t killed.- Alert on
age(datfrozenxid), not just on disk. Wraparound stops writes entirely. - Alert on inactive replication slots. They retain WAL until the disk fills.
CREATE INDEX CONCURRENTLYon live tables — and check for invalid indexes afterwards.pg_dumpall --globals-onlyalongsidepg_dump. Roles are not in the data dump.- Time a real restore this quarter. That measured number is your RTO.
- Pool connections before raising
max_connections. GRANT USAGE ON SCHEMAandALTER DEFAULT PRIVILEGES— the two halves everyone forgets.- Turn on
log_min_duration_statement. It costs almost nothing and answers most questions.
Frequently asked questions
What is the difference between pg_cancel_backend and pg_terminate_backend?
pg_cancel_backend cancels the currently running query but leaves the connection and its transaction open — the gentler option, and enough for a runaway SELECT. pg_terminate_backend closes the entire connection and rolls back its transaction. Use terminate for a session stuck idle in transaction, because there is no query to cancel; that session is holding locks while doing nothing.
Why does my query get slower after a big data load?
The planner is working from stale statistics. After a bulk insert, update or restore, the row estimates it uses can be wildly wrong, which makes it choose a nested loop where it should hash-join. Run ANALYZE tablename — it is cheap and non-blocking. Autovacuum will get there eventually, but “eventually” is not helpful during the load window.
Is VACUUM FULL better than VACUUM?
No — it is a different, far more disruptive operation. Plain VACUUM marks dead rows reusable and does not block reads or writes; it is what you want almost always. VACUUM FULL rewrites the entire table to return space to the operating system, taking an ACCESS EXCLUSIVE lock for the duration and needing enough free disk for a second copy. On a live system use pg_repack, which achieves the same result without the outage.
How many connections should max_connections be?
Lower than you think, with a pooler in front. Postgres forks a process per connection, so each one costs memory and scheduler time whether it is busy or idle. Most workloads run better with max_connections in the low hundreds and PgBouncer in transaction mode absorbing thousands of client connections. Raising max_connections to fix connection exhaustion usually just converts it into a memory problem.
Why did my new table lose its permissions?
Because GRANT ... ON ALL TABLES IN SCHEMA applies only to tables that exist at the moment you run it. Anything created later starts with no grants. ALTER DEFAULT PRIVILEGES fixes it going forward — but it attaches to the role that creates the object, so you must name the migration role with FOR ROLE migrator, not just run it as postgres.
Can I add a NOT NULL column to a big table safely?
Adding the column is fast — since PostgreSQL 11 a non-volatile default no longer rewrites the table. SET NOT NULL is the expensive half, because it scans every row under an ACCESS EXCLUSIVE lock. On PostgreSQL 12 and newer you can avoid the scan: add a CHECK (col IS NOT NULL) NOT VALID constraint, VALIDATE it under a weaker lock, then SET NOT NULL — which the planner satisfies from the validated constraint instead of rescanning.
Related resources
- Guide: Linux Commands — the host-level diagnosis that sits underneath every database incident.
- Guide: System Design — where the database fits in the wider architecture, and what to do before sharding.
- Stack hub: Postgres command centre — the top Postgres errors, tools and runbook in one place.
- Troubleshooting hub: PostgreSQL database errors — the full error cluster.
- Tool: Incident Assistant — paste a symptom, get an ordered triage plan.
Did this solve your problem?
That looks like it may contain a secret (key, token, password, or connection string). Please remove it — a note with a detected secret can’t be published.
Thanks — that helps. Published notes appear after a quick review.
Continue learning
Related Core Guides that build on this one.
- MySQL CommandsA MySQL and MariaDB reference for engineers who run production databases — users and grants, schema changes that do not lock the table, EXPLAIN, locks and deadlocks, utf8mb4, backups, replication, and the numbered errors the server returns.
- Linux CommandsA searchable Linux command reference for engineers — files, text, storage, processes, networking, services and troubleshooting, with Ubuntu-first examples.
- System DesignSystem design for engineers who operate what they build — scalability, availability, data, queues and failure modes, framed around real production architecture.
- Bash ScriptingWrite production-safe Bash: strict mode, error handling, traps, argument parsing and real automation templates you can drop into a pipeline.
- DevOps PracticesThe practices that define modern delivery — IaC, CI/CD, GitOps, observability, SRE, progressive delivery — with when to use each, and when not to.