DBDBMS

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
FamilyStatementsNote
DDL — definitionCREATE, ALTER, DROP, TRUNCATE, RENAMEChanges 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 — manipulationINSERT, UPDATE, DELETE, SELECT, MERGEChanges data. Fully transactional.
DCL — controlGRANT, REVOKEPermissions
TCL — transactionsCOMMIT, ROLLBACK, SAVEPOINTTransaction 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 NUMERICnever 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.

JoinReturnsUse for
INNEROnly rows matching on both sidesThe 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."
RIGHTMirror of LEFTRarely — flip the table order and use LEFT for readability
FULL OUTERAll rows from both sidesReconciliation — "what's in one system but not the other"
CROSSEvery combination (n × m)Generating a grid: all dates × all products
SELFA table joined to itselfHierarchies — employee to manager
ANTI-JOINLeft rows with no match"Customers who never ordered" — via NOT EXISTS or LEFT JOIN … WHERE right IS NULL
SEMI-JOINLeft rows that have at least one match, without duplicating themWHERE 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.
FunctionBehaviour 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, MAXIgnore NULLs. So AVG divides by the count of non-NULLs, not the row count — a classic source of "wrong" averages
SUM over zero rowsReturns NULL, not 0. Wrap in COALESCE(SUM(x), 0).
STRING_AGG / GROUP_CONCATConcatenates 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
FormRunsNote
Scalar subqueryOnce, returns one valueWHERE salary > (SELECT AVG(salary) FROM employees)
UncorrelatedOnceIndependent of the outer query — the optimiser can evaluate it first
CorrelatedConceptually once per outer rowReferences an outer column. Often rewritten by the optimiser into a join, but can be genuinely slow.
Derived tableInline in FROMFROM (SELECT …) AS t
CTE (WITH)Named, readable, reusableSame power, far more legible. Can be recursive.
Lateral joinPer outer row, in FROMLATERAL / 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)
)
FunctionGivesNote
ROW_NUMBER()1, 2, 3, 4 — always distinctUse for deduplication and top-N-per-group
RANK()1, 2, 2, 4 — ties share, then gapCompetition ranking
DENSE_RANK()1, 2, 2, 3 — ties share, no gapThe RANK/DENSE_RANK difference is asked constantly
NTILE(n)Bucket numberQuartiles, deciles
LAG(col, n) / LEADA value from a previous/next rowPeriod-over-period change without a self-join
FIRST_VALUE / LAST_VALUEEdge of the windowLAST_VALUE needs an explicit frame or it returns the current row
SUM/AVG/COUNT … OVERRunning or windowed aggregateRunning 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
ViewMaterialised view
Stores dataNo — it's a stored query, expanded at query timeYes — the result is physically stored
FreshnessAlways currentAs of the last refresh
Read costThe full underlying query, every timeCheap — it's just a table read, and can be indexed
Use forSimplifying complex queries, restricting columns for access control, providing a stable interface over a changing schemaExpensive 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 proceduresForAgainst
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

TrapWhat happensFix
SELECT * in production codeBreaks when a column is added or reordered; reads columns you don't need, defeating covering indexesName your columns
DISTINCT as a bug fixHides accidental row multiplication from a join instead of fixing it — and makes the query slowFind the missing join condition
Deep OFFSETOFFSET 100000 scans and discards 100,000 rowsKeyset pagination: WHERE (created_at, id) < (?, ?)
Function on an indexed columnWHERE LOWER(email) = ? can't use the index on emailIndex the expression, or store a normalised column
Implicit type castsWHERE varchar_col = 123 may cast the column, disabling the indexMatch types exactly
Collation and caseMySQL'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 timestampsDuplicate or missing hours at DST changesTIMESTAMPTZ, store UTC instants
ORDER BY without a tiebreakerRows with equal sort keys come back in arbitrary, unstable order — so paginated results duplicate and skipAlways 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.