ducksdb-mysql-engine is an experimental build of MySQL 9.7 where a table you mark ENGINE=DuckDB answers analytical queries from DuckDB instead of InnoDB. Same server, same connection, no second copy of the data. On TPC-H at scale factor 10, InnoDB times out on 6 of the 22 queries and burns 1317 seconds on the 16 it finishes. The DuckDB tables run all 22 in about 15 seconds.
It’s an experiment, not production software. It patches mysqld and has rough edges, which we list at the end. Source is on GitHub under GPLv2: https://github.com/EvgeniyPatlan/ducksdb-mysql-engine.
MySQL is great for transactions and slow at analytics. A wide GROUP BY over a few hundred million rows, or a six-way join, takes minutes on InnoDB. The usual fix is to copy the data into a column store and keep it in sync, so now you’re running two systems and the pipeline between them.
We wanted the table itself to be the column store, with the heavy queries offloaded for you. Mark it ENGINE=DuckDB, query it the way you always have, and DuckDB does the analytical work.
DuckDB is an in-process columnar query engine, basically SQLite for OLAP. It stores data by column and it’s built for scans and aggregations, which is exactly what a row store is bad at.
We’re not the first to put it behind a relational table. Alibaba’s AliSQL has had a built-in DuckDB engine for a while. MariaDB shipped MariaDB DuckDB about a week ago, while we were already building ours. AliSQL got there first; MariaDB and we landed on the same idea independently, around the same time, them on MariaDB and us on stock MySQL 9.7. Their engine is the closest comparison to ours, so it’s in the benchmarks below.
MySQL doesn’t have a select_handler, the API MariaDB uses to grab a whole SELECT and run it inside an engine. We added our own: a handlerton::pushdown_select hook.

The pushdown path. Either the whole query renders to DuckDB SQL and runs columnar, or it declines and the normal row path handles it.
The engine is compiled into mysqld, and each schema is one DuckDB file under the datadir. Three patches do the integration, and all three are generic, so they’ll fire for any engine that exposes the hook:
The builder only renders a node when the output is provably identical to what MySQL would return. If it can’t, it declines and MySQL runs the query unchanged. Literals are bound as parameters. Collation, NULL ordering and decimal scale are matched on purpose, and an unmapped collation or a REAL literal is enough to make it back off. With that in place, all 22 TPC-H queries push down and match InnoDB row for row.
The fastest way in is the image:
|
1 2 3 4 |
docker run -d --name mysql-duckdb -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=secret \ -v mysql-duckdb-data:/var/lib/mysql \ evgeniypatlan/test-images:mysql-9.7-duckdb-v0.2.0 |
Make a table, put a few rows in, run an aggregate:
|
1 2 3 |
CREATE DATABASE shop; USE shop; CREATE TABLE sales (id INT PRIMARY KEY, region INT, amount DECIMAL(12,2)) ENGINE=DuckDB; INSERT INTO sales VALUES (1,1,100),(2,1,200),(3,2,50); |
|
1 2 3 4 |
SELECT region, SUM(amount) FROM sales GROUP BY region; -- region | SUM(amount) -- 1 | 300.00 -- 2 | 50.00 |
Nothing about that query is special, and that is the point. To check it actually went to DuckDB rather than down the row path, watch the Ducksdb_pushdown_count status variable:
|
1 2 3 4 5 |
SELECT region, SUM(amount) FROM sales GROUP BY region; -- offloaded SHOW STATUS LIKE 'Ducksdb_pushdown_count'; -- counter goes +1 SELECT * FROM sales WHERE id = 3; -- point lookup SHOW STATUS LIKE 'Ducksdb_pushdown_count'; -- counter unchanged |
The single-row lookup stays on the row path deliberately. For one row an index seek beats spinning up a DuckDB result, so there is no reason to offload it. OLTP keeps its path, analytics get the column store, and you do not pick by hand.
If you would rather build it, you need the MySQL 9.7 tree under vendor/mysql-server/ and a DuckDB prefix, then:
|
1 2 |
ln -s ../../engine vendor/mysql-server/storage/duckdb scripts/build-server.sh # applies the 3 patches, builds mysqld + clients |
All 22 TPC-H queries, were executed in a Docker on one laptop (20 cores, 62 GiB RAM), the same data loaded into four engines: InnoDB, our MySQL+DuckDB, MariaDB+DuckDB, and standalone DuckDB as the reference. Warm wall-clock, minimum over a few runs, in seconds.
A handful of rows here; the whole table is in docs/tpch_engine_comparison.md:
| Query | InnoDB | MySQL+DuckDB | MariaDB +DuckDB | Native DuckDB |
| Q1 | >180 | 1.77 | 0.84 | 0.77 |
| Q5 | 127.6 | 0.53 | 0.46 | 0.71 |
| Q7 | 145.0 | 0.45 | 0.38 | 0.67 |
| Q9 | >180 | 1.52 | 2.30 | 1.78 |
| Q18 | 101.7 | 1.35 | 1.39 | 1.31 |
| Q19 | 120.4 | 0.15 | 0.67 | 0.83 |
| All 22 | Finished 16/22 | 15.1s | 13.3s | 16.3 |

SF10, all 22 queries, log scale (lower is better). Hatched InnoDB bars did not finish inside 180 s. The three DuckDB engines sit in a tight band near the floor.
InnoDB is somewhere between 100 and 340 times slower per query, and on 6 of the 22 it never finished inside the 180-second cap (the correlated subqueries and the heaviest scans). It burned 1317 seconds on just the 16 it did finish. The three DuckDB engines get through all 22 in about 15 seconds, and ours lands right between MariaDB and plain DuckDB. The gap is so big there is not much else to say about it.
At this size InnoDB is out of the running (a copy of the data alone is about 100 GB and queries run for hours), so it is the three DuckDB engines only, run one at a time:
| Query | MySQL+DuckDB | MariaDB+DuckDB | Native DuckDB |
| Q1 | 15.25 | 6.50 | 5.50 |
| Q9 | 20.97 | 115.29 | 19.54 |
| Q10 | 10.64 | ERR | 8.14 |
| Q13 | 19.75 | ERR | 13.27 |
| Q18 | 14.88 | 29.62 | 11.08 |
| Q19 | 2.91 | 7.98 | 6.61 |
| Correct | 22/22 | 20/22 | 22/22 |

SF100, three DuckDB engines, log scale (lower is better). Q15 for our engine is shown at its matched-memory time (~4 s); the capped run measured 1309 s, explained below. MariaDB errored on Q10 and Q13.
At 600 million rows ours is still correct on all 22 and stays close to plain DuckDB. MariaDB’s engine drops two queries (Q10, and Q13 on its column-list syntax) and is a lot slower on the big joins – Q9 took 115 seconds against our 21 and native’s 20.
One honest word on Q15 at SF100, because in the full table it shows an ugly number for our engine. It is not a real loss. We capped DuckDB’s memory so it spills to disk instead of getting OOM-killed inside mysqld, and under that cap Q15’s CTE spills a lot. Give it the memory MariaDB had and it runs in about 4 seconds, like native. The answer was always right; only the clock was bad.
And one number we did not expect: loading those 600 million rows took about 20 minutes with our engine (the COPY shortcut) versus about 80 minutes with MariaDB, which loads row by row on a single core. Roughly four times faster to get the data in.
If any of this sounds useful, pull the image and throw your own queries at it:
|
1 2 |
docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=secret \ evgeniypatlan/test-images:mysql-9.7-duckdb-v0.2.0 |
The source is on GitHub (GPLv2), patches and benchmark harness included: https://github.com/EvgeniyPatlan/ducksdb-mysql-engine. The full per-query benchmark and how we measured it live in docs/tpch_engine_comparison.md.
Resources
RELATED POSTS