1. Cheat sheets
  2. Data

8 minReference

SQL and databases cheat sheet

Everyday SQL in PostgreSQL on a few screens: the query for what you want, joins and window functions, indexes and query plans, safe migrations, and the commands a DBA reaches for when something is slow, locked or broken. Keep it open while you write queries or sit on call.

Query: "I want to..."

Examples use Mangrove Market's tables: sales (one row per till line), site (a store or distribution centre), article (a product or SKU) and app_order. Money is stored as integer cents, so totals never pick up rounding errors (Everyday SQL).

I want to...SQLLesson
See a few rows to learn a tableSELECT * FROM sales LIMIT 20;Everyday SQL
Filter rowsWHERE site_id = 'S-014' AND sold_at >= '2026-10-01'Everyday SQL
Match one of several valuesWHERE status IN ('paid', 'packed')Everyday SQL
Find missing valuesWHERE phone IS NULL (never = NULL)Everyday SQL
Search text, ignoring caseWHERE name ILIKE '%rice%' (Postgres only)Everyday SQL
Total per groupSELECT site_id, SUM(amount_cents) FROM sales GROUP BY site_id;Everyday SQL
Keep only big groupsGROUP BY site_id HAVING COUNT(*) > 1000Everyday SQL
Count distinct customersCOUNT(DISTINCT customer_id)Everyday SQL
Group by daydate_trunc('day', sold_at)Everyday SQL
Replace NULL with a defaultCOALESCE(discount_cents, 0)Everyday SQL
Avoid divide-by-zerocut_lines * 1.0 / NULLIF(total_lines, 0)Everyday SQL
Label rows by a ruleCASE WHEN amount_cents >= 5000 THEN 'big' ELSE 'small' ENDEveryday SQL
Count only some rows inside one queryCOUNT(*) FILTER (WHERE status = 'cut') (Postgres)Everyday SQL
Top 10 by revenueORDER BY revenue DESC LIMIT 10Everyday SQL
Name a step to keep a query readableWITH daily AS (...) SELECT ... FROM dailyEveryday SQL
Rows in A with no match in BWHERE NOT EXISTS (SELECT 1 FROM b WHERE b.id = a.id)Everyday SQL
Insert, or update if the key existsINSERT ... ON CONFLICT (site_id, local_txn_id) DO NOTHING or DO UPDATE SET ...Transactions
Get the new row back after insertINSERT ... RETURNING idEveryday SQL

Joins in one table

Most wrong totals come from a join that multiplies rows. Before you sum, check the row count of the join against the row count of the table you meant to count (Everyday SQL, Data modeling).

JoinKeepsUse it for
INNER JOINOnly rows with a match on both sidesSales with their article names
LEFT JOINEvery left row; NULLs where the right has no matchEvery store, even ones with no sales today
FULL JOINEvery row from both sidesReconciling two lists, such as till totals against bank deposits
CROSS JOINEvery combinationA store-by-day grid to fill gaps with zeros
Self joinA table joined to itself under two aliasesComparing each week with the week before
LEFT JOIN ... WHERE b.id IS NULLLeft rows with no match (an anti join)Articles that never sold

Window functions

A window function adds a value to every row without collapsing rows the way GROUP BY does. PARTITION BY says which rows belong together, ORDER BY says their order inside the window (Everyday SQL).

I want to...SQLLesson
Number rows per store, newest firstROW_NUMBER() OVER (PARTITION BY site_id ORDER BY sold_at DESC)Everyday SQL
Keep the latest row per keyWrap the query above and keep rn = 1; in Postgres also SELECT DISTINCT ON (site_id) ... ORDER BY site_id, sold_at DESCEveryday SQL
Rank with ties sharing a placeRANK() (gaps after ties) or DENSE_RANK() (no gaps)Everyday SQL
Running totalSUM(amount_cents) OVER (PARTITION BY site_id ORDER BY day)Everyday SQL
Compare with the previous rowLAG(revenue) OVER (PARTITION BY site_id ORDER BY day); LEAD for the nextEveryday SQL
7-day moving averageAVG(revenue) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)Everyday SQL
Share of the store's totalamount_cents * 1.0 / SUM(amount_cents) OVER (PARTITION BY site_id)Everyday SQL
Split into four equal bucketsNTILE(4) OVER (ORDER BY revenue)Everyday SQL

The order SQL runs in

You write SELECT first, but the database runs FROM and joins, then WHERE, GROUP BY, HAVING, window functions, SELECT, DISTINCT, ORDER BY and finally LIMIT. That is why a column alias from SELECT cannot be used in WHERE, and why you filter a window function's result in an outer query or CTE (Everyday SQL).

Modeling rules of thumb

Model the business first, then the screens (Data modeling).

  • Every table gets a primary key. Use bigint GENERATED ALWAYS AS IDENTITY or a UUID; keep natural keys such as (site_id, local_txn_id) as a UNIQUE constraint.
  • Store money as bigint cents (or numeric), never float or real.
  • Store times as timestamptz. Postgres stores it in UTC and converts on display.
  • One fact in one place. A many-to-many link (orders and articles) gets its own table, order_line.
  • Put rules in the database where you can: NOT NULL, CHECK (qty > 0), foreign keys. Every app and script then obeys them.
  • Index the foreign key columns you join or delete by; Postgres does not create those indexes for you.
  • Denormalize on purpose, for reports, and write down who keeps the copy in step.

Indexes: which one

An index speeds reads and costs a little on every write. Add one for a query you can name, then prove it with EXPLAIN ANALYZE (Indexes).

Query shapeIndexLesson
Equality or range on one columnB-tree (the default): CREATE INDEX ON sales (sold_at);Indexes
WHERE site_id = ? AND sold_at > ?Composite, equality column first: (site_id, sold_at)Indexes
Return columns without touching the tableCovering: (site_id, sold_at) INCLUDE (amount_cents) (Postgres 11+)Indexes
Only a small slice is ever queriedPartial: ... WHERE status = 'pending'Indexes
Search on a function of a columnExpression: CREATE INDEX ON customer (lower(email));Indexes
JSONB keys, arrays, full-text searchGINIndexes
Huge append-only table, ranges on timeBRIN, tiny and good when rows arrive in time orderIndexes, Partitioning
Business rule "only one per key"CREATE UNIQUE INDEX or a UNIQUE constraintIndexes

Reading a query plan

Run EXPLAIN (ANALYZE, BUFFERS) before the query. ANALYZE really executes it, so wrap writes in BEGIN; ... ROLLBACK;. Read the plan from the innermost node outwards (Query plans).

You seeIt meansTry
Seq Scan on a big table with a selective filterNo usable indexAn index on the filter columns
Index Scan or Index Only ScanThe index is usedNothing; Index Only Scan is the best case
rows=10 estimated, actual rows=200000Stale or missing statisticsANALYZE sales;, then plan again
Sort Method: external merge DiskThe sort spilled to diskAn index in sort order, or more work_mem for this session
Nested Loop with a huge loop countThe inner side runs once per outer rowAn index on the inner join column
Lots of shared read in BUFFERSPages came from disk, not the cacheRead fewer rows; check the index
The same small query thousands of times in the app logAn N+1 from the ORMOne query with a join or WHERE id = ANY($1)

Transactions and locks

Postgres runs at Read Committed unless you ask for more. Keep transactions short, never wait for a user or an HTTP call inside one (Transactions).

ProblemPatternLesson
Two people sell the last unitAtomic conditional update: UPDATE stock SET qty = qty - 1 WHERE article_id = $1 AND qty > 0 RETURNING qty;Transactions, No overselling
Read, decide, then write the same rowSELECT ... FOR UPDATE inside the transactionTransactions
Edits from two screens overwrite each otherA version column: UPDATE ... WHERE id = $1 AND version = $2Transactions
Many workers taking jobs from one tableSELECT ... LIMIT 10 FOR UPDATE SKIP LOCKEDTransactions
A retry creates a duplicateUnique key plus ON CONFLICT DO NOTHINGTransactions, POS sales sync
Deadlock detectedLock rows in the same order everywhere, for example by idTransactions
Must behave as if run one at a timeSET TRANSACTION ISOLATION LEVEL SERIALIZABLE and retry on serialization failuresTransactions

Safe migrations on a busy table

Change a live schema in small steps: expand, move data and code, contract. Each step can ship and roll back on its own (Safe migrations).

ChangeSafe wayLesson
Any DDL on a hot tableSET lock_timeout = '3s'; first, and retry if it times outSafe migrations
Add an indexCREATE INDEX CONCURRENTLY, alone, outside a transactionSafe migrations
Add a columnNullable, or with a constant default (fast since Postgres 11)Safe migrations
Add NOT NULL to an existing columnAdd CHECK (col IS NOT NULL) NOT VALID, then VALIDATE CONSTRAINT, then SET NOT NULL (Postgres 12+ uses the check)Safe migrations
Add a foreign keyADD CONSTRAINT ... NOT VALID, then VALIDATE CONSTRAINT laterSafe migrations
Rename a columnAdd the new one, write both, backfill in batches, read the new one, drop the oldSafe migrations
Backfill millions of rowsBatches of a few thousand by id range, with a pause betweenSafe migrations

DBA: "something is wrong right now"

Look before you kill. These read from the system views and are safe to run on a primary (Slow queries, Postgres internals).

SQL
-- What is running, and for how long
SELECT pid, now() - query_start AS runtime, state, wait_event_type, left(query, 80)
FROM pg_stat_activity
WHERE state <> 'idle'
ORDER BY runtime DESC;

-- Who is blocking whom
SELECT pid, pg_blocking_pids(pid) AS blocked_by, left(query, 80)
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;

-- Stop a query politely, then end its session if it will not stop
SELECT pg_cancel_backend(12345);
SELECT pg_terminate_backend(12345);
I want to...Do thisLesson
Find the queries that use the most time overallpg_stat_statements, ordered by total_exec_time (column name in Postgres 13+)Slow queries
Log every query slower than 500 mslog_min_duration_statement = 500Slow queries
See table and index sizespg_size_pretty(pg_total_relation_size('sales'))Postgres internals
Find tables with many dead rowspg_stat_user_tables: n_dead_tup, last_autovacuumPostgres internals, Postgres ops
Find unused indexespg_stat_user_indexes where idx_scan = 0, checked on every replica tooSlow queries
Check replica lagOn the primary: pg_stat_replication (replay_lag)Failover, Replication
Stop runaway queries and idle transactionsstatement_timeout and idle_in_transaction_session_timeout per roleSlow queries
Fix "too many connections"PgBouncer in transaction mode; smaller app poolsPostgres ops
Check transaction ID wraparound riskSELECT datname, age(datfrozenxid) FROM pg_database; and watch it trendPostgres internals

Backups, restores and failover

A backup you have never restored is a hope, not a backup. Schedule a restore test and time it (Restored backups, Backups).

NeedToolLesson
Copy one database, logicallypg_dump -Fc -d mangrove -f mangrove.dump; restore with pg_restore -d target mangrove.dumpRestored backups
Copy roles and other cluster-wide objectspg_dumpall --globals-onlyRestored backups
Restore to any minute (PITR)Base backup (pg_basebackup or a tool such as pgBackRest) plus archived WALRestored backups, Backups
Survive losing the primaryA streaming replica and a tested promotion runbook (pg_ctl promote or SELECT pg_promote();, Postgres 12+)Failover
Avoid two primaries after failoverFence the old primary before promoting; let a tool such as Patroni hold the leader lockFailover
Upgrade a major versionpg_upgrade (with --link for speed) or logical replication for near-zero downtime; rehearse on a restored copyUpgrades and on-call

Access and security

Give each app and person a role with the least it needs, and log in with those roles, never as the superuser (DB security).

SQL
CREATE ROLE reporting NOLOGIN;
GRANT USAGE ON SCHEMA public TO reporting;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO reporting;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO reporting;
CREATE ROLE tomas LOGIN IN ROLE reporting;   -- set the password with \password, not in a script

-- Row-level security: store managers see only their store
ALTER TABLE sales ENABLE ROW LEVEL SECURITY;
CREATE POLICY own_store ON sales
  USING (site_id = current_setting('app.site_id'));

Always pass user input as parameters ($1), never by gluing strings into SQL. Require TLS in pg_hba.conf (hostssl) and use scram-sha-256 passwords (DB security, Security).

psql shortcuts

Meta-commands start with a backslash and need no semicolon (Postgres internals).

CommandDoes
\l, \c mangroveList databases; connect to one
\dt, \d salesList tables; describe one, with its indexes
\di, \du, \dnList indexes, roles, schemas
\x autoShow wide rows vertically when they do not fit
\timing onPrint how long each query took
\eEdit the last query in your editor
\copy (SELECT ...) TO 'out.csv' CSV HEADERExport a result to a file on your machine
\watch 5Re-run the last query every 5 seconds
\qQuit

Numbers worth remembering

Rough figures for planning, not promises (Estimation).

WhatRoughlyLesson
Postgres page size8 kBPostgres internals
B-tree depth for millions of rows3 or 4 levelsIndexes
Mangrove checkout lines a dayabout 400,000 transactions, a few million linesPartitioning
When to think about partitioningA table of hundreds of millions of rows, or old data you must drop by monthPartitioning
shared_buffers starting pointabout 25% of RAM on a dedicated serverPostgres internals
Connectionseach one is a process; hundreds hurt, so pool themPostgres ops
Autovacuum trigger (default)about 20% of a table's rows changed, plus 50 rowsPostgres ops

Traps that catch everyone

  • NOT IN (subquery) returns no rows if the subquery has a single NULL. Use NOT EXISTS.
  • COUNT(col) skips NULLs; COUNT(*) counts rows.
  • AVG of an integer column in Postgres returns numeric, but 5 / 2 is 2. Multiply by 1.0 before dividing.
  • BETWEEN '2026-10-01' AND '2026-10-31' on a timestamp misses most of the 31st. Use >= '2026-10-01' AND < '2026-11-01'.
  • A LEFT JOIN becomes an inner join when WHERE filters on the right table. Put that filter in the ON clause.
  • UPDATE or DELETE without WHERE changes every row. Run it as a SELECT first, inside BEGIN.
  • Wrapping an indexed column in a function (WHERE date(sold_at) = ...) stops the index being used. Use a range or an expression index.

Glossary

Plain definitions, with the lesson that explains each one.

Aggregate
A function that turns many rows into one value, such as SUM, COUNT or AVG. Everyday SQL
Autovacuum
The background process that cleans dead row versions and refreshes statistics. Postgres ops
Cardinality
How many distinct values a column has, or how many rows a step returns; the planner's estimates depend on it. Query plans
CTE (common table expression)
A named subquery written with WITH, so a long query reads in steps. Everyday SQL
Constraint
A rule the database enforces on every write: primary key, foreign key, unique, check, not null. Data modeling
Covering index
An index that holds every column a query needs, so the table itself is not read. Indexes
Dead tuple
An old row version left behind by an update or delete, waiting for vacuum. Postgres internals
Deadlock
Two transactions each waiting for a lock the other holds; Postgres cancels one of them. Transactions
Failover
Promoting a replica to primary when the primary is lost. Failover
Foreign key
A column that must match a key in another table, so lines cannot point to orders that do not exist. Data modeling
Isolation level
How much concurrent transactions see of each other: Read Committed, Repeatable Read, Serializable. Transactions
Lock timeout
How long a statement waits for a lock before giving up, which stops a migration from queueing everyone behind it. Safe migrations
MVCC
Multi-version concurrency control: readers see a snapshot, so they do not block writers. Postgres internals
Normalization
Storing each fact once and referring to it by key. Data modeling
Partitioning
Splitting one table into child tables by a key such as month. Partitioning
PITR
Point-in-time recovery: restore a base backup, then replay WAL up to a chosen moment. Restored backups
Primary key
The column or columns that identify each row uniquely. Data modeling
Query planner
The part of the database that picks how to run a query, using table statistics. Query plans
Replica
A copy of the primary kept up to date from its WAL, used for reads and failover. Replication
Row-level security (RLS)
Policies that filter which rows each role may see or change. DB security
RPO and RTO
How much data you may lose, and how long you may be down. Restored backups
Schema migration
A versioned script that changes tables, run the same way in every environment. Safe migrations
Sharding
Splitting data across several database servers by a key. Sharding
Transaction
A group of statements that succeed or fail together. Transactions
WAL
Write-ahead log: every change written to a log before the data files, used for crash recovery, replicas and PITR. Postgres internals
Window function
A function computed over related rows while keeping every row, such as a running total. Everyday SQL

Courses on Kolom

  • The PostgreSQL documentation at postgresql.org/docs. Read the chapters on queries, indexes, concurrency control and routine maintenance.
  • Use The Index, Luke by Markus Winand, free at use-the-index-luke.com. How indexes work, query by query.
  • Modern SQL by Markus Winand at modern-sql.com. Window functions, CTEs and other newer features, with which databases support them.
  • The Art of PostgreSQL by Dimitri Fontaine. SQL as a programming language, with real datasets.
  • Designing Data-Intensive Applications by Martin Kleppmann. Replication, partitioning and transactions explained from first principles.