Syntax leans Postgres (most expressive), with vendor differences called out where they bite. Standard
SQL:2016 covers ~80% of rows here. Where a row uses a vendor-specific extension (e.g.
JSONB, LATERAL,
INSERT … ON CONFLICT), the desc says so.
clients · psqlSetup
psql -h host -U user -d dbname
Postgres CLI.
\dt / \d table / \df / \du
Tables / columns / functions / users.
\timing on / \x auto
Query timing + expanded-column display.
EXPLAIN ANALYZE SELECT …
Runs the query, returns the plan + actual times.
mysql -h host -u user -p dbname
MySQL CLI.
sqlite3 path/to.db
SQLite REPL.
pgcli / mycli / litecli
Modern REPLs with auto-completion.
DBeaver / TablePlus / DataGrip
GUI clients. Use for ad-hoc work; commit migrations to SQL files.
read queriesSELECT
SELECT a, b FROM t WHERE p ORDER BY a LIMIT 20 OFFSET 40
Logical order: FROM → WHERE → GROUP → HAVING → SELECT → ORDER → LIMIT.
SELECT a AS alias / FROM t AS x
Aliases.
DISTINCT col
Drop duplicates. Cheap on indexed columns.
DISTINCT ON (col) col, other FROM t ORDER BY col, other
Postgres: first row per group.
CASE WHEN x > 0 THEN 'pos' ELSE 'neg' END
Inline conditional.
COALESCE(a, b, c)
First non-null value.
NULLIF(a, b)
Returns NULL if a = b.
IS NULL / IS NOT NULL
Use these — = NULL is always NULL.
IN (subquery) / EXISTS (subquery)
Use EXISTS for large dependent subqueries.
BETWEEN x AND y
Inclusive on both sides. Be wary on dates.
LIKE / ILIKE / SIMILAR TO
Pattern matching. ILIKE is case-insensitive (Postgres).
LIMIT N OFFSET M
Pagination. Keyset / cursor scales better than offset.
combining tablesJoins
INNER JOIN x ON x.id = y.x_id
Default. Drop rows with no match.
LEFT JOIN x ON …
Keep rows from the left; columns are NULL when no match.
RIGHT JOIN x ON …
Mirror of LEFT. Reorder the join instead — clearer.
FULL OUTER JOIN x ON …
Both sides; NULL where unmatched.
CROSS JOIN x
Cartesian product. Combine with a CTE for known small sets.
USING (id)
Short form when both sides have the same column name.
SELF JOIN: FROM t a JOIN t b ON …
Same table twice. Tree walks, comparisons.
LATERAL (SELECT …) sub
Postgres: subquery can reference outer columns. Top-N-per-group.
Anti-join: LEFT JOIN … WHERE x.id IS NULL
Rows with no match.
Semi-join: WHERE EXISTS (SELECT 1 FROM …)
Rows that match without duplicating.
group + summariseAggregation
SELECT a, COUNT(*) FROM t GROUP BY a
Standard count by group.
COUNT(*) / COUNT(col) / COUNT(DISTINCT col)
All / non-null / distinct non-null.
SUM, AVG, MIN, MAX
Numeric aggregates. AVG returns float / numeric.
STRING_AGG(col, ',' ORDER BY col) / GROUP_CONCAT
Concatenate per group. GROUP_CONCAT in MySQL.
ARRAY_AGG(col ORDER BY col)
Aggregate into an array (Postgres).
JSON_AGG / JSONB_AGG
Aggregate rows into JSON arrays.
HAVING count(*) > 1
Filter after grouping (post-aggregate predicate).
GROUP BY ROLLUP(a, b)
Subtotals at each level + grand total.
GROUP BY CUBE(a, b)
All combinations of grouping sets.
FILTER (WHERE p) inside aggregate
SUM(x) FILTER (WHERE y > 0). Postgres-standard.
per-row but row-awareWindow functions
ROW_NUMBER() OVER (ORDER BY x)
1, 2, 3 with stable ordering.
RANK() / DENSE_RANK()
Tie handling: gaps vs no-gaps.
PARTITION BY a, b
Reset numbering / aggregation per partition.
LAG(x, 1) / LEAD(x, 1)
Previous / next row in partition.
SUM(x) OVER (PARTITION BY a ORDER BY t ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
Running total.
NTILE(4) OVER (…)
Bucket rows into quartiles / deciles.
FIRST_VALUE / LAST_VALUE / NTH_VALUE
Boundary values within the window.
WINDOW w AS (PARTITION BY …)
Name a window and reuse: SUM(x) OVER w.
Top-N-per-group via QUALIFY (DuckDB / Snowflake) or subquery + rank
Postgres needs the subquery + rank filter pattern.
sql
-- Window functions: rank, partition, running totals.
SELECT
order_id,
customer_id,
created_at,
total,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
) AS recency_rank,
SUM(total) OVER (
PARTITION BY customer_id
ORDER BY created_at
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total,
LAG(total) OVER (PARTITION BY customer_id ORDER BY created_at) AS prev_total,
NTILE(4) OVER (ORDER BY total DESC) AS spend_quartile
FROM orders
WHERE status = 'shipped';
-- Common helpers:
-- ROW_NUMBER / RANK / DENSE_RANK / PERCENT_RANK
-- FIRST_VALUE / LAST_VALUE / NTH_VALUE
-- LEAD / LAG / NTILE
-- SUM / AVG / COUNT / MIN / MAX -- all work as window aggregates.
named subqueriesCTEs
WITH x AS (SELECT …) SELECT … FROM x
Named, single-use subquery. Improves readability.
WITH RECURSIVE descendants AS (…) SELECT …
Recursive walk for trees / graphs.
Chained CTEs
Stack WITH a AS …, b AS … — each can reference the previous.
WITH inserted AS (INSERT … RETURNING *) SELECT … — Postgres.
Cycle detection: WITH RECURSIVE … CYCLE id SET is_cycle USING path
Postgres 14+. Avoid infinite walks.
sql
-- Named subqueries (CTEs)
WITH recent_orders AS (
SELECT *
FROM orders
WHERE created_at >= NOW() - INTERVAL '30 days'
),
totals AS (
SELECT customer_id, SUM(total) AS spent
FROM recent_orders
GROUP BY customer_id
)
SELECT c.id, c.name, t.spent
FROM customers c
JOIN totals t ON t.customer_id = c.id
WHERE t.spent > 1000;
-- Recursive CTE: walk a tree / graph.
WITH RECURSIVE descendants AS (
SELECT id, parent_id, name, 0 AS depth
FROM categories
WHERE id = 1
UNION ALL
SELECT c.id, c.parent_id, c.name, d.depth + 1
FROM categories c
JOIN descendants d ON c.parent_id = d.id
)
SELECT * FROM descendants
ORDER BY depth, name;
scalar & correlatedSubqueries
WHERE x = (SELECT …)
Scalar subquery. Must return one row, one column.
WHERE x IN (SELECT …)
Set membership.
WHERE EXISTS (SELECT 1 FROM … WHERE x.id = o.id)
Correlated subquery. Often faster than IN on large sets.
SELECT a, (SELECT MAX(x) FROM t2 WHERE t2.a_id = a.id) FROM a
Per-row scalar subquery. Easy to read; risk of N+1.
FROM (SELECT …) sub
Derived table. CTEs are usually clearer.
Decorrelation
Rewrite correlated subqueries as joins when performance matters.
schema changesDDL
CREATE TABLE t (…)
Use NOT NULL + defaults aggressively. NULL costs you later.
CREATE TABLE t (LIKE source INCLUDING ALL)
Copy structure (Postgres).
CREATE TABLE t AS SELECT … / CTAS
Materialise a query result into a new table.
ALTER TABLE t ADD COLUMN x TEXT
Add column. Defaults can lock the table; in Postgres 11+ adding NULL default is free.
ALTER TABLE t ALTER COLUMN x SET NOT NULL
Add NOT NULL constraint. Backfill first.
ALTER TABLE t RENAME COLUMN old TO new
Rename. Watch app code + views.
CREATE INDEX i ON t (a, b)
Composite index. Order matters (leftmost prefix).
CREATE INDEX CONCURRENTLY
Postgres: build without blocking writes. Always use in prod.
CREATE UNIQUE INDEX i ON t (lower(email))
Functional / expression index.
CREATE INDEX i ON t (status) WHERE status = 'pending'
Partial index. Small, focused.
DROP TABLE t / TRUNCATE t
Delete table / fast-clear rows.
CHECK (…) / FOREIGN KEY (…) ON DELETE CASCADE
Declare data invariants in the schema.
GENERATED ALWAYS AS (a + b) STORED
Computed columns.
sql
-- Tables, constraints, indexes (Postgres-leaning syntax).
CREATE TABLE customers (
id BIGSERIAL PRIMARY KEY,
email CITEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (status IN ('pending','shipped','cancelled')),
total NUMERIC(12, 2) NOT NULL CHECK (total >= 0),
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (customer_id, id)
);
-- Indexes
CREATE INDEX orders_customer_status_idx
ON orders (customer_id, status, created_at DESC);
-- Partial index — only the rows you actually query for.
CREATE INDEX orders_pending_idx
ON orders (created_at)
WHERE status = 'pending';
-- Add a column without downtime.
ALTER TABLE orders ADD COLUMN shipped_at TIMESTAMPTZ;
ALTER TABLE orders RENAME COLUMN total TO amount_total;
Leftmost-prefix rule. (a, b) helps WHERE a=… but not WHERE b=….
Covering / include columns
CREATE INDEX … (a) INCLUDE (b). Index-only scans.
Partial
Only the hot rows: WHERE deleted = false.
Functional
Index on expression: lower(email), date_trunc('day', ts).
EXPLAIN (ANALYZE, BUFFERS)
Postgres: actual time + buffer reads. Read top-down.
pg_stat_statements / sys.dm_exec_query_stats
Find the slow + frequent queries.
ACID & isolationTransactions
BEGIN; … COMMIT; / ROLLBACK;
Explicit transaction.
SAVEPOINT s; … ROLLBACK TO s;
Nested partial rollback.
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ
Per-transaction isolation override.
READ COMMITTED (default)
Each statement sees data committed before it started.
REPEATABLE READ
Snapshot at first statement; same view through the txn.
SERIALIZABLE
As if transactions ran one at a time. Postgres uses SSI (predicate locking).
SELECT … FOR UPDATE
Row-level lock. Hold until COMMIT.
SELECT … FOR UPDATE SKIP LOCKED
Queue / work-stealing patterns. Skip rows others have locked.
Idempotent writes
Use natural unique keys + ON CONFLICT DO NOTHING for safe retries.
insert · update · upsertWrites
INSERT INTO t (a, b) VALUES (…), (…)
Multi-row insert.
INSERT INTO t SELECT … FROM source
Insert-from-select.
INSERT … RETURNING id, created_at
Read what you wrote (Postgres / SQLite).
INSERT … ON CONFLICT (col) DO UPDATE SET …
Preferred Postgres / SQLite upsert.
INSERT … ON DUPLICATE KEY UPDATE
MySQL upsert. Triggers on any unique-key conflict.
UPDATE t SET x = y FROM other WHERE …
Joined update (Postgres).
DELETE FROM t WHERE … RETURNING *
Delete + return removed rows.
MERGE INTO target USING src ON … WHEN MATCHED … WHEN NOT MATCHED …
SQL-standard upsert. Postgres 15+, MySQL 8, all the big DBs.
sql
-- Postgres: INSERT ... ON CONFLICT (the "upsert").
INSERT INTO inventory (sku, qty)
VALUES ('ABC-1', 10)
ON CONFLICT (sku) DO UPDATE
SET qty = inventory.qty + EXCLUDED.qty,
updated_at = NOW();
-- INSERT IGNORE-like behaviour: skip duplicates.
INSERT INTO inventory (sku, qty)
VALUES ('ABC-1', 10)
ON CONFLICT (sku) DO NOTHING;
-- RETURNING — read what you wrote in one round trip.
INSERT INTO orders (customer_id, total)
VALUES (42, 120.00)
RETURNING id, created_at;
-- UPDATE ... FROM (Postgres) for joined updates.
UPDATE orders o
SET status = 'cancelled'
FROM refunds r
WHERE r.order_id = o.id
AND r.requested_at >= NOW() - INTERVAL '7 days';
-- MySQL upsert: INSERT ... ON DUPLICATE KEY UPDATE.
-- SQLite upsert: INSERT ... ON CONFLICT (same syntax as Postgres).
make it fastPerformance
EXPLAIN ANALYZE
Run + return the plan with actual times.
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
Postgres: include I/O + buffer reads.
Seq Scan vs Index Scan vs Index-Only Scan
Three core access methods. Index-Only is usually best.
Nested Loop vs Hash Join vs Merge Join
Pick changes with row counts + indexes.
ANALYZE / VACUUM ANALYZE
Refresh statistics so the planner picks good plans.
pg_stat_statements
Top-by-total-time queries. Source of optimisation work.
Avoid SELECT *
Fewer columns = less I/O + index-only scans possible.
Avoid functions on indexed columns in WHERE
WHERE lower(email) = … can’t use a plain email index; build a functional index.
Use LIMIT in correlated subqueries
Otherwise the planner pessimistically scans everything.
Partition huge tables
Range / list / hash partitioning. Postgres native, MySQL too.
joins + CTE + windowEnd-to-end · Top customer per month
Aggregate, rank, filter, join — the structure of most real analytics queries. Swap the columns and it’s
your reporting query.
sql
-- "Top customer by month" — joins + window + CTE in one.
WITH monthly AS (
SELECT
DATE_TRUNC('month', created_at)::DATE AS month,
customer_id,
SUM(total) AS spent
FROM orders
WHERE status = 'shipped'
AND created_at >= NOW() - INTERVAL '12 months'
GROUP BY 1, 2
),
ranked AS (
SELECT
month,
customer_id,
spent,
ROW_NUMBER() OVER (PARTITION BY month ORDER BY spent DESC) AS rank
FROM monthly
)
SELECT
r.month,
c.name AS top_customer,
r.spent
FROM ranked r
JOIN customers c ON c.id = r.customer_id
WHERE r.rank = 1
ORDER BY r.month;
Best practiceGood to know
Default to NOT NULL.
Every nullable column is a future CASE WHEN x IS NULL THEN … in someone’s
query. Only allow NULL when “unknown” is a meaningful state.
Always CREATE INDEX CONCURRENTLY in production (Postgres).
Plain CREATE INDEX takes an ACCESS EXCLUSIVE
lock — writes block. The concurrent version is slower but online.
Read query plans top-down.
Indented nodes feed their parent. The bottom-most nodes are the access methods; the top is what you
asked for. Time + row counts at each node tell you where the cost is.
Common trapsWatch out for
NULL = NULL is NULL, not TRUE.
Use IS NULL. NOT IN (subquery with NULLs) can
return zero rows by surprise — use NOT EXISTS.
Implicit casts hide bugs.WHERE id = '42' against a BIGINT column
may or may not use the index, depending on engine. Cast in code, not in the WHERE clause.
OFFSET pagination grows linearly.LIMIT 50 OFFSET 100000 reads 100050 rows. Switch to keyset pagination
(WHERE id > ?) on big tables.
SQL (Structured Query Language) is the standard language for querying and manipulating relational databases. It is used by PostgreSQL, MySQL, SQLite, MariaDB, SQL Server, Oracle, Snowflake, BigQuery, and DuckDB — among many others. Most databases follow ANSI SQL with their own extensions for features like window functions, JSON, and full-text search.
What is the difference between INNER JOIN, LEFT JOIN, and FULL OUTER JOIN?
INNER JOIN returns only rows where the join condition matches in both tables. LEFT JOIN returns all rows from the left table and matching rows from the right — unmatched right columns are NULL. RIGHT JOIN is the mirror image. FULL OUTER JOIN returns all rows from both tables, with NULLs for non-matching sides. Use LEFT JOIN most of the time — it is the most intuitive.
What are SQL window functions?
Window functions compute a value across a set of rows related to the current row without collapsing them into a group. Use OVER (PARTITION BY col ORDER BY col) to define the window. ROW_NUMBER() assigns a unique row number, RANK() and DENSE_RANK() handle ties, LAG/LEAD access adjacent rows, and SUM/AVG compute running totals. They require SQL 2003 or later.
What is a CTE in SQL?
A Common Table Expression (WITH name AS (...) SELECT ...) is a named temporary result set scoped to the current query. CTEs make complex queries readable by breaking them into named steps. Recursive CTEs (WITH RECURSIVE) can traverse hierarchical data like org charts or file trees. Most databases have supported CTEs since SQL 2005.
How do SQL indexes work?
An index is a data structure (usually a B-tree) that lets the database find rows matching a WHERE or JOIN condition without scanning every row. Create with CREATE INDEX idx_name ON table(col). Indexes speed up reads but slow down writes and use extra storage. Add indexes on columns used in WHERE, JOIN ON, and ORDER BY clauses. Use EXPLAIN to see if the query planner uses them.
What are SQL transactions and what is ACID?
A transaction groups multiple SQL statements into an atomic unit: BEGIN; UPDATE ...; UPDATE ...; COMMIT. If any statement fails, ROLLBACK undoes all changes. ACID stands for Atomicity (all-or-nothing), Consistency (constraints hold), Isolation (concurrent transactions do not interfere), and Durability (committed data survives crashes). Use transactions for any multi-step operation that must not be partially applied.