OLAP – Phase 9 Query Planner and Optimizer

The parser produces an AST — a syntactic representation of the SQL query. But the AST doesn’t know whether table names exist, what types columns have, or how to execute the query efficiently. This phase bridges the gap with three components: a binder that resolves names and types, an optimizer that rewrites the logical plan for efficiency, and a physical planner that maps logical operators to executable physical operators.

The pipeline is: AST → Binder → Logical Plan → Optimizer → Physical Planner → Physical Plan.

In DuckDB, these are the Binder (src/planner/binder/), Optimizer (src/optimizer/), and PhysicalPlanGenerator (src/execution/physical_plan_generator.cpp).

Here is the roadmap for the phases to come:

  • Phase 9: Query planner and optimizer
  • Phase 10: Sorting, parallel execution, REPL, and server

Full Source Code

The code referenced in this post can be found in https://gitlab.com/kimserey.lam/olap-learn.

Logical Operators

The logical plan is a tree of operators that describe what to compute, not how:

1
2
3
4
type LogicalOperator interface {
    Children() []LogicalOperator
    SetChildren(children []LogicalOperator)
}

The concrete operators mirror the query structure:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
type LogicalGet struct {
    TableName     string
    ColumnIndices []int
    ColumnTypes   []vector.LogicalType
    ColumnNames   []string
}

type LogicalFilter struct {
    Predicate BoundExpr
    child     LogicalOperator
}

type LogicalProjection struct {
    Expressions []BoundExpr
    Aliases     []string
    child       LogicalOperator
}

type LogicalAggregate struct {
    GroupBy    []BoundExpr
    Aggregates []AggregateBinding
    child      LogicalOperator
}

type LogicalJoin struct {
    JoinType  LogicalJoinType
    Condition BoundExpr
    left      LogicalOperator
    right     LogicalOperator
}

type LogicalSort struct {
    Keys  []LogicalSortKey
    child LogicalOperator
}

type LogicalLimit struct {
    Limit  int64
    Offset int64
    child  LogicalOperator
}

Bound Expressions

The binder resolves AST expressions into bound expressions — expressions with resolved types and column indices:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
type BoundColumnRef struct {
    TableName string
    ColName   string
    ColIndex  int
    Typ       vector.LogicalType
}

type BoundConstant struct {
    Value any
    Typ   vector.LogicalType
}

type BoundBinaryExpr struct {
    Left  BoundExpr
    Op    string
    Right BoundExpr
    Typ   vector.LogicalType
}

type BoundFunctionCall struct {
    Name     string
    Args     []BoundExpr
    Star     bool
    Distinct bool
    Typ      vector.LogicalType
}

The key difference from AST expressions: a BoundColumnRef has a resolved ColIndex and Typ, not just a name string.


The Binder

The binder walks the AST and resolves every name against the catalog:

  1. Look up the table name → get schema (column names and types)
  2. Resolve each column reference → find the column index and type
  3. Type-check expressions → ensure comparisons and arithmetic are valid
  4. Build the logical plan tree

For a query like:

1
2
3
4
SELECT region, SUM(amount)
FROM orders
WHERE year > 2023
GROUP BY region

The binder:

  • Looks up orders in the catalog, gets its columns
  • Resolves region → column index 1, type Varchar
  • Resolves amount → column index 2, type Float64
  • Resolves year → column index 0, type Int32
  • Checks that year > 2023 is a valid comparison (Int32 > Int64)
  • Builds: LogicalProjection → LogicalAggregate → LogicalFilter → LogicalGet

Optimizer

The optimizer rewrites the logical plan to reduce work. We implement three rules:

1. Predicate Pushdown

Move filter conditions as close to the data source as possible. Before:

1
2
3
4
Filter(country='US')
  └── Join(o.rid = r.id)
        ├── Scan(orders)
        └── Scan(regions)

After:

1
2
3
4
Join(o.rid = r.id)
  ├── Scan(orders)
  └── Filter(country='US')
        └── Scan(regions)

The filter on country only references the regions table, so it can be pushed below the join. This eliminates non-matching regions before the join, reducing the number of rows that flow through the (expensive) join operator.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func pushFilter(filter *LogicalFilter) LogicalOperator {
    join, ok := filter.child.(*LogicalJoin)
    if !ok {
        return filter
    }

    leftTables := collectTableNames(join.left)
    rightTables := collectTableNames(join.right)
    refs := collectColumnRefs(filter.Predicate)

    allLeft := true
    allRight := true
    for _, ref := range refs {
        if !tableInSet(ref.TableName, leftTables) { allLeft = false }
        if !tableInSet(ref.TableName, rightTables) { allRight = false }
    }

    if allLeft && !allRight {
        join.left = &LogicalFilter{Predicate: filter.Predicate, child: join.left}
        return join
    }
    if allRight && !allLeft {
        join.right = &LogicalFilter{Predicate: filter.Predicate, child: join.right}
        return join
    }
    return filter // references both sides, can't push
}

2. Constant Folding

Evaluate constant expressions at plan time instead of at execution time:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func foldExpr(expr BoundExpr) BoundExpr {
    bin, ok := expr.(*BoundBinaryExpr)
    if !ok {
        return expr
    }
    bin.Left = foldExpr(bin.Left)
    bin.Right = foldExpr(bin.Right)

    leftConst, leftOk := bin.Left.(*BoundConstant)
    rightConst, rightOk := bin.Right.(*BoundConstant)
    if !leftOk || !rightOk {
        return bin
    }

    // both sides are constants — evaluate now
    switch bin.Op {
    case "+":
        return &BoundConstant{Value: lf + rf, Typ: bin.Typ}
    case "-":
        return &BoundConstant{Value: lf - rf, Typ: bin.Typ}
    // ...
    }
}

WHERE amount > 50 + 50 becomes WHERE amount > 100.0 — the 50 + 50 is folded into a single constant.

3. Projection Pushdown

Only read columns that are actually needed by the query. The optimizer collects all column references from the plan tree, then prunes each LogicalGet to only include those columns:

1
2
3
4
func ProjectionPushdown(plan LogicalOperator) LogicalOperator {
    needed := collectNeededColumns(plan)
    return applyProjectionPushdown(plan, needed)
}

If a table has 10 columns but the query only uses 2, the scan reads and decompresses only those 2 column segments.


Physical Planner

The physical planner maps logical operators to physical operators:

Logical Physical
LogicalGet SeqScanOperator
LogicalFilter FilterOperator
LogicalProjection ProjectionOperator
LogicalAggregate HashAggregateOperator
LogicalJoin HashJoinOperator
LogicalSort SortOperator
LogicalLimit LimitOperator

The mapping is mostly one-to-one, but the physical planner also handles details like:

  • Converting BoundExpr to Expression (execution-layer expressions)
  • Setting up column index mappings between operators
  • Choosing join build/probe sides

End-to-End Example

1
2
3
4
5
SELECT o.region, SUM(o.amount)
FROM orders o
JOIN regions r ON o.region_id = r.id
WHERE r.country = 'US'
GROUP BY o.region;

Before optimization:

1
2
3
4
5
6
Projection [o.region, SUM(o.amount)]
  └── Aggregate [GROUP BY o.region, SUM(o.amount)]
        └── Filter [r.country = 'US']
              └── Join [o.region_id = r.id]
                    ├── Get [orders]
                    └── Get [regions]

After predicate pushdown:

1
2
3
4
5
6
Projection [o.region, SUM(o.amount)]
  └── Aggregate [GROUP BY o.region, SUM(o.amount)]
        └── Join [o.region_id = r.id]
              ├── Get [orders]
              └── Filter [r.country = 'US']
                    └── Get [regions]

After projection pushdown:

1
2
Same structure, but Get[orders] only reads columns: region, amount, region_id
and Get[regions] only reads columns: id, country

Summary

The planner pipeline transforms SQL text into an executable plan through three stages: binding (name resolution and type checking), optimization (predicate pushdown, constant folding, projection pushdown), and physical planning (mapping logical to physical operators). Each optimization reduces the amount of data processed — fewer rows through early filtering, fewer columns through projection pruning, and fewer computations through constant folding.