Cheat sheet · SQL and PostgreSQL From Zero

SQL & PostgreSQL cheat sheet

Everyday PostgreSQL, from SELECT and joins to window functions, schema design, indexes, transactions and safe migrations. SQL says what you want, the planner picks how. NULL means unknown.

Read the full series →

blogs · jp-blogs.vercel.app/cheatsheets/series/sql-postgres

psql and data typesLesson 0 →

TypeUse
integer / bigintWhole numbers (2.1 billion / 9 quintillion)
numeric(8,2)Exact decimals: money
text, booleanText of any length, true/false/NULL
timestamptzOne exact moment. Prefer it to timestamp

SELECT, NULL and pagingLesson 0 →

SELECT name, price FROM products
WHERE category = 'bags' AND stock > 0
ORDER BY price DESC LIMIT 2 OFFSET 2;

-- keyset paging: fast at any depth
WHERE id > :last_id ORDER BY id LIMIT 20;

No ORDER BY means no promised order. Text uses single quotes.

TrapWhyFix
city = NULLUnknown, never trueIS NULL
city <> 'Pune'Drops NULL rowsOR city IS NULL
NOT IN (…, NULL)Returns no rowsNOT EXISTS
BETWEEN on timesMisses the last day>= a AND < b
  • false AND NULL is false, true OR NULL is true, the rest stay unknown. WHERE keeps only true.
  • coalesce(a, b) first non-NULL. float8 is approximate: 0.1+0.2 gives 0.30000000000000004.

Changing dataLesson 0 →

INSERT INTO customers (name, email)
  VALUES ('Gus', 'g@x.com') RETURNING id;
UPDATE products SET stock = stock - 1
  WHERE name = 'Pen' RETURNING stock;
INSERT ... ON CONFLICT (email) DO NOTHING;

Watch out: UPDATE or DELETE with no WHERE hits every row. Run the same WHERE as a SELECT first. Failed inserts still use identity numbers, so ids have gaps.

JoinsLesson 1 →

JoinKeeps
INNER JOINOnly matching rows
LEFT JOINAll left rows, NULL on the right if no match
CROSS JOINEvery pair. A forgotten ON does this
-- customers with no orders
SELECT c.name FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o
  WHERE o.customer_id = c.id);
-- self join: referrer name
FROM customers c
LEFT JOIN customers r
  ON r.id = c.referred_by
  • A join gives one row per matching pair. A one-to-many join repeats the one side, so summing it double counts.
  • LEFT JOIN then WHERE on a right column turns it into an inner join. Put right-table filters in ON.

Aggregates and GROUP BYLesson 2 →

  • count(*) rows. count(col) non-NULL. count(DISTINCT col) different values. Other aggregates ignore NULL.
  • After a LEFT JOIN, count a right-table column: count(o.id), not count(*).
  • WHERE filters rows, HAVING filters groups. Every selected column is grouped or aggregated.
SELECT customer_id, count(*) AS orders,
  count(*) FILTER (WHERE status='paid')
    AS paid
FROM orders GROUP BY customer_id
HAVING count(*) >= 2;
-- monthly report
GROUP BY date_trunc('month', ordered_at)

ROLLUP (a, b) adds subtotals. generate_series plus LEFT JOIN fills empty months.

  1. FROM / JOIN, then WHERE, then GROUP BY and aggregates.
  2. HAVING, then SELECT (aliases born here), then DISTINCT.
  3. ORDER BY, then LIMIT.

Subqueries and CTEsLesson 3 →

  • Scalar subquery: one row, one column. Two rows is an error, no row is NULL.
  • IN and EXISTS plan the same. Use NOT EXISTS, never NOT IN, with nullable data.
  • CTE used once is inlined (PostgreSQL 12+). Used twice it is computed once. Force with MATERIALIZED or NOT MATERIALIZED.
WITH RECURSIVE tree AS (
  SELECT id, name, 0 AS depth
  FROM categories WHERE parent_id IS NULL
  UNION ALL
  SELECT c.id, c.name, t.depth + 1
  FROM categories c
  JOIN tree t ON c.parent_id = t.id
) SELECT * FROM tree;

Cycles loop forever: add CYCLE id SET is_cycle USING path (14+) or a depth limit.

Window functionsLesson 3 →

FunctionOn ties
row_number()Unique 1,2,3,4,5. Add a tie-breaker
rank()Same rank then a gap: 1,2,2,2,5
dense_rank()Same rank, no gap: 1,2,2,2,3
SELECT * FROM (
  SELECT customer_id, total,
    row_number() OVER (
      PARTITION BY customer_id
      ORDER BY total DESC) AS rn
  FROM ot) x WHERE rn <= 3;
-- running total, explicit frame
sum(total) OVER (ORDER BY id ROWS BETWEEN
  UNBOUNDED PRECEDING AND CURRENT ROW)
  • Windows keep every row. They are not allowed in WHERE: wrap in a subquery.
  • lag(x) / lead(x) read neighbours. Default frame includes ties, so last_value returns the current row.

Schema designLesson 4 →

  • 1NF one value per cell. 2NF every column depends on the whole key. 3NF on the key and nothing else.
  • Constraints: NOT NULL, UNIQUE, CHECK, PRIMARY KEY, FOREIGN KEY. A CHECK passes on NULL, so add NOT NULL.
  • Many-to-many needs a junction table with a composite key. Index the other direction.
ON DELETEEffect
NO ACTION / RESTRICTDelete refused (default)
CASCADEChildren deleted. Only for parts of the parent
SET NULLChild column becomes NULL

Keys, money, time, soft deleteLesson 4 →

KeySizeNote
integer identity4 bytesRuns out at 2.1 billion
bigint identity8 bytesDefault choice
uuid16 bytesUnguessable, made anywhere, scatters the index
  • Use GENERATED ALWAYS AS IDENTITY, not serial: it refuses manual ids.
  • Money: numeric(p,s) or integer cents (price_cents). 1999/100 is 19: use / 100.0.
  • timestamptz stores one moment (UTC) and shows it in the session zone. Pick one zone for reports.
  • Soft delete: partial unique index ON (lower(email)) WHERE deleted_at IS NULL, plus a view. Every query needs the filter.

Reading EXPLAINLesson 5 →

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders_big
WHERE customer_id = 777;

ANALYZE runs the statement: wrap writes in a transaction and roll back.

Look atMeaning
cost=a..bStartup and total, in arbitrary units
rows= vs actual10x apart means stale statistics
Rows Removed by FilterMillions: an index would help
Heap Fetches: 0Index only scan never touched the table
ScanWins when
Seq ScanMost of the table, or a tiny table
Index ScanFew rows, or rows already in order
Bitmap Heap ScanMiddle share spread over many pages
Index Only ScanAll columns in the index, after VACUUM

Index designLesson 5 →

-- equality columns first, then range
CREATE INDEX ON orders
  (customer_id, status);
-- partial: only the rows you query
CREATE INDEX ON orders (ordered_at)
  WHERE status = 'new';
-- covering
CREATE INDEX ON orders (customer_id)
  INCLUDE (amount_cents);
  • Index (a, b) serves a and a AND b, not b alone. It makes (a) redundant.
  • Not used when: you need most rows, WHERE id::text = .. or col + 0 (function on the column), LIKE '%x', table tiny, stale stats.
  • Every write pays: 4 extra indexes made a load 11 times slower. Check idx_scan = 0 in pg_stat_user_indexes.

Transactions and isolationLesson 6 →

  • BEGIN / COMMIT / ROLLBACK: all or nothing. After an error the transaction is aborted until ROLLBACK. SAVEPOINT undoes part.
  • Lost update: read, compute in the app, write. Fix: atomic SET stock = stock - 4 WHERE stock >= 4, or SELECT ... FOR UPDATE.
  • Write skew (two doctors go off call) passes Repeatable Read. Serializable cancels one.
LevelSnapshotStill allows
Read Committed (default)Each statementNon-repeatable reads, lost updates
Repeatable ReadFirst statementWrite skew; may fail 40001
SerializablePlus conflict trackingNothing, but you must retry

Watch out: Retry the whole transaction on 40001 (serialization) or 40P01 (deadlock). Limit tries, add jitter, no emails inside.

Locks, queues, idempotencyLesson 6 →

  • ACCESS EXCLUSIVE (most ALTER TABLE) blocks even reads, and requests queue: one waiting ALTER froze a plain SELECT for 2.2 s. Use lock_timeout.
  • Deadlock = opposite lock order. Lock the lower id first, keep transactions short. Detected after 1 s.
  • Long or idle in transaction sessions block vacuum and bloat tables. Set idle_in_transaction_session_timeout.
-- job queue: workers never block
SELECT id FROM jobs
WHERE status = 'queued' ORDER BY id
LIMIT 1 FOR UPDATE SKIP LOCKED;
-- idempotency key in the same transaction
INSERT INTO payments (idem_key, ...)
ON CONFLICT (idem_key) DO NOTHING;

NOWAIT errors at once instead of waiting.

JSONB, search, arrays, upsertLesson 7 →

OperatorMeaning
-> / ->>JSON value / text (cast it)
@>Contains: attrs @> '{"brand":"Acme"}'
?Has key or string element
@@Full-text match: search @@ websearch_to_tsquery(..)
  • GIN index on jsonb took a query from 173 ms to 16.7 ms. It serves @> and ?, not ->>. Planner has no stats per key.
  • Fields you filter, join or constrain belong in real columns. Arrays: @> and && use GIN, = ANY(tags) does not.
INSERT INTO stock_levels (product_id, qty)
VALUES (1, 5)
ON CONFLICT (product_id) DO UPDATE
  SET qty = stock_levels.qty
          + EXCLUDED.qty
RETURNING *;

Safe migrationsLesson 7 →

ChangeCost on 2M rows
Add nullable column2 ms (catalog only)
Add column, constant default2.5 ms (PostgreSQL 11+)
Add column, volatile default3.8 s rewrite under ACCESS EXCLUSIVE
SET NOT NULL directly354 ms scan, blocks all
CREATE INDEX plain / CONCURRENTLYBlocks writes / does not
  1. Start with SET lock_timeout = '500ms' and retry on failure.
  2. NOT NULL: ADD CONSTRAINT ... CHECK (x IS NOT NULL) NOT VALID, VALIDATE, then SET NOT NULL.
  3. Backfill in batches of about 100,000 rows, one transaction each.
  4. CREATE INDEX CONCURRENTLY (not in a transaction). A failure leaves an INVALID index.
  5. Rename or retype by expand and contract: add, sync, backfill, switch code, drop.

Tip: Test on production-sized data and read pg_locks. A 1,000-row dev table hides every problem here.

Interview one-linersLesson 0 →

QuestionShort answer
= NULL vs IS NULL?NULL is unknown, so = NULL is never true.
WHERE vs HAVING?Rows before grouping vs groups after.
IN vs EXISTS?Same for positive checks. NOT EXISTS is NULL-safe.
rank vs dense_rank?rank leaves gaps after ties.
Why is my index unused?Most rows, function on column, wrong column order, stale stats.

A cheat sheet is a reminder, not a teacher. Each card links to the lesson that explains it step by step.