Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 29

Optimizing Join Order with Dynamic Programming

Recommended resources

Hello! Welcome back to our module on Query Optimization.

In the last lesson, we saw how to take a single physical query plan and calculate its estimated I/O cost. You learned that choices about join algorithms and access paths can lead to orders-of-magnitude differences in performance. That raises a critical question: with so many possible plans, how does a database find the best one?

Today, we will answer that question. Our learning outcome is to apply a dynamic programming algorithm (e.g., System R) to find an optimal join order. We'll explore the enormous search space of potential plans and learn the classic, elegant algorithm that forms the backbone of most modern query optimizers for systematically navigating this space.

The Combinatorial Explosion of Join Ordering

When a query joins tables, the number of possible join orders grows explosively. The optimizer can't simply generate and cost every single plan—it would take far longer than just running a mediocre plan.

For example, for a 4-table join (A, B, C, D), you could have plans like:

  • (((A ⋈ B) ⋈ C) ⋈ D)
  • ((A ⋈ B) ⋈ (C ⋈ D))
  • ((C ⋈ A) ⋈ D) ⋈ B)
  • ...and many, many more.

The number of structurally different binary trees is given by Catalan numbers, and when you factor in the different permutations of tables, the total number of plans becomes immense. For just 10 tables, there are over 175,000 different join trees.

The pioneering optimizer in IBM's System R solved this by making two key simplifying assumptions:

  1. Only consider left-deep join trees, which have the form (...((R1 ⋈ R2) ⋈ R3) ⋈ ... ⋈ Rn).
  2. Do not consider plans that involve Cartesian products if a join with a predicate is possible.

Left-deep plans are particularly well-suited for pipelined execution, where the output of one join is streamed directly as the input to the next, minimizing the need for memory to store large intermediate results. Let's quickly see why.

Query Optimization (part 3)

To understand why the System R optimizer focuses on a specific type of join tree, let's look at this section from the University of Washington's lecture notes. It explains the practical advantages of 'left-deep' plans.

Please review slide 14, titled 'Selinger Optimizer', which lists the key strategies, and slide 15, 'Why Left-Deep', which details the benefits for pipelining and memory usage.

Even with these simplifications, the search space is large. The solution is to explore it intelligently using dynamic programming.


The System R Dynamic Programming Algorithm

The core idea is to solve the join ordering problem in a bottom-up fashion. We find the best plans for small sets of tables first, and then use those results to find the best plans for progressively larger sets. This relies on the principle of optimality: an optimal plan for joining tables must be composed of an optimal plan for joining of those tables, joined with the remaining table.

Let's see this elegant algorithm in action.

#15 - Query Planning & Optimization (CMU Intro to Database Systems)

Now let's see the System R dynamic programming algorithm in action. This segment from the CMU Database Systems course provides a clear, bottom-up walkthrough of how the optimizer builds and costs join plans iteratively.

Watch from 27:46 to 43:54. Focus on: The concept of passes (single table, multi-table). How the algorithm builds up from single-relation plans to two-relation plans, and so on. The principle of optimality: why you can safely prune more expensive sub-plans. The specific focus on 'left-deep' trees, which you just read about.

The Algorithm in Detail

As you saw in the video, the algorithm proceeds in passes, filling a table (or cache) of optimal subplans. Let's formalize this.

For a join of N tables {R1, R2, ..., Rn}:

  • Pass 1: Find best plans for subsets of size 1.

    • For each table Ri, find the cheapest access path (e.g., file scan, or an index scan if a predicate applies).
    • Store (Ri, plan, cost) in the cache.
  • Pass 2: Find best plans for subsets of size 2.

    • For each subset {Ri, Rj}, find the cost of optimal_plan(Ri) ⋈ Rj.
    • The cost is cost(optimal_plan(Ri)) + cost(access Rj) + join_cost.
    • Compare this to optimal_plan(Rj) ⋈ Ri.
    • Store the cheaper of the two plans as the optimal plan for {Ri, Rj}.
  • Pass k: Find best plans for subsets of size k.

    • For each subset S of size k, consider every way to split it into an optimal plan for a subset of size k-1 and a single table.
    • For each split (S - {Ri}, Ri), calculate the cost: cost(optimal_plan(S - {Ri})) + cost_to_join_with_Ri.
    • The cost for optimal_plan(S - {Ri}) is retrieved directly from the cache (computed in Pass k-1).
    • The cheapest plan found across all possible splits becomes the optimal plan for subset S.

This process continues until k = N, at which point we have the optimal plan for the entire set of tables.

To help solidify this process, let's look at another excellent resource that visualizes the DP table being populated.

Query Optimization (part 3)

To reinforce the process you just saw, these slides from the University of Washington provide a static, step-by-step illustration of the same dynamic programming algorithm for a 4-table join. This is a great way to review the mechanics of how the DP table is populated.

Carefully study slides 18 through 32. Trace how the optjoin table is built from d=1 (single tables) to d=4 (the final plan). For each step, see how it reuses the cached costs from the previous steps.

Test your understanding!

Imagine you are in Pass 3 of the algorithm for a join of tables A, B, C, and D. You need to find the optimal plan for the subset {A, B, C}. Your cache from Pass 2 already contains the following optimal plans and costs:

  • opt_plan({A, B}): Plan is B ⋈ A, Cost = 156
  • opt_plan({A, C}): Plan is A ⋈ C, Cost = 250
  • opt_plan({B, C}): Plan is B ⋈ C, Cost = 98

To find opt_plan({A, B, C}), you must consider three possibilities. What are they?

Show answer

You must consider joining a cached size-2 plan with the remaining size-1 table:

  1. Join opt_plan({A, B}) with C. The plan would be (B ⋈ A) ⋈ C.
  2. Join opt_plan({A, C}) with B. The plan would be (A ⋈ C) ⋈ B.
  3. Join opt_plan({B, C}) with A. The plan would be (B ⋈ C) ⋈ A.

The optimizer would calculate the total cost for each of these three options and store only the cheapest one as the optimal plan for {A, B, C}.


Refinements and Modern Approaches

The basic System R algorithm is powerful, but real-world optimizers add more sophistication. Two important refinements are interesting orders and using a join graph.

  • Interesting Orders: A sub-plan might not be the absolute cheapest, but it might produce tuples sorted in an "interesting" way (e.g., sorted on a future join key). This could make a subsequent Sort-Merge Join much cheaper. Optimizers often keep track of the cheapest plan for each interesting order, not just the single cheapest overall plan.
  • Join Graph: Instead of trying to join all possible sub-plans, the optimizer first looks at the WHERE clause to build a graph connecting tables that have explicit join predicates. It then only enumerates join orders that correspond to traversing this graph, effectively pruning away all plans that would require a Cartesian product.

Let's watch a short video that explains these two key ideas.

Database Systems: Query Optimization (Part 1), rule- vs cost-based, join graph, interesting orders

The basic DP algorithm is powerful, but real-world optimizers use some crucial refinements. This video explains two of the most important ones: 'interesting orders' and using the 'join graph' to avoid considering pointless cross products.

Watch from 01:12:24 to 01:23:22. First, focus on the 'interesting orders' section (until 01:19:38). Understand how a plan producing a sorted output can be valuable even if it's not the cheapest. Then, watch the next section on how using the join graph prunes the search space by ignoring joins between tables that aren't related by a predicate.

The System R DP algorithm (DPsize in the literature) is still the foundation. However, for queries with very complex predicates (e.g., R1.a + R2.b = R3.c) or a very large number of tables, more advanced techniques are needed.

pg_dphyp: teach PostgreSQL to JOIN tables in a different ...

The bottom-up DP algorithm we've studied is the foundation, but what happens when queries get very complex? This technical blog post introduces a more modern algorithm called DPhyp, used in systems like PostgreSQL, which is smarter about how it explores the search space.

This is an advanced but insightful read. Skim the 'DPsize' section to see a description of the algorithm we've just learned. Read the 'DPhyp' section to understand its core idea: using a 'hypergraph' to represent join predicates, which is more powerful than a simple graph for multi-way join conditions. Finally, read the 'Query complexity' section. It explains a key insight: DPhyp's complexity depends on the query graph's shape, not just the number of tables. This is why it can handle some queries with many tables very efficiently.


Conclusion

You now understand the core logic that database systems use to tackle one of their hardest problems: finding an efficient join order. By systematically building solutions to small problems and reusing those solutions to solve larger ones, the dynamic programming approach tames a problem that would otherwise be computationally intractable.

Key Takeaways:

  • The number of possible join orders for a query grows factorially, making exhaustive search impossible.
  • The System R dynamic programming algorithm provides a systematic, bottom-up approach to find an optimal join order.
  • The algorithm works in passes, finding optimal plans for subsets of tables of increasing size (1, 2, 3, ..., N).
  • It relies on the principle of optimality, caching and reusing the costs of optimal sub-plans to build larger plans.
  • Heuristics like considering only left-deep plans and refinements like using a join graph and tracking interesting orders are crucial for making the algorithm efficient and effective in practice.

Preview of the Next Lesson

We have now determined the best logical plan—that is, the optimal sequence of joins. The final step is to create the full physical plan. In the next lesson, we will see how the optimizer translates this chosen logical plan into an executable physical plan by selecting the most appropriate physical algorithm (e.g., Hash Join, Index Nested Loop Join) for each logical operator.