Block B · Topics 8–14
SQL
The part of this subject you'll be asked to produce, not
describe. Read the patterns, then write them by hand — the gap between recognising
a window function and writing one cold is the whole interview.
08
DDL, DML & constraints
DDL/DML/DCL/TCLconstraintstypes
| Family | Statements | Note |
| DDL — definition | CREATE, ALTER, DROP, TRUNCATE, RENAME | Changes structure. Historically auto-committing — in MySQL it still is, so you can't roll back a DDL change. Postgres DDL is transactional, which is a genuinely useful difference to know. |
| DML — manipulation | INSERT, UPDATE, DELETE, SELECT, MERGE | Changes data. Fully transactional. |
| DCL — control | GRANT, REVOKE | Permissions |
| TCL — transactions | COMMIT, ROLLBACK, SAVEPOINT | Transaction boundaries |
DELETE vs TRUNCATE vs DROP
DELETE is DML: removes rows one at a time, can have a
WHERE, fires row triggers, is logged per row (so it's slow on big
tables) and is fully rollback-able. TRUNCATE is DDL: removes all rows
by deallocating pages, no WHERE, doesn't fire row triggers, resets
identity sequences, and is dramatically faster. DROP removes the table
itself. This three-way comparison is asked constantly.
Constraints — let the database enforce it
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id)
ON DELETE RESTRICT,
order_no TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','paid','shipped','cancelled')),
total_cents BIGINT NOT NULL CHECK (total_cents >= 0),
currency CHAR(3) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
shipped_at TIMESTAMPTZ,
CHECK (shipped_at IS NULL OR shipped_at >= created_at)
);
-- Every constraint here removes a class of bug permanently. An
-- application-level check protects one code path; a CHECK constraint
-- protects every path, including the migration script someone runs at
-- 2am and the manual UPDATE in a psql session.
Type choices that come up
- Money as integer minor units (
total_cents BIGINT)
or NUMERIC — never FLOAT. Binary floating point
can't represent 0.10 exactly, and the error compounds.
TIMESTAMPTZ, not TIMESTAMP. Store
an absolute instant; convert to local time for display. Storing naive local times
is how you get duplicate or missing hours at DST transitions.
VARCHAR(n) vs TEXT: in Postgres
they perform identically, so the length is a constraint choice, not a performance
one. In MySQL it affects storage and index size.
- Prefer an enum table or a
CHECK over a bare
string column for status — otherwise you'll find "Pending",
"pending" and "PENDING" in production.
09
Joins
innerleftfullselfanti-join
Why joins are the core skill
Normalisation puts related facts in different tables, so almost every real query
recombines them. The two things interviewers test: choosing the right join type, and
predicting how many rows come out.
| Join | Returns | Use for |
| INNER | Only rows matching on both sides | The default. "Orders with their customer." |
| LEFT (OUTER) | All left rows; NULLs where the right has no match | "All customers, with their order count including zero." |
| RIGHT | Mirror of LEFT | Rarely — flip the table order and use LEFT for readability |
| FULL OUTER | All rows from both sides | Reconciliation — "what's in one system but not the other" |
| CROSS | Every combination (n × m) | Generating a grid: all dates × all products |
| SELF | A table joined to itself | Hierarchies — employee to manager |
| ANTI-JOIN | Left rows with no match | "Customers who never ordered" — via NOT EXISTS or LEFT JOIN … WHERE right IS NULL |
| SEMI-JOIN | Left rows that have at least one match, without duplicating them | WHERE EXISTS — avoids the row multiplication a plain join causes |
-- Customers with no orders. Two idioms; prefer the first.
SELECT c.* FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
SELECT c.* FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL; -- the "left join and test for NULL" trick
-- Self join: every employee with their manager's name
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
-- LEFT, not INNER — otherwise the CEO (no manager) disappears.
-- Forgetting that is the classic mistake in this question.
The two mistakes that produce silently wrong answers
1. A condition in WHERE instead of ON turns
your LEFT JOIN into an INNER JOIN.
-- BROKEN: rows with no 2026 order have status NULL, and
-- NULL = 'paid' is UNKNOWN, so those rows are filtered out.
FROM customers c LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid' -- ← now effectively an INNER JOIN
-- CORRECT: filter the right table as part of the join condition
FROM customers c LEFT JOIN orders o
ON o.customer_id = c.id AND o.status = 'paid'
2. Row multiplication. Joining a
customer to 5 orders and then to 3 addresses gives 15 rows, and
SUM(order_total) is now three times too big. The fix is to aggregate
each side separately (in subqueries or CTEs) before joining. This is one of the
most common real-world reporting bugs, and worth stating unprompted.
Row-count intuition: a join on a unique key gives at most one match per
row. A join on a non-unique column multiplies. CROSS JOIN of 1,000 ×
1,000 is a million rows — which is what an accidentally missing ON
clause produces, and why a query suddenly "hangs".
10
Aggregation
GROUP BYHAVINGexecution order
One diagram explains every error message
SQL is written in one order and executed in another. Almost every
"column does not exist" or "must appear in GROUP BY" error follows directly from
this list, and knowing it lets you answer those questions from first
principles.
── Written order ────────── ── EXECUTION order ─────────────────
SELECT … 1. FROM / JOIN build the row source
FROM … 2. WHERE filter individual rows
WHERE … 3. GROUP BY form groups
GROUP BY … 4. HAVING filter GROUPS
HAVING … 5. SELECT compute expressions, apply aliases
ORDER BY … 6. DISTINCT
LIMIT … 7. ORDER BY sort the result
8. LIMIT / OFFSET
Consequences that answer real questions:
· WHERE cannot use a SELECT alias — WHERE runs before SELECT exists.
(ORDER BY CAN, because it runs after. That asymmetry confuses people.)
· WHERE cannot use an aggregate — groups don't exist yet. Use HAVING.
· HAVING CAN use aggregates, because groups now exist.
· A non-aggregated column in SELECT must be in GROUP BY, because the
database cannot know which of the group's values you meant.
| Function | Behaviour with NULL |
COUNT(*) | Counts rows, including all-NULL ones |
COUNT(col) | Counts non-NULL values of that column — a real difference |
COUNT(DISTINCT col) | Distinct non-NULL values |
SUM, AVG, MIN, MAX | Ignore NULLs. So AVG divides by the count of non-NULLs, not the row count — a classic source of "wrong" averages |
SUM over zero rows | Returns NULL, not 0. Wrap in COALESCE(SUM(x), 0). |
STRING_AGG / GROUP_CONCAT | Concatenates group values |
-- Departments where the average salary exceeds 50,000,
-- counting only current employees, highest first.
SELECT d.name,
COUNT(*) AS headcount,
AVG(e.salary) AS avg_salary
FROM employees e
JOIN departments d ON d.id = e.department_id
WHERE e.active -- filters ROWS, before grouping
GROUP BY d.id, d.name -- group by the PK, select the name
HAVING AVG(e.salary) > 50000 -- filters GROUPS, after
ORDER BY avg_salary DESC -- alias works here, not in WHERE
LIMIT 10;
-- Conditional aggregation — a pattern worth having ready.
-- One pass, several answers, no self-joins:
SELECT customer_id,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'paid') AS paid,
SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) AS refunded
FROM orders GROUP BY customer_id;
-- FILTER is the standard form (Postgres); the CASE trick is portable.
11
Subqueries & CTEs
correlatedEXISTS vs INrecursive CTE
| Form | Runs | Note |
| Scalar subquery | Once, returns one value | WHERE salary > (SELECT AVG(salary) FROM employees) |
| Uncorrelated | Once | Independent of the outer query — the optimiser can evaluate it first |
| Correlated | Conceptually once per outer row | References an outer column. Often rewritten by the optimiser into a join, but can be genuinely slow. |
| Derived table | Inline in FROM | FROM (SELECT …) AS t |
CTE (WITH) | Named, readable, reusable | Same power, far more legible. Can be recursive. |
| Lateral join | Per outer row, in FROM | LATERAL / CROSS APPLY — the clean way to do "top 3 per group" |
NOT IN with NULLs — the trap
-- If the subquery returns ANY NULL, this returns NO ROWS. Ever.
SELECT * FROM customers
WHERE id NOT IN (SELECT customer_id FROM orders); -- nullable column!
-- Because `id NOT IN (1, 2, NULL)` expands to
-- id != 1 AND id != 2 AND id != NULL
-- and `id != NULL` is UNKNOWN, so the whole AND can never be TRUE.
-- Use NOT EXISTS, which is NULL-safe and usually faster:
SELECT * FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
General guidance: EXISTS can short-circuit
on the first match and is NULL-safe, so prefer it for existence tests.
IN is fine and readable for a small literal list.
CTEs, including recursive
-- Readable multi-step query. Each CTE is a named intermediate result.
WITH monthly AS (
SELECT date_trunc('month', created_at) AS month,
customer_id, SUM(total_cents) AS spend
FROM orders WHERE status = 'paid'
GROUP BY 1, 2
), ranked AS (
SELECT *, RANK() OVER (PARTITION BY month ORDER BY spend DESC) AS rk
FROM monthly
)
SELECT month, customer_id, spend FROM ranked WHERE rk <= 3;
-- RECURSIVE CTE: walk a hierarchy of unknown depth.
-- This is the standard answer to "find all reports under a manager".
WITH RECURSIVE subordinates AS (
-- anchor: the starting row
SELECT id, name, manager_id, 1 AS depth
FROM employees WHERE id = 42
UNION ALL
-- recursive term: joins back to the CTE itself
SELECT e.id, e.name, e.manager_id, s.depth + 1
FROM employees e
JOIN subordinates s ON e.manager_id = s.id
WHERE s.depth < 10 -- depth guard against cycles
)
SELECT * FROM subordinates;
-- Two things to say about recursive CTEs:
-- · UNION ALL not UNION — UNION deduplicates on every iteration, which
-- is slow and usually not what you want.
-- · Always bound the depth, or a cycle in the data loops forever.
CTE performance — one nuance worth knowing
In PostgreSQL before version 12, a CTE was an optimisation fence:
it was always materialised, so predicates couldn't be pushed into it and it could
be much slower than the equivalent subquery. From 12 onward simple CTEs are inlined
by default, and you can force either behaviour with
MATERIALIZED / NOT MATERIALIZED. Knowing this is a strong
signal that you've actually tuned queries.
12
Window functions
ROW_NUMBERRANKLAGrunning totals
Why they exist
GROUP BY collapses rows — you lose the detail. A window function
computes across a set of related rows while keeping every row. So
you can show each employee and their department average on the same line,
which is impossible with plain aggregation. This is the single highest-value SQL
topic for interviews, because it separates people who write SQL from people who
have read about it.
function() OVER (
PARTITION BY col -- reset the window per group (optional)
ORDER BY col -- order within the window (required for ranking/offset)
ROWS BETWEEN … -- the frame (optional)
)
| Function | Gives | Note |
ROW_NUMBER() | 1, 2, 3, 4 — always distinct | Use for deduplication and top-N-per-group |
RANK() | 1, 2, 2, 4 — ties share, then gap | Competition ranking |
DENSE_RANK() | 1, 2, 2, 3 — ties share, no gap | The RANK/DENSE_RANK difference is asked constantly |
NTILE(n) | Bucket number | Quartiles, deciles |
LAG(col, n) / LEAD | A value from a previous/next row | Period-over-period change without a self-join |
FIRST_VALUE / LAST_VALUE | Edge of the window | LAST_VALUE needs an explicit frame or it returns the current row |
SUM/AVG/COUNT … OVER | Running or windowed aggregate | Running totals, moving averages |
-- ── The single most-asked pattern: TOP N PER GROUP ──────────────
-- "The 3 highest-paid employees in each department"
WITH ranked AS (
SELECT name, department_id, salary,
ROW_NUMBER() OVER (PARTITION BY department_id
ORDER BY salary DESC) AS rn
FROM employees
)
SELECT * FROM ranked WHERE rn <= 3;
-- You cannot filter on a window function in WHERE — windows are computed
-- after WHERE — so it must be wrapped in a CTE or subquery. That
-- restriction is itself a favourite follow-up question.
-- ── Nth highest value (the classic "second highest salary") ─────
SELECT DISTINCT salary FROM (
SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rk
FROM employees) t
WHERE rk = 2;
-- DENSE_RANK, not ROW_NUMBER: if two people share the top salary,
-- the "second highest salary" should be the next distinct value.
-- ── Running total and moving average ───────────────────────────
SELECT day, revenue,
SUM(revenue) OVER (ORDER BY day) AS running_total,
AVG(revenue) OVER (ORDER BY day
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma7
FROM daily_revenue;
-- ── Change from the previous row ───────────────────────────────
SELECT month, revenue,
LAG(revenue) OVER (ORDER BY month) AS prev,
revenue - LAG(revenue) OVER (ORDER BY month) AS delta
FROM monthly_revenue;
-- ── Detail alongside its own group aggregate ───────────────────
SELECT name, department_id, salary,
AVG(salary) OVER (PARTITION BY department_id) AS dept_avg,
salary - AVG(salary) OVER (PARTITION BY department_id) AS vs_avg
FROM employees;
-- Impossible with GROUP BY without a self-join. This is the whole point.
-- ── Deduplication: keep the newest row per key ─────────────────
DELETE FROM events WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY event_key
ORDER BY created_at DESC) AS rn
FROM events) t
WHERE rn > 1);
The three facts to have ready
1. ROW_NUMBER always distinct;
RANK leaves gaps after ties; DENSE_RANK doesn't.
2. You can't filter a window function in WHERE —
wrap it in a CTE — because windows are computed after WHERE.
3. Windows keep every row; GROUP BY collapses them.
If you need both detail and aggregate, it's a window.
13
Views, triggers, procedures
materialised viewstriggersstored procedures
| View | Materialised view |
| Stores data | No — it's a stored query, expanded at query time | Yes — the result is physically stored |
| Freshness | Always current | As of the last refresh |
| Read cost | The full underlying query, every time | Cheap — it's just a table read, and can be indexed |
| Use for | Simplifying complex queries, restricting columns for access control, providing a stable interface over a changing schema | Expensive aggregations read far more often than they change — dashboards, reports |
Views are also the practical vehicle for logical data independence: split
a table in two and redefine the view, and queries against the view keep working. Note
a view is only updatable if the database can unambiguously map a row back to one base
row — so simple single-table views usually are, and anything with joins,
aggregates or DISTINCT usually isn't.
Triggers: know them, use them sparingly
A trigger fires automatically on INSERT/UPDATE/DELETE,
BEFORE or AFTER, per row or per statement. Legitimate
uses: maintaining an audit table, enforcing an invariant that a
CHECK can't express, keeping a derived column in sync.
Why to be cautious, and this is the answer they want: triggers are
invisible side effects. A developer reads the
INSERT and has no way to see that it also wrote three other tables.
They run inside your transaction, so they extend lock duration and can deadlock;
they can cascade (trigger fires trigger); they're hard to test and don't appear in
application stack traces. Prefer explicit application logic unless you need the
guarantee that it happens no matter which client did the write — which is
exactly when a trigger is right.
| Stored procedures | For | Against |
| One round trip instead of many — big win for chatty multi-statement logic. Logic enforced regardless of client. Precompiled plans. Can grant execute without granting table access. | Business logic outside version control and CI unless you work at it. Hard to test and debug. Database-specific, so it blocks migration. Scaling means scaling the database, which is the expensive tier. |
The balanced position: use procedures for data-intensive operations where moving
the logic to the data avoids many round trips, and keep business rules in the
application. Note the distinction that gets asked: a function
returns a value and can be used in a query; a procedure is invoked
for its effects and can manage transactions.
14
Correctness traps
NULLthree-valued logicinjectioncollation
Why this is its own topic
These produce queries that run successfully and return the wrong answer,
which is far worse than an error. NULL handling in particular is the most reliable
source of silently-wrong SQL, and interviewers use it precisely because it separates
careful from careless.
Three-valued logic
SQL has TRUE, FALSE and UNKNOWN. Any comparison with NULL is UNKNOWN,
and WHERE only keeps rows where the condition is TRUE.
NULL = NULL → UNKNOWN (not TRUE!)
NULL != NULL → UNKNOWN
NULL = 5 → UNKNOWN
NULL IS NULL → TRUE ← the only way to test for NULL
NULL IS NOT DISTINCT FROM NULL → TRUE (NULL-safe equality)
TRUE AND UNKNOWN → UNKNOWN
FALSE AND UNKNOWN → FALSE ← short-circuits
TRUE OR UNKNOWN → TRUE
NOT UNKNOWN → UNKNOWN
NULL + 5 → NULL -- arithmetic propagates
'abc' || NULL → NULL -- so does concatenation
COALESCE(a, b, 0) → first non-NULL ← the fix
NULLIF(a, 0) → NULL if a = 0 -- guards division by zero
Five NULL bugs that ship
WHERE status != 'cancelled' excludes rows where status
is NULL. Almost never what you meant. Write
WHERE status IS DISTINCT FROM 'cancelled' or add
OR status IS NULL.
NOT IN (subquery) returns nothing if the subquery yields any
NULL.
COUNT(col) silently ignores NULLs while
COUNT(*) doesn't — so two "counts" of the same table
disagree.
AVG(col) divides by the non-NULL count, so missing data
inflates the average rather than lowering it.
UNIQUE allows multiple NULLs, so a unique constraint doesn't
prevent duplicate "unknowns".
Other traps
| Trap | What happens | Fix |
SELECT * in production code | Breaks when a column is added or reordered; reads columns you don't need, defeating covering indexes | Name your columns |
DISTINCT as a bug fix | Hides accidental row multiplication from a join instead of fixing it — and makes the query slow | Find the missing join condition |
Deep OFFSET | OFFSET 100000 scans and discards 100,000 rows | Keyset pagination: WHERE (created_at, id) < (?, ?) |
| Function on an indexed column | WHERE LOWER(email) = ? can't use the index on email | Index the expression, or store a normalised column |
| Implicit type casts | WHERE varchar_col = 123 may cast the column, disabling the index | Match types exactly |
| Collation and case | MySQL's default collation is case-insensitive; Postgres is case-sensitive. The same query gives different results. | Be explicit; use CITEXT or a functional index |
| Timezone-naive timestamps | Duplicate or missing hours at DST changes | TIMESTAMPTZ, store UTC instants |
ORDER BY without a tiebreaker | Rows with equal sort keys come back in arbitrary, unstable order — so paginated results duplicate and skip | Always add a unique column as the final sort key |
SQL injection — and why concatenation is the only bug
-- VULNERABLE: the input becomes part of the query TEXT
query = "SELECT * FROM users WHERE email = '" + input + "'"
-- input: ' OR '1'='1 → returns every user
-- input: '; DROP TABLE users; -- → exactly what you fear
-- SAFE: parameterised. The driver sends the query and the values
-- SEPARATELY, so the value can never be parsed as SQL.
cursor.execute("SELECT * FROM users WHERE email = %s", (input,))
Escaping input is a weaker, error-prone fallback —
parameterisation is structural. Then add defence in depth: least-privilege database
users (the app doesn't need DROP), validate input, and never expose raw
database errors to users. Note that an ORM protects you only while you use its
parameter binding — raw string interpolation into a .raw() call is
just as vulnerable.