Every PostgreSQL DBA ends up with a personal notebook of queries: how big is this database, who is blocking that session, when did autovacuum last run, how do I give this user read-only access? This post is mine, cleaned up and organised so you can find what you need in seconds.
It contains 59 ready-to-use recipes in 12 sections. Each one has a short title, a note where it matters, and code you can copy straight into psql or the shell.
$PGDATA and service names (for example postgresql-16) depend on how PostgreSQL was installed. Lines starting with -- are SQL comments; lines starting with # are shell comments.Contents
Connect and find your way around
3 recipes02
Server information
6 recipes03
Databases
3 recipes04
Schemas, tables and objects
8 recipes05
Indexes
4 recipes06
Users, roles and privileges
7 recipes07
Tablespaces
4 recipes08
Sessions and locks
4 recipes09
VACUUM, ANALYZE and bloat
7 recipes10
WAL and archiving
3 recipes11
Monitoring and performance
6 recipes12
Backup and data movement
4 recipes
01Connect and find your way around
psql -h dbhost -p 5432 -U postgres -d appdb -- Inside psql \conninfo -- who and where am I connected? \c otherdb -- switch database \q -- quit
\l+ -- databases with size and tablespace \dn+ -- schemas \dt+ app.* -- tables in schema app, with size \d+ app.orders -- describe a table: columns, indexes, constraints \di+ -- indexes \dv -- views \ds -- sequences \du -- roles \db+ -- tablespaces \dx -- installed extensions \x auto -- expanded output for wide rows \timing on -- show execution time of every query \? and \h -- help for meta-commands and SQL syntax
SELECT current_database(), current_user, session_user,
current_schema(), inet_server_addr(), inet_server_port();02Server information
SELECT version(); SHOW server_version; -- From the OS postgres -V cat $PGDATA/PG_VERSION
SELECT pg_postmaster_start_time() AS started_at,
date_trunc('second', now() - pg_postmaster_start_time()) AS uptime;SHOW data_directory; SHOW config_file; -- postgresql.conf SHOW hba_file; -- pg_hba.conf SHOW ident_file; -- pg_ident.conf
SHOW work_mem;
SELECT name, setting, unit, source, context, pending_restart
FROM pg_settings
WHERE name IN ('shared_buffers','work_mem','max_connections','timezone');ALTER SYSTEM SET log_min_duration_statement = '2s'; SELECT pg_reload_conf(); -- When was the config last reloaded? Any errors in the files? SELECT pg_conf_load_time(); SELECT sourcefile, name, setting, error FROM pg_file_settings WHERE error IS NOT NULL;
SHOW timezone;
SELECT now(), clock_timestamp(), current_setting('TimeZone');03Databases
CREATE DATABASE appdb OWNER app_owner TABLESPACE ts_app ENCODING 'UTF8' LC_COLLATE 'en_US.UTF-8' LC_CTYPE 'en_US.UTF-8' TEMPLATE template0; -- or from the shell createdb -O app_owner appdb
SELECT datname,
pg_size_pretty(pg_database_size(datname)) AS size,
datconnlimit
FROM pg_database
WHERE NOT datistemplate
ORDER BY pg_database_size(datname) DESC;WITH (FORCE) (PostgreSQL 13+) disconnects other sessions first.ALTER DATABASE appdb RENAME TO appdb_old; ALTER DATABASE appdb CONNECTION LIMIT 50; -- -1 = unlimited, 0 = block new logins DROP DATABASE appdb_old; DROP DATABASE appdb_old WITH (FORCE); -- PG 13+ dropdb -e appdb_old -- from the shell
04Schemas, tables and objects
CREATE SCHEMA app AUTHORIZATION app_owner; DROP SCHEMA app; -- fails if not empty DROP SCHEMA app CASCADE; -- drops everything inside, use with care SELECT nspname, pg_get_userbyid(nspowner) AS owner FROM pg_namespace WHERE nspname NOT LIKE 'pg_%' AND nspname <> 'information_schema';
SELECT n.nspname AS schema,
CASE c.relkind WHEN 'r' THEN 'table' WHEN 'i' THEN 'index' WHEN 'S' THEN 'sequence'
WHEN 'v' THEN 'view' WHEN 'm' THEN 'mat view' WHEN 'p' THEN 'partitioned table'
ELSE c.relkind::text END AS type,
count(*)
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname NOT IN ('pg_catalog','information_schema') AND n.nspname NOT LIKE 'pg_toast%'
GROUP BY 1, 2 ORDER BY 1, 2;SELECT n.nspname AS schema,
pg_size_pretty(SUM(pg_total_relation_size(c.oid))) AS total_size
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r','m','p')
AND n.nspname NOT IN ('pg_catalog','information_schema')
GROUP BY n.nspname
ORDER BY SUM(pg_total_relation_size(c.oid)) DESC;SELECT schemaname, relname,
pg_size_pretty(pg_table_size(relid)) AS table_size,
pg_size_pretty(pg_indexes_size(relid)) AS index_size,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 10;SELECT pg_size_pretty(pg_total_relation_size('app.orders')); -- table + indexes + TOAST
SELECT pg_size_pretty(pg_relation_size('app.orders_pkey')); -- one index
-- Total bytes used by one column
SELECT pg_size_pretty(SUM(pg_column_size(order_notes))) FROM app.orders;SELECT pg_relation_filepath('app.orders'); -- relative to $PGDATASELECT schemaname, viewname FROM pg_views
WHERE schemaname NOT IN ('pg_catalog','information_schema');
SELECT schemaname, sequencename, last_value, increment_by FROM pg_sequences;
SELECT setval('app.orders_id_seq', 100000); -- reset a sequence
-- Partitions of a partitioned table
SELECT inhrelid::regclass AS partition,
pg_get_expr(c.relpartbound, c.oid) AS bounds
FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid
WHERE inhparent = 'app.sales'::regclass;SELECT conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE conrelid = 'app.orders'::regclass AND contype = 'f';
05Indexes
CONCURRENTLY avoids blocking writes on a busy table. It takes longer and cannot run inside a transaction block.CREATE INDEX idx_orders_cust ON app.orders (customer_id); CREATE INDEX CONCURRENTLY idx_orders_date ON app.orders (order_date); -- Partial index: only the rows you actually query CREATE INDEX idx_orders_open ON app.orders (order_date) WHERE status = 'OPEN'; DROP INDEX CONCURRENTLY idx_orders_date;
SELECT s.schemaname, s.relname AS table_name, s.indexrelname AS index_name,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS size,
s.idx_scan,
pg_get_indexdef(s.indexrelid) AS definition
FROM pg_stat_user_indexes s
ORDER BY pg_relation_size(s.indexrelid) DESC;REINDEX INDEX CONCURRENTLY app.idx_orders_cust; -- PG 12+ REINDEX TABLE CONCURRENTLY app.orders;
SELECT pid, relid::regclass AS table_name, index_relid::regclass AS index_name,
phase, blocks_done, blocks_total,
round(100.0 * blocks_done / NULLIF(blocks_total,0), 1) AS pct
FROM pg_stat_progress_create_index;06Users, roles and privileges
CREATE ROLE app_read NOLOGIN; -- a group role CREATE ROLE dev_user LOGIN PASSWORD 'Str0ng#Pass' VALID UNTIL '2027-03-31'; CREATE ROLE dba_admin LOGIN SUPERUSER PASSWORD 'An0ther#Pass'; -- use sparingly GRANT app_read TO dev_user; -- add a user to a group role
ALTER ROLE dev_user PASSWORD 'New#Pass2026'; ALTER ROLE dev_user VALID UNTIL 'infinity'; ALTER ROLE dev_user NOLOGIN; -- lock the account ALTER ROLE dev_user SUPERUSER; -- or NOSUPERUSER REASSIGN OWNED BY dev_user TO app_owner; DROP OWNED BY dev_user; DROP ROLE dev_user;
SELECT rolname, rolsuper, rolcanlogin, rolcreatedb, rolconnlimit, rolvaliduntil FROM pg_roles WHERE rolname NOT LIKE 'pg_%' ORDER BY 1; -- Who is a member of which role? SELECT r.rolname AS role, m.rolname AS member FROM pg_auth_members am JOIN pg_roles r ON r.oid = am.roleid JOIN pg_roles m ON m.oid = am.member ORDER BY 1, 2;
GRANT CONNECT ON DATABASE appdb TO app_read; GRANT USAGE ON SCHEMA app TO app_read; GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_read; -- Make the grant apply to tables created in future too ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app GRANT SELECT ON TABLES TO app_read; -- Write access, functions, tablespaces GRANT INSERT, UPDATE, DELETE ON app.orders TO app_write; GRANT USAGE ON ALL SEQUENCES IN SCHEMA app TO app_write; GRANT EXECUTE ON FUNCTION app.calc_tax(numeric) TO app_write; GRANT CREATE ON TABLESPACE ts_app TO app_owner;
REVOKE INSERT, UPDATE, DELETE ON app.orders FROM app_write; REVOKE ALL ON ALL TABLES IN SCHEMA app FROM dev_user; REVOKE CONNECT ON DATABASE appdb FROM PUBLIC; -- stop every role connecting by default
-- Table privileges
SELECT table_schema, table_name, string_agg(privilege_type, ', ') AS privileges
FROM information_schema.role_table_grants
WHERE grantee = 'app_read'
GROUP BY 1, 2 ORDER BY 1, 2;
-- Quick yes/no checks
SELECT has_table_privilege('app_read', 'app.orders', 'SELECT'),
has_schema_privilege('app_read', 'app', 'USAGE'),
has_database_privilege('app_read', 'appdb', 'CONNECT');SHOW search_path; ALTER ROLE dev_user SET search_path = app, public; ALTER ROLE dev_user IN DATABASE appdb SET search_path = app; -- Settings stored per role / database SELECT r.rolname, d.datname, s.setconfig FROM pg_db_role_setting s LEFT JOIN pg_roles r ON r.oid = s.setrole LEFT JOIN pg_database d ON d.oid = s.setdatabase;
07Tablespaces
-- As root: mkdir -p /pgdata/ts_app && chown postgres:postgres /pgdata/ts_app CREATE TABLESPACE ts_app OWNER app_owner LOCATION '/pgdata/ts_app'; ALTER TABLESPACE ts_app RENAME TO ts_app_data; ALTER TABLESPACE ts_app_data OWNER TO dba_admin; DROP TABLESPACE ts_app_data;
SELECT spcname, pg_get_userbyid(spcowner) AS owner,
pg_tablespace_location(oid) AS location,
pg_size_pretty(pg_tablespace_size(oid)) AS size
FROM pg_tablespace;
-- Default tablespace of each database
SELECT d.datname, t.spcname
FROM pg_database d JOIN pg_tablespace t ON t.oid = d.dattablespace;SHOW default_tablespace; SET default_tablespace = ts_app; -- this session ALTER DATABASE appdb SET default_tablespace = ts_app; -- new sessions in appdb SHOW temp_tablespaces; ALTER SYSTEM SET temp_tablespaces = 'ts_temp'; SELECT pg_reload_conf();
ALTER TABLE app.orders SET TABLESPACE ts_app; ALTER INDEX app.idx_orders_cust SET TABLESPACE ts_idx; -- Whole database: nobody may be connected to it ALTER DATABASE appdb SET TABLESPACE ts_app;
08Sessions and locks
SELECT pid, usename, datname, client_addr, application_name, state,
now() - query_start AS running_for,
wait_event_type, wait_event,
left(query, 80) AS query
FROM pg_stat_activity
WHERE backend_type = 'client backend'
ORDER BY query_start;pg_cancel_backend first: it stops the query and keeps the connection.SELECT pg_cancel_backend(12345); -- cancel the running query SELECT pg_terminate_backend(12345); -- end the whole session -- End all sessions of one user (except your own) SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = 'dev_user' AND pid <> pg_backend_pid();
SELECT a.pid AS blocked_pid, a.usename AS blocked_user,
now() - a.query_start AS waiting_for, left(a.query, 60) AS blocked_query,
b.pid AS blocking_pid, b.usename AS blocking_user, b.state AS blocking_state,
left(b.query, 60) AS blocking_query
FROM pg_stat_activity a
CROSS JOIN LATERAL unnest(pg_blocking_pids(a.pid)) AS bp(pid)
JOIN pg_stat_activity b ON b.pid = bp.pid;SELECT l.pid, a.usename, l.locktype, l.relation::regclass AS object,
l.mode, l.granted, left(a.query, 60) AS query
FROM pg_locks l JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.pid <> pg_backend_pid()
ORDER BY l.granted, l.pid;09VACUUM, ANALYZE and bloat
ANALYZE app.orders; -- refresh optimizer statistics VACUUM app.orders; -- mark dead rows as reusable VACUUM (VERBOSE, ANALYZE) app.orders; vacuumdb -d appdb -z -j 4 -- whole database from the shell, 4 parallel jobs
VACUUM FULL rewrites the table and holds an ACCESS EXCLUSIVE lock for the whole run. On production prefer the pg_repack extension.SELECT pg_size_pretty(pg_total_relation_size('app.orders')); -- before
VACUUM FULL app.orders;
SELECT pg_size_pretty(pg_total_relation_size('app.orders')); -- afterSELECT schemaname, 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_vacuum, last_autovacuum, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;SELECT p.pid, p.relid::regclass AS table_name, p.phase,
p.heap_blks_scanned, p.heap_blks_total,
round(100.0 * p.heap_blks_scanned / NULLIF(p.heap_blks_total,0), 1) AS pct
FROM pg_stat_progress_vacuum p;SELECT name, setting FROM pg_settings WHERE name LIKE 'autovacuum%' ORDER BY 1;
ALTER TABLE app.orders SET (autovacuum_vacuum_scale_factor = 0.02,
autovacuum_analyze_scale_factor = 0.01);
ALTER TABLE app.orders RESET (autovacuum_vacuum_scale_factor);
-- Tables with custom settings
SELECT relname, reloptions FROM pg_class WHERE reloptions IS NOT NULL;ALTER TABLE app.orders ALTER COLUMN status SET STATISTICS 1000; ANALYZE app.orders;
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT table_len, tuple_percent, dead_tuple_percent, free_percent
FROM pgstattuple('app.orders');10WAL and archiving
archive_mode needs a restart; archive_command only needs a reload. The test ! -f part stops an existing archive file from being overwritten.-- As the postgres OS user mkdir -p /pgarchive ALTER SYSTEM SET wal_level = 'replica'; ALTER SYSTEM SET archive_mode = 'on'; ALTER SYSTEM SET archive_command = 'test ! -f /pgarchive/%f && cp %p /pgarchive/%f'; -- Restart (archive_mode / wal_level) pg_ctl -D $PGDATA restart -- or: sudo systemctl restart postgresql-16
SELECT archived_count, last_archived_wal, last_archived_time,
failed_count, last_failed_wal, last_failed_time
FROM pg_stat_archiver;
SELECT pg_switch_wal(); -- close the current WAL file and archive it
SELECT pg_current_wal_lsn(), pg_walfile_name(pg_current_wal_lsn());SELECT count(*) AS wal_files, pg_size_pretty(SUM(size)) AS total_size FROM pg_ls_waldir();
11Monitoring and performance
shared_preload_libraries. If the parameter already lists other libraries, keep them in the list.ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements'; -- restart PostgreSQL, then in each database you want to query: CREATE EXTENSION pg_stat_statements;
SELECT queryid, calls,
round(total_exec_time::numeric/1000, 1) AS total_sec,
round(mean_exec_time::numeric, 1) AS mean_ms,
rows, left(query, 80) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
SELECT pg_stat_statements_reset(); -- start a fresh measurement windowwork_mem and the queries involved.SELECT datname, temp_files, pg_size_pretty(temp_bytes) AS temp_size, stats_reset FROM pg_stat_database WHERE datname IS NOT NULL ORDER BY temp_bytes DESC;
SELECT datname,
round(100.0 * blks_hit / NULLIF(blks_hit + blks_read,0), 2) AS cache_hit_pct
FROM pg_stat_database WHERE datname IS NOT NULL;SELECT pg_rotate_logfile(); SELECT pg_current_logfile();
SELECT extname, extversion FROM pg_extension; SELECT name, default_version, installed_version FROM pg_available_extensions ORDER BY name;
12Backup and data movement
COPY reads and writes files on the database server and needs superuser or the pg_write_server_files role. \copy in psql uses files on your machine and needs no special rights.COPY app.orders TO '/tmp/orders.csv' WITH (FORMAT csv, HEADER); COPY app.orders (id, customer_id) TO '/tmp/orders_ids.csv' WITH (FORMAT csv); COPY app.orders FROM '/tmp/orders.csv' WITH (FORMAT csv, HEADER); \copy app.orders TO 'orders.csv' WITH (FORMAT csv, HEADER) \copy (SELECT * FROM app.orders WHERE order_date > now() - interval '7 days') TO 'last7.csv' CSV HEADER
-Fc) or directory format (-Fd) is the most flexible: compressed, selective restore, parallel restore.pg_dump -d appdb -Fc -f appdb.dump # whole database pg_dump -d appdb -Fd -j 4 -f appdb_dir # directory format, 4 parallel jobs pg_dump -d appdb -n app -Fc -f app_schema.dump # one schema pg_dump -d appdb -t app.orders -Fc -f orders.dump # one table pg_dump -d appdb --schema-only -f appdb_ddl.sql # DDL only pg_dumpall --globals-only -f globals.sql # roles and tablespaces
pg_restore -l appdb.dump | head # list the contents pg_restore -d appdb_new -j 4 appdb.dump # parallel restore pg_restore -d appdb_new -t orders appdb.dump # one table only psql -f globals.sql postgres # restore roles first
pg_basebackup -h dbhost -U replicator -D /backup/base_$(date +%F) \
-Ft -z -Xs -PConclusion
Most day-to-day PostgreSQL questions are answered by a handful of catalog views: pg_stat_activity, pg_stat_user_tables, pg_settings, pg_roles and pg_stat_statements. Learn what each one holds and the rest of this list becomes easy to remember.
Bookmark this page, and tell me in the comments which command you would add.
Tamilselvan Perumal · Lead Product Engineer & Senior DBA
