Why Postgres times out on TPC-H Q17 and Q20?


TL;DR: The Postgres optimizer lacks subquery unnesting.

In recent years, I’ve been working on boosting the analytical capabilities of OLTP databases like Postgres. The most popular approach is embedding a DuckDB instance. To explain why this is worthwhile, I keep referring to the following TPC-H benchmark from the DuckDB blog. Notice vanilla Postgres times out on Q17 and Q20.

queryduckdbduckdb/postgrespostgres
10.030.741.12
20.010.200.18
30.020.550.21
40.030.520.11
50.020.700.13
60.010.240.21
70.040.560.20
80.020.740.18
90.051.340.61
100.040.410.35
110.010.150.07
120.010.270.36
130.040.180.32
140.010.190.21
150.030.360.46
160.030.090.12
170.050.75>60.00
180.080.971.05
190.030.320.31
200.050.37>60.00
210.091.530.35
220.030.150.15

So what’s going on with these two queries? Let’s dig into Q17.

SELECT
    sum(l_extendedprice) / 7.0 AS avg_yearly
FROM
    lineitem,
    part
WHERE
    p_partkey = l_partkey
    AND p_brand = 'Brand#23'
    AND p_container = 'MED BOX'
    AND l_quantity < (
        SELECT
            0.2 * avg(l_quantity)
        FROM
            lineitem
        WHERE
            l_partkey = p_partkey);

The problem is the correlated subquery referring p_partkey from the outer query. In Postgres, this subquery runs for every row of part, resulting in an intermediate table. Extremely inefficient.

-- postgres explain Q17 here

DuckDB handles this differently by unnesting correlated subqueries. It replaces the correlated subquery with a join, bringing complexity down to .

-- duckdb explain Q17 here

This optimization technique originates from the paper Unnesting arbitrary queries (2015). As a follow-up, A Formalization of Top-Down Unnesting (2024) provides a formal proof of correctness for the unnesting approach presented in the 2015 paper and extends it to a top-down algorithm. Today, (almost) every modern OLAP database and engine implements this optimization.

Q20 suffers from the same issue in Postgres. Skip here.

Let’s circle back to the benchmark. You might’ve noticed that while DuckDB beats Postgres on all queries, the DuckDB-Postgres connector (duckdb_pg) doesn’t always beat vanilla Postgres. Same thing happens with DuckDB-in-Postgres pg_duckdb (name so confusing 😂).

The performance gap comes from all the modern techniques DuckDB uses: vectorized execution, morsel-based parallelism, columnar storage, you name it. But when query plans are of the same complexity, the execution speedup gets eaten by the overhead of data conversion between row format and columnar format. For complex queries like Q17 and Q20 though, the conversion cost is worth it for a much better query plan.