GEQO for Queries Joining Many Tables #
A report built on four views joins fourteen base tables. Its plan changes shape after routine analyzes, sometimes runs in two seconds and sometimes in forty, and nobody can reproduce the slow version on demand. The query has crossed geqo_threshold, and its join order is no longer found by exhaustive search but by a genetic algorithm sampling the space.
How the planner searches below that threshold is covered in join order and collapse limits. This page covers what changes above it.
The Planner Condition #
GEQO replaces dynamic programming for one join list when:
geqo = on(the default), and- the list has at least
geqo_thresholditems (default 12).
“Items” are the relations in the list after collapsing. Because collapse limits default to 8, a query only reaches GEQO in one list if its relations are comma-joined, or the collapse limits have been raised, or subqueries have been flattened into a large list. Explicitly nested joins above 8 are usually split into smaller lists first — which is why raising the collapse limits is a common route into GEQO.
The algorithm treats a join order as a sequence of relations. It creates a population of random sequences, costs each by building the joins in that order, and repeatedly combines pairs of cheaper sequences to form new candidates. The number of generations and the population size scale with geqo_effort (1–10, default 5) unless set explicitly with geqo_pool_size and geqo_generations. The random number generator starts from geqo_seed (default 0).
The key property: GEQO evaluates a tiny fraction of possible orders. For 14 relations there are tens of billions of join trees, and GEQO costs perhaps a few thousand sequences. It usually finds a reasonable plan and rarely the best one, and which reasonable plan it finds depends on the costs it saw.
Annotated EXPLAIN Evidence #
EXPLAIN does not print “GEQO”. The evidence is a comparison:
BEGIN;
SET LOCAL geqo = on;
EXPLAIN (ANALYZE, SUMMARY) SELECT … ; -- 14-relation report
ROLLBACK;
Hash Join (actual time=31044.2..38910.6 rows=22104 loops=1)
… (a nested-loop chain driven from order_events, actual rows=18,210,442)
Planning Time: 11.8 ms
Execution Time: 38975.4 ms
BEGIN;
SET LOCAL geqo = off;
EXPLAIN (ANALYZE, SUMMARY) SELECT … ;
ROLLBACK;
Hash Join (actual time=1802.6..2107.3 rows=22104 loops=1)
… (driven from the 340-row filtered accounts set)
Planning Time: 612.4 ms -- 50× more planning
Execution Time: 2170.9 ms -- 18× less execution
Reading the comparison:
-
Different leaf driving the plan — GEQO started from a large relation; exhaustive search started from the selective one.
-
Planning Time11.8 ms vs 612.4 ms — the price of enumerating the space. For a report run a few times an hour, that is free. For a statement executed hundreds of times a second, it is not. -
Stability test — plan with several seeds. If the plan changes between seeds, GEQO is landing on different local minima:
SET LOCAL geqo_seed = 0.3; EXPLAIN SELECT … ; SET LOCAL geqo_seed = 0.7; EXPLAIN SELECT … ;
Step-by-Step Resolution #
-
Count relations per list. Expand views and pulled-up subqueries.
EXPLAIN (VERBOSE)output lists every scanned relation; count distinct scan nodes under the same query level. -
Measure GEQO on vs off with
EXPLAIN (ANALYZE, SUMMARY)as above. Record bothPlanning TimeandExecution Time, and multiply planning time by how often the statement is planned — once per execution unless a cached plan is reused. -
Check seed sensitivity. Plan with three or four
geqo_seedvalues. If execution time varies by more than a small factor between them, the query is a poor fit for sampled search. -
Raise the threshold for the workload that benefits, not server-wide:
ALTER ROLE reporting SET geqo_threshold = 16; -- or per transaction SET LOCAL geqo_threshold = 16;If exhaustive planning is too slow, try
geqo_effort = 8or10first: more generations often find a better order at a fraction of exhaustive cost. -
Shrink the list where possible. Precompute a stable dimension join into a materialized view, or materialize a selective subset with a
MATERIALIZEDCTE — a fence that splits one large list into two smaller ones, each below the threshold. The trade-offs of that fence are covered in materialized vs not materialized CTEs.
Before and After #
-- BEFORE: geqo_threshold = 12, 14 relations → GEQO
Planning Time: 11.8 ms Execution Time: 38975.4 ms
-- AFTER: ALTER ROLE reporting SET geqo_threshold = 16
Planning Time: 612.4 ms Execution Time: 2170.9 ms
When the trade is worth it #
The report above runs a few dozen times a day, so 600 ms of extra planning per execution is irrelevant next to 36 seconds of saved execution. The calculation changes for statements executed at high frequency. A 13-table query behind an API endpoint called 200 times a second would spend two minutes of CPU per second planning exhaustively — clearly impossible. For such statements, a prepared statement whose generic plan is found once by exhaustive search and then reused is the escape: planning cost is paid on the first executions and never again, so a high geqo_threshold costs almost nothing. That requires the driver to prepare statements and the plan to be stable across parameter values, which the prepared statement plan pinning section covers.
Views and ORMs make relation counts easy to underestimate. A single “customer summary” view that joins nine tables, joined to two other views, puts a query well above the threshold while its text mentions three names. Counting scan nodes in the actual plan is the reliable method.
Common Pitfalls #
Turning GEQO off globally. Exhaustive search on a 20-relation list can plan for seconds or far longer, and a single ad-hoc query can stall a backend. Diagnostic signal: sessions stuck in planning with pg_stat_activity.state = 'active' and no I/O. Fix: raise the threshold for specific roles instead.
Raising collapse limits without noticing GEQO. Setting join_collapse_limit = 20 to let the planner reorder a 14-table query moves that query from separate small lists into one list above geqo_threshold. Diagnostic signal: planning time drops while execution gets worse after raising the limits. Fix: raise geqo_threshold together with the collapse limits.
Attributing seed variance to data changes. Small statistic changes move GEQO to very different plans. Diagnostic signal: large plan flips after ordinary autoanalyze, only on queries above the threshold. Fix: test seed sensitivity, then raise the threshold or reduce the list.
Using GEQO plans on skewed estimates. Sampled search amplifies poor estimates because it has fewer alternatives to fall back on. Diagnostic signal: wildly wrong rows= on the driving relation. Fix: repair statistics first; a better estimate helps both search methods.
Frequently Asked Questions #
How can I tell whether GEQO planned my query?
EXPLAIN does not label it. Count relations in the largest join list after expansion; if the count reaches geqo_threshold with geqo on, the genetic optimizer ran. Confirm by planning with SET LOCAL geqo = off.
Is GEQO non-deterministic?
It is deterministic for fixed inputs because geqo_seed defaults to 0. Changing the seed, statistics or cost settings can lead it to a very different order.
Should I just turn GEQO off?
Not globally. Raise geqo_threshold for the workload that benefits and keep GEQO as the safety net for lists too large to search exhaustively.
Related #
- Join Order and Collapse Limits — parent guide: exhaustive search, collapsing and the thresholds
- join_collapse_limit and Explicit Join Order — sibling: pinning an order instead of searching
- Materialized vs Not Materialized CTEs — using a CTE fence to split a large join list