Materializations¶
A materialization is a query precomputed into a DuckDB table. Requests are served from the answer rather than from the data.
Measured on 10,000,000 sales, a panel that ranks the top makes by revenue within each region —
a GROUP BY feeding a windowed rank():
| the panel, against the base table | 25.0 ms |
| from the materialization (18 rows) | 0.8 ms — 32x |
| sliced a way it was never asked for | 1.1 ms |
| refresh | 22.6 ms |
Results are identical — the materialization is the panel's own output.
Using one¶
DuckDBDatabase.ManagedMaterialization topMakes = database.materialize("top_makes")
.as("SELECT region, make, sum(price) AS revenue,"
+ " rank() OVER (PARTITION BY region ORDER BY sum(price) DESC) AS rank"
+ " FROM sale GROUP BY 1, 2")
.build(); // builds the table if it is not there already
// It is an ordinary table. Query it like one.
List<Top> top = database.query("SELECT * FROM top_makes WHERE rank <= 3").records(Top.class);
build() creates the table if absent and leaves it alone if present, however stale — building is
not refreshing. The name you give it is the name you query.
refresh() |
rebuild it now, atomically |
refreshIfOlderThan(Duration) |
rebuild only if it has aged past that |
isOlderThan(Duration), getBuiltAt() |
when this JVM last built it |
rowCount(), exists() |
|
drop() |
remove the table and forget it |
Without CQEngine, the same thing takes a connection per call, like TableWriter:
Materialization topMakes = new Materialization("top_makes", sql);
topMakes.createIfAbsent(connection);
topMakes.refresh(connection);
Refreshing does not interrupt readers¶
This is the part worth having in a library rather than writing yourself.
The obvious refresh is DROP TABLE then CREATE TABLE AS, which leaves a window in which the
table does not exist. Measured with four threads reading across fifteen refreshes:
| refresh style | reader failures |
|---|---|
DROP then CREATE TABLE AS |
1,280 — Catalog Error: Table with name ... does not exist! |
what refresh() does |
0 |
refresh() builds the replacement alongside under another name and swaps the two inside one
transaction. DuckDB's catalog is transactional, so a reader's statement resolves either the old
table or the new one, never the gap between them:
CREATE OR REPLACE TABLE top_makes__quackjvm_next AS SELECT ...;
BEGIN;
DROP TABLE top_makes;
ALTER TABLE top_makes__quackjvm_next RENAME TO top_makes;
COMMIT;
It is safe to refresh while readers are reading — that is the point. It is not safe to refresh the same materialization from two threads at once.
Two kinds, and they complement each other¶
Materialize the panel when you know the panel. Smallest result, biggest speedup, answers that one question.
Materialize a dimensional roll-up when you do not. Keep every dimension a panel might group or
filter by, with additive measures only — counts and sums, never averages — and derive the rest
at query time as sum(total)/sum(n). You cannot average an average.
database.materialize("sales_rollup")
.as("SELECT region, make, colour, year, count(*) AS n, sum(price) AS total"
+ " FROM sale GROUP BY 1, 2, 3, 4")
.build();
On 10,000,000 rows that roll-up is 18,000 rows, 0.18% of the base table, and it answers questions it was never designed for — a colour × year breakdown nobody anticipated runs in 0.9 ms against 21.9 ms on the base table.
| on 10M rows | build | the panel it was built for | a question it never saw |
|---|---|---|---|
| base table | – | 39.6 ms | 21.9 ms |
| the panel's result (30 rows) | 39.8 ms | 0.4 ms | cannot answer it |
| dimensional roll-up (18,000 rows) | 55.2 ms | 2.1 ms | 0.9 ms |
Why not a cache, and why not a view¶
A cached List sits on the Java heap this library exists to keep empty, and it is opaque —
you can hand it back for the exact query it answers and nothing else. A materialization is a
columnar, compressed, off-heap table you can still filter, join and aggregate further.
A VIEW is not a materialization. DuckDB has no CREATE MATERIALIZED VIEW — it is a parser
error — and a plain CREATE VIEW stores the query, not its result, re-running it every time. On
4,000,000 rows: 5.6 ms for the aggregate against the base table, 5.7 ms through a view, 0.2 ms
against a real materialization.
Keeping it in step¶
Refreshing is a full rebuild, which on ten million rows is 22–55 ms. For append-only data with additive measures you can do better by folding in only the new rows:
long appended = rollup.appendDelta(
"SELECT region, make, colour, year, count(*), sum(price)"
+ " FROM sale WHERE saleId >= ? GROUP BY 1, 2, 3, 4",
watermark);
3.5 ms against 13.8 ms for a rebuild, with an identical result. Two conditions, and both are on you:
- The measures must be additive.
count,sum,minandmaxcombine across partial groups; an average or a median does not. Storecount(*) AS nandsum(x) AS total, and derive the average at query time assum(total)/sum(n). - Panels must re-aggregate. After an append the table holds partial groups — the same key
appears once per delta — so a panel reads
SELECT make, sum(n) FROM rollup GROUP BY 1, notSELECT make, n. That is exactly what makes appending cheap: nothing is merged in place.
refresh() collapses the partials again, because it rebuilds from the base data. Refresh
occasionally so the table does not grow a delta at a time.
Why you write the delta query rather than us deriving it
Deriving it would mean editing the materialization's own SQL to add a watermark predicate, and a mistake there would put wrong numbers in the table silently. The same machinery used for seamless rewrite is safe there precisely because a failed match just falls through to the original query and costs nothing but speed. Correctness-critical edits and best-effort optimisations do not deserve the same trust.
The trade is the same as any precomputation: a materialization is as old as its last refresh. The difference from a cache is that a stale materialization still answers every question it covers, slightly behind, where a stale cache entry is simply wrong for the one query it holds.
What it costs¶
- Storage. It is a real table. A roll-up grouped on something high-cardinality — a model, an
id — will be as large as the data. Watch
rowCount(). - Non-additive measures cannot be rolled up. A median or an exact
count(DISTINCT)needs the base table, or a full rebuild of a materialization that computes it directly. - It does not redirect anything. Queries name the materialization. Rewriting a query against the base table to target one automatically is proposed but not built.