Free Ebook cover SQL Joins Demystified: Inner, Left, Right, Full, and Self Joins

SQL Joins Demystified: Inner, Left, Right, Full, and Self Joins

New course

12 pages

Join Order and Optimizer Realities: Avoiding Common Misconceptions

Capítulo 9

Estimated reading time: 14 minutes

+ Exercise

Why “Join Order” Is Confusing in SQL

When people talk about “join order,” they often mix together three different ideas: (1) the order of tables written in the SQL text, (2) the logical order implied by parentheses in a query, and (3) the physical order in which the database engine actually executes operations. The first two are visible to you; the third is mostly controlled by the optimizer. Many misconceptions come from assuming these three are the same.

In most modern relational databases, SQL is a declarative language: you describe the result you want, not the step-by-step procedure to get it. The optimizer is free to reorder joins and choose algorithms (nested loop, hash join, merge join) as long as the final result is equivalent to the query’s logical meaning.

This chapter focuses on what you can and cannot assume about join order, how optimizers really behave, and how to avoid common performance and correctness traps—without rehashing basic join definitions.

Logical Join Order vs Physical Execution Order

Logical meaning: what must be preserved

The SQL query has a logical meaning that must be preserved. For many queries, the optimizer can swap the order of joins because the result is mathematically equivalent (commutativity and associativity). However, there are important exceptions where reordering changes results, especially when outer joins, non-deterministic functions, or filters are involved.

Physical execution: what the engine actually does

The optimizer chooses a plan based on estimated cost. That plan may join tables in a different order than you wrote them. It may also push filters down, reorder predicates, choose different indexes, or even rewrite parts of the query. Two queries that look different can compile to the same plan; two queries that look similar can compile to very different plans depending on statistics and parameter values.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

Common Misconception #1: “The Database Joins Tables Left-to-Right as Written”

Writing:

SELECT ... FROM A JOIN B ON ... JOIN C ON ...

does not guarantee the engine will join A to B first, then join that result to C. The optimizer may decide that joining B and C first is cheaper, or that it can start from C using an index and then probe into A and B.

Practical reality

  • If all joins are inner joins and there are no constraints that prevent reordering, the optimizer typically explores multiple join orders and picks a low-cost one.
  • If you see a plan that starts from a table you listed last, that is normal.

Step-by-step: how to verify join order in practice

Use your database’s plan inspection tools:

  • PostgreSQL:
    EXPLAIN (ANALYZE, BUFFERS) SELECT ...
  • SQL Server: “Actual Execution Plan” in SSMS, or
    SET STATISTICS IO, TIME ON;
  • MySQL:
    EXPLAIN FORMAT=JSON SELECT ...
  • Oracle:
    EXPLAIN PLAN FOR SELECT ...; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);

Then identify: (1) which table is accessed first, (2) which join algorithm is used, and (3) whether filters are applied early or late.

Common Misconception #2: “Parentheses Force Execution Order”

Parentheses can affect the logical meaning in some cases, but they do not necessarily force the physical execution order. For inner joins, parentheses usually do not change the meaning, so the optimizer can still reorder. For outer joins, parentheses can change meaning and therefore restrict reordering.

Inner-join-only example: parentheses rarely matter

-- Query 1 (no parentheses)  
SELECT ...
FROM A
JOIN B ON ...
JOIN C ON ...;

-- Query 2 (parentheses)  
SELECT ...
FROM (A JOIN B ON ...)
JOIN C ON ...;

If all joins are inner joins and predicates are equivalent, these are typically the same logically, so the optimizer can choose the same plan.

Outer join example: parentheses can change results

With outer joins, grouping can change which rows are preserved. That changes the query’s meaning, so the optimizer must respect it. The key point is not “parentheses force execution,” but “parentheses can change semantics, which limits reordering.”

Common Misconception #3: “The Optimizer Always Finds the Best Plan”

Optimizers are sophisticated but not omniscient. They rely on statistics and assumptions. They often do a cost-based search that may be limited by time/complexity, and they may use heuristics. The result is usually good, but not always optimal.

Why the optimizer can be wrong

  • Bad cardinality estimates: If the optimizer misestimates how many rows pass a filter or match a join, it may pick the wrong join order or algorithm.
  • Outdated or missing statistics: If stats don’t reflect current data distribution, estimates drift.
  • Parameter sensitivity: A plan compiled for one parameter value may be poor for another (common in prepared statements and stored procedures).
  • Correlation between columns: Optimizers often assume independence (e.g., filter on country and city), which can be wrong.
  • Skew: A few values occur extremely often; averages mislead.

Step-by-step: diagnosing estimate problems

  • Inspect estimated vs actual rows in the execution plan (where available).
  • Look for big gaps (e.g., estimated 10 rows, actual 1,000,000).
  • Check whether the join order seems to be driven by a wrong assumption (starting from a huge table when a selective filter exists elsewhere).
  • Update statistics (or analyze/vacuum, depending on DB) and re-check.

Join Reordering Rules: When the Optimizer Has Freedom (and When It Doesn’t)

Inner joins: high freedom

With only inner joins, the optimizer can usually reorder joins freely because inner join is associative and commutative (under typical relational assumptions). This is why “write the joins in the best order” is often less important than “write predicates that allow good selectivity and indexing.”

Outer joins: restricted freedom

Outer joins preserve rows from one side even when there is no match. That preservation requirement restricts reordering. Some transformations are still possible, but only if they preserve semantics. In practice, outer joins often “pin” parts of the join order.

Non-equality predicates and complex conditions

Joins that use inequalities, ranges, or expressions can limit algorithm choices and reordering. For example, a merge join typically needs compatible ordering; a hash join typically needs equality; nested loops can handle more general predicates but may be expensive.

Non-deterministic functions and volatile expressions

If join predicates or filters use functions like random number generators, current timestamps, or user-defined functions with side effects, the optimizer may be more conservative. Even when it can reorder, the results might not be stable across executions because the function values change.

Filter Pushdown: The “Hidden Join Order” You Should Think About

Many performance outcomes attributed to “join order” are actually about when filters are applied. Optimizers try to apply selective filters as early as possible (predicate pushdown) to reduce intermediate row counts before joining.

Misconception: “WHERE runs after joins”

Logically, SQL has an order of evaluation, but physically the optimizer can apply filters earlier if it is safe. If a filter references only one table, it can often be applied before the join, reducing work.

Practical example: selective filter changes everything

SELECT o.order_id, c.customer_id
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE o.order_date >= DATE '2026-01-01';

If orders has an index on order_date, the optimizer may start from the filtered subset of orders and then join to customers. If it misestimates selectivity, it might start from customers instead, causing far more work.

Step-by-step: making filter pushdown easier

  • Keep predicates sargable when possible (avoid wrapping indexed columns in functions that prevent index use).
  • Filter early using derived tables/CTEs only when it changes semantics or helps the optimizer see selectivity (but be aware some systems may materialize CTEs).
  • Ensure statistics exist on filtered columns.

CTEs, Derived Tables, and Materialization: Another Source of Misconceptions

A common belief is that a CTE is “just a named subquery” and will always be inlined. That is not universally true. Some databases may materialize a CTE (compute it and store the result temporarily), which can prevent join reordering across the CTE boundary and can change performance drastically.

Example pattern

WITH recent_orders AS (
  SELECT *
  FROM orders
  WHERE order_date >= DATE '2026-01-01'
)
SELECT ro.order_id, c.customer_id
FROM recent_orders ro
JOIN customers c ON c.customer_id = ro.customer_id;

Depending on the database and version, the optimizer might inline recent_orders (allowing full optimization) or materialize it (potentially good if reused, potentially bad if huge and used once).

Step-by-step: how to handle CTE performance uncertainty

  • Check the execution plan to see whether the CTE is materialized.
  • If materialization hurts and your DB supports it, use hints/options to inline or avoid materialization.
  • If you actually want materialization (e.g., reuse the CTE multiple times), ensure it is selective and consider indexing strategies (temporary tables in some systems).

Join Algorithms and Why They Change the “Best” Join Order

Even if two join orders are logically equivalent, the best physical plan depends on join algorithms and available indexes.

Nested loop joins

Often effective when one input is small and the other has an index on the join key. In that case, the optimizer may prefer to start with the small, filtered table and probe into the larger one.

Hash joins

Often effective for large inputs with equality joins, especially when no useful indexes exist. The optimizer chooses a build side (typically the smaller input) and a probe side. If it misestimates sizes, it may build the hash table on the wrong side, increasing memory usage and spilling to disk.

Merge joins

Often effective when both inputs are already sorted on join keys (or can be sorted cheaply). Join order may be influenced by whether the engine can exploit existing order (e.g., from an index scan).

Step-by-step: reading plans with algorithms in mind

  • Identify the join operator (nested loop/hash/merge).
  • For nested loops: confirm the inner side uses an index seek/lookup rather than a full scan.
  • For hash joins: check memory/spill indicators and the estimated vs actual row counts.
  • For merge joins: check whether sorts were required and how expensive they were.

Cardinality Estimation: The Real Driver Behind Join Order Choices

Optimizers choose join order largely to minimize intermediate result sizes. That depends on cardinality estimates: how many rows remain after filters and how many matches occur in joins.

Where estimates go wrong

  • Many-to-many joins: If the optimizer assumes near-uniqueness but the relationship is many-to-many, intermediate results explode.
  • Skewed distributions: A “hot” key value can produce far more matches than average.
  • Compound predicates: Multiple filters on the same table may not be independent.

Practical mitigation techniques

  • Create appropriate statistics (including multi-column statistics where supported) when columns are correlated.
  • Use constraints (primary keys, foreign keys) where true; optimizers can use them to improve estimates.
  • Consider rewriting queries to make selectivity clearer (e.g., pre-aggregating large detail tables before joining when you only need aggregated results).

Query Rewrites That Change the Optimizer’s Options (Without Changing Results)

Sometimes you rewrite not because the result is wrong, but because the optimizer needs help seeing a cheaper path. The goal is to preserve semantics while improving estimations or enabling indexes.

Rewrite 1: Pre-aggregate before joining

If you join a large fact table to dimensions and only need totals per key, pre-aggregating can shrink the join input dramatically.

-- Instead of joining all line items then aggregating:
SELECT c.customer_id, SUM(li.amount) AS total
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
JOIN line_items li ON li.order_id = o.order_id
WHERE o.order_date >= DATE '2026-01-01'
GROUP BY c.customer_id;

-- Pre-aggregate line_items by order first (one row per order):
WITH li_by_order AS (
  SELECT order_id, SUM(amount) AS order_total
  FROM line_items
  GROUP BY order_id
)
SELECT c.customer_id, SUM(lbo.order_total) AS total
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
JOIN li_by_order lbo ON lbo.order_id = o.order_id
WHERE o.order_date >= DATE '2026-01-01'
GROUP BY c.customer_id;

Both return totals per customer, but the second can reduce join volume if line_items is much larger than orders.

Rewrite 2: Replace OR conditions that block index usage

Complex OR predicates can prevent good index plans, which indirectly affects join order. Sometimes splitting into UNION ALL branches (when safe) allows better access paths.

-- Potentially hard to optimize:
SELECT ...
FROM A
JOIN B ON ...
WHERE A.status = 'OPEN' OR A.priority = 'HIGH';

-- Sometimes better (ensure duplicates are acceptable or deduplicate explicitly):
SELECT ...
FROM A
JOIN B ON ...
WHERE A.status = 'OPEN'
UNION ALL
SELECT ...
FROM A
JOIN B ON ...
WHERE A.priority = 'HIGH' AND A.status <> 'OPEN';

This is not universally better, but it demonstrates how access path changes can lead to different join orders and costs.

When You Might Need to Influence Join Order

Most of the time, you should not fight the optimizer. But there are cases where influencing join order is reasonable:

  • Severe misestimation that persists even with updated statistics.
  • Parameter-sensitive workloads where one cached plan is bad for many executions.
  • Complex queries where the optimizer’s search space is huge and it settles on a suboptimal plan.

Ways to influence (database-dependent)

  • Hints (e.g., join order hints, join algorithm hints). Use sparingly; they can become liabilities as data changes.
  • Rewriting to reduce ambiguity (pre-aggregation, selective subqueries, splitting logic).
  • Indexing to make the desired access path cheaper so the optimizer naturally chooses it.
  • Plan guides/baselines in systems that support them, to stabilize known-good plans.

Step-by-Step Workflow: Avoiding Join-Order Myths in Real Tuning

Step 1: Define what “slow” means

Measure runtime, CPU, reads, and memory. A query can be slow due to I/O, CPU, contention, or spills—not just join order.

Step 2: Capture the actual execution plan

Do not tune based on the SQL text alone. The plan reveals the real join order, algorithms, and where time is spent.

Step 3: Check row estimates vs actuals

Large mismatches indicate the optimizer is making decisions with bad information. Fixing estimates often fixes join order automatically.

Step 4: Identify the biggest cost driver

  • Large scans that should be seeks?
  • Hash join spilling to disk?
  • Nested loop doing millions of lookups?
  • Sorts that dominate runtime?

Step 5: Apply the least invasive fix first

  • Update/create statistics.
  • Add or adjust indexes aligned with filters and join keys.
  • Rewrite to reduce intermediate rows (pre-aggregate, simplify predicates).

Step 6: Re-test with representative parameters and data

Join order can change with parameter values. Test typical and worst-case values. If plan instability is the issue, consider parameter-handling strategies supported by your DB (e.g., recompile options, plan baselines, or query variants).

Correctness Traps: When “Optimizing Join Order” Breaks Results

Some “optimizations” change semantics. These are common ways people accidentally change results while trying to force a join order or reduce rows:

Trap 1: Moving predicates across outer join boundaries

A filter that references the preserved side vs the non-preserved side can change which rows survive. If you rewrite by moving predicates between a subquery and the outer query, confirm the logical meaning is unchanged.

Trap 2: Turning an outer join into an inner join unintentionally

Adding filters on the nullable side in a way that eliminates unmatched rows changes the result set. If your intent is to keep unmatched rows, be careful with predicate placement and with rewrites that “simplify” the query.

Trap 3: Using DISTINCT to “fix duplicates” created by join changes

DISTINCT can hide logical issues and can be expensive. If duplicates appear after a rewrite, verify whether the join cardinality changed (e.g., many-to-many) and whether you should aggregate or join to a deduplicated/unique set instead.

What to Remember While Writing Joins (Without Obsessing Over Order)

  • Write joins for correctness first; the optimizer can reorder many inner joins.
  • Expect the physical join order to differ from the SQL text; verify with execution plans.
  • Most “join order problems” are actually estimation, indexing, or filter pushdown problems.
  • Outer joins and complex predicates restrict reordering; rewrites can change meaning.
  • Use hints only when you have evidence (plans + measurements) and a maintenance strategy.

Now answer the exercise about the content:

Which statement best reflects how SQL join order relates to performance and execution in modern databases?

You are right! Congratulations, now go to the next page

You missed! Try again.

SQL is declarative: the optimizer can reorder many joins and apply filters early if it keeps the same semantics. The written join order and parentheses often do not determine the physical plan, especially for inner joins.

Next chapter

Handling Missing Matches: Defaults, COALESCE, and Filtering Without Accidental Inner Joins

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.