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.
blogs · jp-blogs.vercel.app/cheatsheets/series/sql-postgres
psql and data typesLesson 0 →
| Type | Use |
|---|---|
integer / bigint | Whole numbers (2.1 billion / 9 quintillion) |
numeric(8,2) | Exact decimals: money |
text, boolean | Text of any length, true/false/NULL |
timestamptz | One 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.
| Trap | Why | Fix |
|---|---|---|
city = NULL | Unknown, never true | IS NULL |
city <> 'Pune' | Drops NULL rows | OR city IS NULL |
NOT IN (…, NULL) | Returns no rows | NOT EXISTS |
BETWEEN on times | Misses the last day | >= a AND < b |
false AND NULLis false,true OR NULLis true, the rest stay unknown.WHEREkeeps only true.coalesce(a, b)first non-NULL.float8is approximate:0.1+0.2gives0.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 →
| Join | Keeps |
|---|---|
INNER JOIN | Only matching rows |
LEFT JOIN | All left rows, NULL on the right if no match |
CROSS JOIN | Every 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 JOINthenWHEREon a right column turns it into an inner join. Put right-table filters inON.
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), notcount(*). WHEREfilters rows,HAVINGfilters 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.
- FROM / JOIN, then WHERE, then GROUP BY and aggregates.
- HAVING, then SELECT (aliases born here), then DISTINCT.
- ORDER BY, then LIMIT.
Subqueries and CTEsLesson 3 →
- Scalar subquery: one row, one column. Two rows is an error, no row is NULL.
INandEXISTSplan the same. UseNOT EXISTS, neverNOT IN, with nullable data.- CTE used once is inlined (PostgreSQL 12+). Used twice it is computed once. Force with
MATERIALIZEDorNOT 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 →
| Function | On 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, solast_valuereturns 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. ACHECKpasses on NULL, so addNOT NULL. - Many-to-many needs a junction table with a composite key. Index the other direction.
| ON DELETE | Effect |
|---|---|
NO ACTION / RESTRICT | Delete refused (default) |
CASCADE | Children deleted. Only for parts of the parent |
SET NULL | Child column becomes NULL |
Keys, money, time, soft deleteLesson 4 →
| Key | Size | Note |
|---|---|---|
integer identity | 4 bytes | Runs out at 2.1 billion |
bigint identity | 8 bytes | Default choice |
uuid | 16 bytes | Unguessable, made anywhere, scatters the index |
- Use
GENERATED ALWAYS AS IDENTITY, notserial: it refuses manual ids. - Money:
numeric(p,s)or integer cents (price_cents).1999/100is19: use/ 100.0. timestamptzstores 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 at | Meaning |
|---|---|
cost=a..b | Startup and total, in arbitrary units |
rows= vs actual | 10x apart means stale statistics |
Rows Removed by Filter | Millions: an index would help |
Heap Fetches: 0 | Index only scan never touched the table |
| Scan | Wins when |
|---|---|
| Seq Scan | Most of the table, or a tiny table |
| Index Scan | Few rows, or rows already in order |
| Bitmap Heap Scan | Middle share spread over many pages |
| Index Only Scan | All 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)servesaanda AND b, notbalone. It makes(a)redundant. - Not used when: you need most rows,
WHERE id::text = ..orcol + 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 = 0inpg_stat_user_indexes.
Transactions and isolationLesson 6 →
BEGIN/COMMIT/ROLLBACK: all or nothing. After an error the transaction is aborted untilROLLBACK.SAVEPOINTundoes part.- Lost update: read, compute in the app, write. Fix: atomic
SET stock = stock - 4 WHERE stock >= 4, orSELECT ... FOR UPDATE. - Write skew (two doctors go off call) passes Repeatable Read. Serializable cancels one.
| Level | Snapshot | Still allows |
|---|---|---|
| Read Committed (default) | Each statement | Non-repeatable reads, lost updates |
| Repeatable Read | First statement | Write skew; may fail 40001 |
| Serializable | Plus conflict tracking | Nothing, 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(mostALTER TABLE) blocks even reads, and requests queue: one waitingALTERfroze a plainSELECTfor 2.2 s. Uselock_timeout.- Deadlock = opposite lock order. Lock the lower id first, keep transactions short. Detected after 1 s.
- Long or
idle in transactionsessions block vacuum and bloat tables. Setidle_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 →
| Operator | Meaning |
|---|---|
-> / ->> | JSON value / text (cast it) |
@> | Contains: attrs @> '{"brand":"Acme"}' |
? | Has key or string element |
@@ | Full-text match: search @@ websearch_to_tsquery(..) |
- GIN index on
jsonbtook 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 →
| Change | Cost on 2M rows |
|---|---|
| Add nullable column | 2 ms (catalog only) |
| Add column, constant default | 2.5 ms (PostgreSQL 11+) |
| Add column, volatile default | 3.8 s rewrite under ACCESS EXCLUSIVE |
SET NOT NULL directly | 354 ms scan, blocks all |
CREATE INDEX plain / CONCURRENTLY | Blocks writes / does not |
- Start with
SET lock_timeout = '500ms'and retry on failure. NOT NULL:ADD CONSTRAINT ... CHECK (x IS NOT NULL) NOT VALID,VALIDATE, thenSET NOT NULL.- Backfill in batches of about 100,000 rows, one transaction each.
CREATE INDEX CONCURRENTLY(not in a transaction). A failure leaves an INVALID index.- 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 →
| Question | Short 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.