Repository navigation
Closed
Labels
wontfixThis will not be worked onThis will not be worked on
Description
What happens?
Some parameterised queries run much slower than the equivalent non-parameterised queries, even when parameterised with an empty list.
For the examples in the repro script below, the parameterised queries take 4.6s to run, and the un-parameterised query takes 1.2s.
python version: 3.14.0 (main, Oct 28 2025, 12:13:17) [Clang 20.1.4 ]
duckdb version: v1.4.3
--------------------------------------------------------------------------------
sql: SELECT t1.value FROM t1 JOIN t2 ON t1.t2_id = t2.id WHERE t2.code LIKE ?
params: ['03%']
elapsed: 4.578065395355225
--------------------------------------------------------------------------------
sql: SELECT t1.value FROM t1 JOIN t2 ON t1.t2_id = t2.id WHERE t2.code LIKE '03%'
params: []
elapsed: 4.612151384353638
--------------------------------------------------------------------------------
sql: SELECT t1.value FROM t1 JOIN t2 ON t1.t2_id = t2.id WHERE t2.code LIKE '03%'
params: None
elapsed: 1.2341203689575195
(With production data and queries, we see parameterised queries take ~40x longer than the equivalent non-parameterised ones.)
EXPLAIN ANALYZE shows the same query plan for each query:
`EXPLAIN ANALYZE`
python version: 3.14.0 (main, Oct 28 2025, 12:13:17) [Clang 20.1.4 ]
duckdb version: v1.4.3
--------------------------------------------------------------------------------
sql: SELECT t1.value FROM t1 JOIN t2 ON t1.t2_id = t2.id WHERE t2.code LIKE ?
params: ['03%']
┌─────────────────────────────────────┐
│┌───────────────────────────────────┐│
││ Query Profiling Information ││
│└───────────────────────────────────┘│
└─────────────────────────────────────┘
EXPLAIN ANALYSE SELECT t1.value FROM t1 JOIN t2 ON t1.t2_id = t2.id WHERE t2.code LIKE ?
┌────────────────────────────────────────────────┐
│┌──────────────────────────────────────────────┐│
││ Total Time: 0.0632s ││
│└──────────────────────────────────────────────┘│
└────────────────────────────────────────────────┘
┌───────────────────────────┐
│ QUERY │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ EXPLAIN_ANALYZE │
│ ──────────────────── │
│ 0 rows │
│ (0.00s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PROJECTION │
│ ──────────────────── │
│ value │
│ │
│ 10,000,000 rows │
│ (0.00s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ HASH_JOIN │
│ ──────────────────── │
│ Join Type: INNER │
│ │
│ Conditions: ├──────────────┐
│ t2_id = id │ │
│ │ │
│ 10,000,000 rows │ │
│ (0.03s) │ │
└─────────────┬─────────────┘ │
┌─────────────┴─────────────┐┌─────────────┴─────────────┐
│ TABLE_SCAN ││ TABLE_SCAN │
│ ──────────────────── ││ ──────────────────── │
│ Table: t1 ││ Table: t2 │
│ Type: Sequential Scan ││ Type: Sequential Scan │
│ ││ Projections: id │
│ Projections: ││ │
│ t2_id ││ Filters: │
│ value ││ code>='03' AND code<'04' │
│ ││ │
│ 10,000,000 rows ││ 10,000 rows │
│ (0.67s) ││ (0.00s) │
└───────────────────────────┘└───────────────────────────┘
--------------------------------------------------------------------------------
sql: SELECT t1.value FROM t1 JOIN t2 ON t1.t2_id = t2.id WHERE t2.code LIKE '03%'
params: []
┌─────────────────────────────────────┐
│┌───────────────────────────────────┐│
││ Query Profiling Information ││
│└───────────────────────────────────┘│
└─────────────────────────────────────┘
EXPLAIN ANALYSE SELECT t1.value FROM t1 JOIN t2 ON t1.t2_id = t2.id WHERE t2.code LIKE '03%'
┌────────────────────────────────────────────────┐
│┌──────────────────────────────────────────────┐│
││ Total Time: 0.0624s ││
│└──────────────────────────────────────────────┘│
└────────────────────────────────────────────────┘
┌───────────────────────────┐
│ QUERY │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ EXPLAIN_ANALYZE │
│ ──────────────────── │
│ 0 rows │
│ (0.00s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PROJECTION │
│ ──────────────────── │
│ value │
│ │
│ 10,000,000 rows │
│ (0.00s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ HASH_JOIN │
│ ──────────────────── │
│ Join Type: INNER │
│ │
│ Conditions: ├──────────────┐
│ t2_id = id │ │
│ │ │
│ 10,000,000 rows │ │
│ (0.03s) │ │
└─────────────┬─────────────┘ │
┌─────────────┴─────────────┐┌─────────────┴─────────────┐
│ TABLE_SCAN ││ TABLE_SCAN │
│ ──────────────────── ││ ──────────────────── │
│ Table: t1 ││ Table: t2 │
│ Type: Sequential Scan ││ Type: Sequential Scan │
│ ││ Projections: id │
│ Projections: ││ │
│ t2_id ││ Filters: │
│ value ││ code>='03' AND code<'04' │
│ ││ │
│ 10,000,000 rows ││ 10,000 rows │
│ (0.65s) ││ (0.00s) │
└───────────────────────────┘└───────────────────────────┘
--------------------------------------------------------------------------------
sql: SELECT t1.value FROM t1 JOIN t2 ON t1.t2_id = t2.id WHERE t2.code LIKE '03%'
params: None
┌─────────────────────────────────────┐
│┌───────────────────────────────────┐│
││ Query Profiling Information ││
│└───────────────────────────────────┘│
└─────────────────────────────────────┘
EXPLAIN ANALYSE SELECT t1.value FROM t1 JOIN t2 ON t1.t2_id = t2.id WHERE t2.code LIKE '03%'
┌────────────────────────────────────────────────┐
│┌──────────────────────────────────────────────┐│
││ Total Time: 0.108s ││
│└──────────────────────────────────────────────┘│
└────────────────────────────────────────────────┘
┌───────────────────────────┐
│ QUERY │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ EXPLAIN_ANALYZE │
│ ──────────────────── │
│ 0 rows │
│ (0.00s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PROJECTION │
│ ──────────────────── │
│ value │
│ │
│ 10,000,000 rows │
│ (0.00s) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ HASH_JOIN │
│ ──────────────────── │
│ Join Type: INNER │
│ │
│ Conditions: ├──────────────┐
│ t2_id = id │ │
│ │ │
│ 10,000,000 rows │ │
│ (0.05s) │ │
└─────────────┬─────────────┘ │
┌─────────────┴─────────────┐┌─────────────┴─────────────┐
│ TABLE_SCAN ││ TABLE_SCAN │
│ ──────────────────── ││ ──────────────────── │
│ Table: t1 ││ Table: t2 │
│ Type: Sequential Scan ││ Type: Sequential Scan │
│ ││ Projections: id │
│ Projections: ││ │
│ t2_id ││ Filters: │
│ value ││ code>='03' AND code<'04' │
│ ││ │
│ 10,000,000 rows ││ 10,000 rows │
│ (1.17s) ││ (0.00s) │
└───────────────────────────┘└───────────────────────────┘
To Reproduce
Here's a script that demonstrates the problem. Note that EXPLAIN ANALYSE shows the same plan for each query.
Run with uv run demo.py demo.db.
# /// script
# dependencies = [
# "duckdb",
# ]
# ///
import sys
import time
import duckdb
T1_ROWS = 1_000_000_000
T2_ROWS = 1_000_000
def print_versions():
print(f"python version: {sys.version}")
conn = duckdb.connect(sys.argv[1])
print("duckdb version:", conn.execute("SELECT version()").fetchall()[0][0])
def setup():
conn = duckdb.connect(sys.argv[1])
conn.execute(f"""
CREATE TABLE IF NOT EXISTS t1 AS
SELECT i AS value, i % {T2_ROWS} AS t2_id
FROM range(0, {T1_ROWS}) AS r(i)
""")
conn.execute(f"""
CREATE TABLE IF NOT EXISTS t2 AS
SELECT i AS id, lpad(CAST(i AS VARCHAR), 6, '0') AS code
FROM range(0, {T2_ROWS}) AS r(i)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_t2_code ON t2(code)")
def time_query(sql, params):
conn = duckdb.connect(sys.argv[1])
print("-" * 80)
print("sql:", sql)
print("params:", params)
t0 = time.time()
conn.sql(sql, params=params).fetchall()
print("elapsed: ", time.time() - t0)
results = conn.sql(f"EXPLAIN ANALYSE {sql}", params=params).fetchall()
print(results[0][1])
print_versions()
setup()
time_query(
"SELECT t1.value FROM t1 JOIN t2 ON t1.t2_id = t2.id WHERE t2.code LIKE ?",
params=["03%"],
)
time_query(
"SELECT t1.value FROM t1 JOIN t2 ON t1.t2_id = t2.id WHERE t2.code LIKE '03%'",
params=None,
)
time_query(
"SELECT t1.value FROM t1 JOIN t2 ON t1.t2_id = t2.id WHERE t2.code LIKE '03%'",
params=[],
)
OS:
Ubuntu
DuckDB Package Version:
1.4.3
Python Version:
3.14
Full Name:
Peter Inglesby
Affiliation:
Bennett Institute of Applied Data Science, Oxford University
What is the latest build you tested with? If possible, we recommend testing with the latest nightly build.
I have tested with a stable release
Did you include all relevant data sets for reproducing the issue?
Yes
Did you include all code required to reproduce the issue?
- Yes, I have
Did you include all relevant configuration to reproduce the issue?
- Yes, I have
Metadata
Metadata
Assignees
Labels
wontfixThis will not be worked onThis will not be worked on
Activity
paultiq commentedon Dec 29, 2025
Reproduced
Two notes:
inglesp commentedon Dec 30, 2025
I've reproduced after building my own version.
However, the timings are much much slower -- about 500x slower. Could this be an artifact of running a debug build?
inglesp commentedon Dec 30, 2025
Looking at the code, it seems like the we take a totally different code path depending on whether there are params or not. If there aren't params, we create a
Relationfor lazy execution; if there are params, we create aMaterializedRelation.duckdb-python/src/duckdb_py/pyconnection.cpp
Lines 1544 to 1583 in 3f1f615
In the comment
// FIXME: currently we can't create relations with prepared parameters, it's not clear to me how hard this current limitation is.evertlammerts commentedon Feb 11, 2026
Alright, I managed to spend some time on this.
Relation.sql(query, params) -> Relationis problematic. There is currently no Relation type that expresses a parameterized query. The way this is currently worked around is by creating aMaterializedRelation. This means the query is immediately planned, executed, materialized and the stored in aColumnDataCollection. When the relation is fetched, the query re-runs (planned and executed, but this time on theColumnDataCollection). Afaics, what happens is that every item in theColumnDataCollectionis ToString'ed to build the query. This is where the huge overhead comes from.Without parameters a QueryRelation is created, which is lazy. This relation will only run during materialization, and through the regular execution path.
There are three ways to deal with this:
.sql(...). Given that there is not corresponding relation type in core there is an argument to be made that it should not have supported params to begin with. But now that we have it, removing it is a breaking change that will likely get in the way.Options 2 and 3 are breaking changes, and right now I don't think it's worth making them. We're not currently planning to work on option 1.
In summary: there is currently no performant way to use prepared statements in the relational API. If you want to use them anyway, the better pattern is feeding them in through a replacement scan:
This still materializes the resultset, of course. That's just a fundamental limitation at this moment.
Some follow ups:
.sql()inglesp commentedon Feb 11, 2026
Thanks @evertlammerts.
What's the expected performance of your example:
?
evertlammerts commentedon Feb 11, 2026
That's not easy to answer. It depends on the case.
As a baseline and from what I know now, if we want to use the relational API and a parameterized query then we must materialize the result of the parameterized query before feeding it into a relation that can be chained. That's the fundamental limitation and that's what happens both on the original
con.sql(q, params).filter(...)....fetchall()path, and on the alternative I give above.For the first part of that, as a rule of thumb,
.sql(q, p).fetchall()alone is significantly slower than.execute(q, p).fetchall(). You should assume at least 5X in terms of processing time for small to medium sized in-memory datasets, and up to much bigger numbers on e.g. remote parquet files. The additional memory consumption is also non-trivial, due to theToString()path.After that we have to compare scanning, projecting, filtering, etc on a
MaterializedRelationvs e.g. an Arrow table. This is also likely to be faster on the latter, since we can't push down on theMaterializedRelation.evertlammerts commentedon Feb 11, 2026
Processing time differences:
RSS differences on 1M rows (poor benchmark, might not be super indicative):
Treat empty params the same as None for .sql (#310)
Remove unused parameters parameter
Remove unused parameters parameter
Remove unused parameters parameter
Remove unused parameters parameter
Remove unused parameters parameter
Remove unused parameters parameter
Remove unused parameters parameter