Lindel
Linearize multi-dimensional numeric arrays via Hilbert and Morton (Z-order) space-filling curves.
On this page
Technical Overview
Multi-dimensional data, sorted on one integer
Lindel — linearization and delinearization — maps multi-dimensional points onto a single integer that follows a Hilbert or Morton (Z-order) space-filling curve. Used as the ORDER BY key when writing Parquet, it's the same Z-Order / liquid-clustering optimization Databricks pioneered for Delta Lake.
One sort key for every dimension at once
Parquet readers prune work using per-row-group min/max statistics — but sorting a file by a single column gives you tight bounds on that column only; every other column ends up scattered across row groups, so a predicate on it can't skip anything. A space-filling curve sidesteps this: it collapses N dimensions into one ordering where points that were close in the original space stay close in the file. Sort by that one key and a multi-column predicate gets meaningful per-row-group bounds on all of its columns simultaneously, not just the first sort column. The same coordinate-locality also helps the encoders — adjacent rows have adjacent values, so RLE, dictionary, and delta encodings inside each row group find more runs and shorter deltas, and files shrink.
Hilbert vs Morton
Both curves are locality-preserving and both reduce to bit manipulation over the input dimensions, but they trade locality against throughput. Pick Hilbert by default; pick Morton when encoding speed is the bottleneck.
- • Hilbert — better locality: The Hilbert curve never makes long jumps in input space: consecutive positions along the curve are always neighbours in N-D. That yields tighter row-group bounding boxes at the cost of slightly more CPU per encode. The geometry indexing systems S2 (Google) and H3 (Uber) use Hilbert-style ordering for the same reason, and Delta Lake's liquid clustering moved to it.
-
•
Morton — faster, simpler: The Morton / Z-order curve is plain bit-interleaving of the input dimensions — trivially cheap to compute, but with occasional long jumps at quadrant boundaries that loosen the bounding boxes. The Bing Maps Tile System uses Morton-style quadkeys, and Delta Lake's original
ZORDER BYwas Morton. -
•
Output width follows the inputs: Both encoders are polymorphic over
UTINYINTthroughUHUGEINT. The result is the smallest unsigned integer that fits bit-width × dimensions — e.g. two 32-bit dimensions (64 bits total) return aUBIGINT, three return aUHUGEINT.
What to know before you sort by it
A few honest caveats so you give the curve the right inputs and reach for it in the right situation.
-
•
Inputs are unsigned integers: The curves only carry the bits they're handed, and locality is driven by the high bits of each dimension. Quantize floats first (
(lat * 1e6)::UINTEGERgives ~10 cm resolution worldwide) and map signed ranges to unsigned, pre-scaling so the precision you care about lands in those high bits. -
•
Skew can dominate the ordering: If one input column has far higher cardinality or a far wider range than the others, it dominates the encoded key and the locality benefit on the other columns collapses. Rescale the dimensions to comparable ranges before encoding, or drop the dominant column from the curve and add it as a secondary
ORDER BYterm. - • Decoding doesn't carry types: Reconstructing the original array needs the dimension count, float-ness, and signed-ness passed explicitly — none of that is stored in the encoded integer, so the decode call has to be told the shape the value originally had.
- • It's a write-time ordering, not an index: The gain comes entirely from how rows are laid out in the Parquet file, so it only helps data written in encoded order. There is no live index to maintain: updating means rewriting the affected files, and for streaming inserts you batch and re-sort on a cadence rather than expecting one row at a time to improve skipping. Real-world speedups depend on data shape and filter selectivity — the regime Delta Lake liquid clustering targets.
Deep Dive
Technical Details
What you can do with one query
Replace a multi-column ORDER BY with one locality-preserving sort key. Compute the key and order by it — rows that were close in (x, y, z) come back adjacent:
SELECT x, y, z, hilbert_encode([x, y, z]::INTEGER[3]) AS hilbertFROM pointsORDER BY hilbert;Wrap that ordering in COPY (...) TO 'points.parquet' (FORMAT PARQUET) and Parquet block-skipping starts working on every dimension at once. A later query like WHERE x BETWEEN ... AND y BETWEEN ... AND z BETWEEN ... skips whole row groups whose stats don’t overlap any of the three predicates — instead of skipping only on the first sort column. The same trick compresses the file better, because adjacent rows have adjacent coordinates and Parquet’s RLE / dictionary / delta encodings find more runs.
Lindel encodes multi-dimensional unsigned integer points to one integer. To use it on floats or signed values, quantize first — e.g. (lat * 1e6)::UINTEGER — so the meaningful precision lands in the high bits of each dimension.
It is a write-time sort key, not a runtime index: the gain comes from how the data is laid out in Parquet, so updates mean rewriting the affected files. For streaming inserts, batch and re-sort on a cadence rather than hoping one row at a time will help.
Hilbert vs Morton — pick one
Both hilbert_encode and morton_encode take an array of unsigned integers and return one sortable integer. They differ in how locality-preserving the ordering is:
- Hilbert curve — neighbours along the curve are always neighbours in N-D. Tighter row-group bounds, slightly more CPU to compute. The geometry indexing systems S2 (Google) and H3 (Uber) use Hilbert-style ordering for the same reason. Default choice.
- Morton / Z-order curve — bit-interleave the dimensions. Trivially cheap, occasional long jumps at quadrant boundaries. The Bing Maps Tile System uses Morton-style quadkeys; Delta Lake’s original
ZORDER BYwas Morton before it moved to Hilbert-based liquid clustering.
When in doubt: Hilbert. Pick Morton when encoding throughput dominates and you can tolerate slightly looser row-group bounding boxes.
Type coverage and output width
Both encoders are polymorphic over UTINYINT through UHUGEINT. The output type is the smallest unsigned integer that fits bit-width × number of dimensions:
| Input array | Total bits | Output type |
|---|---|---|
UTINYINT[2] |
16 | USMALLINT |
UINTEGER[2] |
64 | UBIGINT |
UINTEGER[3] |
96 | UHUGEINT |
UBIGINT[2] |
128 | UHUGEINT |
Pre-scale your inputs so the precision you care about lands in the high bits of each dimension — those are the bits that drive locality. For latitude/longitude in degrees, multiply by 1e6 (~10 cm resolution worldwide) and cast to UINTEGER. For epoch milliseconds in time-series, the value is already a sensibly-scaled unsigned integer.
Decoding
hilbert_decode and morton_decode recover the original array. Three trailing arguments tell DuckDB how to reconstruct the values, since they aren’t carried in the encoded integer:
SELECT hilbert_decode( hilbert_encode([5, 8]::UINTEGER[2]), -- the encoded value 2, -- num_elements false, -- return_float true -- return_unsigned);-- [5, 8]Useful for sanity-checking transforms and for storing the encoded value as an indexable single-column key while still being able to read back the components on demand.
Why this works on Parquet
Parquet stores per-row-group min/max statistics for every column. A predicate like WHERE lat BETWEEN 40 AND 41 lets the reader skip any row group whose lat range doesn’t overlap. With single-column sorting, that skipping works for one column at a time — the file is sorted by lat, so lon is scattered across row groups. With Hilbert / Morton sorting, every input column gets tight per-row-group bounds, so multi-column predicates each contribute to skipping.
Performance gains depend on data shape and query mix. For multi-column range queries against multi-million-row Parquet files, expect order-of-magnitude reductions in scanned bytes when the filter selectivity is high — the same regime the Delta Lake liquid clustering work targets.
Compared to alternatives
ORDER BY a, b— sorts onafirst, thenbwithina. Great fora-only or(a, b)-prefix queries; useless if you filter onbalone. Lindel gives every input column meaningful per-row-group bounds.- Multiple Parquet files partitioned by category — works for low-cardinality keys (year, region). Falls apart on continuous dimensions. Lindel sorts continuous dimensions inside one file.
- GeoHash / quadkey strings — same idea (locality-preserving 1-D key), but as strings. Lindel returns native unsigned integers, which compare and store more cheaply and round-trip back to the original components.
Install
INSTALL lindel FROM community;
LOAD lindel;
Quick Start
Encode a multi-dimensional point to one sortable integer
-- Hilbert keeps N-dimensional neighbors close in the 1-D ordering
SELECT hilbert_encode([10, 20]::UINTEGER[2]) AS hilbert;
Order rows by the curve — this is the Parquet write-time sort key
-- Neighbors in (x, y) end up adjacent. Wrap this SELECT in
-- COPY (...) TO 'out.parquet' (FORMAT PARQUET)
-- to persist the layout for multi-column row-group skipping.
SELECT x, y, hilbert_encode([x, y]::UINTEGER[2]) AS hilbert
FROM (VALUES (3, 5), (1, 1), (7, 0), (2, 6), (5, 3), (0, 4)) AS t(x, y)
ORDER BY hilbert;
Morton (Z-order) is cheaper to compute, with weaker locality
SELECT x, y, morton_encode([x, y]::UINTEGER[2]) AS morton
FROM (VALUES (3, 5), (1, 1), (7, 0), (2, 6), (5, 3), (0, 4)) AS t(x, y)
ORDER BY morton;
Reference
Extension Contents
Quick reference to all available functions and settings organized by category.
| Name | Type | Description |
|---|---|---|
|
Hilbert
Hilbert-curve encoding — best locality preservation. Slightly slower to compute than Morton but produces tighter row groups when used as ORDER BY in Parquet, leading to better predicate skipping at query time. |
||
| hilbert_decode() | Reverse the encoding — recover the original N-dimensional array from a Hilbert-encoded integer. | |
| hilbert_encode() | Encode a numeric array along the Hilbert space-filling curve into a single sortable integer. | |
|
Morton (Z-order)
Morton-curve encoding (also known as Z-order). Faster to compute, slightly weaker locality. The classic Z-Order optimization that Delta Lake popularized. |
||
| morton_decode() | Reverse Morton encoding — recover the original N-dimensional array from a Z-ordered integer. | |
| morton_encode() | Encode a numeric array along the Morton (Z-order) curve. | |
No extension contents match that search.
API Reference
Function Documentation
Practical Examples
Cookbook
Real-world recipes and patterns for common use cases.
What this is for
When a query filters on multiple columns at once — lat AND lon, time AND symbol_id, (altitude, lat, lon, time) — sorting Parquet by any single column doesn’t help much. Sorting by both needs a 1D ordering that keeps multi-dimensional neighbors together. That’s a space-filling curve like Hilbert or Morton.
Use Lindel as the ORDER BY when writing Parquet. The result: row groups have tight bounding boxes in every input dimension, so DuckDB / Athena / Trino / Spark / Snowflake can skip whole row groups whose stats don’t match the predicate.
This is the same optimization Databricks calls “Z-Order” (Morton) and now defaults to “Liquid Clustering” (Hilbert).
Hilbert-order rows
Compute one Hilbert key per row and ORDER BY it — points close in (lat, lon) stay adjacent in the result:
SELECT lat, lon, hilbert_encode([lat, lon]::DOUBLE[2]) AS hilbertFROM sourceORDER BY hilbert;To persist that layout, wrap the same ordering in a write — COPY (SELECT * FROM source ORDER BY hilbert_encode([lat, lon]::DOUBLE[2])) TO 'spatial.parquet' (FORMAT PARQUET). Rows close in (lat, lon) end up close in the file, so bounding-box queries (WHERE lat BETWEEN ... AND lon BETWEEN ...) skip irrelevant row groups.
For 4D — say flight history (lat, lon, alt, time) — pass all four dimensions to the encoder:
SELECT lat, lon, alt, time, hilbert_encode([lat, lon, alt, time]::INTEGER[4]) AS hilbertFROM flightsORDER BY hilbert;Morton (Z-order) — faster, slightly weaker
Same recipe, swap the function:
SELECT time, symbol_id, morton_encode([time, symbol_id]::INTEGER[2]) AS mortonFROM sourceORDER BY morton;When in doubt: Hilbert. Pick Morton only when encoding throughput is the bottleneck and you can tolerate slightly larger row-group bounding boxes.
Decode for sanity-checking
SELECT hilbert_decode( hilbert_encode([10, 20]::INTEGER[2]), /* num_elements */ 2, /* return_float */ false, /* return_unsigned */ true) AS roundtrip;-- [10, 20]Type coverage
Both encoders accept:
- Any signed or unsigned integer (TINYINT through HUGEINT).
- FLOAT or DOUBLE.
Output type is the smallest unsigned integer that fits the bit width × number of dimensions. For [INTEGER, INTEGER] (32×2 = 64 bits) you get UBIGINT; for [DOUBLE, DOUBLE] (64×2 = 128 bits) you get UHUGEINT.
Why this works
Parquet stores per-row-group min/max statistics. A predicate like WHERE lat BETWEEN 40 AND 41 lets the reader skip any row group whose lat range doesn’t overlap. With single-column sorting that works for one column at a time. With Hilbert/Morton sorting, every input column gets tight per-row-group bounds, so multi-column predicates each contribute to skipping.
Performance gains depend on the data and query pattern. The Delta Lake / Databricks blog posts linked in the README report 5–100× speedups for multi-column range queries; in DuckDB you’ll see similar with the right data.
Platform Support
Compatibility
Extension availability may vary by platform and DuckDB version. Check below to ensure this extension supports your environment before installation.
Quick Facts
Platforms
- Linux x86_64 aarch64
- Linux (musl) Not available
- macOS Intel Apple Silicon
- Windows x86_64
- WASM eh mvp threads
Compiled binary sizes
| Platform | Architecture | Size |
|---|---|---|
| Linux | x86_64 | 4.14 MB |
| Linux | aarch64 | 3.76 MB |
| macOS | Intel | 2.22 MB |
| macOS | Apple Silicon | 1.92 MB |
| Windows | x86_64 | 7.46 MB |
| WASM | eh | 78.9 KB |
| WASM | mvp | 82.0 KB |
| WASM | threads | 66.5 KB |
Compressed download size from the Haybarn extension repository.
DuckDB & Haybarn
Release calendar- DuckDB v1.5.5 Haybarn 1.5.5-rc1 Supported