Everybody loves JSON. VARIANT is now a first-class data type in DuckDB, and the word to remember is shredding.
Not the guitar kind. Shredding in our VARIANT context means: when DuckDB writes a row group to disk, it looks at your JSON column and finds the fields that show up in most rows with the same kind of value every time.
Here is a common example, one event out of five million:
{"type": "purchase", "user": {"id": 42, "country": "FR", "premium": true},
"props": {"amount": 12.5, "currency": "EUR", "items": 2}, "tags": ["a", "b"]}
The event kind is always text, user.id is always a number, props.amount is always a decimal. Those fields get pulled out into their own real columns under the hood. The rare fields, and the fields that are a number in one row and text in the next, stay together in a binary remainder. So the consistent part of your JSON is stored like a normal table, and only the messy part is stored as a blob.
The perfect case is structured logs. level, service, latency_ms, trace_id are in every line and always the same kind of value, so they all shred. The odd extra object stays in the remainder, still queryable, just slower. The trap: a latency_ms that is 231 in one line and "231ms" in the next falls into the remainder too. Keep value kinds consistent.
VARIANT is not only about speed. Text is greedy on storage as much as on CPU. I took the five million events and stored the same data three ways:
CREATE TABLE ev AS SELECT json AS payload FROM read_ndjson_objects('events.ndjson');
CREATE TABLE ev AS SELECT json::VARIANT AS payload FROM read_ndjson_objects('events.ndjson');
CREATE TABLE ev AS SELECT * FROM read_json('events.ndjson');
Then three queries on each: a filter on two fields, a sum of a numeric field grouped by country, and a list lookup.
SELECT count(*) FROM ev
WHERE payload.type::VARCHAR = 'purchase' AND payload.user.country::VARCHAR = 'FR';
SELECT payload.user.country::VARCHAR AS country, sum(payload.props.amount::DOUBLE) AS amount
FROM ev WHERE payload.type::VARCHAR = 'purchase' GROUP BY ALL ORDER BY 1;
SELECT count(*) FROM ev WHERE list_contains(payload.tags::VARCHAR[], 'c');
| JSON string | VARIANT (2.0 alpha) | VARIANT (1.5.5) | One typed column per field | |
|---|---|---|---|---|
| On disk | 224 MB | 85 MB | 81 MB | 45 MB |
| Q1 filter | 366 ms | 63 ms | 4.96 s | 52 ms |
| Q2 sum by country | 408 ms | 61 ms | 5.28 s | 51 ms |
| Q3 list contains | 357 ms | 1.96 s | 4.65 s | 67 ms |
What can we see here?
- VARIANT is 2.7 times smaller than the JSON string, and you can see why with
pragma_storage_info('ev'): the object is split into sub-columns, the event kind is stored as a dictionary.EXPLAINshows the filter pushed into the scan, so a query onpayload.typereads one sub-column. - On the queries that touch shredded fields, a filter or a sum on a numeric sub-field, VARIANT is about 6 times faster than parsing the JSON text and within 20 percent of the typed columns. Compared to VARIANT in 1.5.5 it is 78 times faster, because 1.5.5 had the type but not the shredding.
- The list query is the exception. Casting a VARIANT list to
VARCHAR[]costs two seconds in this alpha, slower than the JSON path. Field access is where shredding pays today; lists are not there yet.
So the golden rule of modeling is still valid: model what you know. The fields every query touches deserve real columns, and promoting one is two statements:
ALTER TABLE ev ADD COLUMN type VARCHAR;
UPDATE ev SET type = payload.type::VARCHAR;
TL;DR: if your events share a consistent set of fields with consistent value kinds, store them as VARIANT rather than a JSON string. You get a third of the storage and field queries that run like real columns. Promote the fields every query touches to real columns, keep the long tail in the VARIANT, and avoid list casts in hot queries for now.