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... | SQL | Lesson |
|---|---|---|
| See a few rows to learn a table | SELECT * FROM sales LIMIT 20; | Everyday SQL |
| Filter rows | WHERE site_id = 'S-014' AND sold_at >= '2026-10-01' | Everyday SQL |
| Match one of several values | WHERE status IN ('paid', 'packed') | Everyday SQL |
| Find missing values | WHERE phone IS NULL (never = NULL) | Everyday SQL |
| Search text, ignoring case | WHERE name ILIKE '%rice%' (Postgres only) | Everyday SQL |
| Total per group | SELECT site_id, SUM(amount_cents) FROM sales GROUP BY site_id; | Everyday SQL |
| Keep only big groups | GROUP BY site_id HAVING COUNT(*) > 1000 | Everyday SQL |
| Count distinct customers | COUNT(DISTINCT customer_id) | Everyday SQL |
| Group by day | date_trunc('day', sold_at) | Everyday SQL |
| Replace NULL with a default | COALESCE(discount_cents, 0) | Everyday SQL |
| Avoid divide-by-zero | cut_lines * 1.0 / NULLIF(total_lines, 0) | Everyday SQL |
| Label rows by a rule | CASE WHEN amount_cents >= 5000 THEN 'big' ELSE 'small' END | Everyday SQL |
| Count only some rows inside one query | COUNT(*) FILTER (WHERE status = 'cut') (Postgres) | Everyday SQL |
| Top 10 by revenue | ORDER BY revenue DESC LIMIT 10 | Everyday SQL |
| Name a step to keep a query readable | WITH daily AS (...) SELECT ... FROM daily | Everyday SQL |
| Rows in A with no match in B | WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.id = a.id) | Everyday SQL |
| Insert, or update if the key exists | INSERT ... ON CONFLICT (site_id, local_txn_id) DO NOTHING or DO UPDATE SET ... | Transactions |
| Get the new row back after insert | INSERT ... RETURNING id | Everyday 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).
| Join | Keeps | Use it for |
|---|---|---|
INNER JOIN | Only rows with a match on both sides | Sales with their article names |
LEFT JOIN | Every left row; NULLs where the right has no match | Every store, even ones with no sales today |
FULL JOIN | Every row from both sides | Reconciling two lists, such as till totals against bank deposits |
CROSS JOIN | Every combination | A store-by-day grid to fill gaps with zeros |
| Self join | A table joined to itself under two aliases | Comparing each week with the week before |
LEFT JOIN ... WHERE b.id IS NULL | Left 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... | SQL | Lesson |
|---|---|---|
| Number rows per store, newest first | ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY sold_at DESC) | Everyday SQL |
| Keep the latest row per key | Wrap the query above and keep rn = 1; in Postgres also SELECT DISTINCT ON (site_id) ... ORDER BY site_id, sold_at DESC | Everyday SQL |
| Rank with ties sharing a place | RANK() (gaps after ties) or DENSE_RANK() (no gaps) | Everyday SQL |
| Running total | SUM(amount_cents) OVER (PARTITION BY site_id ORDER BY day) | Everyday SQL |
| Compare with the previous row | LAG(revenue) OVER (PARTITION BY site_id ORDER BY day); LEAD for the next | Everyday SQL |
| 7-day moving average | AVG(revenue) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) | Everyday SQL |
| Share of the store's total | amount_cents * 1.0 / SUM(amount_cents) OVER (PARTITION BY site_id) | Everyday SQL |
| Split into four equal buckets | NTILE(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 IDENTITYor a UUID; keep natural keys such as(site_id, local_txn_id)as aUNIQUEconstraint. - Store money as
bigintcents (ornumeric), neverfloatorreal. - 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 shape | Index | Lesson |
|---|---|---|
| Equality or range on one column | B-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 table | Covering: (site_id, sold_at) INCLUDE (amount_cents) (Postgres 11+) | Indexes |
| Only a small slice is ever queried | Partial: ... WHERE status = 'pending' | Indexes |
| Search on a function of a column | Expression: CREATE INDEX ON customer (lower(email)); | Indexes |
| JSONB keys, arrays, full-text search | GIN | Indexes |
| Huge append-only table, ranges on time | BRIN, tiny and good when rows arrive in time order | Indexes, Partitioning |
| Business rule "only one per key" | CREATE UNIQUE INDEX or a UNIQUE constraint | Indexes |
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 see | It means | Try |
|---|---|---|
Seq Scan on a big table with a selective filter | No usable index | An index on the filter columns |
Index Scan or Index Only Scan | The index is used | Nothing; Index Only Scan is the best case |
rows=10 estimated, actual rows=200000 | Stale or missing statistics | ANALYZE sales;, then plan again |
Sort Method: external merge Disk | The sort spilled to disk | An index in sort order, or more work_mem for this session |
Nested Loop with a huge loop count | The inner side runs once per outer row | An index on the inner join column |
Lots of shared read in BUFFERS | Pages came from disk, not the cache | Read fewer rows; check the index |
| The same small query thousands of times in the app log | An N+1 from the ORM | One 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).
| Problem | Pattern | Lesson |
|---|---|---|
| Two people sell the last unit | Atomic 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 row | SELECT ... FOR UPDATE inside the transaction | Transactions |
| Edits from two screens overwrite each other | A version column: UPDATE ... WHERE id = $1 AND version = $2 | Transactions |
| Many workers taking jobs from one table | SELECT ... LIMIT 10 FOR UPDATE SKIP LOCKED | Transactions |
| A retry creates a duplicate | Unique key plus ON CONFLICT DO NOTHING | Transactions, POS sales sync |
| Deadlock detected | Lock rows in the same order everywhere, for example by id | Transactions |
| Must behave as if run one at a time | SET TRANSACTION ISOLATION LEVEL SERIALIZABLE and retry on serialization failures | Transactions |
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).
| Change | Safe way | Lesson |
|---|---|---|
| Any DDL on a hot table | SET lock_timeout = '3s'; first, and retry if it times out | Safe migrations |
| Add an index | CREATE INDEX CONCURRENTLY, alone, outside a transaction | Safe migrations |
| Add a column | Nullable, or with a constant default (fast since Postgres 11) | Safe migrations |
| Add NOT NULL to an existing column | Add CHECK (col IS NOT NULL) NOT VALID, then VALIDATE CONSTRAINT, then SET NOT NULL (Postgres 12+ uses the check) | Safe migrations |
| Add a foreign key | ADD CONSTRAINT ... NOT VALID, then VALIDATE CONSTRAINT later | Safe migrations |
| Rename a column | Add the new one, write both, backfill in batches, read the new one, drop the old | Safe migrations |
| Backfill millions of rows | Batches of a few thousand by id range, with a pause between | Safe 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).
-- 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 this | Lesson |
|---|---|---|
| Find the queries that use the most time overall | pg_stat_statements, ordered by total_exec_time (column name in Postgres 13+) | Slow queries |
| Log every query slower than 500 ms | log_min_duration_statement = 500 | Slow queries |
| See table and index sizes | pg_size_pretty(pg_total_relation_size('sales')) | Postgres internals |
| Find tables with many dead rows | pg_stat_user_tables: n_dead_tup, last_autovacuum | Postgres internals, Postgres ops |
| Find unused indexes | pg_stat_user_indexes where idx_scan = 0, checked on every replica too | Slow queries |
| Check replica lag | On the primary: pg_stat_replication (replay_lag) | Failover, Replication |
| Stop runaway queries and idle transactions | statement_timeout and idle_in_transaction_session_timeout per role | Slow queries |
| Fix "too many connections" | PgBouncer in transaction mode; smaller app pools | Postgres ops |
| Check transaction ID wraparound risk | SELECT datname, age(datfrozenxid) FROM pg_database; and watch it trend | Postgres 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).
| Need | Tool | Lesson |
|---|---|---|
| Copy one database, logically | pg_dump -Fc -d mangrove -f mangrove.dump; restore with pg_restore -d target mangrove.dump | Restored backups |
| Copy roles and other cluster-wide objects | pg_dumpall --globals-only | Restored backups |
| Restore to any minute (PITR) | Base backup (pg_basebackup or a tool such as pgBackRest) plus archived WAL | Restored backups, Backups |
| Survive losing the primary | A streaming replica and a tested promotion runbook (pg_ctl promote or SELECT pg_promote();, Postgres 12+) | Failover |
| Avoid two primaries after failover | Fence the old primary before promoting; let a tool such as Patroni hold the leader lock | Failover |
| Upgrade a major version | pg_upgrade (with --link for speed) or logical replication for near-zero downtime; rehearse on a restored copy | Upgrades 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).
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).
| Command | Does |
|---|---|
\l, \c mangrove | List databases; connect to one |
\dt, \d sales | List tables; describe one, with its indexes |
\di, \du, \dn | List indexes, roles, schemas |
\x auto | Show wide rows vertically when they do not fit |
\timing on | Print how long each query took |
\e | Edit the last query in your editor |
\copy (SELECT ...) TO 'out.csv' CSV HEADER | Export a result to a file on your machine |
\watch 5 | Re-run the last query every 5 seconds |
\q | Quit |
Numbers worth remembering
Rough figures for planning, not promises (Estimation).
| What | Roughly | Lesson |
|---|---|---|
| Postgres page size | 8 kB | Postgres internals |
| B-tree depth for millions of rows | 3 or 4 levels | Indexes |
| Mangrove checkout lines a day | about 400,000 transactions, a few million lines | Partitioning |
| When to think about partitioning | A table of hundreds of millions of rows, or old data you must drop by month | Partitioning |
shared_buffers starting point | about 25% of RAM on a dedicated server | Postgres internals |
| Connections | each one is a process; hundreds hurt, so pool them | Postgres ops |
| Autovacuum trigger (default) | about 20% of a table's rows changed, plus 50 rows | Postgres ops |
Traps that catch everyone
NOT IN (subquery)returns no rows if the subquery has a single NULL. UseNOT EXISTS.COUNT(col)skips NULLs;COUNT(*)counts rows.AVGof an integer column in Postgres returnsnumeric, but5 / 2is2. Multiply by1.0before 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 JOINbecomes an inner join whenWHEREfilters on the right table. Put that filter in theONclause. UPDATEorDELETEwithoutWHEREchanges every row. Run it as aSELECTfirst, insideBEGIN.- 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,COUNTorAVG. 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
- SQL & Data Modeling: tables, everyday queries, indexes, plans, transactions and migrations.
- Databases at Scale: partitioning, replication, sharding, backups and running Postgres.
- Database Administration with PostgreSQL: internals, restores, failover, slow queries, security and upgrades.
- Data Analysis with Excel, Python & AI: turning query results into answers.
What to read next
- 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.