Limitations¶
An honest, consolidated list of what duckrun doesn't do — by design, by an upstream constraint, or by deliberate caution. Most come with a "do this instead." Deeper detail lives in the linked pages.
Gaps vs dbt-duckdb¶
duckrun aims to be a drop-in for dbt-duckdb — same DuckDB SQL, same model and config spelling —
and parity proves that by running real, unmodified type: duckdb projects verbatim
on duckrun, their own tests included. These are the places where it is not the same, in full:
| gap | what happens | why |
|---|---|---|
type: duckrun is its own adapter type |
an existing project's profile must say type: duckrun; a type: duckdb profile cannot be rerouted |
dbt selects the adapter from type. The project itself needs no edit — in dbt the profile lives outside it (that's how the parity runs work, via --profiles-dir) |
materialized='view' |
runs, but is a connection-scoped DuckDB view — nothing on storage, gone next session; swapping a model table ⇄ view isn't supported |
Delta defines no view (below) |
materialized='external' |
errors — duckrun doesn't ship it | materializations dispatch by adapter name, so there's no fallback to dbt-duckdb's. Every duckrun model is already an external table, just in Delta |
materialized='table_function' |
errors — duckrun doesn't ship it | a DuckDB table macro is catalog-only state, and duckrun's DuckDB is in-memory, so it could only ever live for the length of one run |
threads |
honored, but concurrent writers share one memory budget; a microbatch model's batches still run in order | DuckDB's memory_limit is per database, not per model, and a microbatch model's batches all write the same table (below) |
on_schema_change='sync_all_columns' |
only adds columns | delta-rs can't drop columns (below) |
merge_on_using_columns, clause action: error |
rejected with a clear error | no delta-rs equivalent — refusing beats silently running something else (below) |
merge_returning_columns |
accepted and ignored | duckrun never surfaces a returned relation, so it changes no table state |
Everything else carries over: DuckrunCredentials subclasses dbt-duckdb's, so the whole profile
surface (attach, secrets, settings, extensions, plugins, filesystems, remote, retries,
external_root, module_paths, disable_transactions, …) is the same object, not a reimplementation.
Seeds, snapshots, native unit_tests:, data tests, exposures, python models, external_location
sources, and the full merge-config surface (merge_clauses — including do_nothing, the implicit
clause defaults, mode, by: source, insert: {columns, values} — and
merge_update_set_expressions) all behave as they do upstream. duckrun's own additions
(incremental_strategy='insert', partition_by, sort_by, location, catalogs) are a superset:
a dbt-duckdb project never has to use them. Test-by-test detail is in
Conformance.
Setup & versions¶
- Needs
duckdb >= 1.5.4. Older builds — including Microsoft Fabric's bundled stable runtime — fail loud atconnect(). In a Fabric notebook:!pip install duckrun --upgradethen restart. - Pins
deltalake == 1.5.0. delta-rs 1.6.0's MERGE is broken at scale, so duckrun stays on 1.5.0.
Microsoft Fabric / OneLake¶
-
delta-rs
> 1.5.0breaks bulk delete on OneLake. Since 1.5.1 the batch-delete path drops the workspace/artifact ids ("Either WorkspaceId or ArtifactId are missing in the request"), sovacuumand other multi-file deletes fail against OneLake. This is a major reason duckrun pinsdeltalake == 1.5.0. See delta-rs #4401. -
get_statsis slow on tables with deletion vectors. A parquet footer counts rows a DV has already removed, sototal_rowssubtracts the DV total to stay equal toSELECT COUNT(*)— and the only delta-rs API for that total,DeltaTable.deletion_vectors(), expands each bitmap into one boolean per row. That is ~10s on a 150M-row table, to recover a number the Delta log already stores verbatim asadd.deletionVector.cardinality;get_add_actions()just doesn't surface it. TODO: read the cardinality from the log instead (needs handling parquet checkpoints too), or get acardinalitycolumn ontoget_add_actionsupstream. Only affects tables written by Fabric Warehouse / Spark — delta-rs rewrites files rather than emitting a DV, and tables whose protocol doesn't declare thedeletionVectorsreader feature skip the read entirely.
SQL DML (conn.sql)¶
UPDATE … FROMandDELETE … USINGare rejected → rewrite as a correlated subquery.- One statement per
conn.sql()call — multiple statements in a single call are rejected.
The full accepted/rejected matrix is in the Connection API.
dbt & incremental¶
threadsis honored, as in dbt-duckdb — dbt's default of1applies when the profile omits it. Two things behave differently from a pure-SQL adapter, both because a duckrun model writes a real table rather than a row set:- Concurrent writers share one memory budget. DuckDB's
memory_limitapplies to the database, not to a model, so duckrun pins it once for the run (85% of the container-aware effective limit) — no model sets its own. delta-rs merges are serialized: at most one runs at a time and holds the full merge pool and disk spill cap, while the other threads keep running everything else (views, appends, overwrites, insert-only merges). A run of many genuinely small merges therefore won't overlap them; many independent, network-bound models benefit most from more threads. - A microbatch model's batches run in order, even at higher thread counts, because every batch writes the same table: they'd serialize on the Delta log anyway. Different models still run in parallel.
- Concurrent writers share one memory budget. DuckDB's
- None of this is a limit on concurrent writers: separate runs / notebooks / jobs writing the same tables at once is fully supported and safe (every write is snapshot-pinned and fails loud on a conflict).
- Two dbt merge configs are rejected, on purpose.
merge_on_using_columnsand a clauseaction: errorare dbt-duckdb spellings with no delta-rs equivalent (delta-rs can't join on a USING column list, and can't raise from a merge clause), so duckrun raises a clear error rather than silently running something else. The rest of dbt-duckdb's merge surface —merge_clauses(includingdo_nothingand its implicit clause defaults) andmerge_update_set_expressions— is translated and honored.
Schema & constraints¶
- Schema evolution is add-only. delta-rs can't drop columns, so
on_schema_change='sync_all_columns'only adds them. Useappend_new_columnsorfail. - Only
not nullis enforced.check/primary_key/foreign_keyconstraints are declared but not checked — they can't be enforced against adelta_scanview.
DuckDB catalog¶
- No external-table abstraction, so Delta tables are views. DuckDB's catalog has only native
tables (own DuckDB storage) and views — no PostgreSQL-style foreign table whose bytes live
elsewhere but still accepts writes. Since delta-rs owns the data, duckrun registers each Delta
table as a
CREATE VIEWoverdelta_scan(...)and routes writes to delta-rs at the cursor.
Materializations¶
- No persistent views. The Delta spec doesn't define a view, so there's nothing durable to write.
A
materialized='view'model runs fine — it's a real DuckDB catalog view you can query for the rest of the session — but it lives only in that connection and vanishes when it closes; nothing is saved to storage, so the next session won't see it. (And swapping a model betweentableandviewisn't supported.) DROP TABLEis a soft tombstone, not a physical delete.conn.sql("drop table x")unregisters the table and writes a tombstone marker but does not reclaim the data files (a deliberate precaution — you purge them when you're sure). Address dropped tables by name, not by path.
Parquet layout¶
SORTED BY AUTOpicks the key with a naive, lightly-tested heuristic (also reachable from dbt as the model configsort_by: auto). The auto sort-key picker is a cheap greedy single pass over statistical sketches (approximate cardinalities, HyperLogLog functional-dependency tests) — a stack of rules of thumb, each of which can be wrong on a given distribution. It is not guaranteed to shrink anything and can occasionally pick a worse key than the table's natural arrival order, and it has been validated against essentially one dataset, not a broad workload sample. When you know your grain and query patterns, prefer an explicitSORTED BY (cols), and always compareconn.get_stats()before and after. See Automatic sorting.- Adaptive row-group sizing is a heuristic too, tuned on one dataset. Row groups are sized from a
planner row estimate (
ceil(rows / 8), capped at 16M) grounded against that same single benchmark — a rough rule, not a broadly tested optimum. A table with unusual width, cardinality, or skew may well have its sweet spot elsewhere. See How the numbers are grounded. - On a full-table overwrite, that row estimate is a planner guess and can be badly low — not
slightly low, multiples low. DuckDB applies a fixed 0.2 selectivity guess to filters and
anti/semi joins, a set-operation parent carries no cardinality of its own, and a CSV's row count is
extrapolated from file size (so a compressed source is off by its compression ratio). On a real
model — joins feeding an aggregation, the ordinary shape of a fact build — these compound
multiplicatively: measured under-estimates include 9× on a 370M-row fact (issue #22, which
produced 380 row groups where ~34 belong) and 9.7× on a 144M-row mart. duckrun cannot correct
this without reimplementing a planner, so treat the estimate as unreliable by default on any
non-trivial query. The failure is one-sided — an over-estimate is harmless, an under-estimate
shrinks the ceiling — so the defenses apply in order. When the table already exists, the guess
is raised to the prior version's exact row count from the Delta log if that is larger; this is
free and it is what keeps a rebuilt fact table at its proper geometry. It can only ever raise the
estimate, never lower it, so an overwrite that deliberately shrinks a table simply keeps the larger
ceiling. Failing that — a first create, or a log that can't be read — the guess is floored at
8M rows (Power BI's native segment size), capping the damage at half-full segments rather than
the ~1M ones a bad estimate used to produce. The cost of that fallback is that the ~8-lane target
is only reached above ~64M rows; a smaller new table gets fewer, larger segments. If an estimate
still turns out low by 4× or more, the write logs a warning naming the rows it sized for and the
rows that landed. Compaction and
VACUUM <table>are unaffected throughout: they size from the exact row count in the Delta log and keep the 1M floor, so compacting a table restores the finer geometry. When you know the size, don't rely on any of this: the model configmax_row_group_size(dbt) declares the geometry explicitly and bypasses the estimate entirely — the right answer for a big fact's very first build. See the model config reference. - A
replaceWherewrite is never sized. The microbatch strategy andINSERT … REPLACE WHEREreplace a slice of a table, so they keep the flat 16M ceiling and let the 256 MB file roll pick the group — sizing a window off its own row count would say nothing about the table's segment budget.
Memory¶
- Two engines share one machine's memory. DuckDB and delta-rs each keep their own pool in the same
process. duckrun bounds them independently — DuckDB by one pinned
memory_limit, a delta-rs merge by its own spill caps behind a gate — but a bound is not a shared allocator, and delta-rs's merge spill-to-disk is itself flaky. Background in the Design document. The merge pool exists only for themergestrategy and the rawMERGE INTOverb;insert,delete+insert,append,microbatchandoverwritenever run one. mergeis the write path most likely to run out of memory, and the ceiling is not duckrun's. A delta-rsMERGEplans a join against the whole pinned target, so its cost scales with the target's partition span, not the size of the batch — a batch straddling many monthly partitions can exhaust a very large machine. If your model only ever needs to add rows it has never seen (a key-level idempotent append), useincremental_strategy='insert': duckrun computes that as a DuckDB anti-join and commits a plain append, with no delta-rs merge and no file rewritten. For a genuine upsert there is no way around removing old row versions, which means rewriting files — keep each batch inside as few partitions as possible.- delta-rs hard-codes a 100 GB merge disk-spill ceiling — arguably a bug. A wide MERGE (one that
rewrites many partitions) spills to disk, and the DataFusion
DiskManagerunder delta-rs caps that spill at a flat 100 GB regardless of how big the disk is — so a merge aborts with "Resources exhausted … exceeded the allowable limit of 100.0 GB" even on a machine with terabytes free. It should scale to the available disk (or at least be documented and defaulted sanely), not hard-code a constant. duckrun works around it by sizingmax_temp_directory_sizeto ~80% of the spill disk's free space on every merge (override per model withmerge_max_temp_directory_size); still, the true fix for a merge this large is data layout — keep each batch inside one partition so the span, and thus the spill, stays small. See MERGE at scale.
For the test-by-test picture, see Conformance; for why these trade-offs exist, see How it works.