Database Management Systems (DBMS) Module 10 – Query processing and optimisation
Logical optimisation: equivalence rules, rewrites
Push selections and projections down a query tree, test a rewrite on random databases, and see why NULLs and outer joins make some rewrites wrong.
What you will learn
- Apply equivalence rules to push selections and projections down a query tree
- Test a rewrite on random databases and read the smallest counterexample it finds
- Recognise the rewrites that NULLs and outer joins make invalid
- Name the rewrites real engines make, such as view merging and subquery unnesting
Before you start
What you’ll learn · Before you start
What you will learn
- Apply equivalence rules to push selections and projections down a query tree
- Test a rewrite on random databases and read the smallest counterexample it finds
- Recognise the rewrites that NULLs and outer joins make invalid
- Name the rewrites real engines make, such as view merging and subquery unnesting
Before you start
On this page
Logical optimisation rewrites a query into another query that returns the same rows on every possible database but is cheaper to run. It works on the query tree, before the engine picks indexes, join algorithms or a join order. Its rewrites come from the equivalence rules of relational algebra: a selection can be split, reordered and moved below a join to the table it filters; columns nobody needs can be dropped early; inner joins can be swapped and regrouped. The goal is small intermediate results, because every row an operator produces has to be built, held and handed on. Engines apply these rules without being asked, PostgreSQL in its rewriter and planner and SQLite while it compiles a statement. The familiar rules stop holding at outer joins and NULLs, where a wrong rewrite does not fail: it quietly returns different rows.
Why rewriting comes before choosing a plan
A query tree built straight from the SQL text follows the clauses in order: the joins of FROM, then WHERE,
then SELECT. Run as written, it builds every joined row and discards most of them at the end. A rewrite changes
the shape of the tree, never its result, and the planner then prices the cheaper shape. PostgreSQL’s manual gives
that order: the rewrite system replaces each view with its tables, then the planner builds the possible paths and
keeps the cheapest.
Two expressions are equivalent when they return the same result on every database that fits the schema, not only on today’s rows. SQL tables are bags, in which a row may appear more than once, so an equivalence rule must also keep the number of copies of each row.
The equivalence rules
Each rule says that two expressions return the same rows, and when that holds:
- Split a selection, always:
- Reorder selections, always:
- Push a selection below a join, when p uses only columns of E1:
- Turn a product into a join, when p compares columns of E1 and E2:
- Swap the inputs of a join, for inner joins, where only the column order changes:
- Regroup joins, for inner joins, with each condition kept beside the inputs it uses:
- Push a projection below a join, when L1 and L2 keep the columns of L and the columns c compares:
- Push a selection into a union, and in the same way into an intersection or a difference:
Rules 1 and 3 do most of the work: split the WHERE clause into its conditions and move each one down to the
lowest node that has all of its columns. Rule 7 does the same for columns, and rules 5 and 6 let the planner choose
any order for inner joins, the search of the lesson on join ordering.
Pushing selections down, with numbers
Take a college database of 3,000 students, 40 courses and 15,000 enrolments, and ask for the enrolments of CSE students in 4-credit courses. The program evaluates both trees with a small evaluator of its own and counts the rows each operator produces:
# One question, two query trees: filter after the joins, or filter first.
# A tiny evaluator runs both on the same seeded college data and counts the
# rows each operator hands to the next one.
import random
rng = random.Random(7)
DEPTS = ["CSE", "ECE", "EEE", "MECH", "CIVIL", "CHEM"]
students = [{"sid": i, "name": f"S{i:04d}", "dept": rng.choice(DEPTS)} for i in range(1, 3001)]
courses = [{"cid": c, "credits": rng.choice([2, 3, 3, 4])} for c in range(1, 41)]
enrolments = [{"sid": s["sid"], "cid": c} for s in students for c in rng.sample(range(1, 41), 5)]
log = []
def scan(rows, name):
log.append((f"scan {name}", len(rows)))
return rows
def select(rows, label, keep):
out = [r for r in rows if keep(r)]
log.append((f"select {label}", len(out)))
return out
def join(left, right, key, label):
index = {}
for r in right:
index.setdefault(r[key], []).append(r)
out = [{**l, **r} for l in left for r in index.get(l[key], [])]
log.append((f"join {label}", len(out)))
return out
def names(rows):
return sorted(r["name"] for r in rows)
def run(title, plan):
log.clear()
result = plan()
print(title)
for step, n in log:
print(f" {step:<34}{n:>7,} rows")
built = sum(n for step, n in log if step.startswith("join"))
print(f" {'rows built by the two joins':<34}{built:>7,}\n")
return result
late = run("Filter after the joins (the query as written)", lambda: names(
select(join(join(scan(students, "students"), scan(enrolments, "enrolments"), "sid", "on sid"),
scan(courses, "courses"), "cid", "on cid"),
"dept = CSE and credits = 4", lambda r: r["dept"] == "CSE" and r["credits"] == 4)))
early = run("Filters pushed down to their tables", lambda: names(
join(join(select(scan(students, "students"), "dept = CSE", lambda r: r["dept"] == "CSE"),
scan(enrolments, "enrolments"), "sid", "on sid"),
select(scan(courses, "courses"), "credits = 4", lambda r: r["credits"] == 4), "cid", "on cid")))
print("same answer:", late == early, f"({len(early):,} enrolments)") Output
Filter after the joins (the query as written) scan students 3,000 rows scan enrolments 15,000 rows join on sid 15,000 rows scan courses 40 rows join on cid 15,000 rows select dept = CSE and credits = 4 581 rows rows built by the two joins 30,000 Filters pushed down to their tables scan students 3,000 rows select dept = CSE 535 rows scan enrolments 15,000 rows join on sid 2,675 rows scan courses 40 rows select credits = 4 9 rows join on cid 581 rows rows built by the two joins 3,256 same answer: True (581 enrolments)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pushdown.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The college query as written, and with its selections pushed down to their tables
Text description of the diagram
The figure shows two query trees. In each, rows flow from the tables at the top down to the result at the bottom, and every box gives the number of rows that operator produced in the lesson's run.
1. As written: students (3,000 rows) and enrolments (15,000) are joined on sid, giving 15,000 rows. That result is joined with courses (40) on cid, giving 15,000 rows again. Only then does the selection dept = CSE AND credits = 4 run, keeping 581 rows. 2. Selections pushed down: the selection dept = CSE runs on students first and keeps 535 rows, and the selection credits = 4 runs on courses and keeps 9. The join of the 535 students with enrolments on sid gives 2,675 rows, and its join with the 9 courses on cid gives the same 581 rows as before. 3. The arrow between the trees says that both return the same 581 rows, while the two joins of the second tree build 3,256 rows instead of 30,000.
Both trees return the same 581 enrolments. The tree as written builds 15,000 joined rows twice and keeps 581. The rewritten tree filters 3,000 students down to 535 and 40 courses down to 9 first, so its joins build 3,256 rows instead of 30,000, about a ninth. In a real engine the gain is usually larger: smaller join inputs fit in memory, and a condition that reaches its table can use that table’s index.
Rewrites engines make without being asked
Optimisers apply more rewrites than the eight rules, each with a name that appears in plans and manuals:
- View merging, or subquery flattening, replaces a view or a
FROMsubquery with its tables and conditions. - Predicate pushdown copies an outer condition into a subquery that cannot be merged.
- Subquery unnesting, or decorrelation, turns a correlated subquery into a join.
- Outer-join reduction turns a
LEFT JOINinto an inner join whenWHEREremoves every row padded with NULLs. - Join elimination drops a
LEFT JOINthat supplies no column and at most one matching row. - Constant folding computes constant expressions while planning: PostgreSQL’s manual shows
x = 2 + 2becomingx = 4.
SQLite shows two of them in its plans, for a view that joins students to their enrolments:
-- A view over a join, queried with a filter: SQLite merges the view into the query.
CREATE TABLE students (sid INTEGER PRIMARY KEY, name TEXT NOT NULL, dept TEXT NOT NULL);
CREATE INDEX students_dept ON students (dept);
CREATE TABLE enrolments (sid INTEGER NOT NULL, cid INTEGER NOT NULL, PRIMARY KEY (sid, cid));
INSERT INTO students VALUES (1, 'Asha', 'CSE'), (2, 'Ravi', 'ECE'), (3, 'Meera', 'CSE'), (4, 'Kiran', 'MECH');
INSERT INTO enrolments VALUES (1, 10), (1, 11), (2, 10), (3, 11), (4, 12);
CREATE VIEW enrolled AS
SELECT s.sid, s.name, s.dept, e.cid
FROM students AS s JOIN enrolments AS e ON e.sid = s.sid;
SELECT name, cid FROM enrolled WHERE dept = 'CSE' ORDER BY name, cid;
EXPLAIN QUERY PLAN SELECT name, cid FROM enrolled WHERE dept = 'CSE';
-- DISTINCT stops the merge, but the filter is still pushed inside the view.
CREATE VIEW enrolled_once AS
SELECT DISTINCT s.sid, s.name, s.dept
FROM students AS s JOIN enrolments AS e ON e.sid = s.sid;
EXPLAIN QUERY PLAN SELECT name FROM enrolled_once WHERE dept = 'CSE'; Output
┌───────┬─────┐ │ name │ cid │ ├───────┼─────┤ │ Asha │ 10 │ │ Asha │ 11 │ │ Meera │ 11 │ └───────┴─────┘ ┌────┬────────┬─────────┬─────────────────────────────────────────────────────────────────────┐ │ id │ parent │ notused │ detail │ ├────┼────────┼─────────┼─────────────────────────────────────────────────────────────────────┤ │ 4 │ 0 │ 62 │ SEARCH s USING INDEX students_dept (dept=?) │ │ 9 │ 0 │ 56 │ SEARCH e USING COVERING INDEX sqlite_autoindex_enrolments_1 (sid=?) │ └────┴────────┴─────────┴─────────────────────────────────────────────────────────────────────┘ ┌────┬────────┬─────────┬─────────────────────────────────────────────────────────────────────┐ │ id │ parent │ notused │ detail │ ├────┼────────┼─────────┼─────────────────────────────────────────────────────────────────────┤ │ 2 │ 0 │ 0 │ CO-ROUTINE enrolled_once │ │ 7 │ 2 │ 62 │ SEARCH s USING INDEX students_dept (dept=?) │ │ 12 │ 2 │ 56 │ SEARCH e USING COVERING INDEX sqlite_autoindex_enrolments_1 (sid=?) │ │ 27 │ 0 │ 52 │ SCAN enrolled_once │ └────┴────────┴─────────┴─────────────────────────────────────────────────────────────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < flatten.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
In the first plan the view has disappeared: SQLite finds the CSE students through the index on dept, as if the
query had named both tables. The second view uses DISTINCT, which SQLite’s documentation lists among the
conditions that prevent flattening, so it runs as a CO-ROUTINE, with the condition on dept pushed inside it to
the index.
Check yourself
Check yourself
4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Exercise
Exercise · Medium · Python
Write the selection-pushdown rule for small query trees
Write push_down(tree) in pushdown.py. A query tree is a tuple:
("table", name)is one of the tables inSCHEMA;("select", column, value, child)keeps the rows ofchildwhosecolumnequalsvalue;("join", left, right)is the natural join ofleftandright, on the columns they share.
columns(tree), already in the file, returns the set of columns a tree produces. Return an equivalent tree in which every selection sits as low as it can: a selection above a join moves into the side that has its column, and into the left side when both sides have it. Keep pushing until each selection sits directly above a table or above another selection. Tables and joins stay where they are.
The sample tests check the shape of the result for single and stacked selections, a selection on a join column, a tree with nothing to move, and that the rewritten tree returns the same rows as the original on a small database.
Starter code · pushdown.py
"""Selection pushdown on small query trees.
A tree is a tuple:
("table", name) a table of SCHEMA
("select", column, value, child) the rows of child whose column equals value
("join", left, right) the natural join of left and right
"""
SCHEMA = {
"students": ("sid", "name", "dept"),
"enrolments": ("sid", "cid"),
"courses": ("cid", "title", "credits"),
}
def columns(tree):
"""The set of columns a tree produces."""
if tree[0] == "table":
return set(SCHEMA[tree[1]])
if tree[0] == "select":
return columns(tree[3])
return columns(tree[1]) | columns(tree[2])
def push_down(tree):
"""An equivalent tree with every selection moved down as far as it can go."""
# Replace this line with your code.
return tree The sample tests · test_pushdown.py
from pushdown import push_down
S, E, C = ("table", "students"), ("table", "enrolments"), ("table", "courses")
def select(column, value, child):
return ("select", column, value, child)
def join(left, right):
return ("join", left, right)
def evaluate(tree, db):
"""Runs a tree on db (table name -> list of dicts): the result as a sorted list of rows."""
def run(t):
if t[0] == "table":
return [dict(r) for r in db[t[1]]]
if t[0] == "select":
return [r for r in run(t[3]) if r[t[1]] == t[2]]
left, right = run(t[1]), run(t[2])
common = set(left[0]) & set(right[0]) if left and right else set()
return [{**l, **r} for l in left for r in right if all(l[c] == r[c] for c in common)]
return sorted(tuple(sorted(r.items())) for r in run(tree))
DB = {
"students": [{"sid": 1, "name": "Asha", "dept": "CSE"}, {"sid": 2, "name": "Ravi", "dept": "ECE"},
{"sid": 3, "name": "Meera", "dept": "CSE"}],
"enrolments": [{"sid": 1, "cid": 10}, {"sid": 1, "cid": 11}, {"sid": 2, "cid": 10}, {"sid": 3, "cid": 11}],
"courses": [{"cid": 10, "title": "Databases", "credits": 4}, {"cid": 11, "title": "Writing", "credits": 2}],
}
def test_a_selection_moves_into_the_left_table():
"""dept is a column of students, so the selection goes below the join, onto students"""
assert push_down(select("dept", "CSE", join(S, E))) == join(select("dept", "CSE", S), E)
def test_a_selection_moves_into_the_right_table():
"""credits is a column of courses only"""
assert push_down(select("credits", 4, join(E, C))) == join(E, select("credits", 4, C))
def test_two_selections_each_find_their_table():
"""a cascade of selections is split between the two sides"""
tree = select("credits", 4, select("dept", "CSE", join(join(S, E), C)))
assert push_down(tree) == join(join(select("dept", "CSE", S), E), select("credits", 4, C))
def test_a_join_column_goes_to_the_left_side():
"""sid is on both sides of students JOIN enrolments: the left side gets the selection"""
assert push_down(select("sid", 1, join(S, E))) == join(select("sid", 1, S), E)
def test_tables_and_joins_without_selections_stay_as_they_are():
tree = join(join(S, E), C)
assert push_down(tree) == tree
assert push_down(S) == S
def test_the_rewritten_tree_returns_the_same_rows():
"""pushing down changes the shape, never the result"""
tree = select("title", "Databases", select("name", "Asha", join(join(S, E), C)))
assert evaluate(push_down(tree), DB) == evaluate(tree, DB)
assert len(evaluate(tree, DB)) == 1 A hint
Write it recursively. Push down inside the children first. Then, for a selection whose child is a join, test column in columns(left): if it is there, the new tree is a join of the selection applied to the left side (pushed down again, since that side may itself be a join) with the right side unchanged; otherwise the selection goes into the right side.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Proving a rewrite, and refuting one on 1,000 random databases Pro: opens with a Pro, Premium or Ultimate pass
A rewrite is safe only if it holds on every database, and hand-made test tables rarely contain the one that breaks it. This part proves the pushdown rule, then runs eight rewrites on 1,000 random databases with NULLs and shrinks each failure to its smallest counterexample.
In this part:
- Why a selection may move below a join: the proof
- Eight rewrites on 1,000 random databases, measured
- Reading the smallest counterexamples
1 runnable example (Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Outer joins and subqueries: the conditions that make a rewrite legal Pro: opens with a Pro, Premium or Ultimate pass
Most wrong rewrites in practice involve outer joins and subqueries, where the inner-join rules stop holding. This part states the conditions engines check, shows SQLite applying one of them, and reproduces the classic mistake of unnesting a subquery that counts.
In this part:
- When a LEFT JOIN may become an inner join
- Reordering outer joins: three identities and their conditions
- Unnesting a subquery that counts: the bug, reproduced
- A filter that must stay outside a subquery with LIMIT
3 runnable examples (SQL) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
When are two query expressions equivalent, and why does an optimiser rewrite a query before it chooses indexes and join algorithms?
Two expressions are equivalent when they return the same result, with the same number of copies of each row, on every database that fits the schema; agreeing on the rows stored today is not enough. An optimiser rewrites first because a tree built from the SQL text follows the clauses as written: it joins everything and filters at the end. The equivalence rules let it split the WHERE clause, move each condition down to the table it filters, drop unneeded columns early and reorder inner joins, so the intermediate results shrink before any index, join algorithm or join order is priced. In the lesson's college query, pushing two filters down cut the rows built by the joins from 30,000 to 3,256, and the answer stayed the same 581 rows.
-
Mid to senior
A pull request moves p.status = 'paid' from the ON clause of orders LEFT JOIN payments p into the WHERE clause, as a tidy-up. Every test passes, but the next day the unpaid-orders report is empty. Explain what changed, what the optimiser now does with the join, and how you would have caught it before release.
-
Senior to staff
You are adding a rule to an in-house query engine that moves a filter written above a GROUP BY down to the rows before grouping. When is that rewrite valid, when is it not, and how would you test the rule before it ships?
-
Mid to senior
An engine rewrites SELECT d.name FROM depts d WHERE d.budget > 1000 * (SELECT count(*) FROM staff s WHERE s.dept_id = d.id) into a join of depts with the staff counts grouped by dept_id. Which departments can the rewritten query lose, why, and what should the rewrite produce instead?
Key takeaways
- Two queries are equivalent only if they agree on every database, copies of rows included.
- Push each condition of
WHEREto the lowest node that has its columns: the college query’s joins built 3,256 rows instead of 30,000. - Engines merge views, push conditions down, unnest subqueries and fold constants themselves: write the clearest query, then read its plan.
References
- PostgreSQL 18 Documentation: The Path of a Query (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Planner/Optimizer (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Controlling the Planner with Explicit JOIN Clauses (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Function Volatility Categories (The PostgreSQL Global Development Group)
- PostgreSQL source: src/backend/optimizer/README (valid outer join optimizations) (The PostgreSQL Global Development Group)
- The SQLite Query Optimizer Overview (SQLite)
- EXPLAIN QUERY PLAN (SQLite)
- W. Kim: On Optimizing an SQL-like Nested Query (ACM Transactions on Database Systems)
- R. A. Ganski and H. K. T. Wong: Optimization of Nested SQL Queries Revisited (ACM SIGMOD Conference)
- C. Galindo-Legaria and A. Rosenthal: Outerjoin Simplification and Reordering for Query Optimization (ACM Transactions on Database Systems)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress