A DuckDB extension for analytics-optimized JSON storage and querying.
The extension has three main goals:
- spec-driven multi-field extraction from a JSON document into a typed
STRUCTwithjsono_transform; - a pre-parsed JSONO representation for repeated reads over the same JSON column;
- path-shredded JSONO storage (
jsono(value, shredding := spec)) that lifts hot paths into typed shred columns for fast columnar reads while keeping->>andto_jsonworking transparently.
Warning
This extension is experimental and maintained on a best-effort basis by a developer who doesn't write C++ professionally, so expect rough edges. It has not been hardened for production, and the JSONO binary format and SQL API are still being designed and may change between revisions. Validate behavior and performance on your own data before relying on it. Feedback and contributions are welcome via GitHub.
Build and convert:
jsono(json)/try_jsono(json)— parse JSON text orJSONinto JSONO (try_returnsNULLon malformed input instead of raising).jsono(struct)— construct JSONO directly from typed DuckDB values, without serializing through JSON text first.to_json(value)— serialize JSONO back to compactJSON(also available as aCAST).
Extract and project:
jsono_extract(value, path)/value -> path— extract one value as JSONO.jsono_extract_string(value, path)/value ->> path— extract one value asVARCHAR.jsono_transform(value, spec)— project many fields into a typedSTRUCTin one pass (specis aSTRUCTliteral).jsono_entries(value[, key_style])— flatten scalar leaves intoSTRUCT(key VARCHAR, value VARCHAR)[].
Merge and aggregate:
jsono_merge_patch(target, patch[, ...])— RFC 7396 merge patch (later patches win; anullmember deletes the key).jsono_group_merge(value [ORDER BY ...])— aggregate a stream of patches into one value.jsono_group_merge_max(value, order_key)/jsono_group_merge_min(value, order_key)— order-independent keyed merge: per leaf, the value from the row with the greatest (_max) or smallest (_min)order_keywins, without buffering the input.
Shredded storage:
jsono(value, shredding := spec)— build a shreddedSTRUCTfrom JSON text or an existing JSONO, lifting hot paths into typed shred columns;->>andto_jsonstay transparent and read the shreds.
Inspect:
jsono_type(value[, path])— the value's JSON type asVARCHAR(OBJECT,ARRAY, a scalar type, orNULL).jsono_keys(value[, path])— object keys asVARCHAR[].jsono_validate(value)— strict current-format validation asBOOLEAN.jsono_storage_size(value)— physical byte sizes (body blobs, shreds, total) as aSTRUCT.jsono_storage_type()— DDL of the physicalSTRUCTbacking a JSONO value.
Note
JSONO is a format, not a SQL type. A JSONO value is this extension's pre-parsed binary representation of a JSON document — physically a nested STRUCT(jsono STRUCT(body STRUCT(slots BLOB, key_heap BLOB, string_heap BLOB, skips BLOB))), with no registered type name. There is no JSONO to name in a CAST or a column definition. Instead: build a value with jsono(...) / try_jsono(...), declare storage columns with jsono_storage_type(), and let the functions, the -> / ->> operators, and to_json recognise that STRUCT shape structurally — a value read back from Parquet or DuckLake works with no cast. .body (and, on a shredded value, the shred fields beside it) are physical storage details, not a public access API.
Build and load the extension first (see Installation), then:
SELECT jsono_transform(
jsono('{"id": 42, "name": "duck", "active": true}'),
{id: 'BIGINT', name: 'VARCHAR', active: 'BOOLEAN'}
);
-- {'id': 42, 'name': duck, 'active': true}Convert to JSONO when the same JSON values will be read repeatedly:
CREATE TABLE events AS
SELECT jsono(payload) AS payload_jsono
FROM read_parquet('events.parquet');
SELECT (jsono_transform(payload_jsono, {user_id: 'VARCHAR', event_ts: 'BIGINT'})).*
FROM events;Note: This extension is not yet published in DuckDB's extension repository, and the JSONO binary format and SQL API are still being designed. Build it from source and load the produced local extension.
uv run make release
./build/release/duckdb -unsignedLOAD './build/release/extension/jsono/jsono.duckdb_extension';import duckdb
con = duckdb.connect(":memory:", config={"allow_unsigned_extensions": "true"})
con.execute("LOAD './build/release/extension/jsono/jsono.duckdb_extension'")Strictly parses JSON text or JSON into JSONO.
SELECT jsono('{"a":1,"b":"x"}');Malformed or empty input raises an error. Use try_jsono when malformed input should become NULL:
SELECT try_jsono('{not json') IS NULL;To parse and shred hot paths into typed shred columns in one pass, supply a constant shredding := {...} spec — the same option documented under the shredding constructor below:
SELECT jsono('{"kind":"commit","time_us":1700}', shredding := {'$.kind': 'VARCHAR', '$.time_us': 'BIGINT'});A JSONO value is physically this nested STRUCT — one jsono layout field wrapping the four body BLOBs every storage layer (DuckDB-native, Parquet, DuckLake) sees:
STRUCT(jsono STRUCT(body STRUCT(slots BLOB, key_heap BLOB, string_heap BLOB, skips BLOB)))
The JSONO functions (to_json, jsono_transform, …) recognise this STRUCT structurally, so a value read back from Parquet or DuckLake binds to them with no cast. jsono_storage_type() returns the DDL string above for declaring storage columns.
The binary layout of each body BLOB (slot encoding, tag table, string-heap cursor, and the skips navigation metadata) is specified in jsono_format.md.
Constructs JSONO directly from typed DuckDB values without serializing the whole input through JSON text first.
SELECT to_json(jsono({'b': 2, 'a': 'x', 'n': NULL, 'arr': ['c', NULL, 'd']}));Result:
{"a":"x","arr":["c",null,"d"],"b":2,"n":null}STRUCT fields become JSON object keys, LIST values become arrays, JSON children are parsed as JSON subtrees, and VARCHAR children remain JSON strings. Exact integer and decimal values are preserved; non-finite DOUBLE values are rejected because JSON has no NaN/Infinity representation. SQL NULL object fields are serialized as JSON nulls.
Because the input STRUCT already carries its field names and types, jsono(struct) shreds automatically — there is no shredding argument to pass. Every top-level field whose type is a shred type (BIGINT, UBIGINT, DOUBLE, BOOLEAN, or VARCHAR) is lifted into a typed shred column named by the field, exactly as the shredding constructor does for text. Top-level fields of any other type — narrower integers like INTEGER, DECIMAL, NULL, JSON subtrees, nested objects, and lists — stay in the residual; cast a field to its shred type (x::BIGINT, x::DOUBLE) to lift it.
SELECT typeof(jsono({'kind': 'commit', 'time_us': 1700::BIGINT}));
-- a shredded STRUCT: a `jsono` layout wrapping the body blobs plus shreds "kind" VARCHAR and "time_us" BIGINTSerializes JSONO back to compact JSON.
SELECT to_json(jsono('{"b":2,"a":1}'));Result:
{"a":1,"b":2}Object keys are emitted in sorted byte order. Explicit casts are also available:
SELECT CAST(jsono('{"a":1}') AS JSON);
SELECT CAST(jsono('{"a":1}') AS VARCHAR);The JSON cast and to_json produce DuckDB's core JSON logical type, which is provided by the bundled json extension that ships with every standard DuckDB distribution — no manual setup is needed.
Extracts one value using a constant path. value -> path is an alias for the same operation. A bare string path names a literal top-level key, a string path starting with $ uses JSONPath, and a BIGINT path indexes an array:
SELECT CAST(jsono_extract(jsono('{"a.b":"dot","a":{"b":"nested"}}'), 'a.b') AS VARCHAR);
-- "dot"
SELECT CAST(jsono('{"a.b":"dot","a":{"b":"nested"}}') -> '$.a' AS VARCHAR);
-- {"b":"nested"}
SELECT jsono('{"items":[{"name":"duck"}]}') -> 'items' -> 0 ->> 'name';
-- duckJSON null at the selected path returns a JSONO null value. Missing paths return SQL NULL.
Extracts one value using a constant path. value ->> path is an alias for the same operation. Path rules match jsono_extract:
SELECT jsono_extract_string(jsono('{"a.b":"dot","a":{"b":"nested"}}'), 'a.b');
-- dot
SELECT jsono('{"a.b":"dot","a":{"b":"nested"}}') ->> 'a.b';
-- dot
SELECT jsono_extract_string(jsono('{"a.b":"dot","a":{"b":"nested"}}'), '$.a.b');
-- nestedJSON strings return unquoted text, numbers and booleans return their JSON text, JSON null and missing paths return SQL NULL, and arrays/objects return compact JSON text.
Extracts fields from JSONO into a typed DuckDB STRUCT. spec must be a constant STRUCT mapping each output field name to an extraction rule.
SELECT jsono_transform(
jsono('{"a":42,"b":3.5,"c":"hi","d":true}'),
{a: 'BIGINT', b: 'DOUBLE', c: 'VARCHAR', d: 'BOOLEAN'}
);A rule is either a type shorthand string or a wrapper STRUCT. Supported scalar types:
BIGINTUBIGINTDOUBLEVARCHARBOOLEAN
A shorthand reads the matching top-level key (path defaults to $."field"). A wrapper STRUCT reads from an explicit JSONPath:
SELECT jsono_transform(
jsono('{"event":{"timestamp":1234567890}}'),
{ts: {type: 'BIGINT', path: '$.event.timestamp'}}
);Paths support nested keys, array indices, quoted keys, and the [*] wildcard: $.user.name, $.items[0].id, $."my-key", $.tags[*].
Collect array elements into a VARCHAR[] with a single-element list type, or join them into one string with join_separator:
SELECT jsono_transform(
jsono('{"tags":["a","b","c"]}'),
{tags: {type: ['VARCHAR'], path: '$.tags[*]'}}
);
SELECT jsono_transform(
jsono('{"tags":["a","b","c"]}'),
{tags: {type: 'VARCHAR', path: '$.tags[*]', join_separator: ','}}
);The field names type, path, and join_separator are reserved inside a wrapper STRUCT.
The shredding named argument turns jsono() into a shredding constructor: it produces a shredded STRUCT — the four-BLOB JSONO residual followed by one typed shred column per path in spec. The primary form takes JSON text and parses + shreds in one pass; passing an existing JSONO value shreds it directly. Those are the two accepted value inputs — there is no jsono(struct, shredding := …) overload, because a STRUCT built with jsono(struct) is already shredded by that constructor (so to shred a struct-built value, build it with jsono({…}) directly rather than wrapping it). Reads stay transparent — ->>, jsono_extract_string, to_json, and casts work on the shredded value exactly as on a plain JSONO column — but extracting a shredded path becomes a direct read of its typed shred instead of a per-row parse, which the planner can push down and prune on.
spec is a constant STRUCT mapping each path to its shred type string (the same shape as Parquet's SHREDDING option and read_json's columns; same path grammar as jsono_transform, no wildcards; shred types VARCHAR, BIGINT, UBIGINT, DOUBLE, BOOLEAN):
CREATE TABLE events AS
SELECT jsono(payload, shredding := {'$.kind': 'VARCHAR', '$.did': 'VARCHAR', '$.time_us': 'BIGINT'}) AS payload
FROM read_parquet('events.parquet');
-- queried like any JSONO column; the planner reads the shreds
SELECT payload ->> '$.kind' AS kind, count(*) AS n
FROM events GROUP BY kind;
SELECT max(CAST(payload ->> '$.time_us' AS BIGINT)) FROM events;The conversion is lossless: to_json(payload) reconstructs the original document, and any path not in spec still reads from the residual. A value that does not fit its shred type (a string in a BIGINT shred, an explicit JSON null, a missing key) is kept in the residual unchanged, so reconstruction never loses or coerces data. Top-level shredded paths are stored once — in the shred rather than duplicated in the residual — so the shredded column is typically smaller than the plain JSONO column for the same data while answering hot-path queries from narrow typed columns.
SELECT to_json(jsono('{"kind":"commit","time_us":1700,"extra":"e1"}',
shredding := {'$.kind': 'VARCHAR', '$.time_us': 'BIGINT'}));
-- {"extra":"e1","kind":"commit","time_us":1700}Transparent reads over a shredded value go through the bundled json extension (present in every standard DuckDB distribution) and the extension's query optimizer. The shredded shape — the jsono layout wrapping the body blobs and the named shreds columns — survives a plain Parquet round-trip and reads back transparently; the scalar shred leaves keep projection and filter pushdown.
Values shredded differently compose freely. A set operation, CASE or COALESCE over differently-shredded values merges into one shredded type on the union of the shred sets, and an INSERT into a column with any other shred set — fully disjoint included — lands losslessly: the optimizer rewrites the struct cast into a reshred against the column's shred set (single-pass when the target keeps every source shred), and a path with no shred column in the target simply stays in the residual.
The safety net for the raw struct cast (no extension optimizer: another process, SET disabled_optimizers='extension') is the shred manifest: each shredded value records, inside its residual, which paths were stripped into shred columns and as what type. A cast that silently drops or retypes a shred column leaves that record contradicting the data, and every JSONO reader verifies it — reading such a narrowed row raises an error instead of silently returning partial data. The manifest lives in the value's own blobs, so the protection rides with the data through Parquet and DuckLake. Shred order does not matter: the shred field order is canonicalized (sorted by name), so the same shred set always yields the identical type — jsono_storage_type(<shred DDL>) and CREATE TABLE … AS SELECT produce the same column type regardless of the order shreds are written in.
Flattens scalar leaves of a JSONO document into a list of key/value structs. The default key_style is 'jsonpath'; pass the named argument key_style := 'dotted' for dot-separated keys:
SELECT unnest(jsono_entries(jsono('{"a":1,"b":{"c":"x"},"d":true}')));
-- {'key': $.a, 'value': 1}
-- {'key': $.b.c, 'value': x}
-- {'key': $.d, 'value': true}
SELECT unnest(jsono_entries(jsono('{"a":1,"b":{"c":"x"}}'), key_style := 'dotted'));
-- {'key': a, 'value': 1}
-- {'key': b.c, 'value': x}JSON null leaves keep their key and return SQL NULL as value; empty objects and arrays do not produce entries. Dotted output can collide when a literal dotted key and a nested path render to the same string, for example "a.b" and {"a":{"b":...}}.
The order of the returned entries is unspecified — do not rely on it. Over a shredded value the shred leaves are emitted after the residual leaves (the shreds are read straight from their columns), so the same logical document can flatten in a different order than its plain form, which lists leaves in sorted-key order. Sort the result yourself if you need a stable order.
Aggregates a stream of JSON object patches with RFC 7396 merge semantics: later patches in the ordered stream overwrite earlier keys and arrays replace wholesale. null object members are dropped before merging so existing keys stay untouched. Provide an ORDER BY clause to make the fold deterministic.
WITH patches(patch, ts) AS (
VALUES
(jsono('{"a":1,"b":{"x":1}}'), 1),
(jsono('{"a":null,"b":{"y":2}}'), 2),
(jsono('{"c":3}'), 3)
)
SELECT to_json(jsono_group_merge(patch ORDER BY ts))
FROM patches;Result:
{"a":1,"b":{"x":1,"y":2},"c":3}Use GROUP BY for grouped state accumulation:
SELECT session_id,
to_json(jsono_group_merge(patch ORDER BY event_ts)) AS state
FROM session_patch_events
GROUP BY session_id;Use it as a window function to materialize the running state after each patch:
SELECT id,
to_json(jsono_group_merge(patch ORDER BY id)
OVER (ORDER BY id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)) AS state
FROM (
VALUES
(1, jsono('{"theme":"dark"}')),
(2, jsono('{"lang":"en"}')),
(3, jsono('{"theme":"light"}'))
) AS t(id, patch)
ORDER BY id;Result:
1 {"theme":"dark"}
2 {"lang":"en","theme":"dark"}
3 {"lang":"en","theme":"light"}
Order-independent variants of jsono_group_merge that take the ordering key as an ordinary second argument instead of an ORDER BY clause. Per leaf, the value from the row with the greatest order_key wins (jsono_group_merge_max) or the smallest wins (jsono_group_merge_min); null object members never overwrite, exactly like jsono_group_merge. They are drop-in replacements for the ordered form:
jsono_group_merge_max(value, order_key) ≡ jsono_group_merge(value ORDER BY order_key)
jsono_group_merge_min(value, order_key) ≡ jsono_group_merge(value ORDER BY order_key DESC)
WITH hits(params, event_ts) AS (
VALUES
(jsono('{"utm":{"source":"google"},"page":"/a"}'), 1),
(jsono('{"utm":{"medium":"cpc"}}'), 2),
(jsono('{"page":"/b"}'), 3)
)
SELECT to_json(jsono_group_merge_max(params, event_ts)) AS latest_wins,
to_json(jsono_group_merge_min(params, event_ts)) AS earliest_wins
FROM hits;Result:
latest_wins earliest_wins
{"page":"/b","utm":{"medium":"cpc","source":"google"}} {"page":"/a","utm":{"medium":"cpc","source":"google"}}
order_key is any comparable type; for a composite tie-break pass a ROW(...)/struct, which compares lexicographically:
SELECT to_json(jsono_group_merge_max(params, ROW(event_ts, hit_id)))
FROM hits;Why a separate function? jsono_group_merge(value ORDER BY key) is order-dependent, so DuckDB buffers and sorts every input row before folding — O(rows) memory, which can exhaust memory on large groups. The keyed variants resolve conflicts per leaf by the key argument, so they are commutative and associative: DuckDB streams rows straight into the aggregate with no buffer and the state stays O(distinct leaves per group). Use these when folding a large stream (e.g. collapsing event hits into sessions) where the ordered form runs out of memory.
A shredded input keeps its shredding in the output, just like jsono_group_merge.
These functions require each path to keep a consistent kind across rows (always an object, or always a scalar/array). A path that is an object in one row and a scalar/array in another makes per-leaf last-write-wins order-dependent, so it is rejected with a clear error — use jsono_group_merge(value ORDER BY key) for such structurally-inconsistent data.
Applies one or more RFC 7396 merge patches left to right. The function is variadic and requires at least one argument; each null object member in a patch deletes that key from the result.
SELECT to_json(jsono_merge_patch(
jsono('{"a":1,"b":2}'),
jsono('{"b":null,"c":3}')
));Result:
{"a":1,"c":3}Pass additional patches to fold them in sequence:
SELECT to_json(jsono_merge_patch(
jsono('{"a":1}'),
jsono('{"a":2,"b":2}'),
jsono('{"c":3}')
));Result:
{"a":2,"b":2,"c":3}Unlike jsono_group_merge, this is a scalar function over a fixed set of patch arguments rather than an aggregate over rows.
jsono_type(value[, path]) -> VARCHAR -- OBJECT/ARRAY/VARCHAR/BIGINT/UBIGINT/DOUBLE/BOOLEAN/NULL
jsono_keys(value[, path]) -> VARCHAR[] -- object keys
jsono_validate(value) -> BOOLEAN -- strict current-format validation
jsono_storage_size(value) -> STRUCT -- physical byte sizes (body blobs + shreds + total)
jsono_storage_type() -> VARCHAR -- DDL of the physical STRUCT backing JSONOjsono_type and jsono_keys inspect the shape of unknown JSON before extracting it with jsono_transform. The optional path is a constant JSONPath (same grammar as jsono_transform, without wildcards) that points at a nested position; a missing path yields NULL.
SELECT jsono_keys(jsono('{"user":{"name":"a","age":1}}'), '$.user');
-- [age, name]
SELECT jsono_type(jsono('{"items":[1,2,3]}'), '$.items[0]');
-- BIGINTjsono_validate checks the current binary format contract: header flags, sorted unique keys, slots, heaps, navigation metadata, UTF-8 strings, and raw number text. It returns false for malformed physical blobs and NULL for SQL NULL. On a shredded value it validates the residual encoding; the typed shred columns are DuckDB-native and not jsono's to assert.
jsono_storage_size reports slots, key_heap, string_heap, skips, shreds, and total byte counts. shreds is the per-row shred payload (0 for a plain value); total sums the body blobs and the shreds. It is a physical diagnostic and does not validate the value.
jsono_storage_type returns the DDL of the physical STRUCT that a JSONO value is — the form stored everywhere (DuckDB-native, DuckLake, plain Parquet). Use it to declare storage columns without hardcoding the field names; a column of that exact shape binds to jsono ops and to_json structurally, with no cast.
SELECT jsono_storage_type();
-- STRUCT(jsono STRUCT(body STRUCT(slots BLOB, key_heap BLOB, string_heap BLOB, skips BLOB)))These helpers are primarily used by the JSONO workflows and tests. Treat their exact surface as less stable than jsono_transform, jsono, and to_json.
bench/ is the comparable harness for jsono versions and the core DuckDB json baseline. Run a smoke benchmark:
uv run --frozen python bench/bench.py --filter group_merge/1k --runs 1The operation set, version/core-json comparisons, field-sample scenarios, and result-reading contract are documented in bench/README.md; profiling is in bench/PROFILING.md.
uv run make release
uv run make test
uv run --frozen black --check benchUseful commands:
uv run make debug
uv run make reldebug
uv run make clean
uv run --frozen python bench/run_benchmarks.py --list
uv run --frozen python bench/compare_results.py --save-baseline
uv run --frozen python bench/compare_results.pyThe DuckDB submodule lives in duckdb/. Update it only via explicit submodule commands.
JSONO_SHAPE_CACHE_SIZE controls the JSONO writer shape cache size:
JSONO_SHAPE_CACHE_SIZE=16384 ./build/release/duckdb -unsignedUse power-of-two values when comparing performance. The default is tuned for local benchmark workloads, but real data can differ.
- Object keys inside JSONO output are sorted, so serialized JSON text may not preserve input object key order.
jsono_transformrequires a constantSTRUCTspec.jsono(value, shredding := spec)requires a constantSTRUCTspec and rejects wildcard paths; object-key paths that round-trip through their shred type are physically stripped from the residual, while array-index paths and non-lossless shred values stay in the residual.- Shredded values and core json interop: the jsono-named functions (
jsono_extract,jsono_extract_string,jsono_type,jsono_keys,jsono_validate,jsono_storage_size),jsono_group_merge, the::VARCHARcast, and the shredded→plain JSONO reconstruct are bind-correct — they accept a shredded value directly and work even with the extension optimizer disabled. The operators and casts that resolve to the bundled corejsonextension —->>,->,json_extract,::JSON, andto_json— are correct over a shredded value only with the extension optimizer enabled (the default): the optimizer rewrites them to read the shreds and residual. With the optimizer fully disabled (PRAGMA disable_optimizer) those core-routed operations bind into core json and serialize the physical fields instead. This is a structural limit: core json owns theSTRUCT → JSONcast and DuckDB's cast registry keeps the first registration, so the extension cannot intercept it. - Declare a shredded storage column with
CREATE TABLE … AS SELECT jsono(…, shredding := …)or fromjsono_storage_type(<shred DDL>), and access a shred through->>/to_json(the optimizer reads the shred) rather than by struct member name —.bodyand the shred fields are physical storage details. Inserting a value shredded on any other shred set — fully disjoint included — lands losslessly (the optimizer reshredds it to the column's shred set). A raw struct cast that drops or retypes a shred column (only possible without the extension optimizer) is caught at read time by the shred manifest: reading the narrowed row raises an error instead of silently losing the stripped value. jsono_extract/->/jsono_extract_string/->>require a constant path, do not support wildcard list extraction yet, and do not support negative array indexes.- Unsupported scalar types in
jsono_transformfail at bind time. - JSON nested deeper than 1000 levels is rejected with an error (the limit is lowered to 50 under sanitizer builds).
- The binary JSONO format is strict-versioned (current
version = 3, reported byjsono_version()). Incompatible storage changes must bumpjsono::VERSION. A present-but-unreadable header — wrong magic, a different format version, misaligned slots — fails loud on read rather than silently reading as SQLNULL; only an absent value (a slots blob too short to hold a header) reads asNULL, andjsono_validatereports a corrupt blob asfalse. - Malformed input raises DuckDB errors unless
try_jsonois used.
The current surface is intentionally small. The following are not implemented:
- No cast from JSONO to a scalar type (
jsono('42')::INTEGERis unsupported). Project string values out with->>/jsono_extract_stringor typed fields withjsono_transform. - No cast from JSONO to an arbitrary
STRUCT. Only the physical four-BLOB shape is interchangeable with JSONO; field extraction goes throughjsono_transform. - No dedicated table function that unnests a JSONO array or object into rows. Use
unnest(jsono_entries(...))for flattened scalar leaves. - Object key order is not preserved: keys are stored and emitted in sorted byte order (see Error Handling and Caveats).
Contributions and feedback are welcome. Please:
- Open an issue first to discuss proposed changes.
- Add or update SQLLogic tests in
test/sql/for new behavior. - Run
uv run make release && uv run make testbefore submitting a pull request.
See GitHub Issues for current tasks and feature requests.
MIT. See LICENSE.
For third-party components and their licenses, see THIRD_PARTY_NOTICES.md.
This extension is based on the DuckDB Extension Template.