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.
psql just works.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.18Accept the defaults for the components (Server, pgAdmin 4, Command Line Tools) and the data directory. Then:
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.The server is installed as a Windows service called postgresql-x64-18 and starts with Windows.
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")psql --versionpsql (PostgreSQL) 18.6Log in with the password you chose in step 2. The prompt ends in # because postgres is a superuser:
psql -U postgres$ psql -U postgres
Password for user postgres:
psql (18.6)
Type "help" for help.
postgres=#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;
\qFrom 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.
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.With Homebrew installed, ask for version 18 by name. A plain brew install postgresql gives an older default.
brew install postgresql@18This starts it now and at every login:
brew services start postgresql@18Versioned 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"' >> ~/.zshrcIntel Mac:
echo 'export PATH="/usr/local/opt/postgresql@18/bin:$PATH"' >> ~/.zshrcpsql --versionpsql (PostgreSQL) 18.6Homebrew 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:
createdbpsql$ psql
psql (18.6)
Type "help" for help.
you=#The prompt ends in # because Homebrew's role is a superuser.
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.shsudo apt update
sudo apt install -y postgresql-18The package creates a Linux user called postgres, creates a database cluster in /var/lib/postgresql/18/main, and starts the server.
pg_lsclustersVer Cluster Port Status Owner Data directory Log file
18 main 5432 online postgres /var/lib/postgresql/18/main /var/log/postgresql/postgresql-18-main.logOnly 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 $USERpsql --version
psql$ psql --version
psql (PostgreSQL) 18.6 (Ubuntu 18.6-1.pgdg24.04+2)
$ psql
psql (18.6)
Type "help" for help.
you=>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.
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 message | What it means | Fix |
|---|---|---|
| role "you" does not exist | The server is running, but no role has your login name. | Create one: Linux step 4, or Windows step 6. |
| database "you" does not exist | Your 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 failed | Wrong 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 directory | Nothing 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 found | The client isn't on your PATH. | Redo the PATH step for your OS and open a new terminal. |
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.
# 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$ 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)psql --help lists every option. The ones in the code above are the only four you need day to day.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.
\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 -- quityou=> \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.\x and run it again. Type \x once more to switch back.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.
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);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 TABLEtext 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.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.
-- 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');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).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.
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;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)NULL, meaning no value. psql prints NULL as nothing. Run \pset null '(null)' to make it visible.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.
-- 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;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)WHERE clause first and check it with a SELECT before you run UPDATE or DELETE. A missing WHERE changes every row.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.
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; -- correctshop=> 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)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.
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;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".LEFT JOIN shows him with an empty product. An inner JOIN would have left him out entirely.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.
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;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...
^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 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;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)SET NOT NULL refused? Dave has no email. Fill in the missing values first, or keep the column nullable.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.
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;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)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.
-- 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;
\dishop=> 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)Seq Scan before and Bitmap Index Scan after, is what to look for.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.
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;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)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.
-- 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;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)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.
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;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)BEGIN. Run the UPDATE, check the result with a SELECT, and only then COMMIT. If it looks wrong, ROLLBACK and nothing happened.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.
-- 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;-- 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 orderspostgres. 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.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.
-- 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;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.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.
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}');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...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.
# 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 HEADERshop=> \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 7count(*) on your biggest tables. It takes two minutes and tells you the backup really works.#, 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.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.
-- 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;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).
-- 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 offlab=# 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.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.
-- 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';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)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.
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));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)-- [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;[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;
ROLLBACKBuilds 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.
-- 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;[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-- 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;[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)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.
-- 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';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)-- 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;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)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.
-- 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;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;
RESETBuilds 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.
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 offlab=# 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.# 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;"$ 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;"
18000Builds 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.
-- 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;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)-- 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;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)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.
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.
=>. Lessons that change data roll their changes back.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.
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 northwindIf 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.
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.
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.
| Foreign key → what it references | Kind | Why the connection exists |
|---|---|---|
| The sale: who bought what | ||
| orders.customer_id → customers.customer_id | One-to-many | A customer places many orders over time, and each order belongs to exactly one customer. The order stores only the key, so the company name and address are kept once, in customers. Two of the 91 customers have no orders yet, which is only possible because they have their own table. |
| order_details.order_id → orders.order_id | One-to-many | An order can contain any number of products (from 1 to 25 here). That can’t be a fixed set of columns on orders, so each line is its own row that points back at its order. |
| order_details.product_id → products.product_id | One-to-many | The same product appears on many order lines. Together with order_id it forms the composite key (order_id, product_id), so a product can appear on an order only once. This makes order_details the junction for the many-to-many between orders and products. |
| orders.employee_id → employees.employee_id | One-to-many | Records which salesperson took the order. Sales-by-employee reports follow this arrow. |
| orders.ship_via → shippers.shipper_id | One-to-many | Records which of the three carriers delivers the order. The column name differs from the key it references; a foreign key needs matching values, not matching names. |
| The catalogue: what is sold | ||
| products.supplier_id → suppliers.supplier_id | One-to-many | Each product is bought from one supplier (77 products from 29 suppliers). The supplier’s contact details are stored once, however many products they supply. |
| products.category_id → categories.category_id | One-to-many | Groups the products into 8 categories for menus and reports. Each category’s description and picture are stored once, not repeated on every product. |
| The staff: who reports to whom, and where they sell | ||
| employees.reports_to → employees.employee_id | Self-reference | A manager is also an employee, so the key points back at its own table. Andrew Fuller’s reports_to is NULL because he is at the top. See lesson 4. |
| employee_territories.employee_id → employees.employee_id employee_territories.territory_id → territories.territory_id | Many-to-many | An employee covers several territories, and a territory may have several employees. Neither table can hold a list, so this junction table holds one row per pairing, 49 in all. Its primary key is both columns, so the same pairing can’t be entered twice. |
| territories.region_id → region.region_id | One-to-many | The 53 territories roll up into 4 regions: Eastern, Western, Northern and Southern. |
| Customer types: designed but never filled in | ||
| customer_customer_demo.customer_id → customers.customer_id customer_customer_demo.customer_type_id → customer_demographics.customer_type_id | Many-to-many | Lets a customer carry several marketing types. It uses the same junction pattern as employee_territories. Microsoft shipped the design without any data, so both tables have 0 rows. |
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).
-- 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;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)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.
\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 shippersnorthwind=> \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)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 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;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;
ROLLBACKMost 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: 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;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)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.
-- 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;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)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.
-- 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;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)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.
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;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)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: 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;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)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.
-- 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;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)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.
-- 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;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)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: 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;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)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.
\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;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 VIEWNorthwind’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.
-- 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');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)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)$ python cust_order_hist.py
'ALFKI' -> [('Escargots de Bourgogne', 40), ('Chartreuse verte', 21), ('Flotemysost', 20)]
"x' OR '1'='1" -> []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).
-- 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;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)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.
-- 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;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 rowTaking 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.
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;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)\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.
\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';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)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.
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.bmpnorthwind=> 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
categories.picture and shown here converted to PNG: 172 × 120 pixels, 16 colours, drawn for Microsoft Access in the 1990s.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.
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;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)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;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)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;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)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;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)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;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)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;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)SELECT round(avg(subtotal), 2) AS average_order,
count(*) FILTER (WHERE subtotal > (SELECT avg(subtotal) FROM order_subtotals)) AS above_average
FROM order_subtotals;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)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;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)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) | PostgreSQL | Why |
|---|---|---|
| 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). |
| ntext | text | Unlimited length. |
| int IDENTITY(1,1) | integer GENERATED BY DEFAULT AS IDENTITY | BY DEFAULT lets the script load Microsoft’s own IDs; each identity then restarts after the highest one (lesson 2). |
| money | numeric(10,2) | Exact decimal. PostgreSQL has a money type, but its output depends on the server’s locale. |
| real (discount) | real | Kept as Microsoft had it. Cast it with ::numeric in money arithmetic (lesson 8). |
| bit | boolean | true/false, so filters read NOT discontinued. |
| datetime ‘7/4/1996’ | date 1996-07-04 | No order has a time of day. Text input like 7/4/1996 is ambiguous (lesson 9). |
| image | bytea | The bytes are stored unchanged, OLE wrapper included (lesson 17). |
| "Order Details", CustomerID | order_details, customer_id | Unquoted 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 PROCEDURE | CREATE FUNCTION ... RETURNS TABLE | Called like a table: SELECT * FROM cust_order_hist('ALFKI') (lesson 12). SET ROWCOUNT 10 became LIMIT 10. |
| a + ' ' + b | a || ' ' || b | PostgreSQL has no + for text (lesson 11). |
| CONVERT(money, x) | round(x, 2) | Line amounts are rounded to the cent in exact decimal arithmetic. |
| Duplicate indexes | One index each | Two 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.