Topic 04

psql and the System Catalog

Tooling

psql is not the fallback for when the GUI is unavailable. It is the interface the database's own maintainers use, and every backslash command in it is shorthand for a query against the system catalogue. \d orders is pg_class joined to pg_attribute, pg_index and pg_constraint, formatted for a terminal.

Knowing that changes what the terminal is for. Anything \d can display, you can compute, filter, sort, join against your own tables and put on a schedule, which is the difference between looking at a database and monitoring one. This topic covers the commands worth memorizing, the catalogue underneath them, and the four-line configuration file that prevents a whole class of misread results.

A backslash command is a catalogue query
\d ordersone backslash command
psql expands itinto a query it sends itself
A join over the cataloguepg_class · pg_attribute · pg_index · pg_constraint
Formatted for a terminalthe same rows you could select yourself

The Commands That Earn Their Keep

A dozen backslash commands cover most of what an investigation needs, and they compose: every listing command takes an optional pattern, and a + suffix asks for the expensive extra columns such as sizes and descriptions.

The working set, connected to cartwheel as cartwheel_admin
\l                 -- databases in this cluster
\dn                -- schemas in this database
\dt                -- tables the search_path can see
\dt analytics.*    -- tables in one named schema
\d+ orders         -- columns, indexes, constraints, storage, size
\di+ orders*       -- the indexes on orders, with their sizes
\df analytics.*    -- functions in the analytics schema
\du                -- roles, cluster-wide
\dx                -- extensions installed in this database

\x auto            -- expand output only when a row is too wide
\timing on         -- report the duration of every statement
\e                 -- edit the last query in $EDITOR
\watch 5           -- re-run the last query every five seconds

\d+ on a table is the highest-value keystroke here: one screen with every column and its type, every index and what it covers, every constraint, the storage settings, and the total size. Start any investigation there. \x auto is what makes a wide row from pg_stat_activity readable instead of wrapped into noise, and \watch 5 turns any query into a live monitor without leaving the terminal — point it at a lock-waiting query during an incident and you have a poor engineer's dashboard.

One behaviour catches everyone once. With no pattern, these commands list what is visible — what the current search_path can reach. Cartwheel's analytics schema is not on the default path, so a bare \dt shows the eight public tables and gives no hint that anything else exists. \dt *.* or a schema-qualified pattern shows the rest.

Client-Side \copy, Server-Side COPY

The two differ in one respect that decides everything else: which machine's filesystem is involved. COPY with a file name reads and writes on the server, as the operating-system user the server runs as, which is why it is restricted to superusers and to roles granted pg_read_server_files, pg_write_server_files or pg_execute_server_program. \copy is a psql instruction that runs COPY … FROM STDIN and streams the data through the existing connection, so the file is opened by psql with the privileges of whoever is sitting at the terminal.

Same rows, two entirely different file paths
-- server-side: the file must exist on pg-primary,
-- readable by the postgres OS user; needs elevated rights
COPY products (sku, name, price)
  FROM '/var/lib/postgresql/import/products.csv'
  WITH (FORMAT csv, HEADER);

-- client-side: the file is on Nadia's laptop and travels
-- over the connection; no special database privilege
\copy products (sku, name, price) FROM 'products.csv' WITH (FORMAT csv, HEADER)

Loading Cartwheel's product catalogue from a laptop is the second form. Reaching for the first and hitting a permission error is the classic mistake of the first week, and the damaging part is the repair: granting the application superuser to make the message go away solves a permission problem by deleting the permission model. On a managed provider the question does not even arise, because there is no server filesystem you can put a file on.

The Catalogue Is Just Tables

The catalogue is not an API. pg_class holds one row per relation of any kind (tables, indexes, sequences, views) with the planner's estimates reltuples and relpages on it. pg_attribute holds the columns, pg_index the index definitions, pg_constraint the constraints, pg_namespace the schemas. They are ordinary tables, they join like ordinary tables, and every "find the unused indexes" or "list the tables with no primary key" query in this book is written against them.

Every public table with no primary key, largest first
SELECT c.relname, c.reltuples::bigint AS est_rows
  FROM pg_class c
  JOIN pg_namespace n ON n.oid = c.relnamespace
 WHERE c.relkind = 'r'
   AND n.nspname = 'public'
   AND NOT EXISTS (
         SELECT 1 FROM pg_constraint k
          WHERE k.conrelid = c.oid AND k.contype = 'p')
 ORDER BY c.reltuples DESC;

That query walks every ordinary table in the public schema, checks whether any constraint on it is of type primary key, and reports the ones with none, ordered by estimated size. It is a handful of lines, and it answers a question no GUI screen answers directly. Run it against Cartwheel and inventory comes back, because that table has never had a primary key — courier_shifts has none either, and the two gaps get closed differently: inventory gets a real primary key when Chapter 3 puts constraints on the schema, and courier_shifts gets something stronger than a key in Chapter 2, a constraint that makes two overlapping shifts for one courier impossible to store.

Read reltuples for what it is. It is the planner's estimate, refreshed by VACUUM, ANALYZE and a few DDL commands, and it is -1 on a table that has never been vacuumed or analyzed at all. After a bulk load into orders it can be millions of rows out of date, and the gap widens with every write until the next ANALYZE. A true count costs a full scan of the table and returns one number.

information_schema Against pg_catalog

information_schema is defined by the SQL standard, so it is portable and stable across engines and across major versions. pg_catalog is native to Postgres, is modelled on the implementation rather than on the standard, and is implicitly in every session's search path. The information schema is a set of views over the same underlying data, with a layer of standard-shaped joins in between.

The split in practice is clean. Write against information_schema when the tool has to run on more than one engine and asks only standard questions — which columns, which types, which foreign keys. Write against pg_catalog and the pg_stat_* views for anything Postgres-specific, which is everything this book cares about: index usage, dead tuples, last autovacuum time, bloat, TOAST. Those facts have no standard representation, so they are simply absent from the portable view.

Sizes and Live Counters

The three size functions answer three different questions, and mixing them up produces reports that disagree with the disk. pg_relation_size returns one fork of the relation, the main data fork by default, which is the heap alone. pg_indexes_size is every index attached to the table. pg_total_relation_size is the whole footprint: table, indexes and TOAST together. pg_size_pretty formats any of them in units that are powers of two.

Three size functions, three different questions
pg_relation_sizeone fork
The main data fork by default — the heap alone. Comparing it with what the operating system reports for the table's files will never agree, because indexes and TOAST are not in it.
pg_indexes_sizeevery index
All the indexes attached to the table, added together. A table whose index figure exceeds its heap figure is worth a second look.
pg_total_relation_sizethe whole footprint
Table, indexes and TOAST together. pg_size_pretty formats any of the three in units that are powers of two.
Where the disk actually went, per table
SELECT c.relname,
       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,
       s.n_live_tup, s.n_dead_tup, s.seq_scan, s.idx_scan,
       s.last_autovacuum
  FROM pg_class c
  JOIN pg_stat_user_tables s ON s.relid = c.oid
 ORDER BY pg_total_relation_size(c.oid) DESC;

One query, and the shape of a database becomes visible: which table owns the disk, how much of that is index rather than data, how many dead rows are sitting in each, whether anything is being read by sequential scan when it should be using an index, and when autovacuum last touched it. Every number in it except the three sizes is an estimate maintained by the statistics system rather than a measurement taken now, and every one of them is what Chapters 7 and 10 act on.

The .psqlrc That Pays for Itself

psql reads ~/.psqlrc at startup unless it is told not to. Four lines in it remove more confusion than any other configuration in this chapter.

~/.psqlrc
\timing on
\set ON_ERROR_STOP on
\pset null '¤'
\x auto

\timing on means every statement reports its duration, so you never guess whether something was slow. ON_ERROR_STOP makes psql abort on the first error instead of ploughing on: without it a migration script runs statement four after statement three failed, and exits reporting success over a half-applied schema. \pset null matters more than it looks, because psql prints nothing for a NULL by default, and "nothing" is indistinguishable from an empty string when the question is whether a bug is in the data or in the query. \x auto keeps wide rows readable. Set ON_ERROR_STOP inside every script as well; a startup file lives on one laptop, and the script runs on whatever machine the deploy uses.

Common Mistakes
  • Reading pg_class.reltuples as a row count — it is the planner's estimate from the last vacuum or analyze, it is -1 on a table that has never had either, and after a bulk load into orders it can be millions of rows wrong.
  • Running a migration script without ON_ERROR_STOP — psql continues happily past a failed statement and exits with a success code, leaving a schema that is half old and half new.
  • Using COPY where \copy was meant and then granting the application superuser to clear the permission error — a file-path problem answered by dismantling the privilege model.
  • Trusting a bare \dt to show every table — it lists only what the current search_path reaches, so Cartwheel's whole analytics schema is invisible until you qualify it or ask for *.*.
  • Building monitoring on information_schema for Postgres-specific facts — bloat, index usage and vacuum timestamps have no standard representation, so the portable view simply does not contain them.
  • Comparing pg_relation_size against what the operating system reports for the table's directory — the function returns one fork, so indexes and TOAST are missing from the number and the two will never agree.
Best Practices
  • Keep a ~/.psqlrc with \timing on, ON_ERROR_STOP, a visible \pset null marker and \x auto, and set ON_ERROR_STOP inside every script as well.
  • Make \d+ orders the first move in any investigation — columns, indexes, constraints, storage settings and size arrive in one screen before you have formed a theory.
  • Write a catalogue query rather than clicking through a GUI whenever the same question will be asked twice, because a saved query can be scheduled and a click cannot.
  • Use pg_catalog and the pg_stat_* views for anything Postgres-specific, and reserve information_schema for tooling that genuinely has to run against another engine.
  • Quote pg_total_relation_size when someone asks how big a table is, and say which of heap, indexes and TOAST you are including whenever the number goes into a capacity plan.
  • Point \watch 5 at a monitoring query during an incident instead of pressing up-arrow-enter — the interval is fixed, so the readings are comparable.
Comparable toolspgcli psql with autocomplete and highlightingpgAdmin a GUI over the same catalogueDBeaver cross-engine GUI, same underlying queriesmysql client SHOW commands instead of backslash commandspg_stat_statements the query-level view added in Chapter 10

Knowledge Check

Nadia loads a product CSV from her laptop into Cartwheel. Why is \copy the right command and COPY the wrong one?

  • COPY parses each row on the client, which makes it far slower over a link
  • COPY reads the file on the server and needs privileges she should not have
  • COPY cannot read the CSV format and would need the file converted first
  • COPY commits each row separately and cannot be rolled back as one unit

A dashboard reports Cartwheel's orders table at 38.4 million rows, taken from pg_class.reltuples. Why can that number be badly wrong?

  • It counts dead row versions as well, so it always overstates the live rows
  • It is an estimate refreshed only by vacuum, analyze and a few DDL commands
  • It is reset to zero every time the cluster restarts, then climbs back slowly
  • It reports only the rows in the table's first 8 KB page of storage

A migration script of forty statements is piped into psql. Statement 12 fails, and psql exits reporting success. What was missing?

  • ON_ERROR_STOP, so psql carried on past the failure to the end
  • \timing on, without which psql cannot detect a statement that errored
  • A role with enough privilege to see the error message the server returned
  • An explicit BEGIN, which is the only way psql learns a statement failed

A capacity report built on pg_relation_size disagrees with what df shows for the database directory. Why?

  • pg_size_pretty rounds in powers of ten, so every figure reads slightly low
  • The size functions report the value recorded at the last nightly base backup
  • pg_relation_size returns one fork, leaving out indexes and TOAST entirely
  • The WAL segments are counted into every table's reported size on disk

Which question should be answered from pg_catalog and the pg_stat_* views rather than from information_schema?

  • Which columns exist on orders, and what data type each of them has
  • How many dead rows inventory holds and when autovacuum last ran on it
  • Which foreign keys point at customers, and which columns they reference
  • Which views exist in the analytics schema and how each one is defined

You got correct