Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 30 minutes
  • Examples run with sql.js 1.14.2, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 11 of 30 minutes free
Free: Why rewriting comes before choosing a plan · The equivalence rules · Pushing selections down, with numbers · 3 more sections. With a pass: 2 in-depth parts and 3 interview answers, about 19 minutes.

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:

  1. Split a selection, always: σp∧q(E)≡σp(σq(E))\sigma_{p \wedge q}(E) \equiv \sigma_p(\sigma_q(E))
  2. Reorder selections, always: σp(σq(E))≡σq(σp(E))\sigma_p(\sigma_q(E)) \equiv \sigma_q(\sigma_p(E))
  3. Push a selection below a join, when p uses only columns of E1: σp(E1⋈E2)≡σp(E1)⋈E2\sigma_p(E_1 \bowtie E_2) \equiv \sigma_p(E_1) \bowtie E_2
  4. Turn a product into a join, when p compares columns of E1 and E2: σp(E1×E2)≡E1⋈pE2\sigma_p(E_1 \times E_2) \equiv E_1 \bowtie_p E_2
  5. Swap the inputs of a join, for inner joins, where only the column order changes: E1⋈E2≡E2⋈E1E_1 \bowtie E_2 \equiv E_2 \bowtie E_1
  6. Regroup joins, for inner joins, with each condition kept beside the inputs it uses: (E1⋈E2)⋈E3≡E1⋈(E2⋈E3)(E_1 \bowtie E_2) \bowtie E_3 \equiv E_1 \bowtie (E_2 \bowtie E_3)
  7. Push a projection below a join, when L1 and L2 keep the columns of L and the columns c compares: πL(E1⋈cE2)≡πL(πL1(E1)⋈cπL2(E2))\pi_L(E_1 \bowtie_c E_2) \equiv \pi_L(\pi_{L_1}(E_1) \bowtie_c \pi_{L_2}(E_2))
  8. Push a selection into a union, and in the same way into an intersection or a difference: σp(E1∪E2)≡σp(E1)∪σp(E2)\sigma_p(E_1 \cup E_2) \equiv \sigma_p(E_1) \cup \sigma_p(E_2)

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:

The same question, filtered late and filtered early Python · pushdown.py
# 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

Two query trees for the same question: filtering after both joins builds 30,000 joined rows, filtering first builds 3,256.As writtenSelections pushed downstudents3,000enrolments15,000courses40⋈ sid15,000⋈ cid15,000σ dept = CSE AND credits = 4581students3,000σ dept = CSE535enrolments15,000courses40σ credits = 49⋈ sid2,675⋈ cid581same 581 rows,3,256 joined rowsinstead of 30,000

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 FROM subquery 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 JOIN into an inner join when WHERE removes every row padded with NULLs.
  • Join elimination drops a LEFT JOIN that supplies no column and at most one matching row.
  • Constant folding computes constant expressions while planning: PostgreSQL’s manual shows x = 2 + 2 becoming x = 4.

SQLite shows two of them in its plans, for a view that joins students to their enrolments:

A view merged into the query, and a filter pushed into a DISTINCT view SQL · flatten.sql
-- 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

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.

  1. Question 1 of 4 A query computes σ[dept = 'CSE' AND credits = 4](students ⋈ enrolments ⋈ courses), where dept is a column of students and credits a column of courses. Which rewrite is equivalent?

    Choose one answer.

    Show the answer to question 1

    Answer: σ[dept = 'CSE'](students) ⋈ enrolments ⋈ σ[credits = 4](courses)

    Split the AND into two selections (rule 1), then push each one down to the table that has its column (rule 3). students has no credits column and enrolments has no dept column, so the other two choices put a condition where it cannot be evaluated.

  2. Question 2 of 4 When are two query expressions equivalent?

    Choose one answer.

    Show the answer to question 2

    Answer: When they return the same rows, with the same number of copies, on every database that fits the schema

    Equivalence is a promise about every possible state of the database. Two queries that agree on today's rows can still differ after the next insert, and SQL's bags make the number of copies of each row part of the result.

  3. Question 3 of 4 In the lesson's run, how many rows do the two joins build in total once the selections are pushed down to their tables?

    Type a number.

    Show the answer to question 3

    Answer: 3256 rows

    The join of the 535 CSE students with enrolments builds 2,675 rows, and its join with the 9 four-credit courses builds 581: 2,675 + 581 = 3,256, against 15,000 + 15,000 = 30,000 for the tree as written.

  4. Question 4 of 4 SQLite's plan for a query on a view shows CO-ROUTINE for the view, with the outer filter applied inside it. What happened?

    Choose one answer.

    Show the answer to question 4

    Answer: The view could not be merged into the query, so the filter was pushed down into it instead

    A view that cannot be flattened, for example one with DISTINCT, runs as a co-routine that produces its rows on demand. SQLite still copies the outer WHERE condition into it when that is safe, so the view's own index can be used.

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 in SCHEMA;
  • ("select", column, value, child) keeps the rows of child whose column equals value;
  • ("join", left, right) is the natural join of left and right, 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.

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.

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.

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

  1. 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.

  2. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

  4. 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?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Two queries are equivalent only if they agree on every database, copies of rows included.
  • Push each condition of WHERE to 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.