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.
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.
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
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.
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.
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.
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.
qty above 3, and press Check my
answer.FROM, or write qty > three, and read what it says.SELECT name FROM customers WHERE city = Galle, without the quotes
around Galle. The engine refuses it. Which part refused, and why?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.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.
LIMIT 3 from the end of the query and
press Build the plan again: exactly one line disappears, and it is the top one.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.
price > 1000, then Sort by price, then Limit 5. Which is the first thing that actually
happens when the query runs?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.
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.
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.
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.
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.
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.
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.
country. A query asks for every
row where country = 'India', and 400,000 rows match. Would the index help?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.
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.
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.
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.
colour = 'red' matches 200 rows. In fact 940 rows are red. Which
decision is that estimate most likely to ruin?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.
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.
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.
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.
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.
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.
A AND B,
A OR B and NOT A, then see whether WHERE keeps the row.price = NULL evaluate to when price is missing?price IS NULL when you mean to test for the marker itself.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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.