Search Shortcut cmd + k | ctrl + k
factorize

Answers count, sum, avg, min, max and GROUP BY over many-to-many equi-join graphs without materialising the join

Maintainer(s): maxdemarzi

Compatibility Matrix

This table covers all DuckDB releases that have not reached end-of-life, including LTS releases, and shows the 2 most recent patch releases of each. Older patch releases are not listed, but the extension may be available for them as well.

Platform 1.4.4 1.4.5 1.5.5 1.5.6 2.0.0
Linux AMD64
Linux ARM64
macOS AMD64
macOS ARM64
Windows AMD64
Windows ARM64
WebAssembly

Installing and Loading

INSTALL factorize FROM community;
LOAD factorize;

Example

-- The engine keeps intermediate joins in a factorised representation and
-- counts from it, so a result too large to materialise is still countable.
-- With factorize_mode='auto' an ordinary count(*) is taken over only where
-- a cost model predicts a win, and left alone otherwise.
SET factorize_mode = 'auto';

SELECT count(*)
  FROM edges a, edges b, edges c, edges d
 WHERE a.src = b.src AND b.src = c.src AND c.src = d.src;

-- EXPLAIN shows a FACTORIZED operator where it fired, and
-- SET factorize_explain = true prints why it did or did not.

About factorize

A clean-room implementation of Lehner & Neumann, The Data World Is Not Flat: Efficient Factorized Execution for Relational Systems (PVLDB 19(11):3006-3019, 2026), derived from the paper only.

A join's result can be exponentially larger than the data it comes from. A factorised representation keeps it as a product of independent parts rather than writing every combination out: records grow by sum where tuples grow by product. For an aggregate that only needs the count, the count can be read from that representation without ever materialising it.

What it is for. Queries whose join result is too large to produce. On the 442 queries of the cardinality-estimation benchmark whose results exceed 1e9 tuples, this answers 244 count(\*) queries that stock DuckDB does not finish in sixty seconds, in a median of 0.09 s. One of them counts 10.8 trillion results in 24 seconds where the stock plan produces nothing in 180.

That 244 is specific to count(\*), and deliberately says so: which queries stock DuckDB cannot finish depends on the aggregate, not only on the join. Over a random 120-query sample of the same corpus, with both plans timed and a sixty-second cap, stock finishes 38 to 41 of them once the fold is not count(\*) – about a third of the corpus stops being out of reach.

The rescues survive the change of aggregate, which had been assumed rather than measured until it was:

fold          rescued   faster   slower   neither   lost
min                45       34        7        34      0
max                47       33        7        33      0
avg                45       31       10        34      0
sum                46       31        9        33      1
count(c)           59       29       13        18      1
sum(x * y)         46       30       10        34      0

"Neither" is the floor neither plan clears inside the cap. count(c) reaches furthest because the matcher only accepts it where the column cannot be NULL, which makes the fold a counter again.

The slower column is mostly not this engine. Splitting the same runs by whether the gate fired, every one of those slowdowns but two sits among the queries it declined – where auto runs the stock plan and the difference is two timings of the same work, median ratio 0.98 over 151 pairs. Across 458 fired queries there are two bad outcomes, both under count(c): one is the 60s cap falling just past a 58.8s stock plan, and the other is watdiv_acyclic_217_10, which this project has recorded the cost model failing on since well before these measurements.

Where it does regress is the corpus it is not for. On the queries DuckDB can already materialise, about a third of the queries the gate fires on come out slower – 13 of the 37 it fires on for count(*). And auto costs about 2 to 4 milliseconds to decline – the statistics sampling behind the decision – which is invisible past 100ms and around 20% on a 10ms query.

What it is not for. Queries DuckDB already handles. For a plain count(*) DuckDB carries no payload columns through a join and counts empty tuples faster than this engine can build a representation: firing on every eligible query is about 11x slower on the benchmark's live corpus. The cost model exists to tell those regimes apart, and auto declines far more often than it fires.

Scope. count(*), count(c), sum, avg, min, max, bool_and and bool_or over acyclic equi-join graphs of many-to-many joins on integer and DECIMAL columns, with GROUP BY, EXISTS and LIMIT. Also sum(x * y) for two columns, ungrouped, which is the case factorisation is for: over independent branches it decomposes into a product of one-column sums, so the pairings of x and y are summed without being enumerated even though they are the join result itself. Anything else is declined and the stock plan runs unchanged. auto is bit-exact against off; if the factorised path raises, the replaced plan answers instead.

The aggregates beyond count(*) are timed as well as verified (D81). The gate prices min and max separately from the rest, because they are not folded at all: an ungrouped min or max over an acyclic join is answered by a semijoin reducer, which reads the inputs and never builds the join. So the folds no longer fire on the same queries as each other, which they did until the reducer got a cost model of its own.

On the benchmark's live corpus, over the same 119 join graphs, medians of three runs each:

fold          fires   ratio
min              65    3.9x
max              65    3.7x
count(\*)        37    1.7x
count(c)         36    1.4x
avg              29    1.5x
sum(x * y)       29    1.5x
sum              29    1.5x

Single runs of this measurement vary by about 10%, so these are medians and not precise quantities. bool_and and bool_or are not listed separately: the matcher compiles them to the min and max folds with the same binding, so they are those rows with a BOOLEAN result type. An earlier reading of 0.93x for sum(x * y) – "slower than not firing" – was an artifact: 65 of the 119 queries in that corpus multiplied two INTEGER join keys whose product overflows INT32, so they raised instead of answering, and the gate fired on 30 of those against 3 of the rest. It measured how fast each plan reached the same error (D85, D86). In the regime these exist for: on 50 of the excluded queries sum(x * y) is 7 faster against 1 slower with no losses, 221s of stock against 92s.

Known limitations, measured rather than assumed, with the numbers in DECISIONS.md: the cost model's coefficients are fitted on one machine and do not transfer between machines or datasets; its estimates are wrong where it is most confident; and on the benchmark corpus a handful of queries run slower than the plan they replace, one of them past a sixty-second cap. Scored against a per-query oracle with both plans timed, the gate lands within about 1.1x to 1.4x of perfect depending on the aggregate, and most of what is left is one query.

The counts of rescued queries above were measured before the semijoin reducer and before the cost model was refitted for it, both of which only widened what gets answered, so they understate the current state rather than overstating it. DECISIONS.md carries each measurement with the date and method it was taken by.

Added Functions

function_name function_type description comment examples
factorized_count table NULL NULL  
factorized_exists table NULL NULL  
factorized_group_count table NULL NULL  
factorized_stats table NULL NULL  
factorized_steps table NULL NULL  
factorized_tuples table NULL NULL  

Overloaded Functions

This extension does not add any function overloads.

Added Types

This extension does not add any types.

Added Settings

name description input_type scope aliases typed_value
factorize_abandon_after_ms Milliseconds of slice time before factorize_min_compression may abandon; below it a query that compresses badly is left to finish, because finishing is cheap (0 abandons as soon as the floor is missed) DOUBLE GLOBAL [] 2000.0
factorize_containment_exponent Discount a head value in a column that did not store it by that column's share of the class domain, raised to this power (0 = off, the shipped behaviour) DOUBLE GLOBAL [] 0.0
factorize_debug_fail Make the factorized path throw, to exercise the fallback to the stock plan BOOLEAN GLOBAL [] false
factorize_debug_print_plan Print the post-optimizer logical plan seen by the factorize rule BOOLEAN GLOBAL [] false
factorize_duckdb_per_tuple_ms Milliseconds the gate charges DuckDB per result tuple DOUBLE GLOBAL [] 3.981e-06
factorize_estimate_slack Abandon to the stock plan if the f-representation outgrows the gate's size estimate by more than this factor (0 disables) DOUBLE GLOBAL [] 2.0
factorize_explain Print, per aggregate, whether the factorize rule took the plan over and why not BOOLEAN GLOBAL [] false
factorize_fallback Run the replaced plan when the factorized path fails, instead of failing the query; costs the parallelism of the factorized count (plan section 7.5) BOOLEAN GLOBAL [] true
factorize_fallback_max_loss Ignore the catalog's second opinion when the sampled estimate predicts we lose by more than this factor DOUBLE GLOBAL [] 150.0
factorize_fold_cost Multiplier on this engine's predicted per-record cost for an aggregate that reads a column DOUBLE GLOBAL [] 4.0
factorize_gate_exact_stats Have the gate read the join columns and compute exact statistics instead of using the catalog, which has no MCV list; measurement only, it scans every relation to decide whether to scan every relation BOOLEAN GLOBAL [] false
factorize_gate_sample_rows Rows per join column the gate samples to build its most-common-value list, spread across the table; 0 uses the catalog alone, which has no such list and degrades the estimator to the textbook formula DOUBLE GLOBAL [] 16384.0
factorize_learn_cardinality Correct the predicted flat result by what earlier queries over the same tables turned out to be BOOLEAN GLOBAL [] false
factorize_learn_min_queries How many finished queries a table needs before its correction is trusted DOUBLE GLOBAL [] 3.0
factorize_limit Let the gate consider count(*) over a LIMIT, which is the shape EXISTS is planned into. Off because it was measured slower than the stock plan on every corpus dataset but one (D53); on epinions-like data, firing everywhere is a 2.5x win BOOLEAN GLOBAL [] false
factorize_memory_slack How far past the memory budget the predicted f-representation size may go before the gate declines on size alone; the run time slices or abandons either way, so this only guards against a query predicted to need many passes DOUBLE GLOBAL [] 8.0
factorize_min_compression Abandon to the stock plan when a materialized join leaves fewer than this many tuples per record, measured rather than predicted (0 disables) DOUBLE GLOBAL [] 0.0
factorize_min_gain Fire only when factorizing is predicted to beat the stock plan by this factor DOUBLE GLOBAL [] 1.5
factorize_min_rate Abandon to the stock plan when the last materialized join has delivered fewer than this many tuples per millisecond – the unit DuckDB's own cost model is stated in, so the two can be compared rather than guessed between (0 disables) DOUBLE GLOBAL [] 0.0
factorize_min_work_ms Fire only when DuckDB's own predicted work, excluding its fixed startup, exceeds this many milliseconds; below it there is nothing to win DOUBLE GLOBAL [] 5.0
factorize_mode Factorized execution: 'off', 'auto' (fire when the cost gate predicts a win) or 'force' (fire whenever the plan shape matches, ignoring the gate; benchmarking only) VARCHAR GLOBAL [] off
factorize_parallel_fallback Count one bucket of the join key per thread even while carrying the §7.5 fallback. On: 3.00x over the runnable corpus and faster on all 18 queries whose outcome is known, including the heavy ones – hetio_acyclic_205_09 goes 172.9s to 51.2s. It was off until the buckets learned to stop each other and to partition what the plan builds first (DECISIONS D54a through D54d) BOOLEAN GLOBAL [] true
factorize_reduce_extremes Answer an ungrouped min or max over an acyclic join with a semijoin reducer instead of folding an f-representation BOOLEAN GLOBAL [] true
factorize_reduce_threads Cores the semijoin reducer may use, rounded down to a power of two; 0 follows DuckDB's own thread count and 1 keeps the reduction sequential BIGINT GLOBAL [] 0
factorize_second_key Split a bucket that no modulus of its own join key can divide – one skewed value carrying it – on a different key instead of failing. Off: it answers queries that otherwise cannot be, and on the corpus it also spends 270s answering one the stock plan has in 13 (DECISIONS D55) BOOLEAN GLOBAL [] false
factorize_tail_min_domain Carry min(V_R, V_S) forward as the surviving tail domain instead of max, which is the textbook rule (D45) BOOLEAN GLOBAL [] false
temp_directory Set the directory to which temporary files are written. VARCHAR GLOBAL [] post.db.tmp