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
New to sql & data access? 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. Why it matters
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 indexEverything 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.00You 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 producedSo 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;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).-- 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.-- 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.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 | KatherineSELECT 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 rowThe 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.089Read 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-09You 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.00values: 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 gapWhy 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 msRead 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 diskThe 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- 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.
If this comes up in an interview
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.
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.
Watch it explained
This is one section of SQL & data access, 14 hours across 3 sections. Read it in full to get the whole topic in order.
Stop 9 of 50 on the AI Engineer route
0 of 50 topics done · 0h behind you, 603h to go
What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.