Interactive course · about 4 hours

Build a Query Engine

You ask a database for something and it answers. What you never said is how: which table to look at first, whether to read every row or jump straight to the ones you want, how to match two tables up. Something has to decide all of that, and its choices are not close. Two ways of answering the same question, both correct, can differ by a factor of a thousand. Here you build the thing that decides.

How this works

There is a small working database in this page: two tables of made-up shop records, a reader for the language you ask questions in, and a set of parts that fetch, test, sort and match rows. Every cost this course quotes was produced by running that machinery while you watched. You can change the query, change the plan, and watch the count move.

What you need before you start

Nothing about databases, and no programming. You need to be able to count and to compare two numbers. The course names things in the order it uses them, and any word that arrives without warning has a note beside it offering the background. If you have read Inside a Database, which covers how the rows are stored and kept safe, this picks up where that finished, but it does not assume it.

The steps

Step 1

A question, not a recipe

A database is a store of facts that a program can search. The facts sit in tables. A table is a grid: every horizontal line is one row, one thing being recorded, and every vertical strip is one column, one fact recorded about each of those things. The shop in this page has two tables. customers has 40 rows and the columns id, name and city. orders has 200 rows and the columns id, cust, item, qty and price.

Why not just one big table

Because a customer places many orders, and putting the customer's name on every order would write the same name out again and again. Change your name and somebody has to find all of them. Instead each customer gets a number in the id column, and every order carries that number in its cust column. One name, stored once, pointed at from many places.

The price of that tidiness is that a question like "what did people in Galle buy" now needs both tables at once, and matching them up turns out to be the most expensive thing this whole course deals with. Steps 7 and 9 are about almost nothing else.

To ask a table something you write a query: a question written down in a way a machine can read. The language for it is called SQL. A query says what you want back and which rows you want it for. It does not say how to find them.

What SQL is, and why it reads almost like English

SQL was designed in the 1970s so that people who were not programmers could ask questions of a database. That is why SELECT name FROM customers WHERE city = 'Galle' reads roughly as you would say it. The words in capitals are keywords: fixed words the language reserves for itself. Everything else is your table and column names, your numbers, and text in single quotes.

The plain-English look is a surface. Underneath, a query is a precise statement about which rows you want, and it says nothing at all about the order the work is done in. That gap is the subject of this course.

Lab 1 · Ask the shop something
Try this firstPress one customer, then press Answer it. Five rows come back, and the line underneath tells you how many rows the engine had to read to find them. Then edit the number 7 in the box to something else and press Answer it again.
Five rows out, two hundred rows read. That is the whole problem in one line. Nothing you typed asked it to read two hundred rows. Something decided that, and by Step 6 you will have made it stop. Try the other presets too: sorting by price is far more expensive than filtering by it, and the query does not look any harder.
What a "work unit" is, and who decided

To compare two ways of answering a question you need one number for each. This course counts work units. Reading the next row of a table costs 1. Comparing two values costs 1. Jumping straight to one particular row, which Step 6 explains, costs more, and you will get to choose how much more. Putting a row into a lookup table, or looking one up in it, costs 1.

Those weights are a decision, not a law of nature, and a real database makes a similar decision with different numbers. What matters is that every figure printed by this page was produced by running the code and adding up as it went. None of them were typed in by hand.

The thing that turns your question into an answer is a query engine. It reads what you wrote, works out a way of getting it, and carries that way out. There is always more than one way.

Lab 2 · Same answer, two routes
Try this firstPick one of the three guesses, then press Run both routes. Two cards appear, each with a work count, and the line underneath says how many times more one cost than the other.
Both routes are correct. They return the same rows. Route A pairs every customer with every order first and sorts out the mess afterwards, which is 8,000 pairs for a shop this small. Route B narrows down first. Nothing in the question said which to do, so something else had to choose, and that something is what you are about to build.
Two people write two different SQL queries that ask for exactly the same rows. One runs in a blink and the other takes a minute. What follows?
The third. Two queries asking for the same rows are equally correct. What differs is the route the engine picks, and small changes in how a question is written can push it towards a much worse one. In Lab 2 the same route difference cost a factor of about thirty on a table of 200 rows. On a table of two million it would be far worse than that, because the bad route grows with the two table sizes multiplied together.
Step 2

Chopping the query into a tree

Your query arrives as one long line of characters. Nothing about it is organised yet: the engine has a string of letters, spaces and punctuation, and no idea where one idea ends and the next begins. Two jobs fix that, in order.

The first is the tokenizer. It walks the characters from left to right and cuts them into tokens: the smallest pieces that mean something on their own. SELECT is one token. customers is one token. = is one token. Spaces are not tokens at all; their only job was to show the tokenizer where one token stopped.

Lab 3 · Cut it up, one token at a time
Try this firstPress Next token five times. The shaded part of the query grows, and a chip appears below for each piece taken, labelled with what kind of piece it is. Then type your own query into the query text box and press All of them.
Try typing something that is not SQL at all, such as hello # there. The tokenizer still cuts it up: hello is a name, there is a name, and # is marked "not understood". Cutting up is not the same as understanding, and the tokenizer never claims to understand. It has no opinion about whether the pieces make sense in that order.
Why bother with tokens at all

Because everything that comes next gets much simpler. Without tokens, every later step would have to worry about spaces, about whether SELECTED starts with the word SELECT, and about where a piece of quoted text starts and finishes. With tokens, all of that is settled once and never thought about again.

The same split shows up wherever a machine reads text people wrote. A compiler, the program that turns code a person wrote into instructions a processor runs, starts exactly this way. So does the thing that reads a web page.

The second job is the parser. It takes the flat list of tokens and builds a tree: a shape where things sit inside other things. The query has parts that belong to other parts. The test qty > 3 belongs to the WHERE, and the WHERE belongs to the query. A flat list cannot show that. A tree can.

What a tree is, if you have not met one

A tree here is a way of writing down "this thing contains those things". Each entry is a node. A node can hold other nodes underneath it, which are its children. A node with nothing underneath it is a leaf. Drawn on a page it usually goes downwards, with the whole thing at the top and the small pieces at the bottom, which is why it is called a tree drawn upside down.

In this page a tree is shown as indented lines. A line indented further than the one above it sits inside that line. It is the same shape as a list of folders inside folders.

Why the parser cannot just search for the word WHERE

Because order carries meaning. SELECT name FROM customers and FROM customers SELECT name are made of the same tokens, and only the first is a query. The rules about which token may follow which are called a grammar, and the parser is the grammar turned into a procedure: read the next token, check that it is allowed here, use it, move on.

That is why the parser can say something useful when it fails. It always knows exactly what it was expecting when it stopped. The one in this page reports what it wanted and which character it was standing on.

Lab 4 · Repair a query the parser rejects
Try this firstPress Parse it on the broken query as it stands. The line underneath says which character it stopped at and what it wanted there. Then finish the query so it returns every order with a qty above 3, and press Check my answer.
The check does not read your text. It runs your query and compares the rows that come back with the 55 orders that really do have a qty above 3, worked out separately. Any query that returns exactly those rows passes, however you write it. Try breaking it in new ways as well: remove the FROM, or write qty > three, and read what it says.
You type SELECT name FROM customers WHERE city = Galle, without the quotes around Galle. The engine refuses it. Which part refused, and why?
The parser. The tokenizer was perfectly happy: it cut Galle out as a name, the same way it cuts out customers, and it has no view on whether a name belongs there. The parser does. Its rule says a test compares a column against a number or against quoted text, and a bare name is neither. The tokenizer says what the pieces are; the parser says whether they are in a legal order.
Step 3

Write down what the query has to do

The parse tree is a picture of your sentence. It is still the wrong shape for doing work, because it is organised the way people write, not the way rows move. The next step turns it into a plan: a tree of operators, where each operator does one job to a stream of rows and hands the result to the one above it.

There are only six kinds in this course, and every query you can write here is built from them.

  • Get produces every row of one table.
  • Filter lets through only rows that pass a test.
  • Join takes rows from two places and pairs up the ones that match.
  • Sort puts rows in order.
  • Project throws away the columns you did not ask for.
  • Limit stops after a given number of rows.
What "operator" means here, and what a predicate is

An operator is one small machine with rows going in and rows coming out. That is the whole idea. It has no opinion about where its rows came from or where they go. Because of that, operators can be stacked in different orders and swapped for other operators that do the same job differently, which is what the next three steps are entirely about.

The test inside a Filter has a name of its own: a predicate. It is any statement about a row that comes out either true or false, such as price > 1000. You will see the word in any real database's own output, so it is worth having.

Why the plan is read from the bottom

Rows enter at the bottom, at the Get, and travel upwards. Each operator sits above the one that feeds it. So the top line of the printed plan is the last thing to happen, and the most indented lines at the bottom are the first.

This trips up everybody at first, including people who have used databases for years. It is the order rows move in, not the order the lines are printed in, that matters. Every real database prints its plans this way round, so it is worth getting used to here where the plans are four lines long.

Lab 5 · Watch the plan follow the query
Try this firstPress example 2, then Build the plan. A five-line tree appears. Now delete LIMIT 3 from the end of the query and press Build the plan again: exactly one line disappears, and it is the top one.
Each clause of the query becomes one operator. WHERE becomes a Filter, ORDER BY becomes a Sort, LIMIT becomes a Limit, and the list of columns becomes a Project. Try example 3 and example 4, which read two tables and so have two Get nodes with a Join above them. Notice that SELECT * produces no Project at all, because there is nothing to throw away.

Notice what this plan still does not say. There is a Join in it, but no method of joining. There is a Get, but no decision about how to get. That is deliberate, and it is why this is called the logical plan: it says what must happen. Nothing about how.

Lab 6 · Read a plan backwards
Try this firstRead the tree from the bottom line upwards, then press the option button you think produced it. The line underneath says whether you were right and what in the tree gives the answer away. Press Next plan for the next one.
Counting nodes is usually enough. Two Get nodes means two tables. A Sort means an ORDER BY. No Project means SELECT *. Reading a plan is a skill you can practise on three-line trees now and use on forty-line ones later, and the shape is the same in both.
A logical plan reads, from the bottom up: Get orders, then Filter on price > 1000, then Sort by price, then Limit 5. Which is the first thing that actually happens when the query runs?
A row is read. The bottom of the plan is where rows come from, so the Get moves first. The second answer also gets the sorting backwards: the plan sorts and then takes five, so it is the five most expensive rows, not the cheapest, unless the Sort was told to run the other way. The third answer describes something real, though: Limit does eventually stop everything below it. Step 5 shows exactly when.
Step 4

Choose how each step will actually run

The logical plan says a Join must happen. It does not say how, and there is more than one way. Turning every "what" into a chosen "how" produces the physical plan, and it is the physical plan that actually runs. In this engine there are three decisions to make and every combination of them is a correct plan that returns the same rows.

How to reach the rows of a table. Either read every row from the start, which is called a sequential scan, or use an index. An index is a second copy of one column's values, kept in sorted order, with each value remembering which row it came from. Because it is sorted, the engine can find a value in it without looking at everything. Step 6 measures exactly what that saves and what it costs. This shop has an index on orders.cust and one on customers.city, because somebody made those two and nobody made any others.

An index, in one picture

Think of the index at the back of a paper book. The book itself is in page order, which is no help at all if you want the pages about volcanoes. The index is in alphabetical order and each entry names the pages. You find "volcano" quickly because the list is sorted, and then you turn straight to page 212.

A database index works the same way and costs the same things: it takes up space, it has to be kept correct every time a row changes, and it only helps for the column it was built on. An index on city is no use whatever for a question about price.

How to join two tables. Three methods, which Step 7 races properly. A nested loop join takes each row from the left and walks the whole right side looking for matches. A hash join loads one side into a lookup structure first, then checks each row of the other side against it. A merge join sorts both sides and then walks them together once, like matching two sorted lists of names.

Where the tests run. A Filter can sit above the Join, testing the paired-up rows, or below it, testing one table's rows before any pairing happens. Both give the same answer. Step 10 is about why they do not cost the same.

Lab 7 · Build a plan out of three choices
Try this firstPress Run this plan without changing anything. Note the work count. Now set where the test runs to "below the join" and watch the number and the tree both change. Then press Show the cheapest of the twelve.
Three choices, two or three options each, twelve plans. All twelve return the same 30 rows, and the most expensive costs about thirty times the cheapest. The numbers next to each line of the tree say how many rows that operator handed upwards. When a plan is slow, the operator handing up the most rows is nearly always the one to look at.
Logical and physical, said another way

Logical is the shopping list: milk, bread, apples. Physical is the route: which shop, in what order, walking or on the bus. The list does not change when you take the bus instead, and the shopping is the same when you get home. But one route takes ten minutes and another takes an hour.

The engine is allowed to pick any route it likes, as long as the shopping is identical. That is a very strong promise, and it is what lets a database get faster without anybody rewriting their queries.

Lab 8 · How many plans are there
Try this firstDrag the tables in the query slider from 2 up to 6. Six bars are always on screen so you can see the shape of the growth, and the highlighted one follows the slider. The line underneath gives the count and where it comes from.
Trying them all stops working almost immediately. This is why a real engine does not measure every plan and pick the winner. It estimates, using a stored summary of what is in the tables, and estimating is guessing. Steps 8 and 9 are about what happens when the guess is wrong.
A colleague says "our database is slow, so we should rewrite the query with the tables the other way round". What is the flaw in that plan, given what a physical plan is?
The second. The whole point of the two-level split is that the engine is free to pick any physical plan that gives the same rows. Swapping the order of the tables in your text changes the logical plan hardly at all, and a real engine will often produce exactly the same physical plan from both versions. When rewriting does help, it is usually because it changed what the engine could estimate, not because it changed the order of the words.
Step 5

Operators, one row at a time

The operators in a plan do not run one after another, each finishing before the next starts. If they did, a Get on a table of ten million rows would have to build a list of ten million rows in memory before the Filter above it saw a single one.

Instead they are wired into a pipeline, and they run backwards from the way you would expect. Nothing happens until somebody at the top asks for a row. The top operator asks the one below it for a row, which asks the one below that, all the way down to the table. A row comes back up through them, being tested and trimmed on the way. Then the top asks again.

Lab 9 · Pull one row and watch it travel
Try this firstPress Pull one row three times, slowly. After each press the log gains a line saying how many rows the scan had to hand up to produce one row of answer, and the "rows out" numbers on the tree go up by different amounts. Then press it a fourth time.
The fourth press produces nothing. Limit was told to stop at three, so it simply stops asking, and the scan is left partway through the table with rows it will never be asked for. Nobody told the scan to stop. It was just never asked again. That is the whole mechanism, and it is why the next lab works.
Why pull, and not push

The other arrangement is possible: the table could push every row upwards as fast as it can read them. Some engines do work that way, and it is faster when you want all the rows. It has one bad property, though. The bottom of the plan has no idea how many rows the top actually wants, so it cannot stop early. It can produce rows faster than the operator above can absorb them.

Pulling means the demand comes from the top, where the knowledge is. It also means the middle of the plan holds almost nothing: at any moment there is roughly one row inside each operator, not a whole table.

The two operators that cannot stream

Sort is the obvious one. It cannot hand up the smallest row until it has seen every row, because the very last row it reads might be the smallest. So Sort drains everything below it into memory first, then hands rows up one at a time. An operator that has to do this is called blocking.

A hash join is half blocking. It has to load one whole side into its lookup structure before it can check anything against it, but the other side still streams through row by row. That difference in shape is exactly why a hash join and a merge join behave so differently in Step 7.

Lab 10 · What LIMIT is worth
Try this firstDrag the rows asked for slider and watch the two bars. The top bar is the query with your LIMIT, the bottom one is the same query with no LIMIT. Then press ORDER BY price then LIMIT and drag the slider again.
With a Sort in the way, LIMIT saves nothing. The bars go flat. Sort is blocking, so it drains the whole table before Limit ever gets its first row, and asking for 3 rows costs the same as asking for 200. This is one of the most common surprises in real database work: the same LIMIT that made one query instant does nothing at all for the next one.
A plan is Limit 10, above Filter, above Get on a table of five million rows. Roughly how many rows does the Get read, if one row in a hundred passes the filter?
About a thousand. Limit asks ten times. Each time, Filter has to keep pulling until a row passes, which takes about a hundred pulls. Then Limit stops asking and the scan is abandoned a thousand rows into five million. The first answer describes what a scan does when nobody stops it, which is a different thing. The second forgets that the Filter throws rows away, and those thrown-away rows still had to be read.
Step 6

Read every row, or jump to five

Back to the problem from Step 1. Five orders belong to customer 7, and finding them read all 200 rows of the table. An index can do better, and this is where the saving gets measured rather than asserted.

The index on orders.cust is that column's values in sorted order, each remembering its row. Because it is sorted, the engine can find where customer 7 starts by repeated halving: look at the middle entry, decide whether 7 is before or after it, throw away the half that cannot contain it, repeat. Each halving is one search step. Two hundred entries take eight of them, because doubling 1 eight times gets you past 200.

Why eight halvings is enough for two hundred things

Count the other way. One halving can settle between 2 things. Two halvings can settle among 4. Three among 8, then 16, 32, 64, 128, 256. That is eight steps to pick one out of 256, so 200 is comfortably inside eight.

The useful part is how slowly that grows. A thousand entries need ten steps. A million need twenty. A thousand million need thirty. Multiplying the table by a thousand adds ten steps, not a thousand steps, which is why sorted things are worth the trouble of keeping sorted.

Lab 11 · Scan against seek, on the same query
Try this firstPress Race them. Two cards appear with the work each way cost, and the rows the seek found are listed underneath. Then press cust = 25 and race again: the counts barely move, because every customer in this shop has the same number of orders.
Eight search steps, then five jumps, then stop. The seek stops the instant it meets a row that does not match, and it is allowed to, because the index is sorted: if the next entry is customer 8, there are no more 7s anywhere. The scan has no such luck. It must read row 200 to be sure row 200 is not another one.

So why does any engine ever choose a scan? Because reaching a row through an index is not free. The scan reads rows that are sitting next to each other, which is the cheapest thing a machine can do. The index sends you somewhere different every time. How much more that costs depends on the storage underneath, so the engine holds it as a setting, and in the next lab you hold it too.

Why jumping about costs more than going straight on

Storage does not hand out one row at a time. It hands out a block of neighbouring rows at once, because fetching one and fetching fifty next to it takes almost the same time. A sequential scan gets the next forty-nine rows for nothing. A jump to an unrelated row throws that away and pays the full fetch again.

How much more depends on the machine. A spinning disk has to physically move an arm, and a jump might cost hundreds of times a neighbour. A solid-state drive is far kinder, and memory kinder still. This is a real setting in real databases, with a real name, and getting it wrong is a real cause of bad plans.

Lab 12 · Find the crossing point
Try this firstDrag the cost of one index jump slider slowly from 1 up to 20. Two lines are drawn: the solid one is the scan, the dashed one is the seek. At 1 and at 2 they never cross. From 3 upwards a dotted upright line appears where they do, and it slides left as you keep dragging.
Both lines are computed by running both plans at every point on the chart. The scan line is flat because a scan reads the whole table whatever the test is. The seek line climbs because every extra matching row is another jump. The crossing is the answer to "when is an index worth using", and there is no fixed answer: it depends on a number somebody had to guess about the hardware.
Then why not put an index on every column

Three reasons, and all of them are about writing rather than reading. Every index is another copy of a column, so it takes space. Every row you add has to be added to every index, in the right sorted place, so writing gets slower with each index you create. And an index that no query uses still costs both of those every single day.

Real advice, and it is safe to skip this: create the index, measure the query, and delete the index if the plan did not change. Databases will tell you which indexes are never used, and on most systems that have been running a few years, several of them are not.

A table has a million rows and an index on country. A query asks for every row where country = 'India', and 400,000 rows match. Would the index help?
Almost certainly not. The twenty search steps are indeed nothing. The jumps are the problem: 400,000 of them, each costing several times what reading the next row costs, against one million cheap sequential reads. This is the crossing point from Lab 12, and 40 per cent of a table is well past it on any real setting. An index earns its keep when the test throws most of the table away, not when it keeps most of it.
Step 7

Three ways to join, raced

A join pairs up rows from two tables that agree about something. Here, an order's cust value and a customer's id: where those match, the two rows belong together. The column pair being matched on is the join key. Everything else in this step is three different procedures for finding those matches, and a procedure written out precisely enough for a machine to follow is called an algorithm.

The nested loop is the one you would invent first. Take the first row on the left, walk the entire right side comparing it against each row, keep the matches. Then the second left row, and walk the whole right side again. It is correct, needs nothing prepared in advance, and does left times right comparisons.

The hash join prepares. It reads one side completely and files each row under its join key in a hash table: a structure where you can find everything filed under a key without searching, by turning the key into a filing position with a fixed calculation. Then it reads the other side once, and for each row looks up its key. No walking.

What a hash is, and how a lookup can involve no searching

A hash is a calculation that turns any value into a number in a fixed range. Turn the word "Galle" into 47, always the same 47. Then keep an array of numbered pigeonholes, and file the row in pigeonhole 47. To find it later, hash "Galle" again, get 47 again, and look in that one pigeonhole. You never searched.

Two different values sometimes land in the same pigeonhole, which is why each one holds a small list rather than a single row. The cost is memory: the whole filed-away side has to fit somewhere. A real database that runs out of room for it has to spill onto disk, and a hash join that spills is a well known way for a query to fall off a cliff.

The merge join sorts both sides by the join key and then walks the two sorted lists together, advancing whichever side is behind. Each side is read once. Sorting is not free, but if the rows arrive sorted already, from an index or from an earlier Sort, this becomes the cheapest of the three.

Lab 13 · The three, on the real tables
Try this firstPress Race the three, then press Jaffna and race again. Three bars appear, sorted cheapest first, with the work each did. Watch which of the two losing methods is second as you move between the four cities.
The order of the losers flips. With Jaffna there are only 4 customers left after the filter, so the nested loop has almost nothing to loop over, while the merge join still pays to sort 200 orders. With Colombo there are 16 customers and the nested loop's bill grows with every one of them. The hash join wins throughout at this size, which is exactly what the next lab contradicts.
Lab 14 · The same three, at any size
Try this firstPress Run all three, then drag rows in each of the two tables to 4000 and press it again. The bars are drawn to scale, so the nested loop bar quickly leaves the others as a sliver. The line underneath gives the factor between the top two and how long the whole race took.
These are two tables of plain numbers, joined for real by three loops that count as they go. The nested loop's work grows with the sizes multiplied. The other two grow with them added. That difference is the entire reason a database bothers with the other two, and it is why a plan that picks a nested loop for two large tables is not slightly wrong but catastrophically wrong. Step 9 shows one choosing exactly that.
When merge join is nearly free

Most of what a merge join costs is the sorting. If both sides happen to arrive sorted on the join key already, that cost disappears and the join is one walk down two lists. Rows arriving from an index are already in that column's order, so a plan that reads both sides through indexes on the join columns gets its merge join at a discount.

There is a second prize. The rows come out of a merge join in sorted order, so if the query also has an ORDER BY on the join key, the Sort above it can be deleted. Real engines track this carefully, and it is one of the few places where the cheapest join is not the cheapest plan.

You join a table of 10 rows to a table of 5 million rows. A colleague says the nested loop is fine because 10 is a tiny number. What is missing from that?
The third. With the 10-row table on the left and no index, the nested loop walks 5 million rows ten times: 50 million comparisons. With the 5-million table on the left it walks 10 rows five million times, which is the same 50 million. The nested loop is only cheap when the inner side can be reached without walking it, which means an index on the join key. That is exactly the plan a real engine picks here, and it is fast. The first answer is the mistake that causes most nested-loop disasters, and Step 9 walks into it on purpose.
Step 8

What the engine knows before it runs

Every choice in the last two steps depended on a number the engine cannot have. Is the nested loop cheap here? Only if the left side is small. How small is the left side? That is how many rows pass the filter, and finding out means running the filter, which is the thing being planned. The engine has to decide before it is allowed to look.

So it does not look at the table. It looks at a stored summary of the table, made earlier and kept to one side. That summary is called the statistics, and the most useful part of it is a histogram: a count of how many rows carry each value of a column.

What an estimate is, and why guessing is allowed at all

An estimate is a number worked out without measuring the thing itself. The engine is not being lazy: measuring would cost more than the query. What it needs is not the exact count but a figure good enough to tell 4 apart from 4,000, because that is the scale at which the decisions change.

This is worth being clear about, because it is the reason for everything in Step 9. The engine is not choosing the best plan. It is choosing the plan that looks best given a summary that was true at some point in the past.

What a histogram is

A histogram is a tally. Go through the column once, and for each value keep a count of how many rows had it. The result is a small table: pen 1,200, mug 800, chair 900, and so on. It is far smaller than the column it describes, so it can be kept in memory and read instantly.

When a column has too many different values for that, such as a price or a date, the counts are grouped into ranges instead: how many rows between 0 and 100, how many between 100 and 200. Same idea, coarser answer. Either way it is one pass over the table, done occasionally, not per query.

Lab 15 · Build the summary and see the skew
Try this firstLook at the bars for item, then press city. Each bar is one value with its real count, computed by reading the whole column right then. The line underneath compares those counts with what an engine would guess if it had no summary and simply divided.
This is the busy shop: 1,000 customers and 4,000 orders. Real data is lumpy. Six items, but one of them is a third of all orders. Five cities, but more than half the customers are in one. That lumpiness has a name, skew, and an engine that assumes values are spread evenly will be wrong by a large factor on exactly the values that matter most.
Lab 16 · Guess before you look
Try this firstRead the question, then press one of the three numbers. The line underneath gives the real answer, found by running the query, alongside the number the engine's stored summary would have produced. Press Next one for another.
Watch the second number, not your score. The stored summary in this shop was taken when the tables were much smaller, and it has not been retaken. Sometimes its guess is close after scaling up. On one of these four it is out by a factor of more than a hundred, and that one is the subject of the next step.
How a real database builds this without reading everything

On a table of a thousand million rows, one pass to build a histogram is itself an expensive operation. So real systems sample: read a few tens of thousands of rows chosen at random, count those, and scale the counts up. The answer is approximate, and for common values it is approximate in a harmless way.

It is less harmless for rare ones. A value held by 20 rows out of a thousand million may not appear in the sample at all, and then the summary says it does not exist. Safe to skip, but remember it: it is the same failure the next step is built on.

A column has 5 different values and the table has 1,000 rows. With no histogram, an engine estimates that colour = 'red' matches 200 rows. In fact 940 rows are red. Which decision is that estimate most likely to ruin?
The first. Everything the engine decides about joining depends on how big each side is, and this estimate makes one side look five times smaller than it is. That can be enough to tip a hash join into a nested loop, and the nested loop's cost then grows with 940 rather than 200. The third answer is tempting because a factor of five sounds mild. It is mild for a filter and severe for a join, because a join multiplies.
Step 9

One bad guess, and the plan that follows

Now put it together and watch it fail. The busy shop's statistics were collected some time ago, when orders held 500 rows and the shop sold four things. It now holds 4,000 rows and sells six. Two of the items, chair and plant, did not exist when the summary was taken, so the summary has never heard of them.

Ask a real database about a value its histogram has never seen and it does not shrug. It returns the smallest estimate it has: one row. That is a sensible default, because a value absent from a sampled histogram usually is rare. It is also the most dangerous number in this course, because a plan built for one row is a plan with no safety margin at all.

Lab 17 · Plan for one row, meet nine hundred
Try this firstPress Plan it, then run it with item = 'chair' selected. The plan appears with the real row counts filled in, then the work actually spent. Then press item = 'pen' and run that: pen is in the summary, and the engine chooses differently.
Look at the Filter's "rows out" against what the engine expected. It planned for 1 and got 900. Because it expected one row on the left, a nested loop looked almost free: one walk of the customers table. It got 900 walks of a 1,000-row table instead. Nothing malfunctioned. The engine answered a question about a table that has not existed for months, and answered it correctly.
Why the engine cannot just peek at the table first

Because running the filter to find out how many rows pass is most of the work of the query. If planning cost as much as executing, planning would not be worth doing, and every query would pay it twice.

Some engines do a limited version of this anyway. They start executing, notice partway through that the row count is nothing like the estimate, throw the plan away and start again with a better one. That is real and it is called adaptive execution, and it is the exception rather than the rule because throwing away work is its own cost.

There are two ways out, and they are not equally good. You can give the engine a current summary, or you can override its decision for this one query. The first fixes the cause and helps every query. The second fixes the symptom and quietly rots.

Lab 18 · Two repairs, one better than the other
Try this firstPress Collect the statistics again. The log records what that cost, the plan is rebuilt, and the work count drops sharply. Then press Put it back as it was and try Force a hash join instead, and compare the two end states.
Both land on the same plan and the same cost today. They are not the same repair. Collecting statistics costs one pass over the tables and then every query in the database plans better. Forcing the join fixes this query and leaves the engine believing the old numbers for every other query, including the ones nobody has written yet. Real databases have both, and the command for the first is usually spelled ANALYZE.
The other classic way estimates go wrong

An engine that meets two tests at once usually multiplies their selectivities: if one keeps a tenth and the other keeps a fifth, together they keep a fiftieth. That is correct only when the two columns have nothing to do with each other. Ask for city = 'Kandy' and postcode = '20000' and the second test throws away nothing at all, because every Kandy address already has that postcode. The engine estimates a fiftieth and gets a tenth.

This is called correlation, and it is harder to fix than a stale summary, because it needs statistics about pairs of columns rather than single ones. Most systems can be told to collect those for a specific pair, and almost nobody does until a query goes wrong.

A report that ran in two seconds every morning for a year suddenly takes forty minutes. Nothing in the query changed, and no new code was deployed. Which explanation fits best?
The second. Query times usually degrade in step with the data, so a jump by a factor of a thousand from one day to the next points at a change of plan rather than a change of size. Something moved the estimate across a boundary the planner cares about, and it switched joining methods. A missing index can cause a jump too, so the third answer is not silly, but "only a missing index" is what makes it wrong. The first answer fails because a table that grew forty times overnight would be noticed by everybody.
Step 10

Doing less by doing it earlier

One rewrite is worth more than all the others put together, and it is the simplest. If a Filter sits above a Join and its test only reads columns from one of the two tables, the Filter can be moved down below the Join, onto that table alone. The answer is identical. The work is not.

The arithmetic is easy to see. Above the join, the test runs once per pair, and a join of 40 customers against 200 orders makes 8,000 pairs. Below the join, the same test runs 40 times and the join then only ever sees the customers that passed. Moving one node down the tree is called pushing the predicate down and every real database does it before it considers anything else.

Lab 19 · Move one node, twice
Try this firstPress test below the join and compare the two numbers in the line underneath. The tree redraws with the Filter in its new place, and the "rows out" number on the join drops. Then press each of the four cities and watch how much the saving depends on which one.
The saving is largest where the filter is fussiest. Jaffna has 4 customers and Colombo has 16, so pushing the test down leaves the join four times less to do in the first case. The plan above the join costs the same whatever the city, because it builds all 8,000 pairs before it looks at anything. A plan whose cost does not depend on how selective your question was is nearly always the wrong plan.
The other thing worth pushing down

Columns, not just rows. If a query only ever uses name and item, there is no reason to carry price, qty and everything else through the join and the sort. Moving the Project down so unwanted columns are dropped at the earliest possible moment is called projection pushdown.

It saves less than filtering does in this engine, because a row here is a small object in memory. In a real system reading from storage it can matter enormously, and in the column-oriented databases built for reporting it is the single biggest saving there is: a query that names three columns out of two hundred reads three columns' worth of storage.

When a test cannot be pushed down

Two cases. If the test reads columns from both tables, such as orders.price > customers.credit, there is nowhere below the join where both are available, so it has to stay above. And with the kinds of join that keep unmatched rows, filling the missing side with blanks, moving a test across the join changes which rows survive, so the rewrite is not allowed even though it looks harmless.

Safe to skip, but this is a good example of what an engine's rewriting rules actually are: a list of transformations, each with a precise condition under which it does not change the answer. Getting one of those conditions wrong is how a database returns the wrong result, which is far worse than returning the right one slowly.

Lab 20 · Get this query under budget
Try this firstPress Check this plan straight away, before changing anything. It reports the right rows at far too high a cost, and names the budget. Then change the three settings until you are under 700, checking after each change.
The query is fixed and only the plan moves. The check runs your plan and compares the rows against the answer worked out separately, so a plan that is fast and wrong fails loudly. Two of the twelve plans come in under budget and they have one thing in common. When you find them, look at what the "rows out" number on the join does between the two settings.
A join produces 8,000 pairs, and a Filter above it keeps 30 of them. Someone pushes the filter below the join. Which of these changes?
The third. The rewrite is only allowed because the answer is identical, so the first is exactly what a rewrite must never do. The second misses where the cost lives: the test itself is cheap either way, and what changed is that the join no longer builds thousands of pairs that were about to be thrown away. Cost in a query plan is almost never in the tests. It is in the rows that reach them.
Step 11

NULL, types and groups

A parser can accept a legal sentence that still makes no sense. The binder resolves each name to a table and column, checks permissions, inserts safe type conversions and rejects an expression such as a date added to a customer name. It also expands stars and decides which output name each expression receives.

SQL has a special marker called NULL for a missing or unknown value. A comparison with NULL is neither true nor false; it is unknown. A WHERE keeps only true, so both false and unknown are removed. Use IS NULL, not = NULL.

Groups, aggregates and windows

GROUP BY city makes one group per city. COUNT, SUM and AVG reduce each group to one row. HAVING filters groups after aggregation; WHERE filters input rows before it.

A window function also works across related rows, but it keeps every input row. It can number rows, compare with a previous row or compute a running total without collapsing the result.

Lab 21 · Evaluate three-valued logic
Try this firstChoose values for A and B. Compare A AND B, A OR B and NOT A, then see whether WHERE keeps the row.
Unknown is not false. It sometimes behaves like false in WHERE because neither is kept, but the difference appears when NOT, OR, joins and constraints are involved.
Lab 22 · Run a grouped pipeline
Try this firstMove the WHERE and HAVING thresholds. Watch rows become groups, then groups become output rows. Compare that with a running-total window.
Order is part of meaning. Filtering before grouping changes group contents. Filtering with HAVING changes which completed groups survive.
What does price = NULL evaluate to when price is missing?
Unknown. Equality cannot decide whether an unknown value equals anything. Use price IS NULL when you mean to test for the marker itself.
Step 12

Prove a rewrite keeps the answer

An optimiser uses algebraic rules to rearrange a logical plan. Inner joins can often swap sides or group in another order. Filters can combine, constant expressions can be evaluated once, and an unused column can be removed. Every rule needs a condition under which the result is identical.

Outer joins, NULLs, duplicate rows, floating-point arithmetic and functions with side effects limit those rules. Pushing a right-side predicate below a left join can turn a kept unmatched row into a removed row. Reordering two floating-point sums can change the final low bits.

Lab 23 · Find the unsafe rewrite
Try this firstTry each rewrite against ordinary rows, duplicate rows and NULL-extended outer-join rows. The checker compares result bags, not just sets.
SQL normally keeps duplicates. A rule that preserves the set of distinct rows can still be wrong because it changes how many copies appear.
Lab 24 · Build a rule guard
Try this firstSelect join type, predicate side and NULL behaviour. Ask whether predicate pushdown is legal, then read the exact guard condition.
A rewrite library is executable mathematics. Each rule should have property tests that generate small tables and compare the old and new plan over awkward cases.
How an optimiser tests algebraic rules

Generate tiny tables containing duplicates, NULLs and empty inputs. Run the plan before and after the rewrite, then compare complete result bags. Shrink a failing case until the missing guard is obvious.

This catches correctness faults that performance benchmarks cannot see. Keep the reduced case as a permanent regression test.

Why compare result bags when testing a rewrite?
Multiplicity is part of the answer. Unless DISTINCT is present, two identical rows are two result rows. A set comparison would hide a wrong rewrite.
Step 13

Which join happens first

With three tables, the engine can join A with B first or B with C first. With ten tables there are millions of alternatives once access paths and join algorithms are included. Exhaustive search eventually costs more than the query it is planning.

Dynamic programming keeps the cheapest known plan for each subset of tables and builds larger subsets from them. It can find the best plan in its search space, but its state count still grows exponentially. Large queries use restricted shapes, greedy search, randomised search or a planning-time budget.

Lab 25 · Enumerate join trees
Try this firstIncrease the table count from three to ten. Compare left-deep orders, bushy trees and dynamic-programming subset states.
Search is an optimiser workload of its own. A planner seeks a good execution plan within a bounded planning time, not an abstract optimum at any cost.
Lab 26 · One early join changes everything
Try this firstChoose the first pair. The model propagates actual and estimated rows through the remaining joins. Find the order that avoids the large intermediate table.
Intermediate cardinality is the main cost. A selective join early can reduce every later operator. A bad estimate can make the planner choose the opposite order.
Interesting orderings reduce search

Two plans for the same table subset can differ in output order or partitioning. A dearer subplan may avoid a later sort or network shuffle, so dynamic programming sometimes keeps more than one winner per subset.

The state key must include physical properties the parent can use. Keeping every property would make the search large again, so engines keep only useful alternatives.

Why not enumerate every plan for a 20-table query?
The search itself becomes too expensive. Planners use limits and heuristics once the exhaustive dynamic-programming region is too large.
Step 14

When an operator runs out of memory

Sorts, hash joins and hash aggregates are blocking operators: they may need a large input before producing output. If that state fits in the memory budget, they are fast. If not, they partition or write temporary runs to storage and merge them later. This is called a spill.

A memory limit applies per operator and often per parallel worker, not once for the server. Raising it for one report can prevent a spill. Raising it globally can let many simultaneous queries exhaust memory. The planner must estimate row count and row width, then the executor must enforce a hard limit anyway.

Lab 27 · Make a sort spill
Try this firstChange rows, row width and memory. Watch the model form sorted runs and merge passes. Find the smallest memory setting that avoids temporary I/O.
Count bytes, not rows. Ten thousand wide rows may need more memory than a million narrow keys. EXPLAIN output that reports temp reads and writes is evidence of a spill.
Lab 28 · Partition a hash join
Try this firstMake the build side exceed memory. The engine hashes both inputs into matching partitions, then joins one partition at a time.
A spill should remain correct. It is a slower execution path, not an error path. Test it deliberately; otherwise production data may be the first input large enough to use it.
One query's budget is not the server's budget

A plan can contain several memory-hungry operators and several workers. Many copies may run concurrently. Multiply before setting a global limit.

Admission control can delay new heavy queries until memory is available. That queue is often safer than letting the operating system kill an arbitrary process.

A sort has 200 MB of input and a 50 MB memory budget. What is the safe expectation?
It spills. Memory is a performance boundary, not a correctness boundary. The external algorithm must still return every row in order.
Step 15

Rows, batches and compiled loops

The iterator in Step 5 calls for one row at a time. It is easy to compose and good when a query stops early. Analytical engines often process a batch of hundreds or thousands of values per call. A tight loop over one column uses processor caches and SIMD instructions more effectively and pays less function-call overhead.

Just-in-time compilation can turn an expression tree into machine code specialised for this query. Compilation has a startup cost, so it helps long, CPU-heavy work and hurts tiny lookups. Engines often use a cost threshold before compiling.

Lab 29 · Compare row and batch execution
Try this firstIncrease the input size and batch width. Compare calls, cache-friendly loops and time-to-first-row.
Latency and throughput are different goals. Row-at-a-time can produce the first row sooner. Batch execution can finish a large scan sooner.
Lab 30 · Decide whether to compile
Try this firstMove row count and expression complexity. Compare interpreted work with compilation startup plus a cheaper compiled loop.
Compilation must pay back its startup cost. Cache compiled code only when the schema, expression and relevant settings make reuse safe.
Late materialisation

A columnar executor can carry row positions through filters and fetch wide output values only for survivors. This is called late materialisation. It avoids moving unused strings through every operator.

The opposite can win when nearly every row survives or random fetches are expensive. Selectivity and storage layout decide the boundary.

Which query is the best JIT candidate?
The large CPU-heavy scan. It repeats enough work to recover compilation cost.
Step 16

Parallel and distributed plans

A parallel scan divides pages among workers. Partial aggregates run beside the scans, then a gather node combines them. More workers can shorten elapsed time, but startup, coordination and memory use rise. A small input or a serial operator can make extra workers slower.

Across machines, an exchange operator moves rows through the network. A broadcast join copies a small table to every worker. A shuffle join hashes both sides by the join key so matching rows meet. Network bytes, skew and stragglers become part of the cost model.

Lab 31 · Find the useful worker count
Try this firstIncrease workers. Watch scan time fall until coordination and the serial finish dominate. Then change the table size.
Amdahl's law appears in a query plan. Parallel workers cannot shorten a serial planning, gather or finalisation stage.
Lab 32 · Broadcast or shuffle
Try this firstChange both table sizes and key skew. Choose a broadcast or shuffle exchange and compare network bytes and the slowest partition.
Average partition size can hide a straggler. One hot key can send most rows to one worker. Salt, split or handle that key separately when the distribution is known.
Cancellation must cross every exchange

If LIMIT has enough rows or one worker fails, upstream tasks must stop, close network streams and release temporary files. Otherwise a cancelled query keeps consuming the cluster.

Distributed plans therefore carry query ids, deadlines and cancellation signals alongside data batches. Cleanup is part of the operator contract.

When is broadcasting one join side attractive?
Broadcast the small side. It avoids repartitioning the large side, at the cost of one copy per worker.
Step 17

Reuse, cache and maintain results

A prepared statement separates SQL structure from parameter values. It avoids repeated parse and bind work and prevents values being mistaken for SQL syntax. The planner may build a custom plan for each value or reuse a generic one. A rare customer and a common customer can need different plans.

A result cache or materialised view stores answers rather than plans. It helps repeated expensive work, but must define freshness, invalidation and permissions. Incremental view maintenance applies each base-table change to the saved result instead of recomputing everything.

Lab 33 · Generic plan or custom plan
Try this firstRun a parameterised query for common and rare values. Compare one generic plan with a value-specific plan and include planning cost.
Prepared does not mean one plan forever. Replan when schema or statistics change, and measure parameter distributions before forcing one cache mode.
Lab 34 · Recompute or maintain a view
Try this firstChange base-table size, update rate and read rate. Compare recomputation, cached stale results and incremental maintenance.
A cache is a consistency decision. State how old an answer may be and what event invalidates it. Include tenant and permission context in the cache key.
What invalidates a cached plan

Schema changes, dropped indexes and relevant setting changes can make an old plan invalid. Statistics or parameter distributions can make it valid but poor.

Track dependencies from a cached plan to catalog objects. Replan on structural changes, and use measured execution evidence to decide when a merely poor plan should be replaced.

Why bind a user value as a parameter instead of joining it into SQL text?
The value remains data. Parameter binding is both safer and easier to reuse than constructing SQL by string concatenation.
Step 18

Learn from execution without trusting guesses

An adaptive executor checks actual rows at a safe boundary and can switch a later join, resize a hash table or repartition work. It must define when switching is legal and count work already spent. Restarting halfway through a side-effecting statement is not the same as changing a read-only join.

Learned cardinality and cost models can capture correlations missed by hand-written formulas. They also face drift, sparse training regions and expensive mistakes. Keep a conventional fallback, uncertainty gates, an offline replay set and per-operator error measurements. A language model may explain plans or suggest hypotheses; it should not invent statistics or execute a production rewrite.

Lab 35 · Switch after the estimate fails
Try this firstSet estimated and actual rows. Choose never adapt, adapt early or adapt late. The model includes work discarded before the switch.
A switch has a break-even point. Reacting to every small error creates churn. Waiting too long turns adaptation into an expensive restart.
Lab 36 · Gate a learned estimate
Try this firstChange model estimate, uncertainty and drift. The gate chooses learned, extended-statistics or conservative fallback estimates.
Evaluate the decision, not only prediction error. A 10× row error matters most when it crosses a plan boundary. Track plan regret and tail latency beside Q-error.
Adaptation and side effects

A read-only subplan can often be restarted or repartitioned. An update, external function or sequence call may have already changed state. The executor cannot replay it blindly.

Mark safe adaptive boundaries in the plan. Above a side-effecting operator, prefer choices that preserve exactly-once effects or do not require replay.

What is the safest role for a new learned estimator?
Gate and replay it. The model earns authority only where evidence shows it improves decisions, and the engine retains a safe path when inputs drift.
Step 19

Explain, secure and test the plan

EXPLAIN reports estimated rows and costs. EXPLAIN ANALYZE executes and adds actual rows, loops and timing; buffer and memory options expose reads and working space. Multiply rows per loop before comparing. Remember that profiling adds overhead and a modifying statement still modifies unless protected.

A query engine also enforces a security boundary. Bind parameters, resolve names in a controlled schema, check privileges after rewriting, apply row-level policies to every valid plan and limit statement time, rows, memory and temporary storage. A faster plan must never bypass a policy operator.

Lab 37 · Read an instrumented plan
Try this firstSelect a bad estimate, repeated inner scan, spill or buffer miss. Find the first operator where estimate and reality diverge.
Start at the first wrong row count. Time at a parent is often inherited from children. Fix causes before adding hints to symptoms.
Lab 38 · Run the optimiser regression gate
Try this firstChange a cost coefficient or learned estimate. Replay representative queries and check result equivalence, planning time, tail latency and resource caps.
Plan changes are software changes. Keep plan fingerprints and execution evidence, but avoid pinning every plan forever. Data and hardware change, so controlled replanning is necessary.
Current manuals and research

PostgreSQL documents EXPLAIN, extended statistics, JIT and generic and custom prepared plans. Engine-specific behaviour belongs in the matching version's manual.

Learned estimators are an active research area. Treat a paper's benchmark as evidence for that workload, not a universal replacement claim. Reproduce the comparison on your distributions and failure cases.

What is the first useful comparison in an instrumented plan?
Compare row counts. Cardinality drives access paths, join algorithms, join order, memory and parallelism.
Step 20

Your query, your plan

Everything is now on the table. You write a question in SQL. A tokenizer cuts it up, a parser builds a tree, a plan builder turns that into operators, a planner picks how each one will be done using a stored summary that may be out of date, and a pipeline of operators pulls rows through one at a time while a counter adds up the work.

The first lab is a sandbox with no marking. Write anything the parser accepts, on either size of shop, and either let the engine choose or make the three choices yourself.

Lab 39 · The whole engine, nothing to get right
Try this firstPress Run it on the query that is already there, then press busy shop and run it again. Same query, twenty times the orders, and one line of the plan changes: on the busy shop the engine reaches the Kandy customers through the index instead of reading all thousand. Then press I will choose and set the joining method to nested loop.
Things worth trying. Put an ORDER BY on a query with a LIMIT and watch the saving vanish. Filter on cust, which has an index, then on item, which does not, and compare. Ask for a city that does not exist. Join the two tables with no WHERE at all on the busy shop, and see what 4,000 rows against 1,000 costs each way.
What this looks like in a real database

Put the word EXPLAIN in front of any query and most databases print the plan instead of running it. The output is the same shape as the trees in this course: indented operators, read from the bottom up, each with an estimated row count. Add the word ANALYZE as well and it runs the query and prints the real counts beside the estimates.

That side-by-side pair is the single most useful thing in database work. A line where the estimate says 1 and the reality says 900,000 is Step 9 happening to you, and it points at the fix without any guessing.

What to try first when a query is slow

In order. Print the plan and find the operator handing up the most rows, because that is where the cost is. Compare each estimate against the real count and find the first line where they diverge badly, because everything above it was planned on a wrong number. If they diverge, refresh the statistics before touching anything else.

Only then consider an index, and only on a column whose test throws most of the table away. Adding an index is the first thing most people reach for and it is rarely the answer, because an index cannot help a plan whose problem is that it thought one row was coming and got a million.

Lab 40 · Answer the question, under budget
Try this firstPress Check my answer on the starting query. It says what is missing from it. The task: return the customer name and the item for every order placed by a customer who lives in Galle, in under 700 work units.
Both halves are checked by running it. The rows are compared against the 30 correct name-and-item pairs, worked out from the tables without any plan at all, and the cost is the one your query's plan really spent. If the rows are right and the cost is too high, the message says so, and the fix is about where you put the test on city rather than about the columns you asked for.
Someone says "SQL is slow, so I will fetch both whole tables into my own program and match them up there". What have they signed up for?
The first. The work does not disappear when you move it. Every piece of this course still has to happen: the rows have to be reached, the pairs have to be matched, and something has to decide how. The difference is that the database has the statistics, the indexes and thirty years of tuning, and the hand-written version usually has a nested loop. Sometimes moving the work out really is right, and it is right for reasons about your particular data that you can only argue once you know what the engine was going to do instead.

Where to go next

  • Inside a Database. The layer underneath this one: how rows are actually stored on disk, how an index is really built out of a tree of blocks, and what happens to a half-finished change when the power goes out.
  • Build a Language. The tokenizer and parser in Step 2 are the front of every compiler, and that course takes them all the way through to code a processor can run.
  • Distributed Consistency. Everything here assumed one machine holding all the rows. Spread the tables over several and the join gets a network in the middle of it, which changes every cost in this course.