Back to Database Instructions
v 18
What is PostgreSQL?

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

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

Download the installer

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

winget install PostgreSQL.PostgreSQL.18
2

Run the installer

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

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

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

3

Add psql to your PATH

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

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

Verify the installation

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

Connect as the postgres superuser

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

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

Create your own role and database

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

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

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

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

Install with Homebrew

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

brew install postgresql@18
2

Start the server

This starts it now and at every login:

brew services start postgresql@18
3

Put psql on your PATH

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

Apple Silicon (M1 and later):

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

Intel Mac:

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

Verify the installation

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

Create your database

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

createdb
6

Connect

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

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

Installing PostgreSQL 18 on Linux Linux
1

Add the PostgreSQL apt repository (Debian / Ubuntu)

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

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

Install PostgreSQL 18

sudo apt update sudo apt install -y postgresql-18

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

3

Check the server is running

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

Create your own role and database

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

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

Connect

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

Fedora, RHEL, or Arch

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

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

Then create your role and database as in step 4.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Logical backup
A dump of SQL or an archive that can recreate a database; made with pg_dump.
pg_restore
Loads a custom-format dump, optionally in parallel or one table at a time.
\copy
psql's command for moving table data to and from files on your own machine.
# From your terminal, not inside psql: pg_dump shop > shop.sql # plain SQL pg_dump -Fc shop > shop.dump # compressed custom format # Restore into a fresh database createdb shop_copy pg_restore -d shop_copy shop.dump # or: psql -d shop_copy -f shop.sql # Inside psql: export and import CSV \copy users TO 'users.csv' CSV HEADER \copy users (name, email, age, city, joined) FROM 'new_users.csv' CSV HEADER
Terminal Output
shop=> \copy (SELECT id, name, email, age, city FROM users ORDER BY id) TO 'users.csv' CSV HEADER COPY 5 shop=> \! cat users.csv id,name,email,age,city 1,Alice,alice@example.com,34,London 2,Bob,bob@example.com,28,Lisbon 3,Carol,carol@example.com,45,London 4,Dave,,19,Oslo 5,Erin,erin@example.com,53,Rome shop=> \copy orders TO 'orders.csv' CSV HEADER COPY 7
Restore test: once a month, restore last night's dump into a scratch database and run a count(*) on your biggest tables. It takes two minutes and tells you the backup really works.
PostgreSQL Knowledge Quiz
10 questions — click an option to answer
0 / 10 answered
Score: 0 / 0
0/10
0
Correct
0
Incorrect
0%
Score