Back to Database Instructions
v 18
What is PostgreSQL?

PostgreSQL is a free, open-source relational database server. Unlike SQLite, which is a single file your program opens, PostgreSQL runs as a background program that owns its data and answers SQL from many clients at once, over the network or on the same machine. It's known for strict correctness, rich data types (including JSON), and a feature set that matches the big commercial databases.

The one idea to get before installing: the server keeps its own list of users, called roles, separate from your computer's accounts. A fresh install has just one, a superuser. Each OS path below ends with a role and a database of your own, so that a plain psql just works.
Select your OS:
Installing PostgreSQL 18 on Windows Windows
1

Download the installer

Go to postgresql.org/download/windows and download the EDB installer for PostgreSQL 18, Windows x86-64. Or let winget fetch the same installer from PowerShell:

winget install PostgreSQL.PostgreSQL.18
2

Run the installer

Accept the defaults for the components (Server, pgAdmin 4, Command Line Tools) and the data directory. Then:

  • Password for the postgres superuser: pick one and write it down. There's no reset link; this password is the only way in until you create another role.
  • Port: leave it at 5432.
  • Stack Builder: untick it at the end. It installs optional extras you don't need yet.

The server is installed as a Windows service called postgresql-x64-18 and starts with Windows.

3

Add psql to your PATH

The installer doesn't put psql on your PATH. Run this once in PowerShell, then close PowerShell and open a new window:

$p = [Environment]::GetEnvironmentVariable("Path", "User") [Environment]::SetEnvironmentVariable("Path", "$p;C:\Program Files\PostgreSQL\18\bin", "User")
4

Verify the installation

psql --version
Expected Output
psql (PostgreSQL) 18.6
5

Connect as the postgres superuser

Log in with the password you chose in step 2. The prompt ends in # because postgres is a superuser:

psql -U postgres
Expected Output
$ psql -U postgres Password for user postgres: psql (18.6) Type "help" for help. postgres=#
6

Create your own role and database

At the postgres=# prompt, create a normal role that can create databases, and a database with the same name. Replace you with your own name:

CREATE ROLE you WITH LOGIN CREATEDB PASSWORD 'pick-a-password'; CREATE DATABASE you OWNER you; \q

From now on, connect as yourself with psql -U you. To make a bare psql work, set the PGUSER variable once with setx PGUSER you and open a new window.

The code page warning: the first time psql connects it may print Console code page (437) differs from Windows code page (1252). It's harmless for English text; running chcp 1252 before psql makes it go away.
Installing PostgreSQL 18 on macOS macOS
Prefer an app? Postgres.app is a menu-bar app with the server inside. Click Initialize and it creates a superuser role and a database named after you, so you can skip step 5.
1

Install with Homebrew

With Homebrew installed, ask for version 18 by name. A plain brew install postgresql gives an older default.

brew install postgresql@18
2

Start the server

This starts it now and at every login:

brew services start postgresql@18
3

Put psql on your PATH

Versioned formulas are keg-only, so Homebrew doesn't link psql for you. Run the line for your Mac, then open a new terminal.

Apple Silicon (M1 and later):

echo 'export PATH="/opt/homebrew/opt/postgresql@18/bin:$PATH"' >> ~/.zshrc

Intel Mac:

echo 'export PATH="/usr/local/opt/postgresql@18/bin:$PATH"' >> ~/.zshrc
4

Verify the installation

psql --version
Expected Output
psql (PostgreSQL) 18.6
5

Create your database

Homebrew made your macOS username a superuser role, but there's no database with that name yet. With no arguments, createdb creates one named after you:

createdb
6

Connect

psql
Expected Output
$ psql psql (18.6) Type "help" for help. you=#

The prompt ends in # because Homebrew's role is a superuser.

Installing PostgreSQL 18 on Linux Linux
1

Add the PostgreSQL apt repository (Debian / Ubuntu)

Your distribution's own package lags behind; Ubuntu 24.04 ships PostgreSQL 16. The PostgreSQL project's PGDG repository has every supported version. Its helper script sets it up; press Enter when it asks.

sudo apt install -y postgresql-common sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
2

Install PostgreSQL 18

sudo apt update sudo apt install -y postgresql-18

The package creates a Linux user called postgres, creates a database cluster in /var/lib/postgresql/18/main, and starts the server.

3

Check the server is running

pg_lsclusters
Expected Output
Ver Cluster Port Status Owner Data directory Log file 18 main 5432 online postgres /var/lib/postgresql/18/main /var/log/postgresql/postgresql-18-main.log
4

Create your own role and database

Only the postgres Linux user can log in as the postgres role, so run these as that user:

sudo -u postgres createuser --createdb $USER sudo -u postgres createdb --owner=$USER $USER
5

Connect

psql --version psql
Expected Output
$ psql --version psql (PostgreSQL) 18.6 (Ubuntu 18.6-1.pgdg24.04+2) $ psql psql (18.6) Type "help" for help. you=>
6

Fedora, RHEL, or Arch

Fedora / RHEL: sudo dnf install postgresql-server, then sudo postgresql-setup --initdb and sudo systemctl enable --now postgresql. For a newer version, postgresql.org/download generates the PGDG dnf commands.

Arch: sudo pacman -S postgresql, then sudo -iu postgres initdb -D /var/lib/postgres/data and sudo systemctl enable --now postgresql.

Then create your role and database as in step 4.

Using WSL on Windows? You're on Linux: follow these steps inside the WSL terminal.
If the first connection fails

Almost every first-day problem is one of these. Read the error message carefully: connection to server ... failed followed by FATAL means the server answered and refused you, which is a login problem, not an install problem.

Error messageWhat it meansFix
role "you" does not existThe server is running, but no role has your login name.Create one: Linux step 4, or Windows step 6.
database "you" does not existYour role exists, but there's no database with its name.Run createdb, or connect to another database with -d.
Peer authentication failed for user "postgres"On Linux, local logins must use the role that matches your Linux user.Use sudo -u postgres psql, or connect as your own role.
password authentication failedWrong password, or the role has no password set.Check the password; a superuser can set one with ALTER ROLE you PASSWORD '...'.
Connection refused / No such file or directoryNothing is listening: the server isn't running.Linux: sudo systemctl start postgresql. macOS: brew services start postgresql@18. Windows: start postgresql-x64-18 in Services.
psql: command not foundThe client isn't on your PATH.Redo the PATH step for your OS and open a new terminal.
Beginner
Your first steps: connect, create a database and a table, then add, read, change, and delete rows
Step 1 — Connecting with psql

psql is PostgreSQL's command-line client. Type psql in a terminal and it connects to the server and gives you a prompt where you can type SQL.

A PostgreSQL install is two programs. The server (postgres) runs in the background, owns the data directory, and listens for connections on port 5432. A client connects to it, sends SQL, and shows the results. psql is the client that ships with PostgreSQL; your Python, Go, or Node program is a client too, through a driver library. This split is the big difference from SQLite: with SQLite your program opens a file, but with PostgreSQL it opens a connection, and many connections can be open at once.

Every connection needs four things: the host (which machine), the port, the role (which PostgreSQL user you are), and the database. psql fills in defaults for all four: the local machine, port 5432, a role with your operating-system login name, and a database with the same name as the role. That's why a bare psql works once you've created a role and database named after yourself in the Installation tab, and why you can override any of them with -h, -p, -U, and -d. The prompt shows which database you're in, followed by => for a normal role or =# for a superuser.

Server
The background program that owns the data and answers queries; clients connect to it.
Client
Any program that connects to the server and sends it SQL, such as psql.
Role
PostgreSQL's name for a user account; roles are separate from your computer's accounts.
# Connect with all the defaults: this machine, port 5432, # role = your login name, database = your role name psql # Name everything explicitly psql -h localhost -p 5432 -U you -d you # Run one command and exit without opening the shell psql -c "SELECT current_date;" # Leave the shell (Ctrl+D also works) \q
Terminal Output
$ psql psql (18.6) Type "help" for help. you=> SELECT current_user, current_database(); current_user | current_database --------------+------------------ you | you (1 row) you=> SELECT 2 + 2 AS answer; answer -------- 4 (1 row)
Tip: psql --help lists every option. The ones in the code above are the only four you need day to day.
Step 2 — Essential Meta-Commands

Commands that start with a backslash are for psql itself, not SQL. They list and describe things, and change how results are shown.

You can type two kinds of input at the psql prompt. SQL statements such as SELECT and CREATE TABLE go to the server. They end with a semicolon, and they're the same SQL your programs will send. Meta-commands start with a backslash and are handled by psql itself; the server never sees them. They take up one line and need no semicolon. Behind the scenes, many of them run ordinary SQL against PostgreSQL's built-in system catalogs, the tables where the server records its own tables, columns, and roles.

The names follow a pattern: \d means describe, and the letter after it says what to describe. \dt lists tables, \dv views, \di indexes, \dn schemas, and \du roles; \d users describes one table in full. \l lists databases and \c connects to a different one. Two display toggles are worth learning early. \x switches to expanded display, printing each row as a list of column | value lines, which is far easier to read for wide rows. \timing prints how long each query took. \? lists every meta-command, and \h gives the syntax of any SQL statement, such as \h CREATE TABLE.

Meta-command
A backslash command handled by psql itself; it is not SQL and needs no semicolon.
System catalog
PostgreSQL's built-in tables that describe the database itself.
Expanded display
The \x mode that prints each row as a vertical list of column/value pairs.
\l -- list databases \c shop -- connect to another database \dt -- list tables \d users -- describe one table: columns, types, indexes \dn -- list schemas \du -- list roles \x -- toggle expanded (vertical) output \timing -- show how long each query takes \? -- help for meta-commands \h CREATE TABLE -- SQL syntax help \q -- quit
Terminal Output
you=> \dt Did not find any relations. you=> \x Expanded display is on. you=> SELECT current_user, current_database(), 42 AS answer; -[ RECORD 1 ]----+---- current_user | you current_database | you answer | 42 you=> \x Expanded display is off. you=> \timing Timing is on. you=> SELECT 42 AS answer; answer -------- 42 (1 row) Time: 0.223 ms you=> \timing Timing is off.
First habit: when a result is too wide for your screen, type \x and run it again. Type \x once more to switch back.
Step 3 — Create a Database and Your First Table

Create a database for this guide, connect to it, and define a table. PostgreSQL checks every value against the column's type and rules.

A PostgreSQL server holds many databases, and each one is completely separate: a query in one can't see tables in another. It's normal to make one database per project. Inside a database, tables live in schemas, which are named folders; every database starts with one called public, which is where your tables go unless you say otherwise. Since PostgreSQL 15, ordinary roles may not create tables in the public schema of a database they don't own. Creating your own database, as you do here, makes you its owner and avoids that error.

A table's columns each have a data type, and PostgreSQL enforces it strictly: an integer column refuses 'abc', and a date column refuses '2026-02-30'. The everyday types are integer (and bigint for very large numbers), numeric(10,2) for exact decimals such as money, text for strings of any length, boolean, date, and timestamptz for a moment in time. GENERATED ALWAYS AS IDENTITY makes the server number the rows itself; it's the modern replacement for the older serial. Constraints add rules: PRIMARY KEY identifies each row, NOT NULL requires a value, UNIQUE forbids duplicates, CHECK enforces any condition you write, and DEFAULT fills in a value you leave out.

Database
A separate container of schemas and tables on the server; one per project is typical.
Schema
A named folder of tables inside a database; the default one is called public.
Identity column
A column whose values the server generates automatically: 1, 2, 3, and so on.
CREATE DATABASE shop; \c shop CREATE TABLE users ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, email text UNIQUE, age integer CHECK (age >= 0), city text, joined date NOT NULL DEFAULT current_date ); -- Safe to run twice: skips creation if the table exists CREATE TABLE IF NOT EXISTS users (id integer);
Terminal Output
you=> CREATE DATABASE shop; CREATE DATABASE you=> \c shop You are now connected to database "shop" as user "you". shop=> CREATE TABLE users ( shop(> id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, shop(> name text NOT NULL, shop(> email text UNIQUE, shop(> age integer CHECK (age >= 0), shop(> city text, shop(> joined date NOT NULL DEFAULT current_date shop(> ); CREATE TABLE shop=> \dt List of relations Schema | Name | Type | Owner --------+-------+-------+------- public | users | table | you (1 row) -- running the same CREATE TABLE again: shop=> CREATE TABLE users (id integer); ERROR: relation "users" already exists shop=> CREATE TABLE IF NOT EXISTS users (id integer); NOTICE: relation "users" already exists, skipping CREATE TABLE
Why text and not varchar(255)? In PostgreSQL they perform the same. Use text unless there's a real business rule for a maximum length, and then use a CHECK constraint or varchar(n) to say so.
Step 4 — Insert Data

INSERT adds rows. RETURNING hands back the values the server filled in, such as the new row's id.

INSERT INTO names the table, lists the columns you're filling, and gives the values in the same order after VALUES. Columns you leave out get their DEFAULT, or NULL if they have none, and an identity column is numbered for you. One statement can insert many rows: list several parenthesised groups separated by commas. psql reports what happened with a command tag such as INSERT 0 3; the 3 is the number of rows, and the 0 is a leftover from older versions that you can ignore.

RETURNING is a PostgreSQL feature worth using from day one. It makes an INSERT, UPDATE, or DELETE return rows, just like a SELECT, so you get the generated id back without a second query. Two rules about quoting: text values go in single quotes, and an apostrophe inside one is written twice, as in 'O''Brien'. Double quotes are for the names of tables and columns. When a program inserts user input, it must pass values as parameters and never paste them into the SQL text, or a crafted value can rewrite the query. That attack is called SQL injection. When a row breaks a constraint, the server rejects it and names the constraint in the error.

Command tag
The short status psql prints after a statement, such as INSERT 0 3 or UPDATE 1.
RETURNING
A clause that makes INSERT, UPDATE, or DELETE return the rows it affected.
SQL injection
An attack where untrusted input is pasted into SQL text; prevented by using parameters.
-- One row, and ask for the generated id back INSERT INTO users (name, email, age, city, joined) VALUES ('Alice', 'alice@example.com', 34, 'London', '2024-03-15') RETURNING id; -- Several rows in one statement INSERT INTO users (name, email, age, city, joined) VALUES ('Bob', 'bob@example.com', 27, 'Paris', '2024-06-01'), ('Carol', 'carol@example.com', 45, 'London', '2023-11-20'), ('Dave', NULL, 19, 'Berlin', '2025-01-09'), ('Erin', 'erin@example.com', 52, 'Paris', '2022-08-30');
Terminal Output
shop=> INSERT INTO users (name, email, age, city, joined) shop-> VALUES ('Alice', 'alice@example.com', 34, 'London', '2024-03-15') shop-> RETURNING id; id ---- 1 (1 row) INSERT 0 1 shop=> INSERT INTO users (name, email, age, city, joined) VALUES shop-> ('Bob', 'bob@example.com', 27, 'Paris', '2024-06-01'), shop-> ('Carol', 'carol@example.com', 45, 'London', '2023-11-20'), shop-> ('Dave', NULL, 19, 'Berlin', '2025-01-09'), shop-> ('Erin', 'erin@example.com', 52, 'Paris', '2022-08-30'); INSERT 0 4 -- breaking the UNIQUE and CHECK constraints: shop=> INSERT INTO users (name, email) VALUES ('Eve', 'bob@example.com'); ERROR: duplicate key value violates unique constraint "users_email_key" DETAIL: Key (email)=(bob@example.com) already exists. shop=> INSERT INTO users (name, age) VALUES ('Frank', -3); ERROR: new row for relation "users" violates check constraint "users_age_check" DETAIL: Failing row contains (7, Frank, null, -3, null, 2026-09-24).
Notice the ids. The two rejected rows each used up an id number, so the next row you insert gets 8, not 6. Identity values are never reused, so expect gaps and never rely on ids being consecutive.
Step 5 — Read Data with SELECT

SELECT asks questions. Pick the columns, filter with WHERE, sort with ORDER BY, and page with LIMIT and OFFSET.

SELECT is the statement you'll write most, and its answer is always a small table called a result set. A query is made of clauses, each with one job. SELECT lists the columns (* means all of them), FROM names the table, WHERE keeps only the rows where a condition is true, ORDER BY sorts (add DESC for descending), and LIMIT caps the number of rows. OFFSET skips rows first, which is how page 2 of a list is built. AS renames a column in the output; the new name is called an alias.

SQL is declarative: you describe the result you want and the server's query planner decides how to fetch it. Logically, the clauses run in a different order from how you write them: FROM, then WHERE, then SELECT, then ORDER BY, then LIMIT. That's why an alias defined in SELECT can be used in ORDER BY but not in WHERE. Rows have no guaranteed order unless you ask for one. Without ORDER BY, PostgreSQL returns rows in whatever order is cheapest, and that order can change after an update. count(*) counts rows; it's the first of the aggregate functions covered at the Intermediate level.

Result set
The table of rows a query returns.
Clause
One part of a statement with a single job, such as WHERE or ORDER BY.
Query planner
The part of the server that decides how to execute a query efficiently.
SELECT * FROM users; SELECT name, city FROM users WHERE age > 30 ORDER BY name; SELECT name, age FROM users ORDER BY age DESC LIMIT 2; -- Page 2 when there are 2 rows per page SELECT name FROM users ORDER BY id LIMIT 2 OFFSET 2; SELECT count(*) AS total_users FROM users;
Terminal Output
shop=> SELECT * FROM users; id | name | email | age | city | joined ----+-------+-------------------+-----+--------+------------ 1 | Alice | alice@example.com | 34 | London | 2024-03-15 2 | Bob | bob@example.com | 27 | Paris | 2024-06-01 3 | Carol | carol@example.com | 45 | London | 2023-11-20 4 | Dave | | 19 | Berlin | 2025-01-09 5 | Erin | erin@example.com | 52 | Paris | 2022-08-30 (5 rows) shop=> SELECT name, city FROM users WHERE age > 30 ORDER BY name; name | city -------+-------- Alice | London Carol | London Erin | Paris (3 rows) shop=> SELECT name, age FROM users ORDER BY age DESC LIMIT 2; name | age -------+----- Erin | 52 Carol | 45 (2 rows) shop=> SELECT name FROM users ORDER BY id LIMIT 2 OFFSET 2; name ------- Carol Dave (2 rows) shop=> SELECT count(*) AS total_users FROM users; total_users ------------- 5 (1 row)
Why is Dave's email blank? It's NULL, meaning no value. psql prints NULL as nothing. Run \pset null '(null)' to make it visible.
Step 6 — Update and Delete Rows

UPDATE changes existing rows and DELETE removes them. Both act on every row the WHERE clause matches, or every row in the table if there's no WHERE.

UPDATE names a table, sets one or more columns with SET, and changes every row that matches its WHERE clause. DELETE FROM removes every matching row. Neither asks for confirmation, and without a WHERE clause, both affect every row in the table. The command tag tells you how many rows were hit: UPDATE 1 is what you expected, and UPDATE 0 means your condition matched nothing, which is usually a typo. Adding RETURNING * shows you exactly which rows changed.

There are three ways to remove data, and they do different things. DELETE removes rows and can be filtered with WHERE. TRUNCATE empties a whole table at once; it's much faster on big tables and resets nothing unless you add RESTART IDENTITY. DROP TABLE removes the table itself: its structure, data, indexes, and all. The safe habit is to write the WHERE clause first, run it as a SELECT to see which rows it matches, and only then turn it into an UPDATE or DELETE. Transactions, in the Advanced section, give you an undo button on top of that.

WHERE clause
The condition that chooses which rows a statement reads, changes, or deletes.
TRUNCATE
Empties a table in one fast operation, without scanning its rows.
DROP TABLE
Removes a table completely: structure, data, and indexes.
-- Change one row and show the result UPDATE users SET city = 'Madrid' WHERE name = 'Bob' RETURNING name, city; -- Change several columns at once UPDATE users SET age = age + 1, city = 'Rome' WHERE name = 'Erin'; -- A condition that matches nothing changes nothing UPDATE users SET age = 30 WHERE name = 'Zed'; -- Delete a row INSERT INTO users (name) VALUES ('Temp'); DELETE FROM users WHERE name = 'Temp'; -- Remove a whole table CREATE TABLE scratch (x integer); DROP TABLE scratch;
Terminal Output
shop=> UPDATE users SET city = 'Madrid' WHERE name = 'Bob' RETURNING name, city; name | city ------+-------- Bob | Madrid (1 row) UPDATE 1 shop=> UPDATE users SET age = age + 1, city = 'Rome' WHERE name = 'Erin'; UPDATE 1 shop=> UPDATE users SET age = 30 WHERE name = 'Zed'; UPDATE 0 shop=> INSERT INTO users (name) VALUES ('Temp'); INSERT 0 1 shop=> DELETE FROM users WHERE name = 'Temp'; DELETE 1 shop=> CREATE TABLE scratch (x integer); CREATE TABLE shop=> DROP TABLE scratch; DROP TABLE shop=> SELECT id, name, age, city FROM users ORDER BY id; id | name | age | city ----+-------+-----+-------- 1 | Alice | 34 | London 2 | Bob | 27 | Madrid 3 | Carol | 45 | London 4 | Dave | 19 | Berlin 5 | Erin | 53 | Rome (5 rows)
Rule #1: write the WHERE clause first and check it with a SELECT before you run UPDATE or DELETE. A missing WHERE changes every row.
Intermediate
Filtering, relationships and joins, aggregation, changing tables, and built-in functions
Filtering with WHERE

Combine conditions with AND and OR, match ranges and lists, search text with LIKE and ILIKE, and test for NULL the right way.

A WHERE condition is a boolean expression: for each row it works out to true or false, and only rows where it's true are kept. AND needs both sides true and OR needs either. AND binds tighter than OR, the way multiplication binds tighter than addition, so a OR b AND c means a OR (b AND c). Use parentheses whenever you mix them. BETWEEN x AND y is an inclusive range, and IN (...) matches any value in a list, which is tidier than a chain of ORs.

For text patterns, % matches any run of characters and _ matches exactly one. Here PostgreSQL differs from SQLite and MySQL: LIKE is case-sensitive, so 'alice' LIKE 'A%' is false. Use ILIKE for case-insensitive matching. NULL means unknown, and any comparison with an unknown value is itself unknown, so email = NULL is never true, even for rows with no email. SQL calls this three-valued logic. Test for missing values with IS NULL and IS NOT NULL, and use IS DISTINCT FROM when you want a comparison that treats two NULLs as equal.

Boolean expression
A condition that is true, false, or unknown for each row.
ILIKE
PostgreSQL's case-insensitive version of LIKE.
Three-valued logic
SQL's true / false / unknown; any comparison with NULL is unknown.
SELECT name, age, city FROM users WHERE (city = 'London' OR city = 'Rome') AND age > 40; SELECT name, age FROM users WHERE age BETWEEN 20 AND 35 ORDER BY age; SELECT name FROM users WHERE city IN ('Paris', 'Madrid', 'Berlin') ORDER BY name; -- LIKE is case-sensitive, ILIKE is not SELECT name FROM users WHERE name LIKE 'a%'; SELECT name FROM users WHERE name ILIKE 'a%'; -- Testing for missing values SELECT name FROM users WHERE email = NULL; -- never matches SELECT name FROM users WHERE email IS NULL; -- correct
Terminal Output
shop=> SELECT name, age, city FROM users shop-> WHERE (city = 'London' OR city = 'Rome') AND age > 40; name | age | city -------+-----+-------- Carol | 45 | London Erin | 53 | Rome (2 rows) shop=> SELECT name, age FROM users WHERE age BETWEEN 20 AND 35 ORDER BY age; name | age -------+----- Bob | 27 Alice | 34 (2 rows) shop=> SELECT name FROM users WHERE city IN ('Paris', 'Madrid', 'Berlin') ORDER BY name; name ------ Bob Dave (2 rows) shop=> SELECT name FROM users WHERE name LIKE 'a%'; name ------ (0 rows) shop=> SELECT name FROM users WHERE name ILIKE 'a%'; name ------- Alice (1 row) shop=> SELECT name FROM users WHERE email = NULL; name ------ (0 rows) shop=> SELECT name FROM users WHERE email IS NULL; name ------ Dave (1 row)
Relationships and JOINs

A foreign key links a row in one table to a row in another. A JOIN puts the two back together in a single result.

Good table design stores each fact once. Rather than copying a customer's name onto every order, an orders table stores the customer's id in a user_id column. Declaring that column REFERENCES users makes it a foreign key: the server refuses any order whose user_id doesn't exist in users, and refuses to delete a user who still has orders. That guarantee is called referential integrity. ON DELETE CASCADE changes the second rule, so deleting a user deletes their orders too. The alternative, ON DELETE SET NULL, keeps the orders and blanks the link.

A join combines rows from two tables where a condition matches, usually a foreign key equal to a primary key. INNER JOIN (or just JOIN) returns only the pairs that match, so a user with no orders disappears from the result. LEFT JOIN keeps every row from the left-hand table and fills the right-hand columns with NULL where there's no match, which is how you find users who have never ordered. Short table aliases like u and o keep joins readable, and prefixing columns with them (u.name) removes any doubt about which table a column comes from.

Foreign key
A column whose values must exist as a key in another table.
Referential integrity
The guarantee that every foreign key points at a row that really exists.
LEFT JOIN
A join that keeps every left-hand row, with NULLs where the right side has no match.
CREATE TABLE orders ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id integer NOT NULL REFERENCES users (id) ON DELETE CASCADE, product text NOT NULL, amount numeric(10,2) NOT NULL CHECK (amount > 0), status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'shipped', 'delivered')), ordered_at date NOT NULL ); INSERT INTO orders (user_id, product, amount, status, ordered_at) VALUES (1, 'Keyboard', 49.99, 'delivered', '2025-01-10'), (1, 'Monitor', 189.00, 'shipped', '2025-02-02'), (2, 'Mouse', 19.50, 'delivered', '2025-01-15'), (3, 'Laptop', 999.00, 'pending', '2025-02-20'), (3, 'Mouse', 19.50, 'delivered', '2025-02-21'), (5, 'Monitor', 189.00, 'delivered', '2025-01-28'); -- Which user placed each order? SELECT o.id, u.name, o.product, o.amount FROM orders o JOIN users u ON u.id = o.user_id ORDER BY o.id; -- Every user, with or without orders SELECT u.name, o.product FROM users u LEFT JOIN orders o ON o.user_id = u.id ORDER BY u.name;
Terminal Output
shop=> CREATE TABLE orders ( shop(> id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, shop(> user_id integer NOT NULL REFERENCES users (id) ON DELETE CASCADE, shop(> product text NOT NULL, shop(> amount numeric(10,2) NOT NULL CHECK (amount > 0), shop(> status text NOT NULL DEFAULT 'pending' shop(> CHECK (status IN ('pending', 'shipped', 'delivered')), shop(> ordered_at date NOT NULL shop(> ); CREATE TABLE shop=> INSERT INTO orders (user_id, product, amount, status, ordered_at) VALUES shop-> (1, 'Keyboard', 49.99, 'delivered', '2025-01-10'), shop-> (1, 'Monitor', 189.00, 'shipped', '2025-02-02'), shop-> (2, 'Mouse', 19.50, 'delivered', '2025-01-15'), shop-> (3, 'Laptop', 999.00, 'pending', '2025-02-20'), shop-> (3, 'Mouse', 19.50, 'delivered', '2025-02-21'), shop-> (5, 'Monitor', 189.00, 'delivered', '2025-01-28'); INSERT 0 6 shop=> SELECT o.id, u.name, o.product, o.amount shop-> FROM orders o shop-> JOIN users u ON u.id = o.user_id shop-> ORDER BY o.id; id | name | product | amount ----+-------+----------+-------- 1 | Alice | Keyboard | 49.99 2 | Alice | Monitor | 189.00 3 | Bob | Mouse | 19.50 4 | Carol | Laptop | 999.00 5 | Carol | Mouse | 19.50 6 | Erin | Monitor | 189.00 (6 rows) shop=> SELECT u.name, o.product shop-> FROM users u shop-> LEFT JOIN orders o ON o.user_id = u.id shop-> ORDER BY u.name; name | product -------+---------- Alice | Keyboard Alice | Monitor Bob | Mouse Carol | Laptop Carol | Mouse Dave | Erin | Monitor (7 rows) -- the foreign key refuses an order for a user who doesn't exist: shop=> INSERT INTO orders (user_id, product, amount, ordered_at) VALUES (99, 'Cable', 5, '2025-03-01'); ERROR: insert or update on table "orders" violates foreign key constraint "orders_user_id_fkey" DETAIL: Key (user_id)=(99) is not present in table "users".
Dave has no orders, so the LEFT JOIN shows him with an empty product. An inner JOIN would have left him out entirely.
Aggregates and GROUP BY

Aggregate functions boil many rows down to one value. GROUP BY does it once per group, and HAVING filters the groups.

Aggregate functions take a whole set of rows and return one value: count counts, sum adds, avg averages, and min and max find the extremes. They skip NULLs, which is why count(*) (rows) and count(email) (rows that have an email) can differ. round(value, 2) tidies an average to two decimal places. Run an aggregate on its own and you get a single summary row for the whole table.

GROUP BY splits the rows into groups that share a value, such as one group per order status, and runs the aggregates once per group. WHERE filters individual rows before grouping, and HAVING filters whole groups after the aggregates are calculated, which is why HAVING sum(amount) > 100 works and WHERE sum(amount) > 100 is an error. Every column in the SELECT list must either appear in GROUP BY or sit inside an aggregate, and PostgreSQL enforces this where SQLite lets it slide. PostgreSQL also has a tidy FILTER clause for counting only some rows inside an aggregate.

Aggregate function
A function that summarises many rows into one value, such as sum or count.
GROUP BY
Splits rows into groups that share a value, giving one result row per group.
HAVING
Filters groups after aggregation; WHERE filters rows before it.
SELECT count(*) AS orders, sum(amount) AS revenue, round(avg(amount), 2) AS average FROM orders; -- One row per status SELECT status, count(*) AS orders, sum(amount) AS revenue FROM orders GROUP BY status ORDER BY revenue DESC; -- Customers who have spent more than 100 SELECT u.name, sum(o.amount) AS spent FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.name HAVING sum(o.amount) > 100 ORDER BY spent DESC; -- FILTER: count only some rows inside an aggregate SELECT count(*) AS all_orders, count(*) FILTER (WHERE status = 'delivered') AS delivered FROM orders;
Terminal Output
shop=> SELECT count(*) AS orders, sum(amount) AS revenue, round(avg(amount), 2) AS average shop-> FROM orders; orders | revenue | average --------+---------+--------- 6 | 1465.99 | 244.33 (1 row) shop=> SELECT status, count(*) AS orders, sum(amount) AS revenue shop-> FROM orders shop-> GROUP BY status shop-> ORDER BY revenue DESC; status | orders | revenue -----------+--------+--------- pending | 1 | 999.00 delivered | 4 | 277.99 shipped | 1 | 189.00 (3 rows) shop=> SELECT u.name, sum(o.amount) AS spent shop-> FROM users u JOIN orders o ON o.user_id = u.id shop-> GROUP BY u.name shop-> HAVING sum(o.amount) > 100 shop-> ORDER BY spent DESC; name | spent -------+--------- Carol | 1018.50 Alice | 238.99 Erin | 189.00 (3 rows) shop=> SELECT count(*) AS all_orders, shop-> count(*) FILTER (WHERE status = 'delivered') AS delivered shop-> FROM orders; all_orders | delivered ------------+----------- 6 | 4 (1 row) -- a column that is neither grouped nor aggregated: shop=> SELECT status, product, count(*) FROM orders GROUP BY status; ERROR: column "orders.product" must appear in the GROUP BY clause or be used in an aggregate function LINE 1: SELECT status, product, count(*) FROM orders GROUP BY status... ^
Altering Tables

ALTER TABLE changes a table that already holds data: add, rename, and drop columns, change a type, and add constraints.

Real schemas change over time, and ALTER TABLE makes those changes in place without losing data. ADD COLUMN adds a column; existing rows get the column's default, or NULL. RENAME COLUMN and RENAME TO change names. DROP COLUMN removes a column and its data. ALTER COLUMN ... TYPE converts a column to a new type, and when PostgreSQL can't convert automatically, a USING expression tells it how. SET DEFAULT, SET NOT NULL, and ADD CONSTRAINT tighten the rules after the fact, and the server checks the existing rows first: if any break the new rule, the change is refused.

Unlike many databases, PostgreSQL has transactional DDL. DDL means the statements that define structure, such as CREATE, ALTER, and DROP, and in PostgreSQL they can run inside a transaction and be rolled back like any UPDATE. That makes schema changes much safer: wrap a risky migration in BEGIN and check the result before you COMMIT. On large tables, some changes rewrite the whole table and lock it while they run, such as changing a column's type. Adding a nullable column, or one with a constant default, is instant.

ALTER TABLE
The statement that changes the structure of an existing table.
DDL
Data Definition Language: statements that define structure, such as CREATE and ALTER.
Transactional DDL
The ability to roll back structure changes, which PostgreSQL supports.
ALTER TABLE users ADD COLUMN phone text; ALTER TABLE users ADD COLUMN active boolean NOT NULL DEFAULT true; ALTER TABLE users RENAME COLUMN city TO town; -- Change a type; USING says how to convert existing values ALTER TABLE users ALTER COLUMN age TYPE smallint USING age::smallint; -- A rule that existing data breaks is refused ALTER TABLE users ALTER COLUMN email SET NOT NULL; ALTER TABLE users DROP COLUMN phone; ALTER TABLE users RENAME COLUMN town TO city;
Terminal Output
shop=> ALTER TABLE users ADD COLUMN phone text; ALTER TABLE shop=> ALTER TABLE users ADD COLUMN active boolean NOT NULL DEFAULT true; ALTER TABLE shop=> ALTER TABLE users RENAME COLUMN city TO town; ALTER TABLE shop=> ALTER TABLE users ALTER COLUMN age TYPE smallint USING age::smallint; ALTER TABLE shop=> ALTER TABLE users ALTER COLUMN email SET NOT NULL; ERROR: column "email" of relation "users" contains null values shop=> ALTER TABLE users DROP COLUMN phone; ALTER TABLE shop=> ALTER TABLE users RENAME COLUMN town TO city; ALTER TABLE shop=> SELECT id, name, age, city, active FROM users ORDER BY id; id | name | age | city | active ----+-------+-----+--------+-------- 1 | Alice | 34 | London | t 2 | Bob | 27 | Madrid | t 3 | Carol | 45 | London | t 4 | Dave | 19 | Berlin | t 5 | Erin | 53 | Rome | t (5 rows)
Why was SET NOT NULL refused? Dave has no email. Fill in the missing values first, or keep the column nullable.
Built-in Functions and Types

PostgreSQL ships hundreds of functions for text, numbers, and dates. A handful cover most everyday work.

Functions transform values. For text: upper, lower, and length do what they say, || joins strings together, left(s, n) takes the first characters, and split_part cuts a string at a delimiter, which is handy for pulling the domain out of an email address. For numbers: round, ceil, floor, and abs. coalesce(a, b, ...) returns the first argument that isn't NULL, which is the standard way to show a fallback value. CASE WHEN ... THEN ... ELSE ... END is SQL's if/else: it picks a value row by row.

Dates and times are a PostgreSQL strength. current_date and now() give today and this moment. Subtracting two dates gives a number of days, and adding an interval such as interval '30 days' moves a date. age() gives the time between two dates in years, months, and days, extract(year FROM d) pulls out one part, and date_trunc('month', d) rounds down to the start of the month, which is the usual way to group sales by month. The :: operator casts a value to another type, for example '2025-01-31'::date or price::integer, and generate_series produces a run of numbers or dates, which is useful for test data.

Cast
Converting a value to another type, written value::type in PostgreSQL.
Interval
A length of time, such as interval '30 days', that can be added to dates.
coalesce
Returns its first argument that is not NULL.
SELECT upper(name), length(name), name || ' <' || coalesce(email, 'no email') || '>' AS label FROM users ORDER BY id LIMIT 3; SELECT split_part('carol@example.com', '@', 2) AS domain; SELECT name, CASE WHEN age < 30 THEN 'under 30' WHEN age < 50 THEN '30-49' ELSE '50+' END AS age_group FROM users ORDER BY age; SELECT '2025-01-31'::date + interval '1 month' AS next_month, '2025-03-01'::date - '2025-01-01'::date AS days_between, age('2025-06-15'::date, '1990-02-20'::date) AS age; SELECT date_trunc('month', ordered_at)::date AS month, sum(amount) FROM orders GROUP BY 1 ORDER BY 1; SELECT generate_series(1, 5) AS n;
Terminal Output
shop=> SELECT upper(name), length(name), name || ' <' || coalesce(email, 'no email') || '>' AS label shop-> FROM users ORDER BY id LIMIT 3; upper | length | label -------+--------+--------------------------- ALICE | 5 | Alice <alice@example.com> BOB | 3 | Bob <bob@example.com> CAROL | 5 | Carol <carol@example.com> (3 rows) shop=> SELECT split_part('carol@example.com', '@', 2) AS domain; domain ------------- example.com (1 row) shop=> SELECT name, shop-> CASE WHEN age < 30 THEN 'under 30' shop-> WHEN age < 50 THEN '30-49' shop-> ELSE '50+' END AS age_group shop-> FROM users ORDER BY age; name | age_group -------+----------- Dave | under 30 Bob | under 30 Alice | 30-49 Carol | 30-49 Erin | 50+ (5 rows) shop=> SELECT '2025-01-31'::date + interval '1 month' AS next_month, shop-> '2025-03-01'::date - '2025-01-01'::date AS days_between, shop-> age('2025-06-15'::date, '1990-02-20'::date) AS age; next_month | days_between | age ---------------------+--------------+------------------------- 2025-02-28 00:00:00 | 59 | 35 years 3 mons 23 days (1 row) shop=> SELECT date_trunc('month', ordered_at)::date AS month, sum(amount) shop-> FROM orders GROUP BY 1 ORDER BY 1; month | sum ------------+--------- 2025-01-01 | 258.49 2025-02-01 | 1207.50 (2 rows) shop=> SELECT generate_series(1, 5) AS n; n --- 1 2 3 4 5 (5 rows)
Adding a month to 31 January gives 28 February, not 3 March: PostgreSQL clamps to the last day of the shorter month. Date arithmetic is full of edge cases like this, and the built-in functions handle them for you.
Advanced
Indexes, views, subqueries, transactions, privileges, upserts, JSON, and backups
Indexes and EXPLAIN

An index lets the server jump straight to matching rows instead of reading the whole table. EXPLAIN shows which it chose.

Without an index, finding rows means a sequential scan: reading every row in the table and testing each one. That's fine for a few hundred rows and slow for ten million. An index is a separate, sorted structure (a B-tree by default) that maps column values to row locations, so the server can find matches the way you'd use the index at the back of a book. PostgreSQL creates one automatically for every PRIMARY KEY and UNIQUE constraint. You add others for columns you often search, join, or sort on. The price is disk space and slightly slower writes, because every insert and update must keep the index current.

EXPLAIN shows the query plan, the steps the planner chose, without running the query; EXPLAIN ANALYZE runs it and adds real timings. Read plans from the innermost line outward. Seq Scan means the whole table was read. Index Scan or Bitmap Index Scan means an index was used. The cost numbers are the planner's estimates in arbitrary units, and rows is how many rows it expects. The planner decides from statistics that ANALYZE gathers (autovacuum does this in the background), so on a tiny table it will often ignore your index, correctly, because reading a few pages is cheaper than using the index.

Sequential scan
Reading every row of a table to find the matches.
Index
A sorted lookup structure that lets the server find rows without reading them all.
Query plan
The steps the server will take to run a query, shown by EXPLAIN.
-- A bigger table to make the difference visible: 200,000 rows CREATE TABLE events ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id integer NOT NULL, kind text NOT NULL ); INSERT INTO events (user_id, kind) SELECT (random() * 999)::integer + 1, 'click' FROM generate_series(1, 200000); ANALYZE events; EXPLAIN SELECT * FROM events WHERE user_id = 42; CREATE INDEX events_user_id_idx ON events (user_id); EXPLAIN SELECT * FROM events WHERE user_id = 42; \di
Terminal Output
shop=> CREATE TABLE events ( shop(> id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, shop(> user_id integer NOT NULL, shop(> kind text NOT NULL shop(> ); CREATE TABLE shop=> INSERT INTO events (user_id, kind) shop-> SELECT (random() * 999)::integer + 1, 'click' shop-> FROM generate_series(1, 200000); INSERT 0 200000 shop=> ANALYZE events; ANALYZE shop=> EXPLAIN SELECT * FROM events WHERE user_id = 42; QUERY PLAN ------------------------------------------------------------ Seq Scan on events (cost=0.00..3774.00 rows=199 width=18) Filter: (user_id = 42) (2 rows) shop=> CREATE INDEX events_user_id_idx ON events (user_id); CREATE INDEX shop=> EXPLAIN SELECT * FROM events WHERE user_id = 42; QUERY PLAN ----------------------------------------------------------------------------------- Bitmap Heap Scan on events (cost=5.84..536.83 rows=199 width=18) Recheck Cond: (user_id = 42) -> Bitmap Index Scan on events_user_id_idx (cost=0.00..5.79 rows=199 width=0) Index Cond: (user_id = 42) (4 rows) shop=> \di List of relations Schema | Name | Type | Owner | Table --------+--------------------+-------+-------+-------- public | events_pkey | index | you | events public | events_user_id_idx | index | you | events public | orders_pkey | index | you | orders public | users_email_key | index | you | users public | users_pkey | index | you | users (5 rows)
Your numbers will differ. The rows are random, so the costs and row estimates change from run to run. The shape of the plan, Seq Scan before and Bitmap Index Scan after, is what to look for.
Views and Materialized Views

A view is a saved query you can select from like a table. A materialized view also saves the query's results.

A view gives a name to a SELECT. Once CREATE VIEW customer_totals AS ... exists, you can query customer_totals like a table, filter it, and join it to other tables, but no data is copied: each time you select from it, PostgreSQL runs the underlying query again, so a view is always up to date. Views are useful for hiding complicated joins behind a simple name, and for giving a role access to some columns of a table without granting access to the whole table. CREATE OR REPLACE VIEW updates the definition.

A materialized view runs its query once and stores the result like a real table. Reading from it is as fast as reading a table, which makes it ideal for expensive reports, but it's a snapshot: it doesn't change when the underlying tables do. REFRESH MATERIALIZED VIEW runs the query again and replaces the stored rows. On a busy system, adding CONCURRENTLY lets people keep reading the old contents during the refresh; it requires a unique index on the materialized view. \dv lists views and \dm lists materialized views.

View
A named query that runs every time you select from it; it stores no data.
Materialized view
A query whose results are stored; it's fast to read but only as fresh as the last refresh.
REFRESH
Re-runs a materialized view's query and replaces its stored rows.
CREATE VIEW customer_totals AS SELECT u.id, u.name, count(o.id) AS orders, coalesce(sum(o.amount), 0) AS spent FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id, u.name; SELECT name, orders, spent FROM customer_totals ORDER BY spent DESC; CREATE MATERIALIZED VIEW sales_by_month AS SELECT date_trunc('month', ordered_at)::date AS month, sum(amount) AS revenue FROM orders GROUP BY 1; -- New data doesn't appear until you refresh INSERT INTO orders (user_id, product, amount, status, ordered_at) VALUES (2, 'Webcam', 59.00, 'pending', '2025-03-05'); SELECT * FROM sales_by_month ORDER BY month; REFRESH MATERIALIZED VIEW sales_by_month; SELECT * FROM sales_by_month ORDER BY month;
Terminal Output
shop=> CREATE VIEW customer_totals AS shop-> SELECT u.id, u.name, count(o.id) AS orders, coalesce(sum(o.amount), 0) AS spent shop-> FROM users u LEFT JOIN orders o ON o.user_id = u.id shop-> GROUP BY u.id, u.name; CREATE VIEW shop=> SELECT name, orders, spent FROM customer_totals ORDER BY spent DESC; name | orders | spent -------+--------+--------- Carol | 2 | 1018.50 Alice | 2 | 238.99 Erin | 1 | 189.00 Bob | 1 | 19.50 Dave | 0 | 0 (5 rows) shop=> CREATE MATERIALIZED VIEW sales_by_month AS shop-> SELECT date_trunc('month', ordered_at)::date AS month, sum(amount) AS revenue shop-> FROM orders GROUP BY 1; SELECT 2 shop=> INSERT INTO orders (user_id, product, amount, status, ordered_at) shop-> VALUES (2, 'Webcam', 59.00, 'pending', '2025-03-05'); INSERT 0 1 shop=> SELECT * FROM sales_by_month ORDER BY month; month | revenue ------------+--------- 2025-01-01 | 258.49 2025-02-01 | 1207.50 (2 rows) shop=> REFRESH MATERIALIZED VIEW sales_by_month; REFRESH MATERIALIZED VIEW shop=> SELECT * FROM sales_by_month ORDER BY month; month | revenue ------------+--------- 2025-01-01 | 258.49 2025-02-01 | 1207.50 2025-03-01 | 59.00 (3 rows)
Subqueries and CTEs

A query can use the result of another query. WITH names those inner queries so a long query reads top to bottom.

A subquery is a SELECT inside another statement. A scalar subquery returns one value and can go anywhere a value can, for example WHERE amount > (SELECT avg(amount) FROM orders). A subquery that returns a column can feed IN, and EXISTS (SELECT 1 ...) is true when the inner query returns any row at all. A correlated subquery refers to the outer row, as in users who have at least one order, and conceptually runs once per outer row, though the planner often rewrites it as a join.

A common table expression (CTE), written with WITH name AS (...), gives a subquery a name so you can build a query in readable steps and refer to each step more than once. Since PostgreSQL 12, simple CTEs are folded into the main query, so they cost nothing extra. WITH RECURSIVE lets a CTE refer to itself. It starts from a seed row and repeatedly adds new rows until none are produced, which is how you walk a tree such as an organisation chart or a category hierarchy, or generate a sequence.

Subquery
A query nested inside another statement.
Correlated subquery
A subquery that refers to columns of the outer query.
CTE
A named subquery written with WITH; WITH RECURSIVE can refer to itself.
-- Orders bigger than the average order SELECT id, product, amount FROM orders WHERE amount > (SELECT avg(amount) FROM orders) ORDER BY amount DESC; -- Users who have ordered at least once SELECT name FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id) ORDER BY name; -- The same report as a readable pipeline of named steps WITH spend AS ( SELECT user_id, sum(amount) AS total FROM orders GROUP BY user_id ), ranked AS ( SELECT user_id, total FROM spend WHERE total > 50 ) SELECT u.name, r.total FROM ranked r JOIN users u ON u.id = r.user_id ORDER BY r.total DESC; -- WITH RECURSIVE: the first five powers of two WITH RECURSIVE powers(n, value) AS ( SELECT 1, 2 UNION ALL SELECT n + 1, value * 2 FROM powers WHERE n < 5 ) SELECT * FROM powers;
Terminal Output
shop=> SELECT id, product, amount FROM orders shop-> WHERE amount > (SELECT avg(amount) FROM orders) shop-> ORDER BY amount DESC; id | product | amount ----+---------+-------- 4 | Laptop | 999.00 (1 row) shop=> SELECT name FROM users u shop-> WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id) shop-> ORDER BY name; name ------- Alice Bob Carol Erin (4 rows) shop=> WITH spend AS ( shop(> SELECT user_id, sum(amount) AS total FROM orders GROUP BY user_id shop(> ), ranked AS ( shop(> SELECT user_id, total FROM spend WHERE total > 50 shop(> ) shop-> SELECT u.name, r.total shop-> FROM ranked r JOIN users u ON u.id = r.user_id shop-> ORDER BY r.total DESC; name | total -------+--------- Carol | 1018.50 Alice | 238.99 Erin | 189.00 Bob | 78.50 (4 rows) shop=> WITH RECURSIVE powers(n, value) AS ( shop(> SELECT 1, 2 shop(> UNION ALL shop(> SELECT n + 1, value * 2 FROM powers WHERE n < 5 shop(> ) shop-> SELECT * FROM powers; n | value ---+------- 1 | 2 2 | 4 3 | 8 4 | 16 5 | 32 (5 rows)
Transactions

A transaction groups statements so they all succeed or all fail together. BEGIN starts one, COMMIT saves it, and ROLLBACK undoes it.

A transaction makes several statements act as one. Moving money between two accounts is the classic example: take it out of one, put it into the other, and it must never be possible for only half of that to happen. BEGIN starts a transaction, and nothing you change is visible to anyone else until COMMIT. ROLLBACK throws all the changes away. Every statement you run outside BEGIN is its own small transaction that commits immediately; that's called autocommit. Transactions give four guarantees, known as ACID: atomic (all or nothing), consistent (constraints hold), isolated (other sessions don't see half-finished work), and durable (committed data survives a crash).

psql shows the transaction state in the prompt: => outside a transaction, =*> inside one, and =!> inside one that has hit an error. That last state surprises people. After any error, PostgreSQL refuses every further statement in the transaction until you ROLLBACK, so a script can't carry on as if nothing had happened. SAVEPOINT name marks a point inside a transaction, and ROLLBACK TO name undoes back to it without abandoning the whole transaction.

Transaction
A group of statements that commit together or not at all.
Autocommit
The default where each statement outside BEGIN is committed immediately.
ACID
Atomic, consistent, isolated, durable: the guarantees a transaction provides.
BEGIN; UPDATE orders SET status = 'shipped' WHERE status = 'pending'; SELECT id, status FROM orders WHERE product = 'Laptop'; ROLLBACK; -- undo everything since BEGIN -- An error aborts the transaction until you roll back BEGIN; UPDATE users SET age = 35 WHERE name = 'Alice'; SELECT no_such_column FROM users; UPDATE users SET age = 36 WHERE name = 'Alice'; ROLLBACK; -- SAVEPOINT: undo part of a transaction BEGIN; UPDATE users SET city = 'Oslo' WHERE name = 'Dave'; SAVEPOINT before_delete; DELETE FROM orders; ROLLBACK TO before_delete; COMMIT;
Terminal Output
shop=> BEGIN; BEGIN shop=*> UPDATE orders SET status = 'shipped' WHERE status = 'pending'; UPDATE 2 shop=*> SELECT id, status FROM orders WHERE product = 'Laptop'; id | status ----+--------- 4 | shipped (1 row) shop=*> ROLLBACK; ROLLBACK shop=> SELECT id, status FROM orders WHERE product = 'Laptop'; id | status ----+--------- 4 | pending (1 row) shop=> BEGIN; BEGIN shop=*> UPDATE users SET age = 35 WHERE name = 'Alice'; UPDATE 1 shop=*> SELECT no_such_column FROM users; ERROR: column "no_such_column" does not exist LINE 1: SELECT no_such_column FROM users; ^ shop=!> UPDATE users SET age = 36 WHERE name = 'Alice'; ERROR: current transaction is aborted, commands ignored until end of transaction block shop=!> ROLLBACK; ROLLBACK shop=> BEGIN; BEGIN shop=*> UPDATE users SET city = 'Oslo' WHERE name = 'Dave'; UPDATE 1 shop=*> SAVEPOINT before_delete; SAVEPOINT shop=*> DELETE FROM orders; DELETE 7 shop=*> ROLLBACK TO before_delete; ROLLBACK shop=*> COMMIT; COMMIT shop=> SELECT count(*) FROM orders; count ------- 7 (1 row)
Try risky changes inside BEGIN. Run the UPDATE, check the result with a SELECT, and only then COMMIT. If it looks wrong, ROLLBACK and nothing happened.
Roles and Privileges

Roles are PostgreSQL's users. GRANT and REVOKE decide exactly what each role may read and change.

A role can log in, own objects, and hold privileges. Whoever creates a table owns it and can do anything with it; everyone else can do nothing until they're granted it. Creating roles needs the CREATEROLE attribute or a superuser, which is why the first command below runs as postgres. LOGIN lets a role connect, and PASSWORD sets its password. A role without LOGIN acts as a group: grant privileges to the group, then GRANT groupname TO person, and every member inherits them.

Privileges work at several levels, and a role needs all of them to read a table: CONNECT on the database, USAGE on the schema, and SELECT on the table itself. INSERT, UPDATE, and DELETE are granted separately, so a reporting role can read everything and change nothing. GRANT SELECT ON ALL TABLES IN SCHEMA public covers the tables that exist now, and ALTER DEFAULT PRIVILEGES covers tables created later. REVOKE takes privileges away. The principle of least privilege says each program should connect as a role with only the privileges it needs, never as a superuser.

Privilege
A permission such as SELECT or INSERT on a specific object.
Group role
A role without LOGIN used to bundle privileges for its members.
Least privilege
Giving each role only the access it actually needs.
-- As a superuser (on Linux: sudo -u postgres psql -d shop) CREATE ROLE reporter LOGIN PASSWORD 'change-me'; -- As the owner of the tables (you) GRANT CONNECT ON DATABASE shop TO reporter; GRANT USAGE ON SCHEMA public TO reporter; GRANT SELECT ON users, orders TO reporter; -- Now as reporter: reading works, writing doesn't -- psql -U reporter -d shop SELECT count(*) FROM orders; DELETE FROM orders;
Terminal Output
-- connected as postgres shop=# CREATE ROLE reporter LOGIN PASSWORD 'change-me'; CREATE ROLE -- connected as you shop=> GRANT CONNECT ON DATABASE shop TO reporter; GRANT shop=> GRANT USAGE ON SCHEMA public TO reporter; GRANT shop=> GRANT SELECT ON users, orders TO reporter; GRANT -- connected as reporter shop=> SELECT count(*) FROM orders; count ------- 7 (1 row) shop=> DELETE FROM orders; ERROR: permission denied for table orders
Never let your application connect as postgres. A bug or an injection attack would then have full control of every database on the server. Give each app its own role with only the grants it needs.
UPSERT and Window Functions

ON CONFLICT turns an insert into insert-or-update. Window functions compute rankings and running totals without collapsing rows.

An upsert inserts a row, or updates the existing one if it would break a unique constraint. PostgreSQL writes it as INSERT ... ON CONFLICT (column) DO UPDATE SET .... Inside the DO UPDATE, the special name EXCLUDED refers to the row you tried to insert, so SET age = EXCLUDED.age copies the new value over. ON CONFLICT DO NOTHING silently skips duplicates instead, which is handy when importing data that might already be there. The conflict target must match a real UNIQUE or PRIMARY KEY constraint.

Window functions compute a value for each row from a set of related rows, its window, without collapsing them the way GROUP BY does. The OVER (...) clause defines the window: PARTITION BY splits rows into groups and ORDER BY orders them within each group. row_number() numbers rows 1, 2, 3; rank() gives ties the same rank; sum(amount) OVER (ORDER BY ordered_at) is a running total; and lag() reads a value from the previous row, which is how you compute the change from one row to the next.

Upsert
Insert a row, or update the existing one if it conflicts: INSERT ... ON CONFLICT.
EXCLUDED
In ON CONFLICT DO UPDATE, the row that was proposed for insertion.
Window function
A function computed over related rows while keeping every row in the result.
-- Bob exists (unique email), so he is updated, not duplicated INSERT INTO users (name, email, age, city, joined) VALUES ('Bob', 'bob@example.com', 28, 'Lisbon', '2024-06-01') ON CONFLICT (email) DO UPDATE SET age = EXCLUDED.age, city = EXCLUDED.city RETURNING id, name, age, city; -- Skip rows that already exist INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com') ON CONFLICT (email) DO NOTHING; -- Each customer's orders, numbered, with a running total SELECT u.name, o.product, o.amount, row_number() OVER (PARTITION BY u.name ORDER BY o.ordered_at) AS nth, sum(o.amount) OVER (PARTITION BY u.name ORDER BY o.ordered_at) AS running_total FROM orders o JOIN users u ON u.id = o.user_id ORDER BY u.name, nth; -- Rank products by revenue SELECT product, sum(amount) AS revenue, rank() OVER (ORDER BY sum(amount) DESC) AS rank FROM orders GROUP BY product;
Terminal Output
shop=> INSERT INTO users (name, email, age, city, joined) shop-> VALUES ('Bob', 'bob@example.com', 28, 'Lisbon', '2024-06-01') shop-> ON CONFLICT (email) DO UPDATE SET age = EXCLUDED.age, city = EXCLUDED.city shop-> RETURNING id, name, age, city; id | name | age | city ----+------+-----+-------- 2 | Bob | 28 | Lisbon (1 row) INSERT 0 1 shop=> INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com') shop-> ON CONFLICT (email) DO NOTHING; INSERT 0 0 shop=> SELECT u.name, o.product, o.amount, shop-> row_number() OVER (PARTITION BY u.name ORDER BY o.ordered_at) AS nth, shop-> sum(o.amount) OVER (PARTITION BY u.name ORDER BY o.ordered_at) AS running_total shop-> FROM orders o JOIN users u ON u.id = o.user_id shop-> ORDER BY u.name, nth; name | product | amount | nth | running_total -------+----------+--------+-----+--------------- Alice | Keyboard | 49.99 | 1 | 49.99 Alice | Monitor | 189.00 | 2 | 238.99 Bob | Mouse | 19.50 | 1 | 19.50 Bob | Webcam | 59.00 | 2 | 78.50 Carol | Laptop | 999.00 | 1 | 999.00 Carol | Mouse | 19.50 | 2 | 1018.50 Erin | Monitor | 189.00 | 1 | 189.00 (7 rows) shop=> SELECT product, sum(amount) AS revenue, shop-> rank() OVER (ORDER BY sum(amount) DESC) AS rank shop-> FROM orders GROUP BY product; product | revenue | rank ----------+---------+------ Laptop | 999.00 | 1 Monitor | 378.00 | 2 Webcam | 59.00 | 3 Keyboard | 49.99 | 4 Mouse | 39.00 | 5 (5 rows)
INSERT 0 0 after DO NOTHING means the row was skipped. No error, and nothing changed.
JSON with jsonb

The jsonb type stores JSON documents in a column, and PostgreSQL can query inside them and index them.

Sometimes part of your data has no fixed shape: product attributes that differ from one product to the next, or settings that change every release. PostgreSQL's jsonb type stores a whole JSON document in one column. It's parsed and stored in a binary form, so it's validated on the way in (malformed JSON is refused) and fast to query. There's also a plain json type that keeps the exact text, but jsonb is the one to use.

The operators are short: -> gets a field as JSON, ->> gets it as text, and #>> follows a path. @> means contains: attrs @> '{"color": "black"}' finds every product whose attributes include that key and value. ? tests whether a key exists. jsonb_set returns a changed copy of a document, and || merges two. A GIN index on a jsonb column makes @> and ? searches fast even across millions of rows. A good rule of thumb: keep the fields you filter and join on in real columns, and use jsonb for the long tail of optional ones.

jsonb
A column type that stores validated JSON in a binary, queryable form.
Containment (@>)
Tests whether a JSON document contains the given keys and values.
GIN index
An index type suited to searching inside jsonb, arrays, and full text.
CREATE TABLE products ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, attrs jsonb NOT NULL DEFAULT '{}' ); INSERT INTO products (name, attrs) VALUES ('Keyboard', '{"color": "black", "layout": "UK", "wireless": true}'), ('Monitor', '{"color": "silver", "size_in": 27, "ports": ["HDMI", "DP"]}'), ('Mouse', '{"color": "black", "wireless": false}'); SELECT name, attrs->>'color' AS color, attrs->'ports' AS ports FROM products; SELECT name FROM products WHERE attrs @> '{"color": "black"}'; SELECT name FROM products WHERE attrs ? 'wireless'; UPDATE products SET attrs = jsonb_set(attrs, '{color}', '"white"') WHERE name = 'Mouse' RETURNING attrs; CREATE INDEX products_attrs_idx ON products USING gin (attrs); -- Malformed JSON is refused INSERT INTO products (name, attrs) VALUES ('Cable', '{color: red}');
Terminal Output
shop=> CREATE TABLE products ( shop(> id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, shop(> name text NOT NULL, shop(> attrs jsonb NOT NULL DEFAULT '{}' shop(> ); CREATE TABLE shop=> INSERT INTO products (name, attrs) VALUES shop-> ('Keyboard', '{"color": "black", "layout": "UK", "wireless": true}'), shop-> ('Monitor', '{"color": "silver", "size_in": 27, "ports": ["HDMI", "DP"]}'), shop-> ('Mouse', '{"color": "black", "wireless": false}'); INSERT 0 3 shop=> SELECT name, attrs->>'color' AS color, attrs->'ports' AS ports FROM products; name | color | ports ----------+--------+---------------- Keyboard | black | Monitor | silver | ["HDMI", "DP"] Mouse | black | (3 rows) shop=> SELECT name FROM products WHERE attrs @> '{"color": "black"}'; name ---------- Keyboard Mouse (2 rows) shop=> SELECT name FROM products WHERE attrs ? 'wireless'; name ---------- Keyboard Mouse (2 rows) shop=> UPDATE products SET attrs = jsonb_set(attrs, '{color}', '"white"') WHERE name = 'Mouse' shop-> RETURNING attrs; attrs --------------------------------------- {"color": "white", "wireless": false} (1 row) UPDATE 1 shop=> CREATE INDEX products_attrs_idx ON products USING gin (attrs); CREATE INDEX shop=> INSERT INTO products (name, attrs) VALUES ('Cable', '{color: red}'); ERROR: invalid input syntax for type json LINE 1: ...SERT INTO products (name, attrs) VALUES ('Cable', '{color: r... ^ DETAIL: Token "color" is invalid. CONTEXT: JSON data, line 1: {color...
Backup, Restore, and CSV

pg_dump saves a database to a file and pg_restore or psql loads it back. \copy moves table data to and from CSV files.

A logical backup is a file of SQL, or an archive, that can recreate a database. pg_dump shop > shop.sql writes plain SQL that you can read and replay with psql -f. pg_dump -Fc writes PostgreSQL's compressed custom format, which pg_restore can load selectively, for example one table, and in parallel. pg_dump takes a consistent snapshot while the database stays in use, so there's no need to stop anything. pg_dumpall also saves roles, which pg_dump doesn't. Test your backups by actually restoring them into a scratch database: a backup you've never restored is only a hope.

\copy is psql's bridge to spreadsheets. \copy users TO 'users.csv' CSV HEADER writes a CSV file on your machine, and \copy ... FROM reads one back into a table. There's also a server-side SQL command, COPY, which reads and writes files on the server's disk and needs special privileges. That difference trips people up, and \copy is almost always the one you want. Both report the number of rows moved, as in COPY 5. The column list is optional, and it lets you load a CSV whose columns are in a different order from the table's.

Logical backup
A dump of SQL or an archive that can recreate a database; made with pg_dump.
pg_restore
Loads a custom-format dump, optionally in parallel or one table at a time.
\copy
psql's command for moving table data to and from files on your own machine.
# From your terminal, not inside psql: pg_dump shop > shop.sql # plain SQL pg_dump -Fc shop > shop.dump # compressed custom format # Restore into a fresh database createdb shop_copy pg_restore -d shop_copy shop.dump # or: psql -d shop_copy -f shop.sql # Inside psql: export and import CSV \copy users TO 'users.csv' CSV HEADER \copy users (name, email, age, city, joined) FROM 'new_users.csv' CSV HEADER
Terminal Output
shop=> \copy (SELECT id, name, email, age, city FROM users ORDER BY id) TO 'users.csv' CSV HEADER COPY 5 shop=> \! cat users.csv id,name,email,age,city 1,Alice,alice@example.com,34,London 2,Bob,bob@example.com,28,Lisbon 3,Carol,carol@example.com,45,London 4,Dave,,19,Oslo 5,Erin,erin@example.com,53,Rome shop=> \copy orders TO 'orders.csv' CSV HEADER COPY 7
Restore test: once a month, restore last night's dump into a scratch database and run a count(*) on your biggest tables. It takes two minutes and tells you the backup really works.
🎓
Master's Degree
How PostgreSQL works inside: integrity, storage, MVCC, isolation, planning, recursion, durability and time
Graduate-level material. Each lesson explains one part of how PostgreSQL works inside and then shows it happening. Every Terminal Output block is real, captured from PostgreSQL 18.6 on this site's server. The sessions connect as the postgres superuser, which is why the prompt ends in #, and some lessons need it (pageinspect, for example). Where a lesson uses two terminal windows at once, [A] and [B] mark which window each line was typed in.
1 · Relational Integrity: Foreign Keys, Cascades and Normal Forms

Builds on Basics & Commands › Relationships and JOINs

Basics declared user_id integer NOT NULL REFERENCES users (id) and joined on it. A declaration like that is a promise, and this lesson is about how PostgreSQL keeps it. The relational model (Codd, 1970) requires referential integrity: every foreign-key value must match a key in the parent table or be NULL. A row that breaks the rule is an orphan, and every join that touches it silently gives the wrong answer.

Unlike SQLite, PostgreSQL always enforces foreign keys; there is no setting to forget. The orphan insert below fails at once with the constraint’s name and the missing key. The parent column must be a PRIMARY KEY or have a UNIQUE constraint, and PostgreSQL checks that when the foreign key is created, not later. Internally each foreign key is a pair of system triggers, which is why the only ways around one are superuser-only: session_replication_role = replica, which logical replication uses, or disabling the table’s triggers.

Referential actions decide what happens to the children when a parent row is deleted or its key changes. NO ACTION, the default, refuses the change if children would be left behind. RESTRICT refuses too, but its check can never be deferred. CASCADE deletes or updates the children as well, and SET NULL / SET DEFAULT detach them. Choose by what the link means. Use CASCADE when the child can’t exist without its parent (an order line without its order), SET NULL when the link is optional (an employee whose manager leaves), and NO ACTION when deleting the parent would destroy history (a customer with orders). Below, deleting Marketing takes Bob with it, and deleting Alice clears Carol’s manager_id.

When the check runs. A foreign key is NOT DEFERRABLE unless you say otherwise. It is checked at the end of every statement, and SET CONSTRAINTS ALL DEFERRED has no effect on it, as Frank’s insert shows. A key declared DEFERRABLE can be deferred for one transaction, so rows may arrive in any order and the check runs at COMMIT. This is what self-references and cycles need. If a violation is still there at COMMIT, PostgreSQL rolls the whole transaction back: the prompt returns to lab=#, and Eve’s row is gone. SQLite behaves differently and leaves the failed transaction open. Django creates every foreign key on PostgreSQL as DEFERRABLE INITIALLY DEFERRED, so in a Django project these checks happen at COMMIT.

Referential integrity
Every foreign-key value matches a parent key or is NULL. A row that breaks it is an orphan.
Referential action
What a delete or key update on the parent does to the children: NO ACTION, RESTRICT, CASCADE, SET NULL or SET DEFAULT.
DEFERRABLE
A constraint that may be checked at COMMIT instead of after each statement. INITIALLY DEFERRED makes that the default; SET CONSTRAINTS switches it for one transaction.
SQLSTATE 23503
foreign_key_violation: the error code a driver sees when an insert, update or delete breaks a foreign key.
Enforcement, cascades and deferred checks
-- The Basics schema, with referential actions CREATE TABLE departments (id integer PRIMARY KEY, name text NOT NULL); CREATE TABLE employees ( id integer PRIMARY KEY, name text NOT NULL, dept_id integer REFERENCES departments (id) ON DELETE CASCADE, manager_id integer REFERENCES employees (id) ON DELETE SET NULL ); INSERT INTO departments VALUES (1, 'Engineering'), (2, 'Marketing'); INSERT INTO employees VALUES (1, 'Alice', 1, NULL), (2, 'Bob', 2, 1), (3, 'Carol', 1, 1); -- Enforcement is always on: the orphan is refused at once INSERT INTO employees VALUES (4, 'Dave', 99, NULL); -- CASCADE removes Bob; SET NULL clears Carol's manager DELETE FROM departments WHERE id = 2; DELETE FROM employees WHERE id = 1; SELECT * FROM employees; -- SET CONSTRAINTS cannot defer a NOT DEFERRABLE key (the default) BEGIN; SET CONSTRAINTS ALL DEFERRED; INSERT INTO employees VALUES (5, 'Frank', 7, NULL); ROLLBACK; -- A deferrable self-reference, deferred for one transaction CREATE TABLE staff ( id integer PRIMARY KEY, name text, manager_id integer REFERENCES staff (id) DEFERRABLE INITIALLY IMMEDIATE ); BEGIN; SET CONSTRAINTS ALL DEFERRED; INSERT INTO staff VALUES (2, 'Bob', 1); INSERT INTO staff VALUES (1, 'Alice', NULL); COMMIT; -- A failed COMMIT rolls the whole transaction back BEGIN; SET CONSTRAINTS ALL DEFERRED; INSERT INTO staff VALUES (3, 'Eve', 42); COMMIT; SELECT count(*) AS rows_after FROM staff;
Terminal Output
lab=# CREATE TABLE departments (id integer PRIMARY KEY, name text NOT NULL); CREATE TABLE lab=# CREATE TABLE employees ( lab(# id integer PRIMARY KEY, lab(# name text NOT NULL, lab(# dept_id integer REFERENCES departments (id) ON DELETE CASCADE, lab(# manager_id integer REFERENCES employees (id) ON DELETE SET NULL lab(# ); CREATE TABLE lab=# INSERT INTO departments VALUES (1, 'Engineering'), (2, 'Marketing'); INSERT 0 2 lab=# INSERT INTO employees VALUES (1, 'Alice', 1, NULL), (2, 'Bob', 2, 1), (3, 'Carol', 1, 1); INSERT 0 3 lab=# INSERT INTO employees VALUES (4, 'Dave', 99, NULL); ERROR: insert or update on table "employees" violates foreign key constraint "employees_dept_id_fkey" DETAIL: Key (dept_id)=(99) is not present in table "departments". lab=# DELETE FROM departments WHERE id = 2; DELETE 1 lab=# DELETE FROM employees WHERE id = 1; DELETE 1 lab=# SELECT * FROM employees; id | name | dept_id | manager_id ----+-------+---------+------------ 3 | Carol | 1 | (1 row) lab=# BEGIN; BEGIN lab=*# SET CONSTRAINTS ALL DEFERRED; SET CONSTRAINTS lab=*# INSERT INTO employees VALUES (5, 'Frank', 7, NULL); ERROR: insert or update on table "employees" violates foreign key constraint "employees_dept_id_fkey" DETAIL: Key (dept_id)=(7) is not present in table "departments". lab=!# ROLLBACK; ROLLBACK lab=# CREATE TABLE staff ( lab(# id integer PRIMARY KEY, lab(# name text, lab(# manager_id integer REFERENCES staff (id) DEFERRABLE INITIALLY IMMEDIATE lab(# ); CREATE TABLE lab=# BEGIN; BEGIN lab=*# SET CONSTRAINTS ALL DEFERRED; SET CONSTRAINTS lab=*# INSERT INTO staff VALUES (2, 'Bob', 1); INSERT 0 1 lab=*# INSERT INTO staff VALUES (1, 'Alice', NULL); INSERT 0 1 lab=*# COMMIT; COMMIT lab=# BEGIN; BEGIN lab=*# SET CONSTRAINTS ALL DEFERRED; SET CONSTRAINTS lab=*# INSERT INTO staff VALUES (3, 'Eve', 42); INSERT 0 1 lab=*# COMMIT; ERROR: insert or update on table "staff" violates foreign key constraint "staff_manager_id_fkey" DETAIL: Key (manager_id)=(42) is not present in table "staff". lab=# SELECT count(*) AS rows_after FROM staff; rows_after ------------ 2 (1 row)

The missing index on the child side. PostgreSQL indexes the parent key, because it has to be unique, but it never indexes the child column for you. Each time a parent row is deleted, or its key changes, PostgreSQL has to look for that row’s children, and without an index that means scanning the whole child table. In the session below, 1,000 customers who have no orders are deleted. Each delete scans all 200,000 orders, 200 million row checks in all, and the statement takes 9.4 seconds. A query over the system catalogs finds every single-column foreign key that has no index on its child column. With the index in place, the same 1,000 deletes take 9.3 milliseconds, about a thousand times faster.

Joins on the foreign key need the same index, so add one to almost every foreign key. Django does this by default for every ForeignKey field. The catalog query only checks single-column keys; for a composite key, the index has to start with the same columns, in the same order.

Normal forms: where a foreign key belongs. Normalisation is the design theory behind the decision to split a table and link the parts with a key. Each normal form removes one kind of repeated fact. First normal form: every column holds one value, so a list of territories is a junction table, not a comma-separated column. Second: in a table with a composite key, no column depends on only part of the key; a product name in an order-line table depends on the product alone, so it belongs in the products table. Third: no column depends on another non-key column; a department name stored next to every employee’s department ID depends on the department, not the employee. Boyce–Codd tightens third normal form for tables with several overlapping candidate keys. Storing a value twice on purpose is a different matter: Northwind’s order lines keep their own unit price because it records the price on the day of sale (Northwind lesson 5).

Delete parents with and without a child-side index
-- 2,000 customers; 200,000 orders that belong to customers 1-1,000 CREATE TABLE customers (id integer PRIMARY KEY, name text); CREATE TABLE orders ( id integer PRIMARY KEY, customer_id integer REFERENCES customers (id), total numeric(10,2) ); INSERT INTO customers SELECT i, 'customer ' || i FROM generate_series(1, 2000) i; INSERT INTO orders SELECT i, i % 1000 + 1, i * 0.5 FROM generate_series(1, 200000) i; ANALYZE; -- Delete the 1,000 customers who have no orders \timing on DELETE FROM customers WHERE id > 1000; \timing off -- Which single-column foreign keys have no index on the child side? SELECT c.conrelid::regclass AS child_table, a.attname AS fk_column, c.confrelid::regclass AS parent_table FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1] WHERE c.contype = 'f' AND NOT EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.conrelid AND i.indkey[0] = c.conkey[1]); -- Add the index, put the customers back, and delete them again CREATE INDEX orders_customer_id ON orders (customer_id); INSERT INTO customers SELECT i, 'customer ' || i FROM generate_series(1001, 2000) i; \timing on DELETE FROM customers WHERE id > 1000; \timing off
Terminal Output
lab=# CREATE TABLE customers (id integer PRIMARY KEY, name text); CREATE TABLE lab=# CREATE TABLE orders ( lab(# id integer PRIMARY KEY, lab(# customer_id integer REFERENCES customers (id), lab(# total numeric(10,2) lab(# ); CREATE TABLE lab=# INSERT INTO customers SELECT i, 'customer ' || i FROM generate_series(1, 2000) i; INSERT 0 2000 lab=# INSERT INTO orders SELECT i, i % 1000 + 1, i * 0.5 FROM generate_series(1, 200000) i; INSERT 0 200000 lab=# ANALYZE; ANALYZE lab=# \timing on Timing is on. lab=# DELETE FROM customers WHERE id > 1000; DELETE 1000 Time: 9436.650 ms (00:09.437) lab=# \timing off Timing is off. lab=# SELECT c.conrelid::regclass AS child_table, a.attname AS fk_column, lab-# c.confrelid::regclass AS parent_table lab-# FROM pg_constraint c lab-# JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1] lab-# WHERE c.contype = 'f' lab-# AND NOT EXISTS (SELECT 1 FROM pg_index i lab(# WHERE i.indrelid = c.conrelid AND i.indkey[0] = c.conkey[1]); child_table | fk_column | parent_table -------------+-------------+-------------- orders | customer_id | customers (1 row) lab=# CREATE INDEX orders_customer_id ON orders (customer_id); CREATE INDEX lab=# INSERT INTO customers SELECT i, 'customer ' || i FROM generate_series(1001, 2000) i; INSERT 0 1000 lab=# \timing on Timing is on. lab=# DELETE FROM customers WHERE id > 1000; DELETE 1000 Time: 9.252 ms lab=# \timing off Timing is off.
2 · Pages, the Heap and TOAST

Builds on Basics & Commands › Indexes and EXPLAIN

PostgreSQL stores every table and every index as a file of fixed-size pages, 8,192 bytes each (SHOW block_size). This is where it differs most from SQLite. An SQLite table is a B-tree ordered by rowid. A PostgreSQL table is a heap: rows go wherever there is free space, in no particular order, and the primary key is just another index stored in its own file. Below, the 10,000-row table takes 83 pages, its primary-key index 30, and the email index 52.

Every row version has a physical address, its ctid: (page, line pointer). Rows 1 to 136 fill page 0, and row 137 starts page 1. pageinspect shows the layout inside a page. An array of line pointers grows from the front of the page, and the tuples they point at are packed from the back: the first one starts at byte 8,136 and is 52 bytes long. That is a 24-byte tuple header plus 28 bytes of data. An index entry stores a key and a ctid, so an index lookup is a B-tree descent followed by a read of the heap page the ctid names. A primary-key B-tree with 10,000 keys has a root (level 1) pointing at 28 leaf pages, so any key is two page reads away, plus one for the heap.

A row has to fit in a page, so large values go somewhere else. When a row would be bigger than about 2 kB, PostgreSQL first tries to compress its long values, then moves them out of line into the table’s TOAST table in chunks, leaving a pointer behind. pg_column_size shows the result. 128,000 bytes of MD5 hex digits barely compress, so all of them are stored out of line. 127,996 bytes of a repeated sentence compress to 1,499. TOAST is invisible to SQL; you see it only in sizes and speed. A query that doesn’t read a large column never has to fetch its chunks.

Heap
A table file whose rows are stored in no particular order. Every index on it, the primary key included, is a separate file.
ctid
A row version’s physical address, (page, line pointer). It changes when the row is updated, so it is not a stable ID.
Line pointer
A slot near the start of a page that records where a tuple begins and how long it is.
TOAST
The Oversized-Attribute Storage Technique: long values are compressed and/or moved to a side table in chunks.
Look inside the table, an index and the TOAST table
-- pageinspect reads raw pages (superuser only) CREATE EXTENSION pageinspect; CREATE TABLE people (id integer PRIMARY KEY, name text, email text); INSERT INTO people SELECT i, 'user' || i, 'user' || i || '@example.com' FROM generate_series(1, 10000) i; CREATE INDEX people_email ON people (email); VACUUM ANALYZE people; SHOW block_size; -- The table and each index are separate files of 8 kB pages SELECT relname, relkind, relpages, reltuples::integer AS reltuples FROM pg_class WHERE relname IN ('people', 'people_pkey', 'people_email') ORDER BY relpages DESC; -- ctid = (page, line pointer): the physical address of each row version SELECT ctid, * FROM people WHERE id IN (1, 2, 136, 137, 10000) ORDER BY id; -- The first three line pointers on page 0 of the heap SELECT lp, lp_off, lp_len, t_xmin, t_ctid FROM heap_page_items(get_raw_page('people', 0)) LIMIT 3; -- The primary-key B-tree: its root and its depth SELECT root, level FROM bt_metap('people_pkey'); SELECT blkno, type, live_items, btpo_level FROM bt_page_stats('people_pkey', (SELECT root FROM bt_metap('people_pkey'))); -- A value too big for a page moves to the TOAST table CREATE TABLE notes (id integer PRIMARY KEY, body text); INSERT INTO notes SELECT 1, string_agg(md5(i::text), '') FROM generate_series(1, 4000) i; INSERT INTO notes SELECT 2, repeat('All work and no play. ', 5818); SELECT id, octet_length(body) AS bytes, pg_column_size(body) AS stored FROM notes ORDER BY id; SELECT reltoastrelid::regclass AS toast_table FROM pg_class WHERE relname = 'notes';
Terminal Output
lab=# CREATE EXTENSION pageinspect; CREATE EXTENSION lab=# CREATE TABLE people (id integer PRIMARY KEY, name text, email text); CREATE TABLE lab=# INSERT INTO people SELECT i, 'user' || i, 'user' || i || '@example.com' FROM generate_series(1, 10000) i; INSERT 0 10000 lab=# CREATE INDEX people_email ON people (email); CREATE INDEX lab=# VACUUM ANALYZE people; VACUUM lab=# SHOW block_size; block_size ------------ 8192 (1 row) lab=# SELECT relname, relkind, relpages, reltuples::integer AS reltuples lab-# FROM pg_class WHERE relname IN ('people', 'people_pkey', 'people_email') lab-# ORDER BY relpages DESC; relname | relkind | relpages | reltuples --------------+---------+----------+----------- people | r | 83 | 10000 people_email | i | 52 | 10000 people_pkey | i | 30 | 10000 (3 rows) lab=# SELECT ctid, * FROM people WHERE id IN (1, 2, 136, 137, 10000) ORDER BY id; ctid | id | name | email ---------+-------+-----------+----------------------- (0,1) | 1 | user1 | user1@example.com (0,2) | 2 | user2 | user2@example.com (0,136) | 136 | user136 | user136@example.com (1,1) | 137 | user137 | user137@example.com (82,43) | 10000 | user10000 | user10000@example.com (5 rows) lab=# SELECT lp, lp_off, lp_len, t_xmin, t_ctid lab-# FROM heap_page_items(get_raw_page('people', 0)) LIMIT 3; lp | lp_off | lp_len | t_xmin | t_ctid ----+--------+--------+--------+-------- 1 | 8136 | 52 | 1048 | (0,1) 2 | 8080 | 52 | 1048 | (0,2) 3 | 8024 | 52 | 1048 | (0,3) (3 rows) lab=# SELECT root, level FROM bt_metap('people_pkey'); root | level ------+------- 3 | 1 (1 row) lab=# SELECT blkno, type, live_items, btpo_level lab-# FROM bt_page_stats('people_pkey', (SELECT root FROM bt_metap('people_pkey'))); blkno | type | live_items | btpo_level -------+------+------------+------------ 3 | r | 28 | 1 (1 row) lab=# CREATE TABLE notes (id integer PRIMARY KEY, body text); CREATE TABLE lab=# INSERT INTO notes SELECT 1, string_agg(md5(i::text), '') FROM generate_series(1, 4000) i; INSERT 0 1 lab=# INSERT INTO notes SELECT 2, repeat('All work and no play. ', 5818); INSERT 0 1 lab=# SELECT id, octet_length(body) AS bytes, pg_column_size(body) AS stored lab-# FROM notes ORDER BY id; id | bytes | stored ----+--------+-------- 1 | 128000 | 128000 2 | 127996 | 1499 (2 rows) lab=# SELECT reltoastrelid::regclass AS toast_table FROM pg_class WHERE relname = 'notes'; toast_table ------------------------- pg_toast.pg_toast_19121 (1 row)
3 · MVCC: Row Versions, VACUUM and Locks

Builds on Basics & Commands › Transactions

PostgreSQL never changes a row in place. An UPDATE writes a new version of the row and marks the old one as ended. Each version carries two transaction IDs: xmin, the transaction that created it, and xmax, the one that deleted or replaced it. Below, transaction 1058 creates both rows, and the UPDATE in transaction 1059 writes Alice’s new version at (0,3). The old version at (0,1) stays on the page with xmax = 1059 and a pointer to its successor. A query decides which version it can see by comparing these IDs with its snapshot. This is multiversion concurrency control (MVCC), and it is why readers never wait for writers.

Old versions are garbage once no snapshot can see them, and VACUUM reclaims them. After VACUUM, line pointer 1 has lp_flags = 2, a redirect. This update was a HOT (heap-only tuple) update: no indexed column changed and the new version fit on the same page, so the index still points at line pointer 1, and the redirect forwards it to 3. Autovacuum runs VACUUM in the background. It is not optional maintenance. Transaction IDs are 32-bit and wrap around, so VACUUM must also freeze old rows, and a database that never vacuums eventually stops accepting writes to protect itself.

Writers do wait for writers. In the second session, B reads Alice’s balance while A holds an uncommitted update, and isn’t blocked. When B tries to update the same row it waits, and lock_timeout cancels it after 2 seconds. Row locks are not kept in a lock table: a locked row has the locker’s transaction ID in its xmax, so a waiter waits on that whole transaction. That is why pg_stat_activity reports the wait as transactionid and pg_blocking_pids names A. When A commits, B’s update goes ahead. Under READ COMMITTED it re-reads the newest version of the row, so A’s change is not lost: 70 − 10 + 10 = 70.

A deadlock happens when each transaction holds a row the other needs. Neither can continue, so after deadlock_timeout (1 second by default) the waiting backend checks for a cycle in the wait graph and cancels its own transaction. Here that was A, which had been waiting longer, and its later COMMIT can only report ROLLBACK. Applications that update several rows avoid deadlocks by always locking them in the same order, for example by primary key.

MVCC
Multiversion concurrency control: every update writes a new row version, and each snapshot sees only the versions committed before it began.
xmin / xmax
The transaction that created a row version, and the one that deleted, replaced or locked it.
HOT update
A heap-only tuple update: no index changes because no indexed column changed and the new version fit on the same page.
Deadlock
Two or more transactions each waiting for a lock another holds. PostgreSQL breaks the cycle by cancelling one of them.
Watch an UPDATE create a new row version
CREATE EXTENSION pageinspect; CREATE TABLE accounts (id integer PRIMARY KEY, owner text, balance numeric(10,2)); INSERT INTO accounts VALUES (1, 'Alice', 100), (2, 'Bob', 50); -- xmin = the transaction that created this row version; xmax = the one that ended it SELECT ctid, xmin, xmax, * FROM accounts; UPDATE accounts SET balance = balance - 30 WHERE id = 1; SELECT ctid, xmin, xmax, * FROM accounts; -- The old version is still on the page, ended by the UPDATE SELECT lp, lp_flags, t_xmin, t_xmax, t_ctid FROM heap_page_items(get_raw_page('accounts', 0)); VACUUM accounts; SELECT lp, lp_flags, t_xmin, t_xmax, t_ctid FROM heap_page_items(get_raw_page('accounts', 0));
Terminal Output
lab=# CREATE EXTENSION pageinspect; CREATE EXTENSION lab=# CREATE TABLE accounts (id integer PRIMARY KEY, owner text, balance numeric(10,2)); CREATE TABLE lab=# INSERT INTO accounts VALUES (1, 'Alice', 100), (2, 'Bob', 50); INSERT 0 2 lab=# SELECT ctid, xmin, xmax, * FROM accounts; ctid | xmin | xmax | id | owner | balance -------+------+------+----+-------+--------- (0,1) | 1058 | 0 | 1 | Alice | 100.00 (0,2) | 1058 | 0 | 2 | Bob | 50.00 (2 rows) lab=# UPDATE accounts SET balance = balance - 30 WHERE id = 1; UPDATE 1 lab=# SELECT ctid, xmin, xmax, * FROM accounts; ctid | xmin | xmax | id | owner | balance -------+------+------+----+-------+--------- (0,2) | 1058 | 0 | 2 | Bob | 50.00 (0,3) | 1059 | 0 | 1 | Alice | 70.00 (2 rows) lab=# SELECT lp, lp_flags, t_xmin, t_xmax, t_ctid lab-# FROM heap_page_items(get_raw_page('accounts', 0)); lp | lp_flags | t_xmin | t_xmax | t_ctid ----+----------+--------+--------+-------- 1 | 1 | 1058 | 1059 | (0,3) 2 | 1 | 1058 | 0 | (0,2) 3 | 1 | 1059 | 0 | (0,3) (3 rows) lab=# VACUUM accounts; VACUUM lab=# SELECT lp, lp_flags, t_xmin, t_xmax, t_ctid lab-# FROM heap_page_items(get_raw_page('accounts', 0)); lp | lp_flags | t_xmin | t_xmax | t_ctid ----+----------+--------+--------+-------- 1 | 2 | | | 2 | 1 | 1058 | 0 | (0,2) 3 | 1 | 1059 | 0 | (0,3) (3 rows)
Two windows: a lock wait, a timeout and a deadlock
-- [A] BEGIN; UPDATE accounts SET balance = balance - 10 WHERE id = 1; -- B can still read Alice: readers never wait for writers -- [B] SELECT balance FROM accounts WHERE id = 1; -- ...but a second writer of the same row waits SET lock_timeout = '2s'; UPDATE accounts SET balance = balance + 10 WHERE id = 1; RESET lock_timeout; UPDATE accounts SET balance = balance + 10 WHERE id = 1; -- Who is waiting, on what, and for whom? -- [A] SELECT pid, wait_event_type, wait_event, pg_blocking_pids(pid) AS blocked_by, left(query, 30) AS query FROM pg_stat_activity WHERE datname = 'lab' AND pid <> pg_backend_pid(); SELECT pg_backend_pid(); COMMIT; -- [B] SELECT balance FROM accounts WHERE id = 1; -- A deadlock: each transaction holds the row the other one needs -- [A] BEGIN; UPDATE accounts SET balance = balance - 5 WHERE id = 1; -- [B] BEGIN; UPDATE accounts SET balance = balance - 5 WHERE id = 2; -- [A] UPDATE accounts SET balance = balance + 5 WHERE id = 2; -- [B] UPDATE accounts SET balance = balance + 5 WHERE id = 1; COMMIT; -- [A] COMMIT;
Terminal Output
[A] lab=# BEGIN; BEGIN [A] lab=*# UPDATE accounts SET balance = balance - 10 WHERE id = 1; UPDATE 1 [B] lab=# SELECT balance FROM accounts WHERE id = 1; balance --------- 70.00 (1 row) [B] lab=# SET lock_timeout = '2s'; SET [B] lab=# UPDATE accounts SET balance = balance + 10 WHERE id = 1; ERROR: canceling statement due to lock timeout CONTEXT: while updating tuple (0,1) in relation "accounts" [B] lab=# RESET lock_timeout; RESET [B] lab=# UPDATE accounts SET balance = balance + 10 WHERE id = 1; [A] lab=*# SELECT pid, wait_event_type, wait_event, pg_blocking_pids(pid) AS blocked_by, left(query, 30) AS query [A] lab-*# FROM pg_stat_activity WHERE datname = 'lab' AND pid <> pg_backend_pid(); pid | wait_event_type | wait_event | blocked_by | query --------+-----------------+---------------+------------+-------------------------------- 153970 | Lock | transactionid | {153968} | UPDATE accounts SET balance = (1 row) [A] lab=*# SELECT pg_backend_pid(); pg_backend_pid ---------------- 153968 (1 row) [A] lab=*# COMMIT; COMMIT -- B's waiting UPDATE finishes as soon as A commits: UPDATE 1 [B] lab=# SELECT balance FROM accounts WHERE id = 1; balance --------- 70.00 (1 row) [A] lab=# BEGIN; BEGIN [A] lab=*# UPDATE accounts SET balance = balance - 5 WHERE id = 1; UPDATE 1 [B] lab=# BEGIN; BEGIN [B] lab=*# UPDATE accounts SET balance = balance - 5 WHERE id = 2; UPDATE 1 [A] lab=*# UPDATE accounts SET balance = balance + 5 WHERE id = 2; [B] lab=*# UPDATE accounts SET balance = balance + 5 WHERE id = 1; UPDATE 1 -- A's waiting UPDATE is chosen as the victim: ERROR: deadlock detected DETAIL: Process 153968 waits for ShareLock on transaction 1068; blocked by process 153970. Process 153970 waits for ShareLock on transaction 1067; blocked by process 153968. HINT: See server log for query details. CONTEXT: while updating tuple (0,2) in relation "accounts" [B] lab=*# COMMIT; COMMIT [A] lab=!# COMMIT; ROLLBACK
4 · Isolation Levels and Serialization Failures

Builds on Basics & Commands › Transactions

The SQL standard defines four isolation levels by the anomalies each one allows. PostgreSQL accepts all four names but implements three: READ UNCOMMITTED behaves as READ COMMITTED, because MVCC never needs to show uncommitted data. The level decides when a transaction takes its snapshot.

READ COMMITTED, the default, takes a new snapshot for every statement. In the first session A reads 100, B commits a withdrawal, and A’s second read in the same transaction sees 70: a non-repeatable read. REPEATABLE READ takes one snapshot at the transaction’s first query and keeps it, so A keeps seeing 70 after B has committed 50. If A then tries to update that row, PostgreSQL refuses with could not serialize access due to concurrent update instead of overwriting B’s change. That is the lost-update protection SQLite gets from its single writer.

A single snapshot is still not enough. In the doctors example the rule is that at least one doctor stays on call. Each transaction checks the rule, sees two doctors on call, and takes a different doctor off. Neither one changes a row the other wrote, so REPEATABLE READ (snapshot isolation) lets both commit, and nobody is on call. This anomaly is called write skew. SERIALIZABLE in PostgreSQL uses Serializable Snapshot Isolation (Ports & Grittner, 2012). It records what each transaction read with predicate locks and watches for the dependency pattern that only a non-serial schedule can produce. B is cancelled at COMMIT and the rule holds.

SERIALIZABLE doesn’t make transactions wait; it cancels one of them. Every SERIALIZABLE (and REPEATABLE READ) application therefore needs a retry loop: catch SQLSTATE 40001 (serialization_failure) and run the whole transaction again, as the HINT says. In return you can write each transaction as if it ran alone, without reasoning about every possible interleaving.

Snapshot
The set of transactions whose effects a query can see: everything committed before the snapshot was taken.
Write skew
Two transactions read overlapping data, then update different rows, and together break a rule neither broke alone.
SSI
Serializable Snapshot Isolation: snapshot isolation plus tracking of read/write dependencies, cancelling a transaction when a cycle could form.
SQLSTATE 40001
serialization_failure: retry the whole transaction.
READ COMMITTED vs REPEATABLE READ
-- READ COMMITTED (the default): each statement sees the latest committed data -- [A] BEGIN; SELECT balance FROM accounts WHERE id = 1; -- [B] UPDATE accounts SET balance = balance - 30 WHERE id = 1; -- [A] SELECT balance FROM accounts WHERE id = 1; COMMIT; -- REPEATABLE READ: one snapshot for the whole transaction BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT balance FROM accounts WHERE id = 1; -- [B] UPDATE accounts SET balance = balance - 20 WHERE id = 1; -- [A] SELECT balance FROM accounts WHERE id = 1; UPDATE accounts SET balance = balance - 10 WHERE id = 1; ROLLBACK;
Terminal Output
[A] lab=# BEGIN; BEGIN [A] lab=*# SELECT balance FROM accounts WHERE id = 1; balance --------- 100.00 (1 row) [B] lab=# UPDATE accounts SET balance = balance - 30 WHERE id = 1; UPDATE 1 [A] lab=*# SELECT balance FROM accounts WHERE id = 1; balance --------- 70.00 (1 row) [A] lab=*# COMMIT; COMMIT [A] lab=# BEGIN ISOLATION LEVEL REPEATABLE READ; BEGIN [A] lab=*# SELECT balance FROM accounts WHERE id = 1; balance --------- 70.00 (1 row) [B] lab=# UPDATE accounts SET balance = balance - 20 WHERE id = 1; UPDATE 1 [A] lab=*# SELECT balance FROM accounts WHERE id = 1; balance --------- 70.00 (1 row) [A] lab=*# UPDATE accounts SET balance = balance - 10 WHERE id = 1; ERROR: could not serialize access due to concurrent update [A] lab=!# ROLLBACK; ROLLBACK
Write skew, and how SERIALIZABLE stops it
-- Rule: at least one doctor must stay on call. Both check, then both leave. -- [A] BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM doctors WHERE on_call; -- [B] BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM doctors WHERE on_call; -- [A] UPDATE doctors SET on_call = false WHERE name = 'alice'; -- [B] UPDATE doctors SET on_call = false WHERE name = 'bob'; -- [A] COMMIT; -- [B] COMMIT; -- [A] SELECT * FROM doctors ORDER BY name; -- Put both back on call and run the same race under SERIALIZABLE UPDATE doctors SET on_call = true; BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT count(*) FROM doctors WHERE on_call; -- [B] BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT count(*) FROM doctors WHERE on_call; -- [A] UPDATE doctors SET on_call = false WHERE name = 'alice'; -- [B] UPDATE doctors SET on_call = false WHERE name = 'bob'; -- [A] COMMIT; -- [B] COMMIT; -- [A] SELECT * FROM doctors ORDER BY name;
Terminal Output
[A] lab=# BEGIN ISOLATION LEVEL REPEATABLE READ; BEGIN [A] lab=*# SELECT count(*) FROM doctors WHERE on_call; count ------- 2 (1 row) [B] lab=# BEGIN ISOLATION LEVEL REPEATABLE READ; BEGIN [B] lab=*# SELECT count(*) FROM doctors WHERE on_call; count ------- 2 (1 row) [A] lab=*# UPDATE doctors SET on_call = false WHERE name = 'alice'; UPDATE 1 [B] lab=*# UPDATE doctors SET on_call = false WHERE name = 'bob'; UPDATE 1 [A] lab=*# COMMIT; COMMIT [B] lab=*# COMMIT; COMMIT [A] lab=# SELECT * FROM doctors ORDER BY name; name | on_call -------+--------- alice | f bob | f (2 rows) [A] lab=# UPDATE doctors SET on_call = true; UPDATE 2 [A] lab=# BEGIN ISOLATION LEVEL SERIALIZABLE; BEGIN [A] lab=*# SELECT count(*) FROM doctors WHERE on_call; count ------- 2 (1 row) [B] lab=# BEGIN ISOLATION LEVEL SERIALIZABLE; BEGIN [B] lab=*# SELECT count(*) FROM doctors WHERE on_call; count ------- 2 (1 row) [A] lab=*# UPDATE doctors SET on_call = false WHERE name = 'alice'; UPDATE 1 [B] lab=*# UPDATE doctors SET on_call = false WHERE name = 'bob'; UPDATE 1 [A] lab=*# COMMIT; COMMIT [B] lab=*# COMMIT; ERROR: could not serialize access due to read/write dependencies among transactions DETAIL: Reason code: Canceled on identification as a pivot, during commit attempt. HINT: The transaction might succeed if retried. [A] lab=# SELECT * FROM doctors ORDER BY name; name | on_call -------+--------- alice | f bob | t (2 rows)
5 · The Query Planner and Its Statistics

Builds on Basics & Commands › Indexes and EXPLAIN

SQL says what to return, not how to find it. The planner considers the possible plans and estimates each one’s cost in abstract units: reading a page in sequence costs 1 (seq_page_cost), reading one at random costs 4 (random_page_cost), plus small CPU costs per row. Every estimate starts with a guess of how many rows each step returns, and that guess comes from statistics that ANALYZE gathers from a sample of the table and stores in pg_stats.

Below, pg_stats records two distinct statuses with their frequencies, 99% and 1%. The same query shape gets two different plans. For returned the planner expects 2,053 rows (the true count is 2,000) and uses the index. For shipped it expects nearly every row, so reading the whole table in order is cheaper than 198,000 random lookups. EXPLAIN ANALYZE runs the query and prints actual counts beside the estimates. From PostgreSQL 18 it also reports buffer usage by default. The first run’s index-only scan still visits the heap 2,000 times (“Heap Fetches”), because nothing has yet marked those pages all-visible. After VACUUM sets the visibility map, Heap Fetches drops to 0 and the query runs about six times faster.

Most bad plans come from bad row estimates, and the classic cause is correlated columns. The planner assumes conditions are independent, so for city = 'city7' AND country = 'country1' it multiplies 1/50 by 1/10 and expects about 400 rows. Every city belongs to exactly one country, so the true answer is 4,000, ten times more. A ten-fold underestimate is enough to pick the wrong join method or a nested loop that never ends. CREATE STATISTICS ... (dependencies) tells ANALYZE to measure how far one column determines the other, and the estimate becomes 4,173.

The planner also chooses a join method for each join. A hash join builds a hash table from the smaller input and probes it with the larger one, which suits joining whole tables. A nested loop looks up matching rows for each outer row, which wins when the outer side is small and the inner side is indexed, as for one customer. A merge join walks two inputs that are already sorted. Reading plans means comparing estimated with actual rows, finding the first place they diverge, and asking why.

Cost
The planner’s estimate of work, in units of one sequential page read. It compares plans; it is not a time.
Selectivity
The fraction of rows a condition keeps. It decides between an index and a sequential scan.
Visibility map
One bit per heap page meaning “all rows here are visible to everyone”, set by VACUUM. Index-only scans depend on it.
Extended statistics
CREATE STATISTICS: dependencies, distinct counts or most-common values measured across several columns together.
Statistics, selectivity and the visibility map
-- 200,000 orders: 1% returned; 50 cities, five in each of 10 countries CREATE TABLE orders ( id integer PRIMARY KEY, customer_id integer NOT NULL, status text NOT NULL, city text NOT NULL, country text NOT NULL, total numeric(10,2) NOT NULL ); INSERT INTO orders SELECT i, i % 5000 + 1, CASE WHEN i % 100 = 0 THEN 'returned' ELSE 'shipped' END, 'city' || (i % 50), 'country' || (i % 50 / 5), (i % 997) * 1.25 FROM generate_series(1, 200000) i; CREATE INDEX orders_status ON orders (status); ANALYZE orders; -- What the planner knows about a column SELECT n_distinct, most_common_vals, most_common_freqs FROM pg_stats WHERE tablename = 'orders' AND attname = 'status'; -- Same query shape, different value, different plan EXPLAIN SELECT * FROM orders WHERE status = 'returned'; EXPLAIN SELECT * FROM orders WHERE status = 'shipped'; -- PostgreSQL 18: EXPLAIN ANALYZE reports buffers by default EXPLAIN ANALYZE SELECT count(*) FROM orders WHERE status = 'returned'; -- VACUUM sets the visibility map, so an index-only scan can skip the heap VACUUM orders; EXPLAIN ANALYZE SELECT count(*) FROM orders WHERE status = 'returned';
Terminal Output
lab=# CREATE TABLE orders ( lab(# id integer PRIMARY KEY, lab(# customer_id integer NOT NULL, lab(# status text NOT NULL, lab(# city text NOT NULL, lab(# country text NOT NULL, lab(# total numeric(10,2) NOT NULL lab(# ); CREATE TABLE lab=# INSERT INTO orders lab-# SELECT i, i % 5000 + 1, lab-# CASE WHEN i % 100 = 0 THEN 'returned' ELSE 'shipped' END, lab-# 'city' || (i % 50), 'country' || (i % 50 / 5), (i % 997) * 1.25 lab-# FROM generate_series(1, 200000) i; INSERT 0 200000 lab=# CREATE INDEX orders_status ON orders (status); CREATE INDEX lab=# ANALYZE orders; ANALYZE lab=# SELECT n_distinct, most_common_vals, most_common_freqs lab-# FROM pg_stats WHERE tablename = 'orders' AND attname = 'status'; n_distinct | most_common_vals | most_common_freqs ------------+--------------------+-------------------------- 2 | {shipped,returned} | {0.98973334,0.010266666} (1 row) lab=# EXPLAIN SELECT * FROM orders WHERE status = 'returned'; QUERY PLAN ---------------------------------------------------------------------------------- Index Scan using orders_status on orders (cost=0.29..271.94 rows=2053 width=37) Index Cond: (status = 'returned'::text) (2 rows) lab=# EXPLAIN SELECT * FROM orders WHERE status = 'shipped'; QUERY PLAN --------------------------------------------------------------- Seq Scan on orders (cost=0.00..4167.00 rows=197947 width=37) Filter: (status = 'shipped'::text) (2 rows) lab=# EXPLAIN ANALYZE SELECT count(*) FROM orders WHERE status = 'returned'; QUERY PLAN -------------------------------------------------------------------------------------------------------------------------------------------- Aggregate (cost=277.07..277.08 rows=1 width=8) (actual time=1.986..1.986 rows=1.00 loops=1) Buffers: shared hit=1667 read=3 -> Index Only Scan using orders_status on orders (cost=0.29..271.94 rows=2053 width=0) (actual time=0.080..1.762 rows=2000.00 loops=1) Index Cond: (status = 'returned'::text) Heap Fetches: 2000 Index Searches: 1 Buffers: shared hit=1667 read=3 Planning: Buffers: shared hit=6 Planning Time: 0.140 ms Execution Time: 2.012 ms (11 rows) lab=# VACUUM orders; VACUUM lab=# EXPLAIN ANALYZE SELECT count(*) FROM orders WHERE status = 'returned'; QUERY PLAN ------------------------------------------------------------------------------------------------------------------------------------------- Aggregate (cost=49.36..49.37 rows=1 width=8) (actual time=0.345..0.345 rows=1.00 loops=1) Buffers: shared hit=4 -> Index Only Scan using orders_status on orders (cost=0.29..44.22 rows=2053 width=0) (actual time=0.028..0.231 rows=2000.00 loops=1) Index Cond: (status = 'returned'::text) Heap Fetches: 0 Index Searches: 1 Buffers: shared hit=4 Planning: Buffers: shared hit=5 Planning Time: 0.145 ms Execution Time: 0.371 ms (11 rows)
Correlated columns and join methods
-- Two correlated columns: every city lies in exactly one country EXPLAIN (ANALYZE, BUFFERS OFF, SUMMARY OFF) SELECT * FROM orders WHERE city = 'city7' AND country = 'country1'; CREATE STATISTICS orders_city_country (dependencies) ON city, country FROM orders; ANALYZE orders; EXPLAIN (ANALYZE, BUFFERS OFF, SUMMARY OFF) SELECT * FROM orders WHERE city = 'city7' AND country = 'country1'; -- Join methods: the planner picks one per join CREATE TABLE customers (id integer PRIMARY KEY, name text NOT NULL); INSERT INTO customers SELECT i, 'customer ' || i FROM generate_series(1, 5000) i; CREATE INDEX orders_customer_id ON orders (customer_id); ANALYZE; EXPLAIN (COSTS OFF) SELECT c.name, sum(o.total) FROM customers c JOIN orders o ON o.customer_id = c.id GROUP BY c.name; EXPLAIN (COSTS OFF) SELECT c.name, o.total FROM customers c JOIN orders o ON o.customer_id = c.id WHERE c.id = 42;
Terminal Output
lab=# EXPLAIN (ANALYZE, BUFFERS OFF, SUMMARY OFF) lab-# SELECT * FROM orders WHERE city = 'city7' AND country = 'country1'; QUERY PLAN --------------------------------------------------------------------------------------------------------------------------- Gather (cost=1000.00..4473.91 rows=422 width=37) (actual time=0.512..14.131 rows=4000.00 loops=1) Workers Planned: 1 Workers Launched: 1 -> Parallel Seq Scan on orders (cost=0.00..3431.71 rows=248 width=37) (actual time=0.018..7.788 rows=2000.00 loops=2) Filter: ((city = 'city7'::text) AND (country = 'country1'::text)) Rows Removed by Filter: 98000 (6 rows) lab=# CREATE STATISTICS orders_city_country (dependencies) ON city, country FROM orders; CREATE STATISTICS lab=# ANALYZE orders; ANALYZE lab=# EXPLAIN (ANALYZE, BUFFERS OFF, SUMMARY OFF) lab-# SELECT * FROM orders WHERE city = 'city7' AND country = 'country1'; QUERY PLAN -------------------------------------------------------------------------------------------------------------- Seq Scan on orders (cost=0.00..4667.00 rows=4173 width=37) (actual time=0.012..11.263 rows=4000.00 loops=1) Filter: ((city = 'city7'::text) AND (country = 'country1'::text)) Rows Removed by Filter: 196000 (3 rows) lab=# CREATE TABLE customers (id integer PRIMARY KEY, name text NOT NULL); CREATE TABLE lab=# INSERT INTO customers SELECT i, 'customer ' || i FROM generate_series(1, 5000) i; INSERT 0 5000 lab=# CREATE INDEX orders_customer_id ON orders (customer_id); CREATE INDEX lab=# ANALYZE; ANALYZE lab=# EXPLAIN (COSTS OFF) lab-# SELECT c.name, sum(o.total) FROM customers c JOIN orders o ON o.customer_id = c.id lab-# GROUP BY c.name; QUERY PLAN ------------------------------------------- HashAggregate Group Key: c.name -> Hash Join Hash Cond: (o.customer_id = c.id) -> Seq Scan on orders o -> Hash -> Seq Scan on customers c (7 rows) lab=# EXPLAIN (COSTS OFF) lab-# SELECT c.name, o.total FROM customers c JOIN orders o ON o.customer_id = c.id lab-# WHERE c.id = 42; QUERY PLAN ------------------------------------------------------ Nested Loop -> Index Scan using customers_pkey on customers c Index Cond: (id = 42) -> Bitmap Heap Scan on orders o Recheck Cond: (customer_id = 42) -> Bitmap Index Scan on orders_customer_id Index Cond: (customer_id = 42) (7 rows)
6 · Recursive Queries, Graphs and Cycles

Builds on Basics & Commands › Subqueries and CTEs

A recursive CTE computes a fixed point. The first part (the anchor) produces some rows. The second part runs again and again on the rows the previous round produced, and the query stops when a round produces nothing new. That is enough to walk any graph stored as rows: road networks, org charts, bills of materials, dependency trees.

Whether the walk ends depends on UNION versus UNION ALL. With UNION, a row already produced is discarded, so the reachability query finishes even though the network has a loop (A → B → C → A). To list paths, each row must carry its route, so every row is new and UNION can’t stop the loop. PostgreSQL 14 added the CYCLE clause for this. It records the values of the named column along each path and marks a row as a cycle when a value repeats. The anchor starts at A itself for a reason: CYCLE knows only the values it has seen in that column. If the anchor started from the routes leaving A, A would never appear as a destination, and A → B → C → A → E would slip through once. The cheapest of the three routes found is the shortest path, 16 km.

The companion SEARCH clause sets the order of the walk. SEARCH DEPTH FIRST BY name adds a sort column that keeps each subtree together, which is exactly what an indented org chart needs; BREADTH FIRST lists one level at a time. A recursion with no stopping rule never finishes, so protect anything recursive with a statement_timeout or a depth limit in the recursive part.

Fixed point
The state in which applying the recursive step adds nothing new. A recursive CTE stops there.
Anchor / recursive member
The two halves of a recursive CTE: the starting rows, and the query run repeatedly on the previous round’s output.
CYCLE clause
Marks rows whose path revisits a value, so UNION ALL recursions over graphs with loops can end.
SEARCH clause
Adds an ordering column for depth-first or breadth-first output.
Reachability, all paths, and an org chart
-- A small road network with a loop: A -> B -> C -> A CREATE TABLE routes (src text, dst text, km integer, PRIMARY KEY (src, dst)); INSERT INTO routes VALUES ('A','B',5), ('B','C',3), ('C','A',4), ('B','D',9), ('C','D',2), ('D','E',6), ('A','E',20); -- Reachability: UNION discards rows already found, so the loop ends WITH RECURSIVE reach(city) AS ( SELECT 'A' UNION SELECT r.dst FROM routes r JOIN reach ON r.src = reach.city ) SELECT city FROM reach ORDER BY city; -- Every path from A to E. UNION ALL keeps paths, so CYCLE must stop the loop WITH RECURSIVE paths(dst, route, km) AS ( SELECT 'A', ARRAY['A'], 0 UNION ALL SELECT r.dst, p.route || r.dst, p.km + r.km FROM paths p JOIN routes r ON r.src = p.dst ) CYCLE dst SET is_cycle USING visited SELECT array_to_string(route, ' -> ') AS route, km FROM paths WHERE dst = 'E' AND NOT is_cycle ORDER BY km, route; -- SEARCH orders the walk: depth first draws a tree CREATE TABLE staff (id integer PRIMARY KEY, name text, manager_id integer REFERENCES staff); INSERT INTO staff VALUES (1,'Ada',NULL), (2,'Grace',1), (3,'Alan',1), (4,'Edsger',2), (5,'Barbara',2), (6,'Donald',3); WITH RECURSIVE tree(id, name, depth) AS ( SELECT id, name, 0 FROM staff WHERE manager_id IS NULL UNION ALL SELECT s.id, s.name, t.depth + 1 FROM staff s JOIN tree t ON s.manager_id = t.id ) SEARCH DEPTH FIRST BY name SET ord SELECT repeat(' ', depth) || name AS org_chart FROM tree ORDER BY ord; -- A recursion with no stopping rule never finishes: always set a limit SET statement_timeout = '1s'; WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n) SELECT count(*) FROM n; RESET statement_timeout;
Terminal Output
lab=# CREATE TABLE routes (src text, dst text, km integer, PRIMARY KEY (src, dst)); CREATE TABLE lab=# INSERT INTO routes VALUES ('A','B',5), ('B','C',3), ('C','A',4), ('B','D',9), lab-# ('C','D',2), ('D','E',6), ('A','E',20); INSERT 0 7 lab=# WITH RECURSIVE reach(city) AS ( lab(# SELECT 'A' lab(# UNION lab(# SELECT r.dst FROM routes r JOIN reach ON r.src = reach.city lab(# ) lab-# SELECT city FROM reach ORDER BY city; city ------ A B C D E (5 rows) lab=# WITH RECURSIVE paths(dst, route, km) AS ( lab(# SELECT 'A', ARRAY['A'], 0 lab(# UNION ALL lab(# SELECT r.dst, p.route || r.dst, p.km + r.km lab(# FROM paths p JOIN routes r ON r.src = p.dst lab(# ) CYCLE dst SET is_cycle USING visited lab-# SELECT array_to_string(route, ' -> ') AS route, km lab-# FROM paths WHERE dst = 'E' AND NOT is_cycle lab-# ORDER BY km, route; route | km -----------------------+---- A -> B -> C -> D -> E | 16 A -> B -> D -> E | 20 A -> E | 20 (3 rows) lab=# CREATE TABLE staff (id integer PRIMARY KEY, name text, manager_id integer REFERENCES staff); CREATE TABLE lab=# INSERT INTO staff VALUES (1,'Ada',NULL), (2,'Grace',1), (3,'Alan',1), (4,'Edsger',2), lab-# (5,'Barbara',2), (6,'Donald',3); INSERT 0 6 lab=# WITH RECURSIVE tree(id, name, depth) AS ( lab(# SELECT id, name, 0 FROM staff WHERE manager_id IS NULL lab(# UNION ALL lab(# SELECT s.id, s.name, t.depth + 1 FROM staff s JOIN tree t ON s.manager_id = t.id lab(# ) SEARCH DEPTH FIRST BY name SET ord lab-# SELECT repeat(' ', depth) || name AS org_chart FROM tree ORDER BY ord; org_chart ----------------- Ada Alan Donald Grace Barbara Edsger (6 rows) lab=# SET statement_timeout = '1s'; SET lab=# WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n) lab-# SELECT count(*) FROM n; ERROR: canceling statement due to statement timeout lab=# RESET statement_timeout; RESET
7 · WAL, Durability and the Cost of fsync

Builds on Basics & Commands › Transactions

The write-ahead log (WAL) is the record of every change, written before the data pages it describes. Changed pages can stay in memory and reach the disk later, at a checkpoint, because the WAL alone is enough to redo the work. Below, inserting 10,000 small rows writes 1,461 kB of WAL, about 150 bytes a row, once the heap tuple, the index entry and the record headers are counted. pg_current_wal_lsn() is the current position in the log, and the difference between two positions is a byte count.

A COMMIT is durable once its WAL record is on stable storage, so by default every COMMIT waits for an fdatasync of the WAL file. Timing 1,000 one-row transactions measures that wait directly. On this server’s disk they took 442 ms, about 0.44 ms per commit, or roughly 2,300 commits a second from one connection. The SQLite lesson measured about 4.2 ms per commit in rollback-journal mode on the same disk, because that mode syncs both a journal and the database file. With synchronous_commit = off the same 1,000 commits take 7 ms. COMMIT returns as soon as the record is in memory, and the WAL writer flushes it shortly afterwards. A crash can then lose the last fraction of a second of commits, at most three times wal_writer_delay, but never corrupts anything, so it is a reasonable setting for data you could re-create. Putting all 1,000 rows in a single transaction takes 3.4 ms and needs only one flush.

Do not confuse that with fsync = off, which skips flushing altogether and can leave a corrupt database after a power cut. full_page_writes protects against a different failure, a torn page half-written when the power fails. The first time a page changes after each checkpoint, its complete image goes into the WAL, so recovery never has to trust a half-written page. The last session stops the server the way a crash would (pg_ctl stop -m immediate skips the shutdown checkpoint). On the next start PostgreSQL finds the shutdown was not clean, replays the WAL from the last checkpoint, and every committed row is there. An immediate stop tests WAL replay, not the disk, because the operating system still had its cache.

WAL
The write-ahead log: every change is logged, and the log flushed, before the data pages it describes may be written.
Checkpoint
The point at which all changed pages have been written, so recovery can start replaying WAL from there.
synchronous_commit
Whether COMMIT waits for its WAL to reach disk. off trades the last moments of commits on a crash for speed, without corruption.
LSN
Log sequence number: a byte position in the WAL, such as 0/2131C90.
Measure WAL volume and the cost of a commit
SELECT name, setting FROM pg_settings WHERE name IN ('fsync', 'synchronous_commit', 'full_page_writes', 'wal_level', 'checkpoint_timeout') ORDER BY name; CREATE TABLE events (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, note text); -- How much WAL does a change write? Compare log positions before and after. SELECT pg_current_wal_lsn() AS before \gset INSERT INTO events (note) SELECT 'event ' || i FROM generate_series(1, 10000) i; SELECT pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), :'before')) AS wal_written; -- One transaction per row: every COMMIT waits for the WAL to reach the disk CREATE PROCEDURE insert_each(n integer) LANGUAGE plpgsql AS $$ BEGIN FOR i IN 1..n LOOP INSERT INTO events (note) VALUES ('row ' || i); COMMIT; END LOOP; END $$; \timing on CALL insert_each(1000); -- Same work, but COMMIT returns before the flush SET synchronous_commit = off; CALL insert_each(1000); RESET synchronous_commit; -- Same 1,000 rows in a single transaction: one flush INSERT INTO events (note) SELECT 'row ' || i FROM generate_series(1, 1000) i; \timing off
Terminal Output
lab=# SELECT name, setting FROM pg_settings WHERE name IN ('fsync', 'synchronous_commit', 'full_page_writes', 'wal_level', 'checkpoint_timeout') ORDER BY name; name | setting --------------------+--------- checkpoint_timeout | 300 fsync | on full_page_writes | on synchronous_commit | on wal_level | replica (5 rows) lab=# CREATE TABLE events (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, note text); CREATE TABLE lab=# SELECT pg_current_wal_lsn() AS before \gset lab=# INSERT INTO events (note) SELECT 'event ' || i FROM generate_series(1, 10000) i; INSERT 0 10000 lab=# SELECT pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), :'before')) AS wal_written; wal_written ------------- 1461 kB (1 row) lab=# CREATE PROCEDURE insert_each(n integer) LANGUAGE plpgsql AS $$ lab$# BEGIN lab$# FOR i IN 1..n LOOP lab$# INSERT INTO events (note) VALUES ('row ' || i); lab$# COMMIT; lab$# END LOOP; lab$# END $$; CREATE PROCEDURE lab=# \timing on Timing is on. lab=# CALL insert_each(1000); CALL Time: 442.023 ms lab=# SET synchronous_commit = off; SET Time: 0.214 ms lab=# CALL insert_each(1000); CALL Time: 7.108 ms lab=# RESET synchronous_commit; RESET Time: 0.219 ms lab=# INSERT INTO events (note) SELECT 'row ' || i FROM generate_series(1, 1000) i; INSERT 0 1000 Time: 3.423 ms lab=# \timing off Timing is off.
Crash the server and watch it recover
# Only on a throwaway test cluster: PGDATA, PGHOST and PGDATABASE must point at it psql -c "INSERT INTO events (note) SELECT 'before the crash' FROM generate_series(1, 5000);" psql -Atc "SELECT count(*) FROM events;" pg_ctl stop -m immediate pg_ctl start -l server.log grep -oE '(database system was not properly|redo starts|redo done|database system is ready).*' server.log | tail -4 psql -Atc "SELECT count(*) FROM events;"
Terminal Output
$ psql -c "INSERT INTO events (note) SELECT 'before the crash' FROM generate_series(1, 5000);" INSERT 0 5000 $ psql -Atc "SELECT count(*) FROM events;" 18000 $ pg_ctl stop -m immediate waiting for server to shut down.... done server stopped $ pg_ctl start -l server.log waiting for server to start.... done server started $ grep -oE '(database system was not properly|redo starts|redo done|database system is ready).*' server.log | tail -4 database system was not properly shut down; automatic recovery in progress redo starts at 0/2131C90 redo done at 0/2818DE0 system usage: CPU: user: 0.01 s, system: 0.01 s, elapsed: 0.03 s database system is ready to accept connections $ psql -Atc "SELECT count(*) FROM events;" 18000
8 · Temporal Keys: What Was True, and When We Knew It

Builds on Basics & Commands › Built-in Functions and Types

Most tables store only the current value. A bitemporal design stores two separate timelines. Valid time is when a fact is true in the world, such as the period a price applied. Transaction time is when the database believed a fact. When a company restates last year’s revenue, you don’t overwrite the old figure; you close the period in which it was believed and add the new one. An as-of query then answers “what did we know on 30 June 2025?”. Backtests need this: using today’s restated figures adds look-ahead bias, information nobody had at the time.

PostgreSQL has range types for periods. A daterange such as [2025-02-20,2026-02-18) is half-open, so one period can end on the day the next begins without overlapping, and @> tests whether a range contains a date. PostgreSQL 18 adds temporal keys. PRIMARY KEY (ticker, fiscal_year, known WITHOUT OVERLAPS) means one belief per fact at any moment. It is enforced by a GiST exclusion constraint, and the btree_gist extension lets that index handle the plain columns too. Adding the restated figure without closing the old one is refused, with both conflicting periods in the error. The SQLite lesson needed a hand-written trigger to do the same job.

The second session adds a temporal foreign key, also new in PostgreSQL 18. FOREIGN KEY (product_id, PERIOD sold) REFERENCES prices (product_id, PERIOD valid) requires every day of a sale’s period to fall inside the product’s price periods. A sale may span two periods if they meet, as sale 2 does, but a sale before the first price exists is refused. Joining with the overlap operator && then shows which price applied to each part of each sale.

Valid time
When a fact is true in the modelled world.
Transaction time
When the database recorded a fact, and when it was superseded.
WITHOUT OVERLAPS
PostgreSQL 18: the last column of a primary or unique key is a range, and rows with equal other columns may not have overlapping ranges.
PERIOD foreign key
PostgreSQL 18: the child’s range must be fully covered by the parent rows’ ranges for the same key.
A restatement, stored without losing history
-- btree_gist lets a GiST index mix plain columns with ranges CREATE EXTENSION btree_gist; -- Transaction time is a range; WITHOUT OVERLAPS forbids two beliefs at once CREATE TABLE revenue ( ticker text NOT NULL, fiscal_year integer NOT NULL, amount_m integer NOT NULL, known daterange NOT NULL, PRIMARY KEY (ticker, fiscal_year, known WITHOUT OVERLAPS) ); -- Original 10-K INSERT INTO revenue VALUES ('ACME', 2024, 1200, '[2025-02-20,)'); -- Wrong: add the restated figure without closing the old one INSERT INTO revenue VALUES ('ACME', 2024, 1150, '[2026-02-18,)'); -- Right: close the old version and add the new one atomically BEGIN; UPDATE revenue SET known = daterange(lower(known), '2026-02-18') WHERE ticker = 'ACME' AND fiscal_year = 2024 AND upper_inf(known); INSERT INTO revenue VALUES ('ACME', 2024, 1150, '[2026-02-18,)'); COMMIT; SELECT * FROM revenue ORDER BY known; -- As known on 30 June 2025, and as known today SELECT amount_m FROM revenue WHERE ticker = 'ACME' AND fiscal_year = 2024 AND known @> date '2025-06-30'; SELECT amount_m FROM revenue WHERE ticker = 'ACME' AND fiscal_year = 2024 AND known @> current_date;
Terminal Output
lab=# CREATE EXTENSION btree_gist; CREATE EXTENSION lab=# CREATE TABLE revenue ( lab(# ticker text NOT NULL, lab(# fiscal_year integer NOT NULL, lab(# amount_m integer NOT NULL, lab(# known daterange NOT NULL, lab(# PRIMARY KEY (ticker, fiscal_year, known WITHOUT OVERLAPS) lab(# ); CREATE TABLE lab=# INSERT INTO revenue VALUES ('ACME', 2024, 1200, '[2025-02-20,)'); INSERT 0 1 lab=# INSERT INTO revenue VALUES ('ACME', 2024, 1150, '[2026-02-18,)'); ERROR: conflicting key value violates exclusion constraint "revenue_pkey" DETAIL: Key (ticker, fiscal_year, known)=(ACME, 2024, [2026-02-18,)) conflicts with existing key (ticker, fiscal_year, known)=(ACME, 2024, [2025-02-20,)). lab=# BEGIN; BEGIN lab=*# UPDATE revenue SET known = daterange(lower(known), '2026-02-18') lab-*# WHERE ticker = 'ACME' AND fiscal_year = 2024 AND upper_inf(known); UPDATE 1 lab=*# INSERT INTO revenue VALUES ('ACME', 2024, 1150, '[2026-02-18,)'); INSERT 0 1 lab=*# COMMIT; COMMIT lab=# SELECT * FROM revenue ORDER BY known; ticker | fiscal_year | amount_m | known --------+-------------+----------+------------------------- ACME | 2024 | 1200 | [2025-02-20,2026-02-18) ACME | 2024 | 1150 | [2026-02-18,) (2 rows) lab=# SELECT amount_m FROM revenue lab-# WHERE ticker = 'ACME' AND fiscal_year = 2024 AND known @> date '2025-06-30'; amount_m ---------- 1200 (1 row) lab=# SELECT amount_m FROM revenue lab-# WHERE ticker = 'ACME' AND fiscal_year = 2024 AND known @> current_date; amount_m ---------- 1150 (1 row)
Prices over time, and a temporal foreign key
-- Valid time: which price applied on which day CREATE TABLE prices ( product_id integer NOT NULL, valid daterange NOT NULL, price numeric(10,2) NOT NULL, PRIMARY KEY (product_id, valid WITHOUT OVERLAPS) ); INSERT INTO prices VALUES (1, '[2026-01-01,2026-03-01)', 10.00), (1, '[2026-03-01,)', 12.00); -- A temporal foreign key: the sale must fall inside periods where the product had a price CREATE TABLE sales ( id integer PRIMARY KEY, product_id integer NOT NULL, sold daterange NOT NULL, FOREIGN KEY (product_id, PERIOD sold) REFERENCES prices (product_id, PERIOD valid) ); INSERT INTO sales VALUES (1, 1, '[2026-02-10,2026-02-11)'), (2, 1, '[2026-02-27,2026-03-03)'); INSERT INTO sales VALUES (3, 1, '[2025-12-30,2025-12-31)'); SELECT s.id, s.sold, p.valid, p.price FROM sales s JOIN prices p ON p.product_id = s.product_id AND p.valid && s.sold ORDER BY s.id, p.valid;
Terminal Output
lab=# CREATE TABLE prices ( lab(# product_id integer NOT NULL, lab(# valid daterange NOT NULL, lab(# price numeric(10,2) NOT NULL, lab(# PRIMARY KEY (product_id, valid WITHOUT OVERLAPS) lab(# ); CREATE TABLE lab=# INSERT INTO prices VALUES (1, '[2026-01-01,2026-03-01)', 10.00), (1, '[2026-03-01,)', 12.00); INSERT 0 2 lab=# CREATE TABLE sales ( lab(# id integer PRIMARY KEY, lab(# product_id integer NOT NULL, lab(# sold daterange NOT NULL, lab(# FOREIGN KEY (product_id, PERIOD sold) REFERENCES prices (product_id, PERIOD valid) lab(# ); CREATE TABLE lab=# INSERT INTO sales VALUES (1, 1, '[2026-02-10,2026-02-11)'), (2, 1, '[2026-02-27,2026-03-03)'); INSERT 0 2 lab=# INSERT INTO sales VALUES (3, 1, '[2025-12-30,2025-12-31)'); ERROR: insert or update on table "sales" violates foreign key constraint "sales_product_id_sold_fkey" DETAIL: Key (product_id, sold)=(1, [2025-12-30,2025-12-31)) is not present in table "prices". lab=# SELECT s.id, s.sold, p.valid, p.price lab-# FROM sales s JOIN prices p ON p.product_id = s.product_id AND p.valid && s.sold lab-# ORDER BY s.id, p.valid; id | sold | valid | price ----+-------------------------+-------------------------+------- 1 | [2026-02-10,2026-02-11) | [2026-01-01,2026-03-01) | 10.00 2 | [2026-02-27,2026-03-03) | [2026-01-01,2026-03-01) | 10.00 2 | [2026-02-27,2026-03-03) | [2026-03-01,) | 12.00 (3 rows)
Thesis Projects & Reading List
Two thesis-sized projects
Serializable vs. explicit locking under contention

Question: at what level of contention does SERIALIZABLE’s cancel-and-retry cost more than locking rows up front with SELECT ... FOR UPDATE?

Method: implement one workload, such as seat booking or an account ledger, both ways (lessons 3 and 4). Drive it with pgbench custom scripts at rising client counts, measuring throughput, tail latency, retries per commit and deadlocks.

Contribution: a measured crossover point and a decision rule that takes the hot-row ratio of a real workload as input.

Temporal keys in practice

Question: how do PostgreSQL 18’s WITHOUT OVERLAPS keys compare with a trigger-based design for bitemporal financial data?

Method: load restated SEC filings into both designs (lesson 8, and the SQLite guide’s trigger version). Compare correctness under concurrent writers, as-of query plans, index size and load time.

Contribution: evidence for when declarative temporal constraints are worth requiring PostgreSQL 18, and a schema that avoids look-ahead bias.

Primary sources
Books and papers
  • Codd, “A Relational Model of Data for Large Shared Data Banks”, Communications of the ACM 13(6), 1970.
  • Selinger et al., “Access Path Selection in a Relational Database Management System”, SIGMOD 1979.
  • Berenson et al., “A Critique of ANSI SQL Isolation Levels”, SIGMOD 1995.
  • Ports & Grittner, “Serializable Snapshot Isolation in PostgreSQL”, PVLDB 5(12), 2012.
  • Mohan et al., “ARIES: A Transaction Recovery Method”, ACM Transactions on Database Systems 17(1), 1992.
  • Snodgrass, Developing Time-Oriented Database Applications in SQL, Morgan Kaufmann, 2000.
⚓
Northwind Traders
Microsoft’s classic sample database, and every lesson it was built to teach, in PostgreSQL
Every Terminal Output block on this tab is real, captured from PostgreSQL 18.6 on this site’s server. Each lesson starts from a fresh copy of the database, loaded from the northwind-postgresql.sql script you can download below, and connects as an ordinary user, so the prompt ends in =>. Lessons that change data roll their changes back.
What Northwind is, and why it is still worth learning

Northwind Traders is a made-up company that imports speciality foods (Ipoh Coffee from Singapore, Chartreuse from France, Gorgonzola from Italy) and sells them to shops and restaurants in 21 countries. Microsoft created it as the sample database for Microsoft Access in the early 1990s, and SQL Server 7.0 and 2000 installed it alongside pubs. From SQL Server 2005, Microsoft replaced both with AdventureWorks, but Northwind stayed the database every SQL tutorial seemed to use. Microsoft still publishes the original install script, instnwnd.sql, on GitHub under the MIT licence.

It lasted because it is small enough to keep in your head and still realistic. In 13 tables it has a business you already understand and every relationship type: one-to-many, many-to-many through a junction table, and a table that points at itself. It has deliberate copies of data, NULLs with a meaning, derived money values, dates, pictures, and a set of saved reports. It also has 830 orders from July 1996 to May 1998, enough data that a wrong query gives an answer that looks wrong.

This tab teaches what Northwind was built to teach, in the order a course would: the model (tables, keys, relationships, normalisation), asking questions (filtering, NULL, joins, aggregates, dates, subqueries), Microsoft’s own reports (views, stored procedures as PostgreSQL functions, and window functions, which Northwind predates), and keeping data correct (constraints, transactions, indexes, binary data). It ends with exercises and an account of what changed in the move to PostgreSQL. The same lessons for SQLite are in the SQLite3 guide, so you can compare the two databases question by question.

Get the database

The download is a plain SQL script: the schema, all 3,308 rows, 19 indexes, Microsoft’s 16 views and its 7 stored procedures as PostgreSQL functions. It runs in a single transaction, so it either loads completely or not at all. Create an empty database, load the script into it, and connect. Use -q so psql doesn’t print a line for every statement.

createdb northwind psql -q -d northwind -f northwind-postgresql.sql psql -d northwind

If createdb says your role does not exist, see the Installation tab: create a role with your login name first, or run the commands as the postgres user. The read-only Northwind Explorer shows the same data in its SQLite form.

How the 13 tables connect
Northwind entity-relationship diagram: 13 tables and the foreign keys between them customers 91 customer_id PK company_name country customer_customer_demo 0 customer_id PK FK customer_type_id PK FK customer_demographics 0 customer_type_id PK customer_desc orders 830 order_id PK customer_id FK employee_id FK ship_via FK order_date shippers 3 shipper_id PK company_name employees 9 employee_id PK last_name reports_to FK order_details 2,155 order_id PK FK product_id PK FK unit_price quantity discount employee_territories 49 employee_id PK FK territory_id PK FK territories 53 territory_id PK region_id FK products 77 product_id PK supplier_id FK category_id FK unit_price categories 8 category_id PK category_name picture suppliers 29 supplier_id PK company_name region 4 region_id PK region_description

Each arrow runs from a foreign key (FK) to the primary key (PK) it references, from the “many” side to the “one” side. Header numbers are row counts. Read why each connection exists below.

How the tables connect, and why

Every arrow on the diagram above is a foreign key: a column in one table that holds the primary key of a row in another table. Order 10248 has customer_id = 'VINET', and that value is the entire link. orders holds no copy of the customer’s name or address, only the key, and a query uses the key to look up the rest. Each arrow starts at the column holding the key (the child table, the “many” side) and points to the table that key identifies (the parent table, the “one” side).

Why split the data at all? Northwind could have been a single wide spreadsheet with one row per order line and the customer, product, supplier and salesperson typed out on every row. Each customer’s address would then be repeated on every line of every order they placed. To change a phone number you’d have to find every copy, and a copy you missed would contradict the others. A new customer couldn’t be recorded until they ordered something. Splitting the data into tables stores each fact once, and the foreign keys put the pieces back together when you ask a question. Lesson 5 covers the few copies Northwind keeps on purpose.

Northwind’s 13 tables are joined by 14 foreign keys. The table below groups them into four areas of the business and explains why each one exists.

Following a connection in SQL. To follow an arrow in a query, join the child’s foreign-key column to the parent’s primary key, for example JOIN customers c ON c.customer_id = o.customer_id. The query below starts at order 10248 and follows eight arrows. Its three lines repeat the same customer, employee, manager and shipper on every row, because a one-to-many join copies the “one” side onto each of its “many” rows. That repetition exists only in the query result; the database still stores each fact once.

The pg_constraint catalog lists a table’s connections, and pg_get_constraintdef() prints each one as you would write it. In psql, \d orders shows the same list under “Foreign-key constraints” (lesson 16). PostgreSQL enforces every one of these links on every write, so there is no orphan to search for (lesson 14).

Foreign key
A column whose values must match the primary key of a row in another table (or the same table).
Parent / child table
The parent table owns the key; the child table holds a copy of it in a foreign-key column. The arrow runs from child to parent.
Composite key
A primary key made of two or more columns, such as (order_id, product_id). Each junction table uses one.
Self-reference
A foreign key that points at its own table, like reports_to.
List a table’s connections, then follow them
-- Ask PostgreSQL which connections a table has SELECT conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE conrelid = 'orders'::regclass AND contype = 'f' ORDER BY conname; -- Follow every arrow out from one order SELECT c.company_name AS customer, e.first_name || ' ' || e.last_name AS employee, m.first_name || ' ' || m.last_name AS manager, s.company_name AS shipper, p.product_name AS product, cat.category_name AS category, sup.company_name AS supplier FROM orders o JOIN customers c ON c.customer_id = o.customer_id JOIN employees e ON e.employee_id = o.employee_id LEFT JOIN employees m ON m.employee_id = e.reports_to JOIN shippers s ON s.shipper_id = o.ship_via JOIN order_details od ON od.order_id = o.order_id JOIN products p ON p.product_id = od.product_id JOIN categories cat ON cat.category_id = p.category_id JOIN suppliers sup ON sup.supplier_id = p.supplier_id WHERE o.order_id = 10248 ORDER BY od.product_id;
Terminal Output
northwind=> SELECT conname, pg_get_constraintdef(oid) AS definition northwind-> FROM pg_constraint WHERE conrelid = 'orders'::regclass AND contype = 'f' northwind-> ORDER BY conname; conname | definition -------------------------+------------------------------------------------------------- orders_customer_id_fkey | FOREIGN KEY (customer_id) REFERENCES customers(customer_id) orders_employee_id_fkey | FOREIGN KEY (employee_id) REFERENCES employees(employee_id) orders_ship_via_fkey | FOREIGN KEY (ship_via) REFERENCES shippers(shipper_id) (3 rows) northwind=> SELECT c.company_name AS customer, northwind-> e.first_name || ' ' || e.last_name AS employee, northwind-> m.first_name || ' ' || m.last_name AS manager, northwind-> s.company_name AS shipper, northwind-> p.product_name AS product, northwind-> cat.category_name AS category, northwind-> sup.company_name AS supplier northwind-> FROM orders o northwind-> JOIN customers c ON c.customer_id = o.customer_id northwind-> JOIN employees e ON e.employee_id = o.employee_id northwind-> LEFT JOIN employees m ON m.employee_id = e.reports_to northwind-> JOIN shippers s ON s.shipper_id = o.ship_via northwind-> JOIN order_details od ON od.order_id = o.order_id northwind-> JOIN products p ON p.product_id = od.product_id northwind-> JOIN categories cat ON cat.category_id = p.category_id northwind-> JOIN suppliers sup ON sup.supplier_id = p.supplier_id northwind-> WHERE o.order_id = 10248 northwind-> ORDER BY od.product_id; customer | employee | manager | shipper | product | category | supplier ---------------------------+-----------------+---------------+------------------+-------------------------------+----------------+------------------------------------ Vins et alcools Chevalier | Steven Buchanan | Andrew Fuller | Federal Shipping | Queso Cabrales | Dairy Products | Cooperativa de Quesos 'Las Cabras' Vins et alcools Chevalier | Steven Buchanan | Andrew Fuller | Federal Shipping | Singaporean Hokkien Fried Mee | Grains/Cereals | Leka Trading Vins et alcools Chevalier | Steven Buchanan | Andrew Fuller | Federal Shipping | Mozzarella di Giovanni | Dairy Products | Formaggi Fortini s.r.l. (3 rows)
🗂️
Part 1 · The Model
Tables, keys, and the three kinds of relationship Northwind was designed to show
1 · Meet Northwind: the tables

Northwind Traders imports speciality foods from around the world and resells them. Its database records each step of that business. suppliers provide products, which belong to categories. customers place orders, each order has one or more lines in order_details, an employee takes the order, and one of three shippers delivers it. Employees also cover sales territories, grouped into four regions.

\dt lists the 13 tables, and \dv lists the 16 views (Part 3 covers them). The first query uses UNION ALL to stack five one-row counts into a single result, which is a quick way to size up a database you’ve just opened. \d shippers describes one table: its columns and types, its primary key, and which other tables point at it.

Microsoft wrote names such as OrderID and CustomerID in mixed case, and one table name even has a space: "Order Details". This copy uses lower-case snake_case names because of a PostgreSQL rule. Unquoted identifiers are folded to lower case, so Order_Details finds order_details. A quoted identifier is used exactly as written, so "Order_Details" looks for a table with capital letters and fails. If a table is created with quotes and capitals, every query has to quote it forever. Lower-case names never need quotes.

Identifier
The name of a table, column, view or index. Unquoted, PostgreSQL folds it to lower case; double-quoted, it is used exactly.
UNION ALL
Stacks the rows of two SELECTs with the same number of columns, keeping duplicates.
\d, \dt, \dv
psql meta-commands: describe one object, list tables, list views.
List and describe the tables
\dt -- How many rows in the five busiest tables? SELECT 'customers' AS tbl, count(*) AS n FROM customers UNION ALL SELECT 'orders', count(*) FROM orders UNION ALL SELECT 'order_details', count(*) FROM order_details UNION ALL SELECT 'products', count(*) FROM products UNION ALL SELECT 'employees', count(*) FROM employees; -- Unquoted names are folded to lower case; quoted names are not SELECT count(*) FROM Order_Details; SELECT count(*) FROM "Order_Details"; \d shippers
Terminal Output
northwind=> \dt List of tables Schema | Name | Type | Owner --------+------------------------+-------+--------- public | categories | table | student public | customer_customer_demo | table | student public | customer_demographics | table | student public | customers | table | student public | employee_territories | table | student public | employees | table | student public | order_details | table | student public | orders | table | student public | products | table | student public | region | table | student public | shippers | table | student public | suppliers | table | student public | territories | table | student (13 rows) northwind=> SELECT 'customers' AS tbl, count(*) AS n FROM customers northwind-> UNION ALL SELECT 'orders', count(*) FROM orders northwind-> UNION ALL SELECT 'order_details', count(*) FROM order_details northwind-> UNION ALL SELECT 'products', count(*) FROM products northwind-> UNION ALL SELECT 'employees', count(*) FROM employees; tbl | n ---------------+------ customers | 91 orders | 830 order_details | 2155 products | 77 employees | 9 (5 rows) northwind=> SELECT count(*) FROM Order_Details; count ------- 2155 (1 row) northwind=> SELECT count(*) FROM "Order_Details"; ERROR: relation "Order_Details" does not exist LINE 1: SELECT count(*) FROM "Order_Details"; ^ northwind=> \d shippers Table "public.shippers" Column | Type | Collation | Nullable | Default --------------+-----------------------+-----------+----------+---------------------------------- shipper_id | integer | | not null | generated by default as identity company_name | character varying(40) | | not null | phone | character varying(24) | | | Indexes: "shippers_pkey" PRIMARY KEY, btree (shipper_id) Referenced by: TABLE "orders" CONSTRAINT "orders_ship_via_fkey" FOREIGN KEY (ship_via) REFERENCES shippers(shipper_id)
2 · Primary keys: natural, surrogate and composite

Every table needs a primary key: a column, or group of columns, whose value is different in every row. Northwind uses all three common kinds. Customers have a natural key, a five-letter code made from the company name (ALFKI for Alfreds Futterkiste), which people can read and remember. Orders, products and employees have surrogate keys: plain numbers with no meaning of their own. A surrogate key never has to change. A natural key does when the thing it was made from changes, for example when a company is renamed.

order_details has a composite key: (order_id, product_id). Neither column is unique on its own, but the pair is. The pg_constraint catalog lists every rule on the table with its type: p for the primary key, f for foreign keys, c for checks, and, new in PostgreSQL 18, n for each NOT NULL. The key also enforces a business rule: an order can’t list the same product twice. Try it and PostgreSQL names the constraint and the duplicate key.

Surrogate keys here are identity columns, GENERATED BY DEFAULT AS IDENTITY, the SQL-standard replacement for the older serial. Leave the column out of an INSERT and PostgreSQL supplies the next number: the new shipper gets 4. RETURNING hands the new key straight back. The insert ran inside a transaction that was rolled back, so the database is unchanged.

Natural key
A key made from real data people already use, such as a customer code or an ISBN.
Surrogate key
A meaningless number assigned by the database, here from an identity column.
Composite key
A primary key made of two or more columns that are unique only together.
Identity column
GENERATED ... AS IDENTITY: the column draws its values from a sequence. ALWAYS refuses manual values; BY DEFAULT accepts them.
Three kinds of key, and an identity column
-- Natural key: a five-letter code people can read SELECT customer_id, company_name FROM customers ORDER BY customer_id LIMIT 3; -- Surrogate key: a number with no meaning of its own SELECT order_id, customer_id, employee_id, ship_via FROM orders ORDER BY order_id LIMIT 3; -- Every constraint on order_details, as PostgreSQL stores it SELECT conname, contype, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE conrelid = 'order_details'::regclass ORDER BY contype, conname; -- The composite key refuses a second line for the same product INSERT INTO order_details (order_id, product_id, unit_price, quantity) VALUES (10248, 11, 14, 1); -- An identity column hands out the next surrogate key BEGIN; INSERT INTO shippers (company_name) VALUES ('Northwind Express') RETURNING shipper_id; ROLLBACK;
Terminal Output
northwind=> SELECT customer_id, company_name FROM customers ORDER BY customer_id LIMIT 3; customer_id | company_name -------------+------------------------------------ ALFKI | Alfreds Futterkiste ANATR | Ana Trujillo Emparedados y helados ANTON | Antonio Moreno Taquería (3 rows) northwind=> SELECT order_id, customer_id, employee_id, ship_via FROM orders ORDER BY order_id LIMIT 3; order_id | customer_id | employee_id | ship_via ----------+-------------+-------------+---------- 10248 | VINET | 5 | 3 10249 | TOMSP | 6 | 1 10250 | HANAR | 4 | 2 (3 rows) northwind=> SELECT conname, contype, pg_get_constraintdef(oid) AS definition northwind-> FROM pg_constraint WHERE conrelid = 'order_details'::regclass ORDER BY contype, conname; conname | contype | definition -----------------------------------+---------+--------------------------------------------------------------------------------------- order_details_discount_check | c | CHECK (((discount >= (0)::double precision) AND (discount <= (1)::double precision))) order_details_quantity_check | c | CHECK ((quantity > 0)) order_details_unit_price_check | c | CHECK ((unit_price >= (0)::numeric)) order_details_order_id_fkey | f | FOREIGN KEY (order_id) REFERENCES orders(order_id) order_details_product_id_fkey | f | FOREIGN KEY (product_id) REFERENCES products(product_id) order_details_discount_not_null | n | NOT NULL discount order_details_order_id_not_null | n | NOT NULL order_id order_details_product_id_not_null | n | NOT NULL product_id order_details_quantity_not_null | n | NOT NULL quantity order_details_unit_price_not_null | n | NOT NULL unit_price order_details_pkey | p | PRIMARY KEY (order_id, product_id) (11 rows) northwind=> INSERT INTO order_details (order_id, product_id, unit_price, quantity) northwind-> VALUES (10248, 11, 14, 1); ERROR: duplicate key value violates unique constraint "order_details_pkey" DETAIL: Key (order_id, product_id)=(10248, 11) already exists. northwind=> BEGIN; BEGIN northwind=*> INSERT INTO shippers (company_name) VALUES ('Northwind Express') RETURNING shipper_id; shipper_id ------------ 4 (1 row) INSERT 0 1 northwind=*> ROLLBACK; ROLLBACK
3 · One-to-many and many-to-many

Most of Northwind is one-to-many. One customer has many orders, one order has many lines, and one category has many products. The foreign key always sits on the many side: orders.customer_id, not a list of order numbers stored in customers. Joining from the one side to the many side and counting is the most common question you’ll ask a database. PostgreSQL lets the first query group by c.customer_id and still select c.company_name, because the company name depends on the primary key it grouped by.

Employees and territories are many-to-many. An employee covers several territories, and the design allows a territory to have several employees. A many-to-many relationship can’t be stored with a single foreign key, so Northwind uses a junction table, employee_territories, with one row per link and a composite key made of two foreign keys. To get from employees to regions you join through it: employees → employee_territories → territories → region.

The data is narrower than the design allows. The 49 links cover 49 different territories, so no territory is actually shared, and the LEFT JOIN finds four of the 53 territories that nobody covers. This is a real modelling lesson: the schema says what may be true, and only a query tells you what is true.

One-to-many
Each row on the "one" side matches zero or more rows on the "many" side. The foreign key lives on the many side.
Many-to-many
Rows on both sides can match several rows on the other. It needs a junction table.
Junction table
A table whose rows each link one row of table A to one row of table B, also called a bridge or link table.
Walk a one-to-many and a many-to-many relationship
-- One-to-many: one customer, many orders SELECT c.company_name, count(o.order_id) AS orders FROM customers c JOIN orders o ON o.customer_id = c.customer_id GROUP BY c.customer_id ORDER BY orders DESC, c.company_name LIMIT 5; -- Many-to-many: employees and territories, through a junction table SELECT e.first_name || ' ' || e.last_name AS employee, r.region_description AS region, count(*) AS territories FROM employees e JOIN employee_territories et ON et.employee_id = e.employee_id JOIN territories t ON t.territory_id = et.territory_id JOIN region r ON r.region_id = t.region_id GROUP BY e.employee_id, r.region_id ORDER BY e.employee_id; -- The design allows sharing a territory; the data never does SELECT count(*) AS links, count(DISTINCT territory_id) AS territories, count(DISTINCT employee_id) AS employees FROM employee_territories; -- LEFT JOIN finds the territories nobody covers SELECT t.territory_id, t.territory_description FROM territories t LEFT JOIN employee_territories et ON et.territory_id = t.territory_id WHERE et.employee_id IS NULL ORDER BY t.territory_id;
Terminal Output
northwind=> SELECT c.company_name, count(o.order_id) AS orders northwind-> FROM customers c JOIN orders o ON o.customer_id = c.customer_id northwind-> GROUP BY c.customer_id ORDER BY orders DESC, c.company_name LIMIT 5; company_name | orders ------------------------------+-------- Save-a-lot Markets | 31 Ernst Handel | 30 QUICK-Stop | 28 Folk och fä HB | 19 Hungry Owl All-Night Grocers | 19 (5 rows) northwind=> SELECT e.first_name || ' ' || e.last_name AS employee, northwind-> r.region_description AS region, count(*) AS territories northwind-> FROM employees e northwind-> JOIN employee_territories et ON et.employee_id = e.employee_id northwind-> JOIN territories t ON t.territory_id = et.territory_id northwind-> JOIN region r ON r.region_id = t.region_id northwind-> GROUP BY e.employee_id, r.region_id ORDER BY e.employee_id; employee | region | territories ------------------+----------+------------- Nancy Davolio | Eastern | 2 Andrew Fuller | Eastern | 7 Janet Leverling | Southern | 4 Margaret Peacock | Eastern | 3 Steven Buchanan | Eastern | 7 Michael Suyama | Western | 5 Robert King | Western | 10 Laura Callahan | Northern | 4 Anne Dodsworth | Northern | 7 (9 rows) northwind=> SELECT count(*) AS links, count(DISTINCT territory_id) AS territories, northwind-> count(DISTINCT employee_id) AS employees northwind-> FROM employee_territories; links | territories | employees -------+-------------+----------- 49 | 49 | 9 (1 row) northwind=> SELECT t.territory_id, t.territory_description northwind-> FROM territories t LEFT JOIN employee_territories et ON et.territory_id = t.territory_id northwind-> WHERE et.employee_id IS NULL ORDER BY t.territory_id; territory_id | territory_description --------------+----------------------- 29202 | Columbia 72716 | Bentonville 75234 | Dallas 78759 | Austin (4 rows)
4 · Self-reference: the org chart

employees.reports_to is a foreign key that points back into employees. A table can reference itself, and this is how org charts, threaded comments, folder trees and bills of materials are usually stored. To read it you join the table to itself under two aliases: e for the employee and m for the manager. It has to be a LEFT JOIN, because Andrew Fuller, the Vice President, reports to nobody. An inner join would drop him.

A self-join climbs only one level. To walk the whole tree, however deep, use a recursive CTE. The first SELECT (the anchor) finds the root, the person with reports_to IS NULL. The second SELECT joins each new level to the rows found so far, and PostgreSQL repeats it until a level comes back empty. The path column builds a breadcrumb like Fuller > Buchanan > King. Sorting by it puts each manager directly above their own staff, and repeat(' ', depth) indents each level. The anchor casts last_name::text because a recursive CTE’s column must have the same type in both halves, and concatenating varchar values produces text.

The tree is three levels deep. Fuller has five direct reports. One of them, Steven Buchanan, the Sales Manager, runs three more representatives of his own. The Master’s Degree tab shows PostgreSQL’s SEARCH and CYCLE clauses for larger graphs.

Self-referencing key
A foreign key that points at the primary key of its own table.
Table alias
A short second name for a table within one query (employees e). It is required when the same table appears twice.
Recursive CTE
WITH RECURSIVE: an anchor query plus a recursive query that runs repeatedly until it adds no new rows.
Join the table to itself, then walk the tree
-- Join employees to itself: e is the employee, m the manager SELECT e.employee_id, e.first_name || ' ' || e.last_name AS employee, e.title, m.first_name || ' ' || m.last_name AS reports_to FROM employees e LEFT JOIN employees m ON m.employee_id = e.reports_to ORDER BY e.employee_id; -- Walk the whole tree with a recursive CTE WITH RECURSIVE chart(employee_id, name, depth, path) AS ( SELECT employee_id, first_name || ' ' || last_name, 0, last_name::text FROM employees WHERE reports_to IS NULL UNION ALL SELECT e.employee_id, e.first_name || ' ' || e.last_name, chart.depth + 1, chart.path || ' > ' || e.last_name FROM employees e JOIN chart ON e.reports_to = chart.employee_id ) SELECT repeat(' ', depth) || name AS org_chart, depth FROM chart ORDER BY path;
Terminal Output
northwind=> SELECT e.employee_id, e.first_name || ' ' || e.last_name AS employee, e.title, northwind-> m.first_name || ' ' || m.last_name AS reports_to northwind-> FROM employees e LEFT JOIN employees m ON m.employee_id = e.reports_to northwind-> ORDER BY e.employee_id; employee_id | employee | title | reports_to -------------+------------------+--------------------------+----------------- 1 | Nancy Davolio | Sales Representative | Andrew Fuller 2 | Andrew Fuller | Vice President, Sales | 3 | Janet Leverling | Sales Representative | Andrew Fuller 4 | Margaret Peacock | Sales Representative | Andrew Fuller 5 | Steven Buchanan | Sales Manager | Andrew Fuller 6 | Michael Suyama | Sales Representative | Steven Buchanan 7 | Robert King | Sales Representative | Steven Buchanan 8 | Laura Callahan | Inside Sales Coordinator | Andrew Fuller 9 | Anne Dodsworth | Sales Representative | Steven Buchanan (9 rows) northwind=> WITH RECURSIVE chart(employee_id, name, depth, path) AS ( northwind(> SELECT employee_id, first_name || ' ' || last_name, 0, last_name::text northwind(> FROM employees WHERE reports_to IS NULL northwind(> UNION ALL northwind(> SELECT e.employee_id, e.first_name || ' ' || e.last_name, chart.depth + 1, northwind(> chart.path || ' > ' || e.last_name northwind(> FROM employees e JOIN chart ON e.reports_to = chart.employee_id northwind(> ) northwind-> SELECT repeat(' ', depth) || name AS org_chart, depth northwind-> FROM chart ORDER BY path; org_chart | depth ------------------------+------- Andrew Fuller | 0 Steven Buchanan | 1 Anne Dodsworth | 2 Robert King | 2 Michael Suyama | 2 Laura Callahan | 1 Nancy Davolio | 1 Janet Leverling | 1 Margaret Peacock | 1 (9 rows)
5 · Normalisation, and the copies kept on purpose

Normalisation means storing each fact in exactly one place. A category’s name lives only in categories, and a product stores only the number category_id. Rename Dairy Products once and all ten dairy products show the new name, because none of them holds a copy. Copies drift apart; references can’t.

Northwind also breaks the rule twice, and it does so deliberately. Each order_details line has its own unit_price, a copy of the product’s price on the day of the sale. Queso Cabrales was sold at 14.00, then 16.80, then 21.00, and the catalogue now says 21.00. If the order line only pointed at products, every old invoice would be re-priced each time the catalogue changed. Across the database, 658 of the 2,155 lines were charged a price that is no longer the list price. The query counts them with count(*) FILTER (WHERE ...), PostgreSQL’s way to count only the rows that match a condition inside an aggregate.

The second copy is the ship-to address on every order. A customer can ask for delivery somewhere else. Around the Horn’s office is on Hanover Square in London, but all 13 of its orders were shipped to Brook Farm near Colchester. An order also has to keep showing where it actually went after the customer moves. A copy is the right design when it records what was true at that moment, not a second version of a fact that is true now.

Normalisation
Organising tables so each fact is stored once and everything else refers to it by key.
Denormalisation
Storing a copy of data on purpose, usually to record history or to speed up reads.
FILTER
aggregate(...) FILTER (WHERE condition): the aggregate sees only the rows that match.
One fact in one place, and two deliberate copies
-- A category's name is stored once; products hold only its id SELECT p.product_name, p.category_id, c.category_name FROM products p JOIN categories c ON c.category_id = p.category_id WHERE p.product_id IN (1, 11, 38) ORDER BY p.product_id; -- Queso Cabrales: what customers paid, and what it costs today SELECT od.unit_price AS charged, min(o.order_date) AS first_sale, max(o.order_date) AS last_sale, count(*) AS lines, p.unit_price AS list_price_now FROM order_details od JOIN orders o ON o.order_id = od.order_id JOIN products p ON p.product_id = od.product_id WHERE od.product_id = 11 GROUP BY od.unit_price, p.unit_price ORDER BY first_sale; -- Across every order line. FILTER counts only the rows that match. SELECT count(*) AS lines, count(*) FILTER (WHERE od.unit_price <> p.unit_price) AS old_price FROM order_details od JOIN products p ON p.product_id = od.product_id; -- Each order keeps its own ship-to address too SELECT o.customer_id, c.address AS customer_address, o.ship_address, count(*) AS orders FROM orders o JOIN customers c ON c.customer_id = o.customer_id WHERE o.customer_id = 'AROUT' GROUP BY o.customer_id, c.address, o.ship_address;
Terminal Output
northwind=> SELECT p.product_name, p.category_id, c.category_name northwind-> FROM products p JOIN categories c ON c.category_id = p.category_id northwind-> WHERE p.product_id IN (1, 11, 38) ORDER BY p.product_id; product_name | category_id | category_name ----------------+-------------+---------------- Chai | 1 | Beverages Queso Cabrales | 4 | Dairy Products Côte de Blaye | 1 | Beverages (3 rows) northwind=> SELECT od.unit_price AS charged, min(o.order_date) AS first_sale, northwind-> max(o.order_date) AS last_sale, count(*) AS lines, northwind-> p.unit_price AS list_price_now northwind-> FROM order_details od northwind-> JOIN orders o ON o.order_id = od.order_id northwind-> JOIN products p ON p.product_id = od.product_id northwind-> WHERE od.product_id = 11 northwind-> GROUP BY od.unit_price, p.unit_price ORDER BY first_sale; charged | first_sale | last_sale | lines | list_price_now ---------+------------+------------+-------+---------------- 14.00 | 1996-07-04 | 1996-07-04 | 1 | 21.00 16.80 | 1996-09-03 | 1997-03-28 | 11 | 21.00 21.00 | 1997-05-06 | 1998-05-05 | 26 | 21.00 (3 rows) northwind=> SELECT count(*) AS lines, northwind-> count(*) FILTER (WHERE od.unit_price <> p.unit_price) AS old_price northwind-> FROM order_details od JOIN products p ON p.product_id = od.product_id; lines | old_price -------+----------- 2155 | 658 (1 row) northwind=> SELECT o.customer_id, c.address AS customer_address, o.ship_address, count(*) AS orders northwind-> FROM orders o JOIN customers c ON c.customer_id = o.customer_id northwind-> WHERE o.customer_id = 'AROUT' GROUP BY o.customer_id, c.address, o.ship_address; customer_id | customer_address | ship_address | orders -------------+------------------+-------------------------------+-------- AROUT | 120 Hanover Sq. | Brook Farm Stratford St. Mary | 13 (1 row)
🔎
Part 2 · Asking Questions
Filtering, NULLs, joins, aggregates, dates and subqueries against real business data
6 · Filtering, and the NULL trap

WHERE keeps the rows for which a condition is true. IN (...) is shorthand for several ORs, and in LIKE, % matches any run of characters. LIKE is case-sensitive in PostgreSQL; ILIKE is the case-insensitive version. Northwind’s data is international, so München and Münster come back with their umlauts intact.

region is where Northwind teaches NULL. Only some countries’ addresses have a state or province, so just 31 of the 91 customers have one, and the other 60 have no region at all. NULL means “unknown”, and any comparison with an unknown value is itself unknown, never true. So region = NULL matches nothing, not even the 60 NULLs. Use IS NULL. The same logic makes region <> 'WA' return 28, not 88: the 60 customers with no region are not counted as “not WA”. PostgreSQL has a comparison that treats NULL as an ordinary value: IS DISTINCT FROM returns 88.

Orders use NULL for “hasn’t happened yet”: 21 orders, the oldest placed on 8 April 1998, have no shipped_date. count(region) counts only non-NULL values, which makes it a quick NULL census, and coalesce() replaces NULL with something readable. psql prints NULL as an empty cell, which looks exactly like an empty string; \pset null '(null)' makes the difference visible.

NULL
A marker for a missing or unknown value. It is not zero and not an empty string.
Three-valued logic
In SQL a condition is TRUE, FALSE or UNKNOWN. WHERE keeps only rows where it is TRUE.
IS DISTINCT FROM
A comparison in which NULL equals NULL and differs from every value, so it never returns UNKNOWN.
Filter, then fall into the NULL trap
SELECT company_name, city, country FROM customers WHERE country IN ('Germany', 'Austria') AND city LIKE 'M%' ORDER BY company_name; -- Only 31 of 91 customers have a region SELECT count(*) AS customers, count(region) AS with_region FROM customers; -- The trap: = NULL is never true SELECT count(*) FROM customers WHERE region = NULL; SELECT count(*) FROM customers WHERE region IS NULL; -- <> skips NULLs too; IS DISTINCT FROM treats NULL as a value SELECT count(*) FROM customers WHERE region <> 'WA'; SELECT count(*) FROM customers WHERE region IS DISTINCT FROM 'WA'; -- Orders not shipped yet have a NULL shipped_date SELECT count(*) AS unshipped, min(order_date) AS oldest FROM orders WHERE shipped_date IS NULL; -- psql shows NULL as an empty cell unless you tell it otherwise \pset null '(null)' SELECT company_name, region, coalesce(region, '(none)') AS shown FROM customers WHERE country = 'UK' ORDER BY company_name LIMIT 3;
Terminal Output
northwind=> SELECT company_name, city, country FROM customers northwind-> WHERE country IN ('Germany', 'Austria') AND city LIKE 'M%' ORDER BY company_name; company_name | city | country -------------------------+----------+--------- Blauer See Delikatessen | Mannheim | Germany Frankenversand | München | Germany Toms Spezialitäten | Münster | Germany (3 rows) northwind=> SELECT count(*) AS customers, count(region) AS with_region FROM customers; customers | with_region -----------+------------- 91 | 31 (1 row) northwind=> SELECT count(*) FROM customers WHERE region = NULL; count ------- 0 (1 row) northwind=> SELECT count(*) FROM customers WHERE region IS NULL; count ------- 60 (1 row) northwind=> SELECT count(*) FROM customers WHERE region <> 'WA'; count ------- 28 (1 row) northwind=> SELECT count(*) FROM customers WHERE region IS DISTINCT FROM 'WA'; count ------- 88 (1 row) northwind=> SELECT count(*) AS unshipped, min(order_date) AS oldest northwind-> FROM orders WHERE shipped_date IS NULL; unshipped | oldest -----------+------------ 21 | 1998-04-08 (1 row) northwind=> \pset null '(null)' Null display is "(null)". northwind=> SELECT company_name, region, coalesce(region, '(none)') AS shown northwind-> FROM customers WHERE country = 'UK' ORDER BY company_name LIMIT 3; company_name | region | shown -----------------------+--------+-------- Around the Horn | (null) | (none) B's Beverages | (null) | (none) Consolidated Holdings | (null) | (none) (3 rows)
7 · Joins, and the count(*) trap

An order row on its own is mostly codes: VINET, 5, 3. A join follows each foreign key to the row it names and brings back readable columns. Each JOIN ... ON adds one table, and the ON clause says which key matches which. Four tables give the company, the salesperson’s surname and the shipper’s name for each order.

An inner join keeps only rows that have a match on both sides. A left join keeps every row from the left table, and where there is no match it fills the right table’s columns with NULL. That makes LEFT JOIN ... WHERE right.key IS NULL one way to find rows with nothing related, an anti-join. NOT EXISTS says the same thing more directly. In Northwind they find the two customers who have never ordered, FISSA and Paris spécialités.

The trap comes when you count after a left join. A customer with no orders still produces one row, padded with NULLs, so count(*) reports 1 order for FISSA. count(o.order_id) counts only non-NULL values, so it correctly reports 0. Whenever a left join feeds an aggregate, count a column from the right-hand table.

INNER JOIN
Returns only pairs of rows that satisfy the ON condition.
LEFT JOIN
Returns every left row once or more; missing right-hand values become NULL.
Anti-join
A query for rows that have no match: LEFT JOIN plus IS NULL, or NOT EXISTS.
Inner joins, anti-joins and the count(*) trap
-- INNER JOIN: an order with its customer, salesperson and shipper SELECT o.order_id, c.company_name, e.last_name AS employee, s.company_name AS shipper FROM orders o JOIN customers c ON c.customer_id = o.customer_id JOIN employees e ON e.employee_id = o.employee_id JOIN shippers s ON s.shipper_id = o.ship_via ORDER BY o.order_id LIMIT 5; -- Anti-join: customers with no orders, written two ways SELECT c.customer_id, c.company_name FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id WHERE o.order_id IS NULL ORDER BY c.customer_id; SELECT c.customer_id, c.company_name FROM customers c WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id) ORDER BY c.customer_id; -- count(*) counts the padded row; count(column) does not SELECT c.customer_id, count(*) AS wrong, count(o.order_id) AS right FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id WHERE c.customer_id IN ('ALFKI', 'FISSA', 'PARIS') GROUP BY c.customer_id ORDER BY c.customer_id;
Terminal Output
northwind=> SELECT o.order_id, c.company_name, e.last_name AS employee, s.company_name AS shipper northwind-> FROM orders o northwind-> JOIN customers c ON c.customer_id = o.customer_id northwind-> JOIN employees e ON e.employee_id = o.employee_id northwind-> JOIN shippers s ON s.shipper_id = o.ship_via northwind-> ORDER BY o.order_id LIMIT 5; order_id | company_name | employee | shipper ----------+---------------------------+-----------+------------------ 10248 | Vins et alcools Chevalier | Buchanan | Federal Shipping 10249 | Toms Spezialitäten | Suyama | Speedy Express 10250 | Hanari Carnes | Peacock | United Package 10251 | Victuailles en stock | Leverling | Speedy Express 10252 | Suprêmes délices | Peacock | United Package (5 rows) northwind=> SELECT c.customer_id, c.company_name northwind-> FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id northwind-> WHERE o.order_id IS NULL ORDER BY c.customer_id; customer_id | company_name -------------+-------------------------------------- FISSA | FISSA Fabrica Inter. Salchichas S.A. PARIS | Paris spécialités (2 rows) northwind=> SELECT c.customer_id, c.company_name FROM customers c northwind-> WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id) northwind-> ORDER BY c.customer_id; customer_id | company_name -------------+-------------------------------------- FISSA | FISSA Fabrica Inter. Salchichas S.A. PARIS | Paris spécialités (2 rows) northwind=> SELECT c.customer_id, count(*) AS wrong, count(o.order_id) AS right northwind-> FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id northwind-> WHERE c.customer_id IN ('ALFKI', 'FISSA', 'PARIS') northwind-> GROUP BY c.customer_id ORDER BY c.customer_id; customer_id | wrong | right -------------+-------+------- ALFKI | 6 | 6 FISSA | 1 | 0 PARIS | 1 | 0 (3 rows)
8 · Revenue with GROUP BY and HAVING

Northwind has no revenue column. Revenue is derived: each order line is worth unit_price × quantity × (1 − discount), where discount is a fraction such as 0.15. In this copy unit_price is numeric(10,2), exact decimal, but discount is real, a 4-byte floating-point number, as it was in Microsoft’s original. Mixing the two makes PostgreSQL compute in double precision, and 42.40 × 35 × 0.85 comes out as 1261.3999911546707. Casting the discount with discount::numeric keeps the arithmetic exact: 1261.4000. Money calculations belong in numeric.

Summed exactly over all 2,155 lines, revenue is $1,265,793.04. When you round changes the answer. Rounding every line to the cent first, which is what an invoice does, gives $1,265,793.29, 25 cents more. Neither number is wrong: the first is the exact total and the second is what the invoices add up to. Decide which one a report means, and round once, at the step that matches it.

GROUP BY collapses rows into one row per group, and aggregate functions (sum, count, avg) summarise each group. count(DISTINCT od.order_id) counts orders, not lines. WHERE filters rows before grouping, and HAVING filters groups after, so “employees with more than 100 orders” needs HAVING, because the count doesn’t exist until the group does. Beverages leads revenue and Margaret Peacock leads the sales team with 156 orders.

numeric
Exact decimal arithmetic. Use it for money; real and double precision are binary approximations.
GROUP BY
Forms one output row per distinct value of the grouping columns.
HAVING
A filter applied to groups after aggregation. WHERE runs before it.
Exact money, then revenue by group
-- discount is real (a 4-byte float), so this arithmetic is floating point SELECT unit_price, quantity, discount, unit_price * quantity * (1 - discount) AS float_math, pg_typeof(unit_price * quantity * (1 - discount)) FROM order_details WHERE order_id = 10250 AND product_id = 51; -- Cast the discount to numeric and the arithmetic is exact decimal SELECT unit_price * quantity * (1 - discount::numeric) AS exact_math FROM order_details WHERE order_id = 10250 AND product_id = 51; -- Revenue for every order line, exact, then rounded to the cent SELECT round(sum(unit_price * quantity * (1 - discount::numeric)), 2) AS revenue FROM order_details; -- Rounding each line first changes the total SELECT sum(round(unit_price * quantity * (1 - discount::numeric), 2)) AS rounded_lines FROM order_details; -- GROUP BY: revenue per category SELECT c.category_name, count(DISTINCT od.order_id) AS orders, round(sum(od.unit_price * od.quantity * (1 - od.discount::numeric)), 2) AS revenue FROM order_details od JOIN products p ON p.product_id = od.product_id JOIN categories c ON c.category_id = p.category_id GROUP BY c.category_name ORDER BY revenue DESC; -- HAVING filters groups. Grouping by the primary key lets you select its other columns. SELECT e.first_name || ' ' || e.last_name AS employee, count(*) AS orders FROM orders o JOIN employees e ON e.employee_id = o.employee_id GROUP BY e.employee_id HAVING count(*) > 100 ORDER BY orders DESC;
Terminal Output
northwind=> SELECT unit_price, quantity, discount, northwind-> unit_price * quantity * (1 - discount) AS float_math, northwind-> pg_typeof(unit_price * quantity * (1 - discount)) northwind-> FROM order_details WHERE order_id = 10250 AND product_id = 51; unit_price | quantity | discount | float_math | pg_typeof ------------+----------+----------+--------------------+------------------ 42.40 | 35 | 0.15 | 1261.3999911546707 | double precision (1 row) northwind=> SELECT unit_price * quantity * (1 - discount::numeric) AS exact_math northwind-> FROM order_details WHERE order_id = 10250 AND product_id = 51; exact_math ------------ 1261.4000 (1 row) northwind=> SELECT round(sum(unit_price * quantity * (1 - discount::numeric)), 2) AS revenue FROM order_details; revenue ------------ 1265793.04 (1 row) northwind=> SELECT sum(round(unit_price * quantity * (1 - discount::numeric), 2)) AS rounded_lines FROM order_details; rounded_lines --------------- 1265793.29 (1 row) northwind=> SELECT c.category_name, count(DISTINCT od.order_id) AS orders, northwind-> round(sum(od.unit_price * od.quantity * (1 - od.discount::numeric)), 2) AS revenue northwind-> FROM order_details od northwind-> JOIN products p ON p.product_id = od.product_id northwind-> JOIN categories c ON c.category_id = p.category_id northwind-> GROUP BY c.category_name ORDER BY revenue DESC; category_name | orders | revenue ----------------+--------+----------- Beverages | 354 | 267868.18 Dairy Products | 303 | 234507.29 Confections | 295 | 167357.23 Meat/Poultry | 161 | 163022.36 Seafood | 291 | 131261.74 Condiments | 193 | 106047.09 Produce | 129 | 99984.58 Grains/Cereals | 182 | 95744.59 (8 rows) northwind=> SELECT e.first_name || ' ' || e.last_name AS employee, count(*) AS orders northwind-> FROM orders o JOIN employees e ON e.employee_id = o.employee_id northwind-> GROUP BY e.employee_id HAVING count(*) > 100 ORDER BY orders DESC; employee | orders ------------------+-------- Margaret Peacock | 156 Janet Leverling | 127 Nancy Davolio | 123 Laura Callahan | 104 (4 rows)
9 · Real date types

PostgreSQL has a real date type, so this copy stores Northwind’s dates as dates, not as text. Microsoft’s script wrote them as '7/4/1996', and the DateStyle setting shows why that format is dangerous. With the default ISO, MDY it reads as 4 July 1996. Switch to day-first DMY and the same string becomes 7 April. PostgreSQL always accepts ISO 1996-07-04 unambiguously, and always prints dates that way under ISO output.

Date arithmetic is built in. shipped_date - order_date is an integer number of days, so average shipping time is just avg() of that difference. extract(year FROM d) pulls out a part of a date, date_trunc('quarter', d) rounds it down to the start of its quarter, and to_char() formats it. Adding an integer to a date adds days. Adding an interval such as '1 month' understands calendar months and returns a timestamp. age() gives the span in years, months and days.

The data runs from 4 July 1996 to 6 May 1998, so 1996 and 1998 are partial years. Compare 152 orders with 408 and you’re comparing six months with twelve. Federal Shipping is the fastest carrier at 7.5 days on average, and United Package carries the most orders but is late most often.

date
A calendar date with no time of day. timestamp and timestamptz add the time.
DateStyle
The setting that decides how ambiguous input such as 7/4/1996 is read, and how dates are printed.
interval
A span of time (days, months, years) that can be added to dates and timestamps.
Dates, DateStyle, and date arithmetic
-- A real date type SELECT order_date, pg_typeof(order_date) FROM orders ORDER BY order_id LIMIT 1; -- Microsoft's script wrote '7/4/1996'. How it is read depends on DateStyle. SHOW DateStyle; SELECT '7/4/1996'::date AS us_order; SET DateStyle = 'ISO, DMY'; SELECT '7/4/1996'::date AS day_first; RESET DateStyle; -- Orders per year SELECT extract(year FROM order_date) AS year, count(*) AS orders, min(order_date) AS first, max(order_date) AS last FROM orders GROUP BY year ORDER BY year; -- date - date is a whole number of days SELECT s.company_name AS shipper, count(*) AS shipped, round(avg(o.shipped_date - o.order_date), 1) AS avg_days, count(*) FILTER (WHERE o.shipped_date > o.required_date) AS late FROM orders o JOIN shippers s ON s.shipper_id = o.ship_via WHERE o.shipped_date IS NOT NULL GROUP BY s.shipper_id ORDER BY s.shipper_id; -- Quarters, with date_trunc and to_char SELECT to_char(date_trunc('quarter', o.shipped_date), 'YYYY-"Q"Q') AS quarter, sum(s.subtotal) AS sales FROM orders o JOIN order_subtotals s ON s.order_id = o.order_id WHERE o.shipped_date IS NOT NULL GROUP BY quarter ORDER BY quarter; -- Dates and intervals SELECT date '1998-05-06' + 30 AS plus_30_days, date '1998-05-06' + interval '1 month' AS plus_1_month, age(date '1998-05-06', date '1996-07-04') AS span;
Terminal Output
northwind=> SELECT order_date, pg_typeof(order_date) FROM orders ORDER BY order_id LIMIT 1; order_date | pg_typeof ------------+----------- 1996-07-04 | date (1 row) northwind=> SHOW DateStyle; DateStyle ----------- ISO, MDY (1 row) northwind=> SELECT '7/4/1996'::date AS us_order; us_order ------------ 1996-07-04 (1 row) northwind=> SET DateStyle = 'ISO, DMY'; SET northwind=> SELECT '7/4/1996'::date AS day_first; day_first ------------ 1996-04-07 (1 row) northwind=> RESET DateStyle; RESET northwind=> SELECT extract(year FROM order_date) AS year, count(*) AS orders, northwind-> min(order_date) AS first, max(order_date) AS last northwind-> FROM orders GROUP BY year ORDER BY year; year | orders | first | last ------+--------+------------+------------ 1996 | 152 | 1996-07-04 | 1996-12-31 1997 | 408 | 1997-01-01 | 1997-12-31 1998 | 270 | 1998-01-01 | 1998-05-06 (3 rows) northwind=> SELECT s.company_name AS shipper, count(*) AS shipped, northwind-> round(avg(o.shipped_date - o.order_date), 1) AS avg_days, northwind-> count(*) FILTER (WHERE o.shipped_date > o.required_date) AS late northwind-> FROM orders o JOIN shippers s ON s.shipper_id = o.ship_via northwind-> WHERE o.shipped_date IS NOT NULL GROUP BY s.shipper_id ORDER BY s.shipper_id; shipper | shipped | avg_days | late ------------------+---------+----------+------ Speedy Express | 245 | 8.6 | 12 United Package | 315 | 9.2 | 16 Federal Shipping | 249 | 7.5 | 9 (3 rows) northwind=> SELECT to_char(date_trunc('quarter', o.shipped_date), 'YYYY-"Q"Q') AS quarter, northwind-> sum(s.subtotal) AS sales northwind-> FROM orders o JOIN order_subtotals s ON s.order_id = o.order_id northwind-> WHERE o.shipped_date IS NOT NULL GROUP BY quarter ORDER BY quarter; quarter | sales ---------+----------- 1996-Q3 | 63985.18 1996-Q4 | 129331.40 1997-Q1 | 143703.27 1997-Q2 | 145654.82 1997-Q3 | 144320.32 1997-Q4 | 175168.60 1998-Q1 | 276329.99 1998-Q2 | 161362.27 (8 rows) northwind=> SELECT date '1998-05-06' + 30 AS plus_30_days, date '1998-05-06' + interval '1 month' AS plus_1_month, age(date '1998-05-06', date '1996-07-04') AS span; plus_30_days | plus_1_month | span --------------+---------------------+----------------------- 1998-06-05 | 1998-06-06 00:00:00 | 1 year 10 mons 2 days (1 row)
10 · Subqueries and set operations

A subquery is a SELECT nested inside another. A scalar subquery returns one value and can go wherever a value can: Microsoft’s view products_above_average_price compares each price with (SELECT avg(unit_price) FROM products). A correlated subquery refers to the outer row (p2.category_id = p.category_id), so it is worked out again for each product. That gives the most expensive product in each category without any grouping. PostgreSQL has a shorter way to write that: DISTINCT ON (category_name) keeps the first row of each category, in the order ORDER BY sets.

EXISTS asks whether a subquery returns any rows at all, and it stops at the first one. Combining EXISTS with NOT EXISTS describes a pattern that is awkward to write any other way: a customer who ordered in 1996 and never came back. Only Centro comercial Moctezuma fits.

Set operators combine whole result sets. UNION merges two lists and removes duplicates, and Microsoft’s view customer_and_suppliers_by_city uses it to put customers and suppliers in one directory. EXCEPT keeps rows from the first query that the second doesn’t have: nine countries where Northwind sells but buys nothing.

Scalar subquery
A subquery that returns exactly one value.
Correlated subquery
A subquery that refers to columns of the outer query, so it is evaluated for each outer row.
DISTINCT ON
PostgreSQL only: one row per distinct value of the listed expressions, the first in ORDER BY order.
Subqueries, DISTINCT ON, EXISTS and set operators
-- Scalar subquery: compare each price with one computed number SELECT product_name, unit_price FROM products WHERE unit_price > (SELECT avg(unit_price) FROM products) ORDER BY unit_price DESC LIMIT 5; -- Correlated subquery: re-run for every outer row (its own category) SELECT c.category_name, p.product_name, p.unit_price FROM products p JOIN categories c ON c.category_id = p.category_id WHERE p.unit_price = (SELECT max(p2.unit_price) FROM products p2 WHERE p2.category_id = p.category_id) ORDER BY p.unit_price DESC; -- PostgreSQL only: DISTINCT ON keeps the first row of each group SELECT DISTINCT ON (c.category_name) c.category_name, p.product_name, p.unit_price FROM products p JOIN categories c ON c.category_id = p.category_id ORDER BY c.category_name, p.unit_price DESC; -- NOT EXISTS: customers who ordered in 1996 and never again SELECT c.customer_id, c.company_name FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id AND o.order_date < '1997-01-01') AND NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id AND o.order_date >= '1997-01-01'); -- EXCEPT: countries with customers but no suppliers SELECT country FROM customers EXCEPT SELECT country FROM suppliers ORDER BY country; -- UNION: Microsoft's view merges two tables into one list SELECT * FROM customer_and_suppliers_by_city WHERE city = 'London' ORDER BY relationship, company_name;
Terminal Output
northwind=> SELECT product_name, unit_price FROM products northwind-> WHERE unit_price > (SELECT avg(unit_price) FROM products) northwind-> ORDER BY unit_price DESC LIMIT 5; product_name | unit_price -------------------------+------------ Côte de Blaye | 263.50 Thüringer Rostbratwurst | 123.79 Mishi Kobe Niku | 97.00 Sir Rodney's Marmalade | 81.00 Carnarvon Tigers | 62.50 (5 rows) northwind=> SELECT c.category_name, p.product_name, p.unit_price northwind-> FROM products p JOIN categories c ON c.category_id = p.category_id northwind-> WHERE p.unit_price = (SELECT max(p2.unit_price) FROM products p2 northwind(> WHERE p2.category_id = p.category_id) northwind-> ORDER BY p.unit_price DESC; category_name | product_name | unit_price ----------------+-------------------------+------------ Beverages | Côte de Blaye | 263.50 Meat/Poultry | Thüringer Rostbratwurst | 123.79 Confections | Sir Rodney's Marmalade | 81.00 Seafood | Carnarvon Tigers | 62.50 Dairy Products | Raclette Courdavault | 55.00 Produce | Manjimup Dried Apples | 53.00 Condiments | Vegie-spread | 43.90 Grains/Cereals | Gnocchi di nonna Alice | 38.00 (8 rows) northwind=> SELECT DISTINCT ON (c.category_name) c.category_name, p.product_name, p.unit_price northwind-> FROM products p JOIN categories c ON c.category_id = p.category_id northwind-> ORDER BY c.category_name, p.unit_price DESC; category_name | product_name | unit_price ----------------+-------------------------+------------ Beverages | Côte de Blaye | 263.50 Condiments | Vegie-spread | 43.90 Confections | Sir Rodney's Marmalade | 81.00 Dairy Products | Raclette Courdavault | 55.00 Grains/Cereals | Gnocchi di nonna Alice | 38.00 Meat/Poultry | Thüringer Rostbratwurst | 123.79 Produce | Manjimup Dried Apples | 53.00 Seafood | Carnarvon Tigers | 62.50 (8 rows) northwind=> SELECT c.customer_id, c.company_name FROM customers c northwind-> WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id northwind(> AND o.order_date < '1997-01-01') northwind-> AND NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id northwind(> AND o.order_date >= '1997-01-01'); customer_id | company_name -------------+---------------------------- CENTC | Centro comercial Moctezuma (1 row) northwind=> SELECT country FROM customers EXCEPT SELECT country FROM suppliers ORDER BY country; country ------------- Argentina Austria Belgium Ireland Mexico Poland Portugal Switzerland Venezuela (9 rows) northwind=> SELECT * FROM customer_and_suppliers_by_city WHERE city = 'London' ORDER BY relationship, company_name; city | company_name | contact_name | relationship --------+-----------------------+-------------------+-------------- London | Around the Horn | Thomas Hardy | Customers London | B's Beverages | Victoria Ashworth | Customers London | Consolidated Holdings | Elizabeth Brown | Customers London | Eastern Connection | Ann Devon | Customers London | North/South | Simon Crowther | Customers London | Seven Seas Imports | Hari Kumar | Customers London | Exotic Liquids | Charlotte Cooper | Suppliers (7 rows)
📊
Part 3 · Microsoft's Reports
The views and stored procedures that shipped with Northwind, translated to PostgreSQL
11 · Microsoft's views, translated

A view is a named SELECT. It holds no data of its own: \sv order_subtotals prints the query it stores, and PostgreSQL runs that query every time you select from the view. Northwind ships 16 views, and they were its main teaching aid. Each one is a report someone would really ask for (invoices, sales by category, quarterly orders), saved so that nobody has to rewrite the join.

Views can be built on views. order_subtotals sums each order’s lines, product_sales_for_1997 uses it, and category_sales_for_1997 is built on that. Each layer is simple, and together they answer a hard question. Name the layers well and a report becomes a short chain of readable steps.

All 16 views were written in Microsoft’s dialect, T-SQL, and several needed translating. T-SQL joins strings with +. SQLite silently turns 'Nancy' + ' ' into 0, but PostgreSQL refuses: there is no + operator for text, and the HINT says so. String concatenation is ||. A materialized view is the other kind: it stores its rows when created, so reading it is fast, but it shows the data as of the last REFRESH MATERIALIZED VIEW.

View
A stored SELECT you can query like a table. It is re-evaluated on every use.
Materialized view
A view whose result is stored, and recomputed only by REFRESH MATERIALIZED VIEW.
|| (concatenation)
The SQL-standard string-join operator. In PostgreSQL, + on text is an error.
Layered views, a translation, and a materialized view
\dv -- A view on a view on a view: order_subtotals -> product_sales_for_1997 -> this SELECT * FROM category_sales_for_1997 ORDER BY category_sales DESC; SELECT * FROM sales_totals_by_amount ORDER BY sale_amount DESC LIMIT 5; -- A view stores its SELECT, not its rows \sv order_subtotals -- Translating the views: T-SQL joins strings with +, PostgreSQL refuses SELECT first_name + ' ' + last_name FROM employees LIMIT 2; SELECT first_name || ' ' || last_name AS name FROM employees ORDER BY employee_id LIMIT 2; -- A materialized view stores its rows, until you refresh it CREATE MATERIALIZED VIEW monthly_sales AS SELECT date_trunc('month', o.order_date)::date AS month, sum(s.subtotal) AS sales FROM orders o JOIN order_subtotals s ON s.order_id = o.order_id GROUP BY 1; SELECT * FROM monthly_sales ORDER BY month LIMIT 3; REFRESH MATERIALIZED VIEW monthly_sales;
Terminal Output
northwind=> \dv List of views Schema | Name | Type | Owner --------+--------------------------------+------+--------- public | alphabetical_list_of_products | view | student public | category_sales_for_1997 | view | student public | current_product_list | view | student public | customer_and_suppliers_by_city | view | student public | invoices | view | student public | order_details_extended | view | student public | order_subtotals | view | student public | orders_qry | view | student public | product_sales_for_1997 | view | student public | products_above_average_price | view | student public | products_by_category | view | student public | quarterly_orders | view | student public | sales_by_category | view | student public | sales_totals_by_amount | view | student public | summary_of_sales_by_quarter | view | student public | summary_of_sales_by_year | view | student (16 rows) northwind=> SELECT * FROM category_sales_for_1997 ORDER BY category_sales DESC; category_name | category_sales ----------------+---------------- Dairy Products | 114749.79 Beverages | 102074.32 Meat/Poultry | 81338.06 Confections | 80894.18 Seafood | 65544.21 Grains/Cereals | 55948.83 Condiments | 55277.63 Produce | 53019.99 (8 rows) northwind=> SELECT * FROM sales_totals_by_amount ORDER BY sale_amount DESC LIMIT 5; sale_amount | order_id | company_name | shipped_date -------------+----------+----------------------------+-------------- 11188.40 | 10417 | Simons bistro | 1997-01-28 10495.60 | 10479 | Rattlesnake Canyon Grocery | 1997-03-21 10191.70 | 10540 | QUICK-Stop | 1997-06-13 10164.80 | 10691 | QUICK-Stop | 1997-10-22 9921.30 | 10515 | QUICK-Stop | 1997-05-23 (5 rows) northwind=> \sv order_subtotals CREATE OR REPLACE VIEW public.order_subtotals AS SELECT order_id, sum(round(unit_price * quantity::numeric * (1::numeric - discount::numeric), 2)) AS subtotal FROM order_details od GROUP BY order_id northwind=> SELECT first_name + ' ' + last_name FROM employees LIMIT 2; ERROR: operator does not exist: character varying + unknown LINE 1: SELECT first_name + ' ' + last_name FROM employees LIMIT 2; ^ HINT: No operator matches the given name and argument types. You might need to add explicit type casts. northwind=> SELECT first_name || ' ' || last_name AS name FROM employees ORDER BY employee_id LIMIT 2; name --------------- Nancy Davolio Andrew Fuller (2 rows) northwind=> CREATE MATERIALIZED VIEW monthly_sales AS northwind-> SELECT date_trunc('month', o.order_date)::date AS month, sum(s.subtotal) AS sales northwind-> FROM orders o JOIN order_subtotals s ON s.order_id = o.order_id northwind-> GROUP BY 1; SELECT 23 northwind=> SELECT * FROM monthly_sales ORDER BY month LIMIT 3; month | sales ------------+---------- 1996-07-01 | 27861.90 1996-08-01 | 25485.28 1996-09-01 | 26381.40 (3 rows) northwind=> REFRESH MATERIALIZED VIEW monthly_sales; REFRESH MATERIALIZED VIEW
12 · Stored procedures become functions

Northwind’s SQL Server version ships seven stored procedures: queries with named parameters such as CustOrderHist @CustomerID, stored and run by the server. PostgreSQL is a server too, so they translate into real functions, and the download already contains all seven. Each is written LANGUAGE sql and returns a table, so you call it in the FROM clause like a table: SELECT * FROM cust_order_hist('ALFKI'). SET ROWCOUNT 10 became LIMIT 10, and SalesByCategory keeps Microsoft’s default year of 1998, so the second argument is optional.

Writing one yourself takes a few lines. customer_revenue returns a single number, so it can be used inside a SELECT list, once per customer. STABLE tells the planner the function only reads the database and gives the same answer within one statement. PostgreSQL also has CREATE PROCEDURE and CALL: a procedure returns no rows but may commit inside itself, which is what the durability lesson on the Master’s Degree tab uses.

The idea underneath is the parameter: write the SQL once with a placeholder and supply the value separately. PREPARE does that in plain SQL with $1. The value never becomes part of the SQL text, so a customer ID like x' OR '1'='1 is looked up as a strange ID and finds 0 orders, instead of being run as SQL. In application code the driver does the same with its placeholders, %s in Python’s psycopg. That protection against SQL injection was a main reason stored procedures became popular, and parameters give it to every query.

Function
A named routine stored in the database. A set-returning function (RETURNS TABLE) is queried like a table.
Bound parameter
A placeholder ($1, %s) whose value is supplied separately from the SQL text.
SQL injection
An attack in which user input is pasted into SQL text and changes what the statement does.
Call the procedures, write a function, prepare a statement
-- Microsoft's seven stored procedures arrive as PostgreSQL functions SELECT proname AS function, pg_get_function_arguments(oid) AS arguments FROM pg_proc WHERE pronamespace = 'public'::regnamespace ORDER BY proname; -- Ten Most Expensive Products: SET ROWCOUNT 10 became LIMIT 10 SELECT * FROM ten_most_expensive_products() LIMIT 3; -- CustOrderHist @CustomerID: a set-returning function used like a table SELECT * FROM cust_order_hist('ALFKI') ORDER BY product_name; -- SalesByCategory has a default parameter, as Microsoft's did SELECT * FROM sales_by_category('Seafood', '1997') LIMIT 4; SELECT count(*) AS products_1998 FROM sales_by_category('Seafood'); -- Write your own: a SQL function with a named parameter CREATE FUNCTION customer_revenue(p_customer_id varchar) RETURNS numeric LANGUAGE sql STABLE AS $$ SELECT sum(s.subtotal) FROM orders o JOIN order_subtotals s ON s.order_id = o.order_id WHERE o.customer_id = p_customer_id $$; SELECT customer_id, customer_revenue(customer_id) FROM customers WHERE country = 'Norway'; -- Prepared statements: plan once, run with different values PREPARE orders_for(varchar) AS SELECT count(*) FROM orders WHERE customer_id = $1; EXECUTE orders_for('ALFKI'); EXECUTE orders_for('x'' OR ''1''=''1');
Terminal Output
northwind=> SELECT proname AS function, pg_get_function_arguments(oid) AS arguments northwind-> FROM pg_proc WHERE pronamespace = 'public'::regnamespace ORDER BY proname; function | arguments -----------------------------+------------------------------------------------------------------------- cust_order_hist | p_customer_id character varying cust_orders_detail | p_order_id integer cust_orders_orders | p_customer_id character varying employee_sales_by_country | beginning_date date, ending_date date sales_by_category | p_category_name character varying, p_ord_year text DEFAULT '1998'::text sales_by_year | beginning_date date, ending_date date ten_most_expensive_products | (7 rows) northwind=> SELECT * FROM ten_most_expensive_products() LIMIT 3; ten_most_expensive_products | unit_price -----------------------------+------------ Côte de Blaye | 263.50 Thüringer Rostbratwurst | 123.79 Mishi Kobe Niku | 97.00 (3 rows) northwind=> SELECT * FROM cust_order_hist('ALFKI') ORDER BY product_name; product_name | total ---------------------------------+------- Aniseed Syrup | 6 Chartreuse verte | 21 Escargots de Bourgogne | 40 Flotemysost | 20 Grandma's Boysenberry Spread | 16 Lakkalikööri | 15 Original Frankfurter grüne Soße | 2 Raclette Courdavault | 15 Rössle Sauerkraut | 17 Spegesild | 2 Vegie-spread | 20 (11 rows) northwind=> SELECT * FROM sales_by_category('Seafood', '1997') LIMIT 4; product_name | total_purchase ------------------------+---------------- Boston Crab Meat | 9815 Carnarvon Tigers | 15950 Escargots de Bourgogne | 2076 Gravad lax | 629 (4 rows) northwind=> SELECT count(*) AS products_1998 FROM sales_by_category('Seafood'); products_1998 --------------- 12 (1 row) northwind=> CREATE FUNCTION customer_revenue(p_customer_id varchar) northwind-> RETURNS numeric LANGUAGE sql STABLE AS $$ northwind$> SELECT sum(s.subtotal) northwind$> FROM orders o JOIN order_subtotals s ON s.order_id = o.order_id northwind$> WHERE o.customer_id = p_customer_id northwind$> $$; CREATE FUNCTION northwind=> SELECT customer_id, customer_revenue(customer_id) FROM customers WHERE country = 'Norway'; customer_id | customer_revenue -------------+------------------ SANTG | 5735.15 (1 row) northwind=> PREPARE orders_for(varchar) AS SELECT count(*) FROM orders WHERE customer_id = $1; PREPARE northwind=> EXECUTE orders_for('ALFKI'); count ------- 6 (1 row) northwind=> EXECUTE orders_for('x'' OR ''1''=''1'); count ------- 0 (1 row)
The same idea in application code

Save as cust_order_hist.py and install the driver with pip install "psycopg[binary]". The second customer ID is an injection attempt. Because it is passed as a parameter, it is only ever compared as a value.

import psycopg # Microsoft's CustOrderHist procedure, called with a bound parameter with psycopg.connect('dbname=northwind') as con: for customer in ('ALFKI', "x' OR '1'='1"): rows = con.execute( 'SELECT product_name, total FROM cust_order_hist(%s) ' 'ORDER BY total DESC, product_name LIMIT 3', (customer,), ).fetchall() print(repr(customer), '->', rows)
Terminal Output
$ python cust_order_hist.py 'ALFKI' -> [('Escargots de Bourgogne', 40), ('Chartreuse verte', 21), ('Flotemysost', 20)] "x' OR '1'='1" -> []
13 · Window functions: reports Northwind couldn't write

Northwind’s reports were written for SQL Server 2000, which had no window functions. A window function computes across a set of related rows, as an aggregate does, but it keeps every row instead of collapsing them. OVER (PARTITION BY country ORDER BY revenue DESC) means “within each country, ranked by revenue”, so every customer gets a rank in its own country in a single pass.

sum(sales) OVER (ORDER BY month) is a running total: the sum of this month and every month before it. lag(sales) reads the previous row’s value, which gives month-over-month change. The first month has nothing before it, so its change is NULL. Because order_subtotals is exact numeric, every total in the output is exact to the cent, with no rounding needed.

row_number() numbers the rows within each partition, and filtering on that number in an outer query gives a top-N per group, here the two best-selling products in each category. Window functions have been in PostgreSQL since version 8.4 (2009).

Window function
A function evaluated over a "window" of rows related to the current row, without grouping them away.
PARTITION BY
Splits rows into independent windows, one per value.
rank() / row_number() / lag()
Rank with ties, a unique sequence number, and the value from the previous row.
Rankings, running totals and top-N per group
-- rank(): each customer's place within its own country WITH revenue AS ( SELECT c.country, c.company_name, round(sum(od.unit_price * od.quantity * (1 - od.discount::numeric)), 2) AS revenue FROM customers c JOIN orders o ON o.customer_id = c.customer_id JOIN order_details od ON od.order_id = o.order_id GROUP BY c.customer_id ) SELECT country, company_name, revenue, rank() OVER (PARTITION BY country ORDER BY revenue DESC) AS rank_in_country FROM revenue WHERE country IN ('Mexico', 'UK') ORDER BY country, rank_in_country; -- Running total and month-over-month change WITH monthly AS ( SELECT to_char(o.order_date, 'YYYY-MM') AS month, sum(s.subtotal) AS sales FROM orders o JOIN order_subtotals s ON s.order_id = o.order_id GROUP BY month ) SELECT month, sales, sum(sales) OVER (ORDER BY month) AS running_total, sales - lag(sales) OVER (ORDER BY month) AS change FROM monthly ORDER BY month LIMIT 6; -- row_number(): the two best-selling products in each category SELECT category_name, product_name, units FROM ( SELECT c.category_name, p.product_name, sum(od.quantity) AS units, row_number() OVER (PARTITION BY c.category_id ORDER BY sum(od.quantity) DESC) AS rn FROM order_details od JOIN products p ON p.product_id = od.product_id JOIN categories c ON c.category_id = p.category_id GROUP BY c.category_id, p.product_id ) ranked WHERE rn <= 2 ORDER BY category_name, units DESC;
Terminal Output
northwind=> WITH revenue AS ( northwind(> SELECT c.country, c.company_name, northwind(> round(sum(od.unit_price * od.quantity * (1 - od.discount::numeric)), 2) AS revenue northwind(> FROM customers c northwind(> JOIN orders o ON o.customer_id = c.customer_id northwind(> JOIN order_details od ON od.order_id = o.order_id northwind(> GROUP BY c.customer_id northwind(> ) northwind-> SELECT country, company_name, revenue, northwind-> rank() OVER (PARTITION BY country ORDER BY revenue DESC) AS rank_in_country northwind-> FROM revenue WHERE country IN ('Mexico', 'UK') northwind-> ORDER BY country, rank_in_country; country | company_name | revenue | rank_in_country ---------+------------------------------------+----------+----------------- Mexico | Tortuga Restaurante | 10812.15 | 1 Mexico | Antonio Moreno Taquería | 7023.98 | 2 Mexico | Pericles Comidas clásicas | 4242.20 | 3 Mexico | Ana Trujillo Emparedados y helados | 1402.95 | 4 Mexico | Centro comercial Moctezuma | 100.80 | 5 UK | Seven Seas Imports | 16215.33 | 1 UK | Eastern Connection | 14761.04 | 2 UK | Around the Horn | 13390.65 | 3 UK | Island Trading | 6146.30 | 4 UK | B's Beverages | 6089.90 | 5 UK | Consolidated Holdings | 1719.10 | 6 UK | North/South | 649.00 | 7 (12 rows) northwind=> WITH monthly AS ( northwind(> SELECT to_char(o.order_date, 'YYYY-MM') AS month, sum(s.subtotal) AS sales northwind(> FROM orders o JOIN order_subtotals s ON s.order_id = o.order_id northwind(> GROUP BY month northwind(> ) northwind-> SELECT month, sales, northwind-> sum(sales) OVER (ORDER BY month) AS running_total, northwind-> sales - lag(sales) OVER (ORDER BY month) AS change northwind-> FROM monthly ORDER BY month LIMIT 6; month | sales | running_total | change ---------+----------+---------------+---------- 1996-07 | 27861.90 | 27861.90 | 1996-08 | 25485.28 | 53347.18 | -2376.62 1996-09 | 26381.40 | 79728.58 | 896.12 1996-10 | 37515.73 | 117244.31 | 11134.33 1996-11 | 45600.05 | 162844.36 | 8084.32 1996-12 | 45239.63 | 208083.99 | -360.42 (6 rows) northwind=> SELECT category_name, product_name, units FROM ( northwind(> SELECT c.category_name, p.product_name, sum(od.quantity) AS units, northwind(> row_number() OVER (PARTITION BY c.category_id northwind(> ORDER BY sum(od.quantity) DESC) AS rn northwind(> FROM order_details od northwind(> JOIN products p ON p.product_id = od.product_id northwind(> JOIN categories c ON c.category_id = p.category_id northwind(> GROUP BY c.category_id, p.product_id northwind(> ) ranked WHERE rn <= 2 ORDER BY category_name, units DESC; category_name | product_name | units ----------------+----------------------------------+------- Beverages | Rhönbräu Klosterbier | 1155 Beverages | Guaraná Fantástica | 1125 Condiments | Original Frankfurter grüne Soße | 791 Condiments | Louisiana Fiery Hot Pepper Sauce | 745 Confections | Pavlova | 1158 Confections | Tarte au sucre | 1083 Dairy Products | Camembert Pierrot | 1577 Dairy Products | Raclette Courdavault | 1496 Grains/Cereals | Gnocchi di nonna Alice | 1263 Grains/Cereals | Wimmers gute Semmelknödel | 740 Meat/Poultry | Alice Mutton | 978 Meat/Poultry | Pâté chinois | 903 Produce | Manjimup Dried Apples | 886 Produce | Uncle Bob's Organic Dried Pears | 763 Seafood | Boston Crab Meat | 1103 Seafood | Jack's New England Clam Chowder | 981 (16 rows)
🛡️
Part 4 · Keeping Data Correct
Constraints, transactions, indexes and the pictures stored as bytea
14 · Constraints: what the database refuses

PostgreSQL enforces foreign keys on every write, with no setting to turn on. An order for a customer called ZZZZZ, who doesn’t exist, is refused, and so is deleting ALFKI, because six orders still point at it. Each error names the constraint and the key involved. The Master’s Degree tab’s first lesson covers referential actions such as ON DELETE CASCADE.

CHECK constraints enforce rules within a row. This copy keeps Microsoft’s rules: quantities must be positive, a discount must be between 0 and 1, and stock can’t go below zero. The error shows the whole failing row. Declared lengths are enforced too: city is varchar(15), so a 20-character town is refused. Microsoft’s CK_Birthdate required BirthDate < getdate(). PostgreSQL accepts current_date in a CHECK, and the session shows it working, but the result is misleading. A check runs only when a row is written and is never re-checked as time passes, and PostgreSQL assumes checks always give the same answer, so a backup can fail to restore later. This copy checks birth_date < hire_date instead.

Adding a constraint to a table that already has data checks every existing row first. A rule that Côte de Blaye at 263.50 breaks is refused. NOT VALID adds the constraint without checking old rows. New writes must obey it from that moment, and VALIDATE CONSTRAINT checks the old rows later, when it suits you. This is how large production tables get new rules without a long lock.

CHECK constraint
A condition that every row must satisfy. It is tested on INSERT and UPDATE.
NOT VALID
Adds a constraint that applies to new writes immediately, while existing rows are checked later by VALIDATE CONSTRAINT.
varchar(n)
Text with a maximum length that PostgreSQL enforces. Plain text has no limit.
Foreign keys, checks, lengths and NOT VALID
-- Foreign keys are always enforced INSERT INTO orders (customer_id, employee_id, order_date) VALUES ('ZZZZZ', 1, '1998-05-07'); -- A parent with children cannot be deleted DELETE FROM customers WHERE customer_id = 'ALFKI'; -- CHECK constraints copied from Microsoft (CK_Quantity, CK_Discount) UPDATE order_details SET quantity = 0 WHERE order_id = 10248 AND product_id = 11; UPDATE order_details SET discount = 1.5 WHERE order_id = 10248 AND product_id = 11; -- varchar(n) enforces the length Microsoft declared UPDATE customers SET city = 'Llanfairpwllgwyngyll' WHERE customer_id = 'ALFKI'; -- CK_Birthdate used getdate(). PostgreSQL accepts current_date in a CHECK... CREATE TEMP TABLE t (birth_date date CHECK (birth_date < current_date)); INSERT INTO t VALUES ('2026-12-25'); -- ...but it is checked only when a row is written, never again afterwards -- Adding a constraint checks every existing row; NOT VALID skips them ALTER TABLE products ADD CONSTRAINT price_under_200 CHECK (unit_price < 200); ALTER TABLE products ADD CONSTRAINT price_under_200 CHECK (unit_price < 200) NOT VALID; ALTER TABLE products VALIDATE CONSTRAINT price_under_200;
Terminal Output
northwind=> INSERT INTO orders (customer_id, employee_id, order_date) VALUES ('ZZZZZ', 1, '1998-05-07'); ERROR: insert or update on table "orders" violates foreign key constraint "orders_customer_id_fkey" DETAIL: Key (customer_id)=(ZZZZZ) is not present in table "customers". northwind=> DELETE FROM customers WHERE customer_id = 'ALFKI'; ERROR: update or delete on table "customers" violates foreign key constraint "orders_customer_id_fkey" on table "orders" DETAIL: Key (customer_id)=(ALFKI) is still referenced from table "orders". northwind=> UPDATE order_details SET quantity = 0 WHERE order_id = 10248 AND product_id = 11; ERROR: new row for relation "order_details" violates check constraint "order_details_quantity_check" DETAIL: Failing row contains (10248, 11, 14.00, 0, 0). northwind=> UPDATE order_details SET discount = 1.5 WHERE order_id = 10248 AND product_id = 11; ERROR: new row for relation "order_details" violates check constraint "order_details_discount_check" DETAIL: Failing row contains (10248, 11, 14.00, 12, 1.5). northwind=> UPDATE customers SET city = 'Llanfairpwllgwyngyll' WHERE customer_id = 'ALFKI'; ERROR: value too long for type character varying(15) northwind=> CREATE TEMP TABLE t (birth_date date CHECK (birth_date < current_date)); CREATE TABLE northwind=> INSERT INTO t VALUES ('2026-12-25'); ERROR: new row for relation "t" violates check constraint "t_birth_date_check" DETAIL: Failing row contains (2026-12-25). northwind=> ALTER TABLE products ADD CONSTRAINT price_under_200 CHECK (unit_price < 200); ERROR: check constraint "price_under_200" of relation "products" is violated by some row northwind=> ALTER TABLE products ADD CONSTRAINT price_under_200 CHECK (unit_price < 200) NOT VALID; ALTER TABLE northwind=> ALTER TABLE products VALIDATE CONSTRAINT price_under_200; ERROR: check constraint "price_under_200" of relation "products" is violated by some row
15 · Transactions: placing an order

Taking an order is several writes that only make sense together: the order header, its lines, and a reduction in stock. A transaction makes them atomic. Between BEGIN and COMMIT nothing is final, and ROLLBACK throws all of it away. The last query proves it: still 830 orders, and Chai back at 39 in stock. While the transaction is open, psql’s prompt shows =*>.

RETURNING order_id \gset hands back the new key and stores it in a psql variable, so the following statements can use :order_id. The identity column assigned 11078, one more than Microsoft’s highest order. The order lines copy unit_price from products at this moment, the point-in-time copy from lesson 5, and the address is copied the same way.

Then an attempt to sell 100 Chai breaks the units_in_stock >= 0 check, and here PostgreSQL behaves differently from SQLite. An error aborts the whole transaction. The prompt changes to =!>, and every later statement is refused with “current transaction is aborted” until you roll back. A SAVEPOINT set before a risky statement gives you somewhere to return to: ROLLBACK TO SAVEPOINT undoes only the failed part, and the order and its 370.00 subtotal are still there. Applications use savepoints for the same reason, often without knowing it. Many drivers and frameworks, Django’s nested atomic() among them, create a savepoint for each nested block.

Transaction
A group of statements that succeed or fail as a unit (BEGIN ... COMMIT / ROLLBACK).
Aborted transaction
In PostgreSQL, any error inside a transaction block: nothing more runs until ROLLBACK (or ROLLBACK TO a savepoint).
SAVEPOINT
A named point inside a transaction that you can roll back to without abandoning the whole transaction.
Place an order, fail, recover, and roll back
BEGIN; -- The order header, copying the ship-to address from the customer INSERT INTO orders (customer_id, employee_id, order_date, required_date, ship_via, freight, ship_name, ship_address, ship_city, ship_postal_code, ship_country) SELECT customer_id, 4, '1998-05-07', '1998-06-04', 2, 12.50, company_name, address, city, postal_code, country FROM customers WHERE customer_id = 'ALFKI' RETURNING order_id \gset \echo new order :order_id -- Two lines, each copying today's list price INSERT INTO order_details (order_id, product_id, unit_price, quantity) SELECT :order_id, product_id, unit_price, 10 FROM products WHERE product_id IN (1, 2); -- Take the stock UPDATE products SET units_in_stock = units_in_stock - 10 WHERE product_id IN (1, 2) RETURNING product_id, product_name, units_in_stock; -- A savepoint marks a place to roll back to SAVEPOINT before_oversell; -- Overselling breaks CHECK (units_in_stock >= 0), and the error aborts the transaction UPDATE products SET units_in_stock = units_in_stock - 100 WHERE product_id = 1; SELECT * FROM order_subtotals WHERE order_id = :order_id; ROLLBACK TO SAVEPOINT before_oversell; SELECT * FROM order_subtotals WHERE order_id = :order_id; -- Undo all of it ROLLBACK; SELECT count(*) AS orders, (SELECT units_in_stock FROM products WHERE product_id = 1) AS chai_stock FROM orders;
Terminal Output
northwind=> BEGIN; BEGIN northwind=*> INSERT INTO orders (customer_id, employee_id, order_date, required_date, ship_via, northwind(*> freight, ship_name, ship_address, ship_city, ship_postal_code, ship_country) northwind-*> SELECT customer_id, 4, '1998-05-07', '1998-06-04', 2, 12.50, northwind-*> company_name, address, city, postal_code, country northwind-*> FROM customers WHERE customer_id = 'ALFKI' northwind-*> RETURNING order_id \gset INSERT 0 1 northwind=*> \echo new order :order_id new order 11078 northwind=*> INSERT INTO order_details (order_id, product_id, unit_price, quantity) northwind-*> SELECT :order_id, product_id, unit_price, 10 FROM products WHERE product_id IN (1, 2); INSERT 0 2 northwind=*> UPDATE products SET units_in_stock = units_in_stock - 10 northwind-*> WHERE product_id IN (1, 2) RETURNING product_id, product_name, units_in_stock; product_id | product_name | units_in_stock ------------+--------------+---------------- 1 | Chai | 29 2 | Chang | 7 (2 rows) UPDATE 2 northwind=*> SAVEPOINT before_oversell; SAVEPOINT northwind=*> UPDATE products SET units_in_stock = units_in_stock - 100 WHERE product_id = 1; ERROR: new row for relation "products" violates check constraint "products_units_in_stock_check" DETAIL: Failing row contains (1, Chai, 1, 1, 10 boxes x 20 bags, 18.00, -71, 0, 10, f). northwind=!> SELECT * FROM order_subtotals WHERE order_id = :order_id; ERROR: current transaction is aborted, commands ignored until end of transaction block northwind=!> ROLLBACK TO SAVEPOINT before_oversell; ROLLBACK northwind=*> SELECT * FROM order_subtotals WHERE order_id = :order_id; order_id | subtotal ----------+---------- 11078 | 370.00 (1 row) northwind=*> ROLLBACK; ROLLBACK northwind=> SELECT count(*) AS orders, (SELECT units_in_stock FROM products WHERE product_id = 1) AS chai_stock northwind-> FROM orders; orders | chai_stock --------+------------ 830 | 39 (1 row)
16 · Indexes and query plans

\d orders lists the table’s indexes and foreign keys. Microsoft’s script indexes almost every foreign key, and EXPLAIN shows why. For ALFKI’s orders the planner expects 6 of 830 rows and uses orders_customer_id: a bitmap index scan collects the matching row addresses, then a bitmap heap scan reads each page once. Nothing indexes ship_country, so that query reads every row, a Seq Scan.

The composite primary key’s index serves lookups by its first column, order_id. Like a phone book sorted by surname and then first name, it can’t find rows by its second column alone, which is why product_id has an index of its own. An index is used only when the planner thinks it is cheaper. customers has an index on city, but the whole table fits in a few pages, so for London the planner reads them all instead. On 91 rows that is the right choice.

EXPLAIN ANALYZE runs the query and adds actual times and row counts. For Mexico’s orders the planner builds a hash table from the 5 Mexican customers and probes it with all 830 orders, a hash join. The estimates (46 rows) and the actual count (28) are close enough for a good plan. Microsoft’s original also had duplicate indexes, such as two identical ones on the customer column of orders. A duplicate makes every write slower and never helps a read, so this copy keeps one of each. The Master’s Degree tab covers the planner’s statistics in depth.

Seq Scan / Index Scan
In a plan: reading every row in table order, or looking rows up through an index.
Bitmap scan
Collect matching row addresses from an index first, then visit each table page once.
Leftmost prefix
A multi-column index helps queries that constrain its first column (first two, ...), not later columns alone.
Indexes, plans, and when the planner skips an index
\d orders -- An index finds ALFKI's orders directly EXPLAIN SELECT * FROM orders WHERE customer_id = 'ALFKI'; -- Nothing indexes ship_country, so every row is read EXPLAIN SELECT * FROM orders WHERE ship_country = 'France'; -- The composite primary key's index serves lookups by its first column... EXPLAIN SELECT * FROM order_details WHERE order_id = 10248; -- ...but product_id needs an index of its own EXPLAIN SELECT * FROM order_details WHERE product_id = 11; -- Small tables: the planner may read the whole table even when an index exists EXPLAIN SELECT * FROM customers WHERE city = 'London'; -- A join, with the time each step really took EXPLAIN (ANALYZE, BUFFERS OFF, SUMMARY OFF) SELECT c.company_name, o.order_id FROM customers c JOIN orders o ON o.customer_id = c.customer_id WHERE c.country = 'Mexico';
Terminal Output
northwind=> \d orders Table "public.orders" Column | Type | Collation | Nullable | Default ------------------+-----------------------+-----------+----------+---------------------------------- order_id | integer | | not null | generated by default as identity customer_id | character varying(5) | | | employee_id | integer | | | order_date | date | | | required_date | date | | | shipped_date | date | | | ship_via | integer | | | freight | numeric(10,2) | | | 0 ship_name | character varying(40) | | | ship_address | character varying(60) | | | ship_city | character varying(15) | | | ship_region | character varying(15) | | | ship_postal_code | character varying(10) | | | ship_country | character varying(15) | | | Indexes: "orders_pkey" PRIMARY KEY, btree (order_id) "orders_customer_id" btree (customer_id) "orders_employee_id" btree (employee_id) "orders_order_date" btree (order_date) "orders_ship_postal_code" btree (ship_postal_code) "orders_ship_via" btree (ship_via) "orders_shipped_date" btree (shipped_date) Foreign-key constraints: "orders_customer_id_fkey" FOREIGN KEY (customer_id) REFERENCES customers(customer_id) "orders_employee_id_fkey" FOREIGN KEY (employee_id) REFERENCES employees(employee_id) "orders_ship_via_fkey" FOREIGN KEY (ship_via) REFERENCES shippers(shipper_id) Referenced by: TABLE "order_details" CONSTRAINT "order_details_order_id_fkey" FOREIGN KEY (order_id) REFERENCES orders(order_id) northwind=> EXPLAIN SELECT * FROM orders WHERE customer_id = 'ALFKI'; QUERY PLAN --------------------------------------------------------------------------------- Bitmap Heap Scan on orders (cost=4.20..15.61 rows=6 width=98) Recheck Cond: ((customer_id)::text = 'ALFKI'::text) -> Bitmap Index Scan on orders_customer_id (cost=0.00..4.20 rows=6 width=0) Index Cond: ((customer_id)::text = 'ALFKI'::text) (4 rows) northwind=> EXPLAIN SELECT * FROM orders WHERE ship_country = 'France'; QUERY PLAN --------------------------------------------------------- Seq Scan on orders (cost=0.00..25.38 rows=77 width=98) Filter: ((ship_country)::text = 'France'::text) (2 rows) northwind=> EXPLAIN SELECT * FROM order_details WHERE order_id = 10248; QUERY PLAN ------------------------------------------------------------------------------------------ Index Scan using order_details_pkey on order_details (cost=0.28..10.06 rows=2 width=20) Index Cond: (order_id = 10248) (2 rows) northwind=> EXPLAIN SELECT * FROM order_details WHERE product_id = 11; QUERY PLAN ---------------------------------------------------------------------------------------- Bitmap Heap Scan on order_details (cost=4.57..19.05 rows=38 width=20) Recheck Cond: (product_id = 11) -> Bitmap Index Scan on order_details_product_id (cost=0.00..4.57 rows=38 width=0) Index Cond: (product_id = 11) (4 rows) northwind=> EXPLAIN SELECT * FROM customers WHERE city = 'London'; QUERY PLAN ----------------------------------------------------------- Seq Scan on customers (cost=0.00..3.14 rows=6 width=126) Filter: ((city)::text = 'London'::text) (2 rows) northwind=> EXPLAIN (ANALYZE, BUFFERS OFF, SUMMARY OFF) northwind-> SELECT c.company_name, o.order_id FROM customers c northwind-> JOIN orders o ON o.customer_id = c.customer_id WHERE c.country = 'Mexico'; QUERY PLAN --------------------------------------------------------------------------------------------------------------------- Hash Join (cost=3.20..28.78 rows=46 width=24) (actual time=0.064..0.233 rows=28.00 loops=1) Hash Cond: ((o.customer_id)::text = (c.customer_id)::text) -> Seq Scan on orders o (cost=0.00..23.30 rows=830 width=10) (actual time=0.020..0.118 rows=830.00 loops=1) -> Hash (cost=3.14..3.14 rows=5 width=26) (actual time=0.035..0.035 rows=5.00 loops=1) Buckets: 1024 Batches: 1 Memory Usage: 9kB -> Seq Scan on customers c (cost=0.00..3.14 rows=5 width=26) (actual time=0.015..0.029 rows=5.00 loops=1) Filter: ((country)::text = 'Mexico'::text) Rows Removed by Filter: 86 (8 rows)
17 · Binary data: the pictures inside categories and employees

categories.picture and employees.photo hold images as bytea, PostgreSQL’s binary type: raw bytes, stored and returned without interpretation. octet_length() counts bytes; every category picture is exactly 10,746. psql displays bytea in hex, starting with \x.

The first bytes aren’t an image header. Microsoft Access wrapped each picture in an OLE object, a container that recorded which program (Paint) should open it. The real Windows bitmap starts where its signature BM (hex 424d) appears, at byte 79, after a 78-byte wrapper. substring(picture FROM 79) peels the wrapper off.

To save it as a file on your own computer, the session turns the bytes into a temporary large object with lo_from_bytea(), and psql’s \lo_export copies that to a local file. \lo_unlink deletes the temporary object, and \! runs a shell command, here file, which recognises a 172 × 120, 16-colour bitmap. Storing images in the database keeps everything together and transactional, which suits small images like these. For large media, most designs store a file path and keep the bytes elsewhere. Northwind does both: it also has a photo_path column holding the old http://accweb/... address of each photo on Microsoft’s internal network.

bytea
Byte array: PostgreSQL’s type for binary data. '\x424d'::bytea is a literal in hex.
OLE object
An Access/Windows wrapper around embedded content, naming the program that created it.
Large object
A separate PostgreSQL store for big binary values, read and written in pieces; psql can import and export them.
Find the bitmap inside the bytes and save it
SELECT category_id, category_name, pg_typeof(picture), octet_length(picture) FROM categories ORDER BY category_id LIMIT 3; -- The first bytes are not an image header SELECT substring(picture FROM 1 FOR 16) FROM categories WHERE category_id = 1; -- Find the bitmap's 'BM' signature (hex 424d) SELECT position('\x424d'::bytea IN picture) AS bitmap_starts_at FROM categories WHERE category_id = 1; -- Save everything after the 78-byte OLE header to a file on your computer SELECT lo_from_bytea(0, substring(picture FROM 79)) AS loid FROM categories WHERE category_id = 1 \gset \lo_export :loid beverages.bmp \lo_unlink :loid \! file beverages.bmp
Terminal Output
northwind=> SELECT category_id, category_name, pg_typeof(picture), octet_length(picture) northwind-> FROM categories ORDER BY category_id LIMIT 3; category_id | category_name | pg_typeof | octet_length -------------+---------------+-----------+-------------- 1 | Beverages | bytea | 10746 2 | Condiments | bytea | 10746 3 | Confections | bytea | 10746 (3 rows) northwind=> SELECT substring(picture FROM 1 FOR 16) FROM categories WHERE category_id = 1; substring ------------------------------------ \x151c2f00020000000d000e0014002100 (1 row) northwind=> SELECT position('\x424d'::bytea IN picture) AS bitmap_starts_at FROM categories WHERE category_id = 1; bitmap_starts_at ------------------ 79 (1 row) northwind=> SELECT lo_from_bytea(0, substring(picture FROM 79)) AS loid FROM categories WHERE category_id = 1 \gset northwind=> \lo_export :loid beverages.bmp lo_export northwind=> \lo_unlink :loid lo_unlink 35216 northwind=> \! file beverages.bmp beverages.bmp: PC bitmap, Windows 3.x format, 172 x 120 x 4, resolution 2952 x 2952 px/m, 8 important colors, cbSize 10648, bits offset 86
The Beverages category picture: bottles, a teapot and cups
beverages.bmp, extracted from categories.picture and shown here converted to PNG: 172 × 120 pixels, 16 colours, drawn for Microsoft Access in the 1990s.
Exercises

Eight questions a Northwind manager might ask. Write the query yourself first, then open the answer: each one shows a query that works and its real output. There is usually more than one correct query.

1 Which five products have sold the most units?
SELECT p.product_name, sum(od.quantity) AS units FROM order_details od JOIN products p ON p.product_id = od.product_id GROUP BY p.product_id ORDER BY units DESC LIMIT 5;
Terminal Output
northwind=> SELECT p.product_name, sum(od.quantity) AS units northwind-> FROM order_details od JOIN products p ON p.product_id = od.product_id northwind-> GROUP BY p.product_id ORDER BY units DESC LIMIT 5; product_name | units ------------------------+------- Camembert Pierrot | 1577 Raclette Courdavault | 1496 Gorgonzola Telino | 1397 Gnocchi di nonna Alice | 1263 Pavlova | 1158 (5 rows)
2 Which salesperson sold the most in 1997, by order value?
SELECT e.first_name || ' ' || e.last_name AS employee, sum(s.subtotal) AS sales FROM orders o JOIN order_subtotals s ON s.order_id = o.order_id JOIN employees e ON e.employee_id = o.employee_id WHERE o.order_date BETWEEN '1997-01-01' AND '1997-12-31' GROUP BY e.employee_id ORDER BY sales DESC LIMIT 3;
Terminal Output
northwind=> SELECT e.first_name || ' ' || e.last_name AS employee, sum(s.subtotal) AS sales northwind-> FROM orders o northwind-> JOIN order_subtotals s ON s.order_id = o.order_id northwind-> JOIN employees e ON e.employee_id = o.employee_id northwind-> WHERE o.order_date BETWEEN '1997-01-01' AND '1997-12-31' northwind-> GROUP BY e.employee_id ORDER BY sales DESC LIMIT 3; employee | sales ------------------+----------- Margaret Peacock | 128809.83 Janet Leverling | 108026.17 Nancy Davolio | 93148.13 (3 rows)
3 Which products need reordering? (still sold, and stock plus stock on order is at or below the reorder level)
SELECT product_name, units_in_stock, units_on_order, reorder_level FROM products WHERE NOT discontinued AND units_in_stock + units_on_order <= reorder_level ORDER BY product_name;
Terminal Output
northwind=> SELECT product_name, units_in_stock, units_on_order, reorder_level northwind-> FROM products northwind-> WHERE NOT discontinued AND units_in_stock + units_on_order <= reorder_level northwind-> ORDER BY product_name; product_name | units_in_stock | units_on_order | reorder_level -----------------------+----------------+----------------+--------------- Nord-Ost Matjeshering | 10 | 0 | 15 Outback Lager | 15 | 10 | 30 (2 rows)
4 Which countries have the most customers, and how many orders came from each?
SELECT c.country, count(DISTINCT c.customer_id) AS customers, count(o.order_id) AS orders FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id GROUP BY c.country ORDER BY customers DESC, orders DESC LIMIT 5;
Terminal Output
northwind=> SELECT c.country, count(DISTINCT c.customer_id) AS customers, count(o.order_id) AS orders northwind-> FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id northwind-> GROUP BY c.country ORDER BY customers DESC, orders DESC LIMIT 5; country | customers | orders ---------+-----------+-------- USA | 13 | 122 Germany | 11 | 122 France | 11 | 77 Brazil | 9 | 83 UK | 7 | 56 (5 rows)
5 Which supplier provides the most products?
SELECT s.company_name, s.country, count(*) AS products FROM suppliers s JOIN products p ON p.supplier_id = s.supplier_id GROUP BY s.supplier_id ORDER BY products DESC, s.company_name LIMIT 3;
Terminal Output
northwind=> SELECT s.company_name, s.country, count(*) AS products northwind-> FROM suppliers s JOIN products p ON p.supplier_id = s.supplier_id northwind-> GROUP BY s.supplier_id ORDER BY products DESC, s.company_name LIMIT 3; company_name | country | products -----------------------------------+-----------+---------- Pavlova, Ltd. | Australia | 5 Plutzer Lebensmittelgroßmärkte AG | Germany | 5 New Orleans Cajun Delights | USA | 4 (3 rows)
6 Which five orders took longest to ship?
SELECT order_id, order_date, shipped_date, shipped_date - order_date AS days FROM orders WHERE shipped_date IS NOT NULL ORDER BY days DESC, order_id LIMIT 5;
Terminal Output
northwind=> SELECT order_id, order_date, shipped_date, shipped_date - order_date AS days northwind-> FROM orders WHERE shipped_date IS NOT NULL northwind-> ORDER BY days DESC, order_id LIMIT 5; order_id | order_date | shipped_date | days ----------+------------+--------------+------ 10660 | 1997-09-08 | 1997-10-15 | 37 10777 | 1997-12-15 | 1998-01-21 | 37 10380 | 1996-12-12 | 1997-01-16 | 35 10427 | 1997-01-27 | 1997-03-03 | 35 10545 | 1997-05-22 | 1997-06-26 | 35 (5 rows)
7 What is the average order value, and how many orders beat it?
SELECT round(avg(subtotal), 2) AS average_order, count(*) FILTER (WHERE subtotal > (SELECT avg(subtotal) FROM order_subtotals)) AS above_average FROM order_subtotals;
Terminal Output
northwind=> SELECT round(avg(subtotal), 2) AS average_order, northwind-> count(*) FILTER (WHERE subtotal > (SELECT avg(subtotal) FROM order_subtotals)) AS above_average northwind-> FROM order_subtotals; average_order | above_average ---------------+--------------- 1525.05 | 282 (1 row)
8 Which customer placed the single largest order, and what was in it?
SELECT o.order_id, c.company_name, p.product_name, od.quantity, od.unit_price FROM orders o JOIN customers c ON c.customer_id = o.customer_id JOIN order_details od ON od.order_id = o.order_id JOIN products p ON p.product_id = od.product_id WHERE o.order_id = (SELECT order_id FROM order_subtotals ORDER BY subtotal DESC LIMIT 1) ORDER BY p.product_name;
Terminal Output
northwind=> SELECT o.order_id, c.company_name, p.product_name, od.quantity, od.unit_price northwind-> FROM orders o northwind-> JOIN customers c ON c.customer_id = o.customer_id northwind-> JOIN order_details od ON od.order_id = o.order_id northwind-> JOIN products p ON p.product_id = od.product_id northwind-> WHERE o.order_id = (SELECT order_id FROM order_subtotals ORDER BY subtotal DESC LIMIT 1) northwind-> ORDER BY p.product_name; order_id | company_name | product_name | quantity | unit_price ----------+--------------+------------------+----------+------------ 10865 | QUICK-Stop | Chartreuse verte | 80 | 18.00 10865 | QUICK-Stop | Côte de Blaye | 60 | 263.50 (2 rows)
About this conversion: SQL Server to PostgreSQL

northwind-postgresql.sql was generated from the same verified data as the SQLite guide’s northwind.db, which was itself parsed from every INSERT in Microsoft’s instnwnd.sql. No row was typed by hand. All 3,308 rows were compared with the SQLite copy, column by column and byte for byte for the pictures, and they match. The schema, views and procedures are written in T-SQL, so they had to be translated:

SQL Server (T-SQL)PostgreSQLWhy
nvarchar(40), nchar(5)varchar(40), varchar(5)PostgreSQL text is already Unicode, so there are no separate “n” types. The declared lengths are kept and enforced (lesson 14).
ntexttextUnlimited length.
int IDENTITY(1,1)integer GENERATED BY DEFAULT AS IDENTITYBY DEFAULT lets the script load Microsoft’s own IDs; each identity then restarts after the highest one (lesson 2).
moneynumeric(10,2)Exact decimal. PostgreSQL has a money type, but its output depends on the server’s locale.
real (discount)realKept as Microsoft had it. Cast it with ::numeric in money arithmetic (lesson 8).
bitbooleantrue/false, so filters read NOT discontinued.
datetime ‘7/4/1996’date 1996-07-04No order has a time of day. Text input like 7/4/1996 is ambiguous (lesson 9).
imagebyteaThe bytes are stored unchanged, OLE wrapper included (lesson 17).
"Order Details", CustomerIDorder_details, customer_idUnquoted names fold to lower case, so snake_case never needs quotes (lesson 1). Views follow suit: “Order Subtotals” is order_subtotals.
CHECK (BirthDate < getdate())CHECK (birth_date < hire_date)A check is never re-run as time passes, so a rule about “now” can’t really be kept (lesson 14).
CREATE PROCEDURECREATE FUNCTION ... RETURNS TABLECalled like a table: SELECT * FROM cust_order_hist('ALFKI') (lesson 12). SET ROWCOUNT 10 became LIMIT 10.
a + ' ' + ba || ' ' || bPostgreSQL has no + for text (lesson 11).
CONVERT(money, x)round(x, 2)Line amounts are rounded to the cent in exact decimal arithmetic.
Duplicate indexesOne index eachTwo identical indexes cost every write and help no read (lesson 16).

One difference you may notice: on 14 of the 830 orders, the subtotal here is one cent higher than in the SQLite copy. Order 10469 includes a line worth exactly 413.525. PostgreSQL’s numeric rounds that half-cent up to 413.53. SQLite and SQL Server compute it with a floating-point discount, get 413.524997…, and round down. The PostgreSQL figure is the exact one.

Northwind sample data © Microsoft Corporation, published in microsoft/sql-server-samples under the MIT licence. The PostgreSQL conversion on this page is offered under the same licence.

PostgreSQL Knowledge Quiz
10 questions — click an option to answer
0 / 10 answered
Score: 0 / 0
0/10
0
Correct
0
Incorrect
0%
Score