Back to the map
DataFoundation14h

SQL & data access

Getting the data out, correctly.

Most of the world's useful information sits in databases: tables of rows and columns, like very large spreadsheets that many programs share at once. SQL is the language you use to ask a database questions. You describe what you want — which columns, from which table, matching which conditions — and the database works out how to fetch it.

The data your AI feature needs almost always already exists in a company's database, and nobody is going to hand it to you in a tidy file. Being unable to get it yourself means waiting on someone else for every question you want to answer, which in practice means not answering most of them.

It is a foundation, so it sits under nearly everything: the data that trains a model, the documents that feed retrieval, and the numbers that tell you whether a launch worked all start as a query. It pairs with pandas, which does similar work once the data is on your machine.

You describe the result you want, not the steps to get it — that is the whole idea behind the language.

In more detailJoins, aggregations, window functions, and enough understanding of indexes to know why a query is slow. Most real AI features sit on top of an existing database, and most retrieval systems begin as a SQL query before anything is embedded.

Section 1 of 3, 6h

Getting the data out

By the end: You can write the queries an AI feature needs, and tell why a slow one is slow.

  • Select, filter, aggregate
  • Joins, and which one you need
  • Window functions
  • Indexes and why a query is slow

Before anything is embedded or sent to a model, the data has to come out of a database, and that usually means SQL. This section covers the queries an AI feature actually needs — filtering, aggregating, joining, ranking within a group — and then why a query that was instant on a test table crawls on a real one. The narrowing you do here decides what the rest of your pipeline ever gets to see.

What the question asks                        What you reach for
"for each X, how many / total"                GROUP BY X with COUNT or SUM
"only the X with more than N"                 HAVING, not WHERE
"bring in the customer's name"                INNER JOIN
"including the ones with none"                LEFT JOIN
"rank within each X", "the latest per X"      window function, PARTITION BY
"running total", "compared to last month"     window function, SUM OVER / LAG
"the ones that have no matching row"          LEFT JOIN ... WHERE right IS NULL,
                                              or NOT EXISTS
"why is this slow"                            EXPLAIN ANALYZE, then an index
Worth memorising. Recognising which of these you have been handed is most of the work.

Everything below runs against the same two small tables, so you can check the outputs by eye. customers has four rows; orders has six, one of which belongs to a customer id that does not exist, and one customer, Katherine, has never ordered. Those two awkward rows are deliberate: they are what makes the difference between the join types visible.

customers                        orders
id  name        country          id   customer_id  amount  status    created_at
1   Ada         UK               101  1            120.00  paid      2026-01-04
2   Grace       US               102  1             40.00  paid      2026-02-11
3   Alan        UK               103  2            300.00  paid      2026-01-19
4   Katherine   US               104  2             90.00  refunded  2026-03-02
                                 105  3             15.00  paid      2026-03-08
                                 106  9             55.00  paid      2026-03-09

Katherine has no orders. Order 106 has no customer.

The shape of a query, and the order it runs in#

SELECT   customer_id, COUNT(*) AS orders, SUM(amount) AS revenue
FROM     orders
WHERE    status = 'paid'
GROUP BY customer_id
HAVING   SUM(amount) > 100
ORDER BY revenue DESC
LIMIT    20;

-- customer_id | orders | revenue
-- 2           | 1      | 300.00
-- 1           | 2      | 160.00
Alan's single 15.00 order is grouped, then removed by HAVING. Grace's 90.00 refund never reached the grouping, because WHERE ran first.

You write the clauses in that order and the database runs them in a different one, and almost every confusing error in SQL comes from that gap. It evaluates FROM and the joins first, then WHERE, then GROUP BY, then HAVING, then SELECT, then ORDER BY, then LIMIT.

written       runs        what exists at that point
FROM     1.   FROM        the raw rows, joined
WHERE    2.   WHERE       individual rows, no groups yet, no aggregates
GROUP BY 3.   GROUP BY    rows collapsed into one row per group
HAVING   4.   HAVING      groups, so aggregates are now available
SELECT   5.   SELECT      the output columns and their aliases are created here
ORDER BY 6.   ORDER BY    aliases from SELECT are usable
LIMIT    7.   LIMIT       the first N of whatever ORDER BY produced
This table answers three separate questions people ask: why HAVING exists, why an alias fails in WHERE, and why it works in ORDER BY.

So WHERE filters rows before grouping and HAVING filters groups after. WHERE amount > 50 discards small orders and then totals what is left; HAVING SUM(amount) > 50 keeps every order and then discards customers whose total is small. These give different answers on the same data, and neither is more correct — you have to know which question you were asked. Putting SUM(amount) > 50 in WHERE is not a style error, it is a syntax error: the sum does not exist yet.

Counting, and what each count ignores#

SELECT COUNT(*)                  AS rows_,        -- 6: every row
       COUNT(amount)             AS with_amount,  -- rows where amount IS NOT NULL
       COUNT(DISTINCT customer_id) AS customers,  -- 4: distinct values
       SUM(amount)               AS total,        -- 620.00
       AVG(amount)               AS mean,         -- ignores NULLs in the divisor too
       COUNT(*) FILTER (WHERE status = 'refunded') AS refunds   -- 1
FROM orders;
FILTER is Postgres and SQLite. Elsewhere write SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END), which is the same thing.

COUNT(*) counts rows. COUNT(column) counts rows where that column is not null. AVG divides by the count of non-null values, not by the number of rows, so a column that is half null gives the average of the half that exists rather than treating the missing half as zero. Whether that is what you wanted depends entirely on whether the null means unknown or means nothing happened, and the database cannot tell you which.

Joins, and what each one returns#

A join matches rows from two tables on a condition. The type decides what happens to rows on either side that found no match, and that is the whole distinction. Here is each one against the tables above, with the real output.

-- INNER JOIN: only rows that matched on both sides.
SELECT c.name, o.id, o.amount
FROM customers c
JOIN orders o ON o.customer_id = c.id;

-- name   | id  | amount
-- Ada    | 101 | 120.00
-- Ada    | 102 |  40.00
-- Grace  | 103 | 300.00
-- Grace  | 104 |  90.00
-- Alan   | 105 |  15.00
--
-- 5 rows. Katherine is gone (no orders) and order 106 is gone (no customer).
JOIN with no word in front of it means INNER JOIN. This is the one you want when both sides must exist.
-- LEFT JOIN: every row from the left, nulls where the right had no match.
SELECT c.name, o.id, o.amount
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id;

-- name       | id   | amount
-- Ada        | 101  | 120.00
-- Ada        | 102  |  40.00
-- Grace      | 103  | 300.00
-- Grace      | 104  |  90.00
-- Alan       | 105  |  15.00
-- Katherine  | NULL | NULL     <- kept, padded with nulls
--
-- 6 rows. Order 106 is still gone: it is on the right.
Use it when absence is meaningful: every customer including the inactive ones, every document including the ones with no embedding.
-- RIGHT JOIN: the mirror image. Every order, nulls where no customer matched.
SELECT c.name, o.id, o.amount
FROM customers c
RIGHT JOIN orders o ON o.customer_id = c.id;

-- name   | id  | amount
-- Ada    | 101 | 120.00
-- ...
-- NULL   | 106 |  55.00   <- the orphan order, kept

-- FULL OUTER JOIN: both sides kept, nulls on whichever side is missing.
-- 7 rows: the 5 matched, plus Katherine, plus order 106.

-- CROSS JOIN: every combination, no condition. 4 customers x 6 orders = 24 rows.
-- Rarely wanted deliberately. Very often produced by accident.
RIGHT JOIN is rare in practice because swapping the table order and writing LEFT is easier to read. Knowing it exists is enough.

The way to find rows with no match is a LEFT JOIN followed by a test for null on the right side. That is the anti-join, and it is how you answer customers who have never ordered, documents never retrieved, users with no events this week.

SELECT c.id, c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;          -- only the rows the join could not fill

-- id | name
-- 4  | Katherine
NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id) does the same and is usually clearer. NOT IN does not: see the null trap below.
SELECT c.id, c.name,
       COUNT(*)      AS wrong,
       COUNT(o.id)   AS right_
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name;

-- id | name      | wrong | right_
-- 1  | Ada       | 2     | 2
-- 2  | Grace     | 2     | 2
-- 3  | Alan      | 1     | 1
-- 4  | Katherine | 1     | 0     <- COUNT(*) counted the padded row
The counting trap. Katherine has one row after the join and zero orders. COUNT(*) reports the row; COUNT(o.id) reports the order.

The join that multiplies your rows

If the right table has more than one row per key, every left row is repeated once per match, and any SUM over the left side is now inflated. Joining orders to a customer_addresses table with two addresses doubles your revenue, silently, and the number still looks like money. Check with SELECT customer_id, COUNT(*) FROM customer_addresses GROUP BY customer_id HAVING COUNT(*) > 1 before you trust the total, and compare the row count before and after every join.

Window functions#

GROUP BY collapses rows: five orders become one row per customer and the individual orders are gone. A window function computes over a set of related rows and keeps every row, so each order can carry its customer's total next to it. That is the entire difference, and it is what makes ranking within a group, comparing to the previous row, and taking the latest record per entity possible in one pass.

SELECT id, customer_id, amount,
       SUM(amount) OVER (PARTITION BY customer_id)                        AS customer_total,
       ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC)  AS rn,
       ROUND(amount / SUM(amount) OVER (), 3)                             AS share_of_all
FROM orders;

-- id  | customer_id | amount | customer_total | rn | share_of_all
-- 101 | 1           | 120.00 | 160.00         | 1  | 0.194
-- 102 | 1           |  40.00 | 160.00         | 2  | 0.065
-- 103 | 2           | 300.00 | 390.00         | 1  | 0.484
-- 104 | 2           |  90.00 | 390.00         | 2  | 0.145
-- 105 | 3           |  15.00 |  15.00         | 1  | 0.024
-- 106 | 9           |  55.00 |  55.00         | 1  | 0.089
Six rows in, six rows out. PARTITION BY is the grouping; an empty OVER () means the whole result set, which is how share_of_all is computed.

Read OVER as for each. PARTITION BY customer_id says restart the calculation for each customer. ORDER BY inside the OVER decides what counts as before this row, which is what makes ranking and running totals work; it has nothing to do with the ORDER BY at the end of the query, which only sorts the output.

-- Most recent order per customer: rank, then keep the first of each.
WITH ranked AS (
  SELECT o.*,
         ROW_NUMBER() OVER (
           PARTITION BY customer_id ORDER BY created_at DESC, id DESC
         ) AS rn
  FROM orders o
)
SELECT id, customer_id, amount, created_at
FROM ranked
WHERE rn = 1;

-- id  | customer_id | amount | created_at
-- 102 | 1           |  40.00 | 2026-02-11
-- 104 | 2           |  90.00 | 2026-03-02
-- 105 | 3           |  15.00 | 2026-03-08
-- 106 | 9           |  55.00 | 2026-03-09
The most asked SQL pattern there is. The , id DESC is the tiebreaker: without it, two orders at the same timestamp pick a winner arbitrarily and the result changes between runs.

You cannot filter on a window function in WHERE

WHERE rn = 1 in the same SELECT that computes rn is an error, because WHERE runs before SELECT and rn does not exist yet. That is why every example above wraps the window in a CTE or a subquery and filters outside it. The error message names the column and not the reason, which is why this costs people ten minutes the first time.

-- Running total and change against the previous row.
SELECT created_at, amount,
       SUM(amount) OVER (ORDER BY created_at)             AS running_total,
       amount - LAG(amount) OVER (ORDER BY created_at)    AS change_vs_prev
FROM orders;

-- created_at | amount | running_total | change_vs_prev
-- 2026-01-04 | 120.00 | 120.00        | NULL      <- no previous row
-- 2026-01-19 | 300.00 | 420.00        |  180.00
-- 2026-02-11 |  40.00 | 460.00        | -260.00
-- 2026-03-02 |  90.00 | 550.00        |   50.00
-- 2026-03-08 |  15.00 | 565.00        |  -75.00
-- 2026-03-09 |  55.00 | 620.00        |   40.00
An aggregate with ORDER BY in its OVER becomes cumulative. LAG looks back one row, LEAD forwards; both return NULL at the edge, which you must handle.
values:      10  10   9   8
ROW_NUMBER    1   2   3   4     ties broken arbitrarily, never repeats
RANK          1   1   3   4     ties share, then skip
DENSE_RANK    1   1   2   3     ties share, no gap
Say which one you want and why. Top three by RANK can return four rows; by ROW_NUMBER it always returns three, and which three is not deterministic without a tiebreaker.

Why a query is slow#

Nearly always because it is reading far more rows than it returns. A table with no index has to be read from start to finish to answer any question about it, so finding one customer's orders in ten million rows reads ten million rows. That is a sequential scan, and on a small test table it is instant, which is exactly why the problem only appears in production.

An index is a separate sorted structure, usually a B-tree, holding the values of one or more columns and a pointer to the row. Because it is sorted, the database can binary search it: twenty-odd steps instead of ten million reads. The cost is that every insert and update must also update the index, and the index takes disk space, so you index the columns you filter and join on and not the rest.

EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42;

-- Before the index:
-- Seq Scan on orders  (cost=0.00..18334.00 rows=6 width=64)
--                     (actual time=0.412..84.203 rows=6 loops=1)
--   Filter: (customer_id = 42)
--   Rows Removed by Filter: 999994
-- Planning Time: 0.094 ms
-- Execution Time: 84.256 ms

CREATE INDEX orders_customer_id_idx ON orders (customer_id);

-- After:
-- Index Scan using orders_customer_id_idx on orders
--                     (cost=0.42..25.10 rows=6 width=64)
--                     (actual time=0.028..0.041 rows=6 loops=1)
--   Index Cond: (customer_id = 42)
-- Execution Time: 0.068 ms
Postgres. EXPLAIN alone shows the plan it would use; EXPLAIN ANALYZE runs the query and reports what actually happened, so on a write statement it really does write.

Read a plan from the inside out, and read three things on each line. The node type says how the rows were obtained: Seq Scan reads everything, Index Scan walks the index, Bitmap Heap Scan is the compromise when many rows match, Nested Loop is fine for a few rows and catastrophic for many, Hash Join is the usual choice for large joins. The rows estimate against actual rows says whether the planner understood the table; a thousandfold gap means stale statistics and ANALYZE is the fix. And Rows Removed by Filter says how much work was wasted, which is the number that tells you an index would help.

What you see                         What it means
Seq Scan, large table                 no usable index, or the planner chose
                                      not to use one because most rows match
Index Scan                            what you wanted
Bitmap Heap Scan                       index found many rows; collecting them first
Rows Removed by Filter: 999994        99.99% of the work was thrown away
rows=6 ... actual rows=800000         the estimate is wrong; run ANALYZE
Nested Loop, actual loops=50000       a join running per-row; usually the slow part
Sort ... external merge Disk: 84MB    it ran out of work_mem and sorted on disk
Worth memorising the first three lines. The rest you can look up once you know which line to look at.

The index also has to be usable, and several common things stop it being used even when it exists. Wrapping the column in a function is the main one: WHERE lower(email) = 'a@b.com' cannot use an index on email, because the index stores the original values. A leading wildcard in LIKE has the same problem, since the index is sorted by the start of the string. A mismatched type can force a cast on the column and defeat it the same way. And a composite index on (user_id, created_at) serves a filter on user_id, and a filter on both, but not a filter on created_at alone: an index is only useful from its leftmost column inwards, like a phone book sorted by surname then first name.

-- These cannot use a plain index on email:
WHERE lower(email) = 'ada@example.com'      -- fix: CREATE INDEX ... ON t (lower(email))
WHERE email LIKE '%example.com'             -- leading wildcard
WHERE user_id::text = '42'                  -- cast on the column, not the value

-- A composite index, and what it covers:
CREATE INDEX ON llm_calls (user_id, created_at);
WHERE user_id = 42                            -- used
WHERE user_id = 42 AND created_at > '...'     -- used, both columns
WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20   -- used, and no sort needed
WHERE created_at > '...'                      -- not used: wrong leading column
The third case is the one worth having: the index supplies the order, so the database reads twenty rows and stops rather than sorting a million.
  • Index the columns you filter and join on, in the order you filter them. Not every column.
  • Indexes make writes slower and take disk space. On a write-heavy table they are a real trade, not free.
  • The planner will ignore an index if it thinks most rows match, and it is usually right: reading half a table through an index is slower than reading it straight through.
  • LIMIT without ORDER BY returns an arbitrary set, which is rarely what you meant, and the set can change between runs.
  • SELECT * transfers columns you do not need and prevents an index-only scan. Name the columns.

Most retrieval starts as a SQL query

Before anything is embedded, the candidate set usually comes from a database: this tenant, this date range, these permissions. Filtering well in SQL often matters more to retrieval quality than the embedding model does, and a permission filter applied after the vector search rather than before it is both slower and a security bug.

Where people go wrong

SELECT * in production code. It breaks when a column is added, transfers data you do not need, and prevents index-only scans. Name the columns.

Build this: Build a query that returns each customer's most recent order and their lifetime total in one result, then run EXPLAIN ANALYZE and add an index that changes the plan.

Open this section on its own page
Section 2 of 3, 5h

The queries you will actually be asked to write

By the end: You can write the common analytical patterns from memory and avoid the NULL traps that make queries silently wrong.

  • Common table expressions for readable queries
  • Top N per group
  • Running totals and period over period
  • Removing duplicates, keeping the latest
  • Counting conditionally
  • NULL traps
  • Reading a query plan

There is a short list of query shapes that analytical SQL work keeps returning to: the three largest orders per customer, a running total by day, the latest row per key, several counts in one pass. This section works through that list, plus the NULL behaviours that make a query return the wrong answer without raising an error, and how to ask the database why a query is slow. You can already write a SELECT with a join and a GROUP BY; these are the patterns that sit one step past that.

Asked for                                    Pattern
"the three largest per customer"             ROW_NUMBER in a CTE, filter rn <= 3
"the latest row per user"                    the same, with ORDER BY updated_at DESC
"cost per day, cumulative"                   SUM(x) OVER (ORDER BY day)
"this month against last"                    LAG over an ordered window
"errors and calls per model, one row each"   COUNT(*) FILTER, or SUM(CASE WHEN)
"a column per category"                      conditional aggregation
"users with no events"                       NOT EXISTS
"days with no data should still appear"      generate the dates, LEFT JOIN to them
"deduplicate this table"                     rank per key, keep rn = 1
Worth memorising. Nine shapes cover most analytical SQL anyone will ask you to write.

Name the steps with WITH#

A common table expression gives a subquery a name, so a long query reads top to bottom as a series of steps. Interviewers value this because they can follow your thinking, and so will you in six months. Each CTE can refer to the ones above it, which is what lets a five-stage transformation stay flat instead of nesting five subqueries deep.

WITH paid AS (
  SELECT customer_id, amount, created_at
  FROM orders
  WHERE status = 'paid'
),
per_customer AS (
  SELECT customer_id, SUM(amount) AS revenue, COUNT(*) AS orders
  FROM paid
  GROUP BY customer_id
)
SELECT c.name, p.revenue, p.orders
FROM per_customer p
JOIN customers c ON c.id = p.customer_id
ORDER BY p.revenue DESC;
Build it one CTE at a time and run it after each: SELECT * FROM paid LIMIT 5 tells you immediately whether stage one is right.

A CTE is not always free

In Postgres before version 12 a CTE was an optimisation fence: it was materialised, so a filter outside it could not be pushed inside and a query that should have used an index did not. Modern Postgres inlines a CTE used once, and you can force either behaviour with AS MATERIALIZED or AS NOT MATERIALIZED. If a query gets dramatically slower when you tidy a subquery into a CTE on an older database, this is why.

Top N per group#

The most asked SQL pattern there is. Each customer's three largest orders needs a ranking that restarts for every customer, which is exactly what PARTITION BY does. Rank in a CTE, then filter on the rank outside it, because a window function cannot be referenced in the WHERE of the same SELECT that computes it.

WITH ranked AS (
  SELECT customer_id, id, amount,
         ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC, id) AS rn
  FROM orders
)
SELECT customer_id, id, amount
FROM ranked
WHERE rn <= 3;
The , id after amount DESC is the tiebreaker. Without it, two orders of the same amount swap places between runs and a test that compares output flakes.

ROW_NUMBER, RANK or DENSE_RANK

With ties, ROW_NUMBER picks one arbitrarily, RANK gives ties the same number and skips the next, DENSE_RANK gives ties the same number without skipping. On values 10, 10, 9, 8 they produce 1,2,3,4 then 1,1,3,4 then 1,1,2,3. So the top three by RANK can return four rows and by DENSE_RANK can return many. Say which you want and why: for 'show me three' use ROW_NUMBER, for 'everyone who tied for third' use RANK.

Postgres has a shorter form for the special case of one row per group. DISTINCT ON keeps the first row of each group as defined by the ORDER BY, which must start with the same columns.

SELECT DISTINCT ON (customer_id) customer_id, id, amount, created_at
FROM orders
ORDER BY customer_id, created_at DESC, id DESC;   -- latest order per customer
Postgres only. Shorter than the window version and often faster, and it does not port, so know both.

Running totals, moving averages and change over time#

SELECT day,
       revenue,
       SUM(revenue) OVER (ORDER BY day)                         AS running_total,
       revenue - LAG(revenue) OVER (ORDER BY day)               AS change_vs_prev_day,
       AVG(revenue) OVER (ORDER BY day
                          ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)  AS avg_7d
FROM daily_revenue;
The ROWS BETWEEN clause is the window frame. It is what turns an average over everything so far into a rolling seven-day average.

Worth understanding once: adding ORDER BY to an OVER also changes the default frame to everything from the start of the partition up to the current row, which is why SUM becomes cumulative rather than a total. Writing ROWS BETWEEN yourself overrides that. The default is actually RANGE rather than ROWS, and the two differ when several rows share an ordering value: RANGE treats all rows with the same day as one step, so a day with three entries jumps by all three at once. If your running total looks right except where duplicates exist, write ROWS.

ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW    running total (the default shape)
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW           trailing 7 rows
ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING           centred, 7 rows
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING   the whole partition
Four frames cover everything. A month-over-month comparison is LAG with the rows already aggregated to months.

A day with no rows does not appear

Aggregating events by day gives you days that had events, and nothing for the days that did not, so a chart drawn from it silently joins across an outage. Generate the date series and LEFT JOIN the aggregate onto it: in Postgres, SELECT d::date FROM generate_series('2026-01-01', '2026-03-31', interval '1 day') d. This is the single most common wrong answer in a take-home.

Keep only the latest row per key#

WITH latest AS (
  SELECT *,
         ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY updated_at DESC, id DESC) AS rn
  FROM profile_versions
)
SELECT * FROM latest WHERE rn = 1;

-- profile_versions          result
-- user  updated_at   val    user  updated_at   val
-- 1     2026-01-01   a      1     2026-02-01   b
-- 1     2026-02-01   b      2     2026-01-05   d
-- 2     2026-01-05   c
-- 2     2026-01-05   d      <- same timestamp; id DESC decides
This is also how you deduplicate: rank by whatever makes one row preferable and keep rn = 1. To delete rather than select, put the same CTE in a DELETE using the row identity.

The tempting alternative, grouping by user_id and taking MAX(updated_at), gives you the key and the timestamp but not the rest of the row, and joining back to get the rest reintroduces the tie. When a colleague's query returns more rows than there are users, this is nearly always why.

Count several things in one pass#

SELECT model,
       COUNT(*)                                   AS calls,
       COUNT(*) FILTER (WHERE status = 'error')   AS errors,
       COUNT(*) FILTER (WHERE status = 'error')::float / COUNT(*)  AS error_rate,
       COUNT(DISTINCT user_id)                    AS users,
       AVG(latency_ms)                            AS avg_latency,
       PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY latency_ms) AS p95_latency
FROM llm_calls
GROUP BY model;
FILTER is Postgres and SQLite. Elsewhere, SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) does the same. The cast to float matters: integer division gives 0.

The same trick turns rows into columns, which is what people mean by pivoting. One CASE per category inside an aggregate produces a column per category, and it is worth knowing because it is short, portable and does not need an extension.

SELECT date_trunc('day', created_at) AS day,
       SUM(CASE WHEN model = 'haiku'  THEN cost_usd ELSE 0 END) AS haiku,
       SUM(CASE WHEN model = 'sonnet' THEN cost_usd ELSE 0 END) AS sonnet,
       SUM(CASE WHEN model = 'opus'   THEN cost_usd ELSE 0 END) AS opus
FROM llm_calls
GROUP BY 1
ORDER BY 1;
GROUP BY 1 means group by the first output column. Fine in analysis, a maintenance hazard in code somebody edits later.

Report p95 rather than the mean for latency, and be aware that averaging p95 across days is not the p95 of those days. Percentiles do not add up, so recompute them from the underlying rows over whatever period you are reporting.

NULL traps#

NULL does not mean zero and does not mean empty. It means unknown, and SQL evaluates comparisons against it to a third value that is neither true nor false. Any row whose WHERE condition evaluates to that third value is not returned, which is how a filter can quietly drop rows you meant to keep without anything appearing to go wrong.

-- tier: 'pro', NULL, 'free', 'pro'      amount: 10, 20, NULL, 5

SELECT COUNT(*) FROM t WHERE tier = NULL;              -- 0, never any rows
SELECT COUNT(*) FROM t WHERE tier IS NULL;             -- 1
SELECT COUNT(*) FROM t WHERE tier <> 'pro';            -- 1, not 2
SELECT COUNT(*) FROM t WHERE tier IS DISTINCT FROM 'pro';   -- 2, the NULL included

SELECT SUM(amount), AVG(amount), COUNT(amount), COUNT(*) FROM t;
-- 35.0 | 11.67 | 3 | 4      AVG divided by 3, not 4

SELECT 'tier: ' || tier FROM t;    -- NULL for the row where tier is NULL,
                                   -- not the string 'tier: '
The third line is the expensive one. 'everyone not on the pro tier' quietly excludes everyone whose tier was never recorded, and the count looks plausible.
  • NULL = NULL is not true. Use IS NULL, or IS DISTINCT FROM when you want a comparison that treats NULL as a value.
  • COUNT(column) skips NULLs, COUNT(*) counts rows, and AVG divides by the non-null count. They disagree on the same table.
  • WHERE x NOT IN (subquery) returns no rows at all if the subquery contains a single NULL, because x <> NULL is unknown for every row. NOT EXISTS does not have this problem and is the habit to form.
  • Concatenating or adding anything to NULL gives NULL, so one missing field blanks a whole derived string. COALESCE(col, '') fixes it deliberately.
  • An outer join manufactures NULLs. A condition on the right-hand table belongs in the ON clause; put it in WHERE and you have silently turned your LEFT JOIN back into an INNER JOIN.
-- Looks like a LEFT JOIN. Behaves like an INNER JOIN.
SELECT c.name, o.id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid';          -- Katherine's NULL status fails this, so she is dropped

-- What was meant:
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid';
The condition has to be part of the join, not applied after it. This is the most common wrong answer to 'every customer and their paid orders'.

Why is it slow? Ask the database#

EXPLAIN ANALYZE
SELECT * FROM llm_calls WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;

-- Limit  (actual time=612.8..612.8 rows=20 loops=1)
--   ->  Sort  (actual time=612.8..612.8 rows=20 loops=1)
--         Sort Key: created_at DESC
--         Sort Method: top-N heapsort  Memory: 41kB
--         ->  Seq Scan on llm_calls  (actual time=0.3..598.1 rows=8140 loops=1)
--               Filter: (user_id = 42)
--               Rows Removed by Filter: 4991860
-- Execution Time: 613.2 ms

CREATE INDEX llm_calls_user_created_idx ON llm_calls (user_id, created_at DESC);

-- Limit  (actual time=0.04..0.07 rows=20 loops=1)
--   ->  Index Scan using llm_calls_user_created_idx on llm_calls
--         Index Cond: (user_id = 42)
-- Execution Time: 0.09 ms
The Sort node disappears entirely: the index is already in the order asked for, so the database reads twenty rows and stops.

Read a plan inside out, and look at three numbers on each line. Rows Removed by Filter says how much work was thrown away, which is the signal that an index would help. A large gap between the estimated rows and the actual rows means the planner's statistics are stale, and ANALYZE the_table is the fix; a plan built on a wrong estimate picks the wrong join strategy, which is how a query that ran in 50 ms starts taking two minutes after a bulk load. And loops on an inner node multiplies its cost: a Nested Loop showing loops=50000 is running the inner side fifty thousand times.

  • Seq Scan on a large table where you filtered: usually a missing index, occasionally the planner correctly deciding most rows match.
  • Sort with a large Memory or, worse, Disk figure: the query is sorting more than it needs to. An index in the right order removes the node.
  • Nested Loop with a high loop count: fine for tens of rows, ruinous for millions. It often follows a bad row estimate.
  • Hash Join with a Batches count above 1: the hash table did not fit in work_mem and spilled to disk.
  • Estimated rows off by three orders of magnitude: run ANALYZE, then read the plan again. Everything downstream of a bad estimate is a bad decision.

Where people go wrong

Writing one enormous nested query. Break it into named CTEs; you will make fewer mistakes and the interviewer can follow you.

Build this: Log your model calls to a table (user, model, tokens, latency, status, time). Write queries for: cost per user per day, each user's three most expensive calls, error rate per model, and a seven-day running total of cost. Check each with EXPLAIN ANALYZE.

Open this section on its own page
Section 3 of 3, 3h

Writing data safely

By the end: You can write to a database so that retries, crashes and concurrent requests do not corrupt it.

  • Transactions
  • Constraints as the last line of defence
  • Upserts
  • Idempotent writes
  • Two requests at once

Reading from a database is forgiving. Writing to it is not, because requests retry, processes crash halfway, and two of them arrive at the same instant. This section is the four mechanisms that keep a table correct through all of that: transactions, constraints, upserts and row locks. What they prevent is the quiet kind of failure, a double charge or a balance that lost an update, which careful application code cannot catch on its own because the check and the write are two separate moments.

All or nothing#

A transaction is a group of statements that either all take effect or none do. Without one, a process that crashes between two statements leaves the database in a state that neither statement intended: credits deducted with no usage recorded, or an order with no line items. Wrap anything that must stay consistent together, and note that a transaction is also the unit of isolation, so another connection sees nothing until it commits.

BEGIN;
UPDATE accounts SET credits = credits - 10 WHERE id = 7;
INSERT INTO usage (account_id, credits) VALUES (7, 10);
COMMIT;   -- both happen, or on error neither does

-- ROLLBACK undoes everything since BEGIN. Most client libraries do it for you
-- when the block raises:
-- with conn.transaction():
--     ...            # an exception here rolls back and re-raises
Four properties get quoted at this point: atomicity (all or none), consistency (constraints hold), isolation (concurrent transactions do not see each other's half-finished work), durability (a commit survives the power going out).

Keep a transaction short, and never hold one open across a model call

An open transaction holds locks and pins the rows other transactions need. A handler that starts a transaction, calls a model that takes eleven seconds, then writes, blocks every other request touching those rows for eleven seconds, and under load that becomes a connection pool exhausted error somewhere unrelated. Do the slow call first, then open the transaction to write.

Let the database refuse bad data#

CREATE TABLE usage (
  id          bigserial PRIMARY KEY,
  account_id  bigint NOT NULL REFERENCES accounts(id),
  credits     int    NOT NULL CHECK (credits > 0),
  request_id  text   NOT NULL UNIQUE,     -- one row per request, however many retries
  created_at  timestamptz NOT NULL DEFAULT now()
);

Each of those does a specific job. PRIMARY KEY makes the row addressable and unique. REFERENCES accounts(id) is a foreign key: the database refuses a usage row for an account that does not exist, and refuses to delete an account that still has usage, which is what stops the orphan rows that break every later join. NOT NULL says the column must have a value. CHECK encodes a rule the type cannot, here that a charge is positive. UNIQUE is the one doing the real work below.

Application code has bugs, deploys race, and two services write the same table. Constraints do not forget, and they hold for every writer including the migration somebody runs by hand at midnight. A unique request id means a retried request cannot charge twice, and it means that whatever the application logic does.

Insert or update#

-- Retry is harmless: the second attempt inserts nothing and raises nothing.
INSERT INTO usage (account_id, credits, request_id)
VALUES (7, 10, 'req_8f2c')
ON CONFLICT (request_id) DO NOTHING;

-- Upsert: insert, or update what is already there. The excluded pseudo-table
-- holds the row you tried to insert.
INSERT INTO daily_cost (day, model, cost_usd)
VALUES ('2026-03-11', 'sonnet', 12.40)
ON CONFLICT (day, model) DO UPDATE
SET cost_usd = daily_cost.cost_usd + excluded.cost_usd;

-- Return what happened, so the caller knows without a second query.
INSERT INTO usage (account_id, credits, request_id)
VALUES (7, 10, 'req_8f2c')
ON CONFLICT (request_id) DO NOTHING
RETURNING id;    -- no row returned means it was already there
ON CONFLICT needs a unique constraint on those columns to conflict against. Without one it is a syntax error, which is a useful reminder that the constraint is the mechanism.

Two requests at once#

Two requests both read a balance of 10, both subtract 10, both write 0, and one charge disappears. Nothing errored and no code was wrong on its own; the problem is that reading and writing were two separate moments and something happened in between. This is the lost update, and there are three standard fixes.

time   request A                     request B
 1     SELECT credits -> 10
 2                                   SELECT credits -> 10
 3     UPDATE SET credits = 0
 4                                   UPDATE SET credits = 0
       one of the two charges has vanished, and nothing raised
-- 1. Do the arithmetic in the database. One statement, no gap.
UPDATE accounts SET credits = credits - 10
WHERE id = 7 AND credits >= 10
RETURNING credits;      -- no row returned means there were not enough credits

-- 2. Lock the row first, if you must read, decide, then write.
BEGIN;
SELECT credits FROM accounts WHERE id = 7 FOR UPDATE;  -- B blocks here until A commits
-- ... decide ...
UPDATE accounts SET credits = credits - 10 WHERE id = 7;
COMMIT;

-- 3. Optimistic: carry a version and fail if it moved.
UPDATE accounts SET credits = 0, version = version + 1
WHERE id = 7 AND version = 4;     -- 0 rows updated means someone else got there first
Prefer the first. The WHERE credits >= 10 is what makes it safe: the check and the write are the same statement, so nothing can happen between them.

Locking works and costs you concurrency, so take locks in a consistent order across your codebase. Two transactions that lock account 7 then account 9, and account 9 then account 7, will deadlock; Postgres detects it and kills one with a deadlock detected error, which the application should retry rather than treat as a failure.

Read committed is the default, and it is weaker than people assume

At Postgres's default isolation level, each statement sees a fresh snapshot, so two statements inside one transaction can see different data, and a row you read can be changed by someone else before you write it. Everything above exists because of that. Raising the level to serializable makes the database detect the conflict and abort one transaction, which is correct and means your code must handle a serialization failure by retrying.

Where people go wrong

Checking 'does this row exist?' in code and then inserting. Two requests pass the check at the same moment. Let a unique constraint decide.

Build this: Build a credits table and an endpoint that deducts credits per model call. Fire twenty concurrent requests with duplicate request ids and confirm the balance and usage rows are exactly right.

Open this section on its own page

Where you use this

A topic is easier to care about when you can see what it is for. These projects lean on it at a named stage.

Take a model from a table to an endpoint
  • Stage 1, Get a real table and find out what is wrong with it: Most real tables arrive from a warehouse, and the extraction query is part of the pipeline you have to be able to rerun.

Now actually do it

Reading a topic is not learning it. These are the hours that count, and each one tells you when you are finished.

1Twenty queries against a real schema

8h

Load a real dataset. Write queries covering: aggregation with HAVING, a LEFT JOIN with correct counting, a window function for latest-per-entity, a self join, and a query over a date range with gaps filled.

Done when: All twenty return correct results and you did not reach for a subquery where a window function was cleaner.

2Make a slow query fast

4h

Find or create a query doing a sequential scan over a large table. Run EXPLAIN ANALYZE, add an index, and run it again. Then break the index with a function on the column and watch the plan change back.

Done when: You can read the plan and say which access method it used and why.

12 hours of practice

Interview questions

9 questions, grouped by the section that teaches the answer. An answer you have read is not an answer you can give, so use Test yourself and say it out loud first.

Getting the data out

What is the difference between WHERE and HAVING?

WHERE filters rows before grouping. HAVING filters groups after aggregation. You cannot reference an aggregate in WHERE because it has not been computed yet.

When do you use a LEFT JOIN?

When you need every row from the left side regardless of whether there is a match, and absence is meaningful. Watch the counting trap: COUNT(*) counts the unmatched row, COUNT(right_column) does not.

A query is slow. How do you diagnose it?

EXPLAIN ANALYZE and look at whether it is scanning the table or using an index, and how many rows it reads compared to how many it returns. A sequential scan on a large table where you filter on a column usually means a missing index.

What is a window function for?

Computing across a set of related rows while keeping every row, rather than collapsing them as GROUP BY does. Ranking within a group, comparing to the previous row, and taking the latest record per entity are the common uses.

The queries you will actually be asked to write

Write a query for each user's most recent order.

Rank orders within each user by created_at descending with ROW_NUMBER in a CTE, then keep rows where the rank is 1. Mention that if two orders share a timestamp, you need a tiebreaker such as the id.

Then they push further

The table has a billion rows and this is slow. What do you do?

Check the plan. An index on (user_id, created_at) helps. If this is needed often, maintain a latest-order table or column updated on write rather than computing it on every read.

Could you do it without a window function?

Yes: join each order to the maximum created_at for its user from a grouped subquery. It is usually less clear and handles ties worse, which is why the window version is preferred.

What gets marked up. Marked up for handling ties explicitly and for thinking about indexes. Correct SQL with no mention of either passes; with both it stands out.

Why can NOT IN return nothing?

If the subquery contains a NULL, the comparison with that NULL is unknown for every row, so no row satisfies NOT IN. NOT EXISTS does not have this problem.

What is the difference between COUNT(*) and COUNT(column)?

COUNT(*) counts rows. COUNT(column) counts rows where that column is not NULL.

Writing data safely

A client retries a request after a timeout. How do you make sure it is not processed twice?

The client sends a unique request id. The server stores it with a unique constraint as part of the same transaction as the work, and on conflict returns the original result instead of repeating the work.

Then they push further

What if the work includes calling an external API that is not idempotent?

Record the intent with the request id before calling, and on retry check that record and ask the external system what happened rather than calling again.

What gets marked up. Marked up for enforcing it in the database with a constraint rather than only checking in application code, which races.

What is a race condition in a read-modify-write, and how do you prevent it?

Two transactions read the same value, each modifies it, and one write overwrites the other. Prevent it by doing the update atomically in one statement, locking the row before reading, or using optimistic concurrency with a version column.

These questions on their own page, one link per question

Resources

Free to watch and read, picked for this topic.

Stop 9 of 50 on the AI Engineer route

0 of 50 topics done · 0h behind you, 603h to go

Next on this routeSampling & decodingTemperature, top-p, and why zero is not deterministic.9h

What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.