Sort keys and Z-order
Which order should the rows be clustered by, and what does each choice of key cost?
Sort keys and clustering keys
What a sort key does
A total order on a column makes the [min, max] range of each unit disjoint (non-overlapping) and increasing from unit to unit. A range filter then intersects exactly the units that span the range. The exclusion proof is exact: there are no false positives except at the two boundary units. This is the strongest skipping available. Everything else in this article is a weaker version of it.
The composite-key rule
People get hurt by composite (multi-column) sort keys. The rule has a two-line derivation.
Sort by (a, b). Column b is ordered only inside a run of equal a values. For b's per-unit range to be tight, one value of a must fill at least one whole unit: N / C_a ≥ G, where C_a is the number of distinct values of a (its cardinality). This is the same as:
Example. Sort a table by (user_id, event_time) with 10 million users and 1,000 units. Each user has far fewer rows than one unit, so every unit contains events from all of time. event_time is decorative. Its metadata exists, looks reasonable and prunes nothing. This is probably the most common clustering mistake, and it is invisible unless you measure how much the per-unit ranges overlap.
Parcelo's version. If Kavya sorts scan_events by (parcel_id, scan_time), there are roughly 2 billion ÷ 12 ≈ 170 million distinct parcels but only 3,125 files. C_a is about 50,000 times larger than U, so scan_time would prune nothing. If she sorts by (depot_id, scan_time) with 500 depots, C_a = 500 ≤ 3,125. Each depot fills about 3,125 ÷ 500 ≈ 6 files, and the time ranges inside those 6 files are tight. The second column works.
Clustering is rent, not a price
Every insert lands somewhere and breaks the order. The order is restored only by rewriting. The decay rate is proportional to the write rate. The cost to restore it is the U/d from Mutation strategies (copy-on-write: you rewrite a whole unit to change a few rows), because reclustering is copy-on-write with a different motive.
This is the principle sortedness decays, now fully established:
Clustering has no steady state and no purchase price. It has a monthly payment proportional to your write rate. If the rent is more than the savings from skipping, the correct clustering key is none. Very few teams compute this before they choose a key.
One order per copy of the data
There is only one total order per copy of the data. A second access pattern needs a second copy. Vertica's projections (extra sorted copies of a table) are an honest implementation of that idea. This is the conserved-cost rule: the additional bet is paid for in write amplification and storage. It is never free.
Global versus local ordering
Sorting inside each file is cheap. Sorting across all files needs a global shuffle with a range partitioner (a step that sends each row to the file that owns its key range) and a sample-based boundary estimate (the engine samples the data to choose the range boundaries). The two give completely different skipping and differ by about an order of magnitude in cost. In Spark, sortWithinPartitions (local) versus a global sort is a real daily decision.
The difference matters for skipping. With local sorting only, every file still contains the whole range of keys, so every file's [min, max] is wide and the zone maps (explained below) prune nothing. Only inside a file are the row groups or pages tidy. With global sorting, the files' ranges are disjoint, and whole files can be skipped.
Z-order and space-filling curves
The problem
You want to keep nearby points close in d dimensions (for example depot_id, scan_time and customer_country). Storage offers only one dimension: a line. A space-filling curve maps d dimensions onto one.
The honest description of what it achieves is not "preserves locality". That is impossible. Every such curve has pairs of points that are neighbours in d-dimensional space but far apart along the curve. What the curve achieves is this:
Z-order (also called Morton order) is the common version. It builds the sort key by interleaving the bits of the column values: first bit of a, first bit of b, second bit of a, second bit of b, and so on. Hilbert curves are a smarter curve of the same family.
The derivation
Z-ordering divides space into roughly equal cube-like cells, one per unit. In coordinates scaled to the range 0 to 1, each cell has side L with L^d ≈ 1/U, so
L = U^(−1/d).
A query constrains one dimension to a slab of thickness s and leaves the other dimensions free. It touches every cell whose extent in that dimension meets the slab. That is a fraction max(s, L) of all units. Therefore the floor on units touched is U^(1−1/d), whatever s is.
- number of units
- columns interleaved in the curve
- side of one cell, in coordinates scaled to 0–1
Data table
| Units U | series | Floor on units touched |
|---|---|---|
| 100 | d = 2 | 10 |
| 115 | d = 2 | 10.7 |
| 133 | d = 2 | 11.5 |
| 154 | d = 2 | 12.4 |
| 178 | d = 2 | 13.3 |
| 205 | d = 2 | 14.3 |
| 237 | d = 2 | 15.4 |
| 274 | d = 2 | 16.5 |
| 316 | d = 2 | 17.8 |
| 365 | d = 2 | 19.1 |
| 422 | d = 2 | 20.5 |
| 487 | d = 2 | 22.1 |
| 562 | d = 2 | 23.7 |
| 649 | d = 2 | 25.5 |
| 750 | d = 2 | 27.4 |
| 866 | d = 2 | 29.4 |
| 1K | d = 2 | 31.6 |
| 1.15K | d = 2 | 34 |
| 1.33K | d = 2 | 36.5 |
| 1.54K | d = 2 | 39.2 |
| 1.78K | d = 2 | 42.2 |
| 2.05K | d = 2 | 45.3 |
| 2.37K | d = 2 | 48.7 |
| 2.74K | d = 2 | 52.3 |
| 3.16K | d = 2 | 56.2 |
| 3.65K | d = 2 | 60.4 |
| 4.22K | d = 2 | 64.9 |
| 4.87K | d = 2 | 69.8 |
| 5.62K | d = 2 | 75 |
| 6.49K | d = 2 | 80.6 |
| 7.5K | d = 2 | 86.6 |
| 8.66K | d = 2 | 93.1 |
| 10K | d = 2 | 100 |
| 11.5K | d = 2 | 107 |
| 13.3K | d = 2 | 115 |
| 15.4K | d = 2 | 124 |
| 17.8K | d = 2 | 133 |
| 20.5K | d = 2 | 143 |
| 23.7K | d = 2 | 154 |
| 27.4K | d = 2 | 165 |
| 31.6K | d = 2 | 178 |
| 36.5K | d = 2 | 191 |
| 42.2K | d = 2 | 205 |
| 48.7K | d = 2 | 221 |
| 56.2K | d = 2 | 237 |
| 64.9K | d = 2 | 255 |
| 75K | d = 2 | 274 |
| 86.6K | d = 2 | 294 |
| 100K | d = 2 | 316 |
| 115K | d = 2 | 340 |
| 133K | d = 2 | 365 |
| 154K | d = 2 | 392 |
| 178K | d = 2 | 422 |
| 205K | d = 2 | 453 |
| 237K | d = 2 | 487 |
| 274K | d = 2 | 523 |
| 316K | d = 2 | 562 |
| 365K | d = 2 | 604 |
| 422K | d = 2 | 649 |
| 487K | d = 2 | 698 |
| 562K | d = 2 | 750 |
| 649K | d = 2 | 806 |
| 750K | d = 2 | 866 |
| 866K | d = 2 | 931 |
| 1M | d = 2 | 1K |
| 1.15M | d = 2 | 1.07K |
| 1.33M | d = 2 | 1.15K |
| 1.54M | d = 2 | 1.24K |
| 1.78M | d = 2 | 1.33K |
| 2.05M | d = 2 | 1.43K |
| 2.37M | d = 2 | 1.54K |
| 2.74M | d = 2 | 1.65K |
| 3.16M | d = 2 | 1.78K |
| 3.65M | d = 2 | 1.91K |
| 4.22M | d = 2 | 2.05K |
| 4.87M | d = 2 | 2.21K |
| 5.62M | d = 2 | 2.37K |
| 6.49M | d = 2 | 2.55K |
| 7.5M | d = 2 | 2.74K |
| 8.66M | d = 2 | 2.94K |
| 10M | d = 2 | 3.16K |
| 100 | d = 3 | 21.5 |
| 115 | d = 3 | 23.7 |
| 133 | d = 3 | 26.1 |
| 154 | d = 3 | 28.7 |
| 178 | d = 3 | 31.6 |
| 205 | d = 3 | 34.8 |
| 237 | d = 3 | 38.3 |
| 274 | d = 3 | 42.2 |
| 316 | d = 3 | 46.4 |
| 365 | d = 3 | 51.1 |
| 422 | d = 3 | 56.2 |
| 487 | d = 3 | 61.9 |
| 562 | d = 3 | 68.1 |
| 649 | d = 3 | 75 |
| 750 | d = 3 | 82.5 |
| 866 | d = 3 | 90.9 |
| 1K | d = 3 | 100 |
| 1.15K | d = 3 | 110 |
| 1.33K | d = 3 | 121 |
| 1.54K | d = 3 | 133 |
| 1.78K | d = 3 | 147 |
| 2.05K | d = 3 | 162 |
| 2.37K | d = 3 | 178 |
| 2.74K | d = 3 | 196 |
| 3.16K | d = 3 | 215 |
| 3.65K | d = 3 | 237 |
| 4.22K | d = 3 | 261 |
| 4.87K | d = 3 | 287 |
| 5.62K | d = 3 | 316 |
| 6.49K | d = 3 | 348 |
| 7.5K | d = 3 | 383 |
| 8.66K | d = 3 | 422 |
| 10K | d = 3 | 464 |
| 11.5K | d = 3 | 511 |
| 13.3K | d = 3 | 562 |
| 15.4K | d = 3 | 619 |
| 17.8K | d = 3 | 681 |
| 20.5K | d = 3 | 750 |
| 23.7K | d = 3 | 825 |
| 27.4K | d = 3 | 909 |
| 31.6K | d = 3 | 1K |
| 36.5K | d = 3 | 1.1K |
| 42.2K | d = 3 | 1.21K |
| 48.7K | d = 3 | 1.33K |
| 56.2K | d = 3 | 1.47K |
| 64.9K | d = 3 | 1.62K |
| 75K | d = 3 | 1.78K |
| 86.6K | d = 3 | 1.96K |
| 100K | d = 3 | 2.15K |
| 115K | d = 3 | 2.37K |
| 133K | d = 3 | 2.61K |
| 154K | d = 3 | 2.87K |
| 178K | d = 3 | 3.16K |
| 205K | d = 3 | 3.48K |
| 237K | d = 3 | 3.83K |
| 274K | d = 3 | 4.22K |
| 316K | d = 3 | 4.64K |
| 365K | d = 3 | 5.11K |
| 422K | d = 3 | 5.62K |
| 487K | d = 3 | 6.19K |
| 562K | d = 3 | 6.81K |
| 649K | d = 3 | 7.5K |
| 750K | d = 3 | 8.25K |
| 866K | d = 3 | 9.09K |
| 1M | d = 3 | 10K |
| 1.15M | d = 3 | 11K |
| 1.33M | d = 3 | 12.1K |
| 1.54M | d = 3 | 13.3K |
| 1.78M | d = 3 | 14.7K |
| 2.05M | d = 3 | 16.2K |
| 2.37M | d = 3 | 17.8K |
| 2.74M | d = 3 | 19.6K |
| 3.16M | d = 3 | 21.5K |
| 3.65M | d = 3 | 23.7K |
| 4.22M | d = 3 | 26.1K |
| 4.87M | d = 3 | 28.7K |
| 5.62M | d = 3 | 31.6K |
| 6.49M | d = 3 | 34.8K |
| 7.5M | d = 3 | 38.3K |
| 8.66M | d = 3 | 42.2K |
| 10M | d = 3 | 46.4K |
| 100 | d = 4 | 31.6 |
| 115 | d = 4 | 35.2 |
| 133 | d = 4 | 39.2 |
| 154 | d = 4 | 43.7 |
| 178 | d = 4 | 48.7 |
| 205 | d = 4 | 54.2 |
| 237 | d = 4 | 60.4 |
| 274 | d = 4 | 67.3 |
| 316 | d = 4 | 75 |
| 365 | d = 4 | 83.5 |
| 422 | d = 4 | 93.1 |
| 487 | d = 4 | 104 |
| 562 | d = 4 | 115 |
| 649 | d = 4 | 129 |
| 750 | d = 4 | 143 |
| 866 | d = 4 | 160 |
| 1K | d = 4 | 178 |
| 1.15K | d = 4 | 198 |
| 1.33K | d = 4 | 221 |
| 1.54K | d = 4 | 246 |
| 1.78K | d = 4 | 274 |
| 2.05K | d = 4 | 305 |
| 2.37K | d = 4 | 340 |
| 2.74K | d = 4 | 379 |
| 3.16K | d = 4 | 422 |
| 3.65K | d = 4 | 470 |
| 4.22K | d = 4 | 523 |
| 4.87K | d = 4 | 583 |
| 5.62K | d = 4 | 649 |
| 6.49K | d = 4 | 723 |
| 7.5K | d = 4 | 806 |
| 8.66K | d = 4 | 898 |
| 10K | d = 4 | 1K |
| 11.5K | d = 4 | 1.11K |
| 13.3K | d = 4 | 1.24K |
| 15.4K | d = 4 | 1.38K |
| 17.8K | d = 4 | 1.54K |
| 20.5K | d = 4 | 1.72K |
| 23.7K | d = 4 | 1.91K |
| 27.4K | d = 4 | 2.13K |
| 31.6K | d = 4 | 2.37K |
| 36.5K | d = 4 | 2.64K |
| 42.2K | d = 4 | 2.94K |
| 48.7K | d = 4 | 3.28K |
| 56.2K | d = 4 | 3.65K |
| 64.9K | d = 4 | 4.07K |
| 75K | d = 4 | 4.53K |
| 86.6K | d = 4 | 5.05K |
| 100K | d = 4 | 5.62K |
| 115K | d = 4 | 6.26K |
| 133K | d = 4 | 6.98K |
| 154K | d = 4 | 7.77K |
| 178K | d = 4 | 8.66K |
| 205K | d = 4 | 9.65K |
| 237K | d = 4 | 10.7K |
| 274K | d = 4 | 12K |
| 316K | d = 4 | 13.3K |
| 365K | d = 4 | 14.9K |
| 422K | d = 4 | 16.5K |
| 487K | d = 4 | 18.4K |
| 562K | d = 4 | 20.5K |
| 649K | d = 4 | 22.9K |
| 750K | d = 4 | 25.5K |
| 866K | d = 4 | 28.4K |
| 1M | d = 4 | 31.6K |
| 1.15M | d = 4 | 35.2K |
| 1.33M | d = 4 | 39.2K |
| 1.54M | d = 4 | 43.7K |
| 1.78M | d = 4 | 48.7K |
| 2.05M | d = 4 | 54.2K |
| 2.37M | d = 4 | 60.4K |
| 2.74M | d = 4 | 67.3K |
| 3.16M | d = 4 | 75K |
| 3.65M | d = 4 | 83.5K |
| 4.22M | d = 4 | 93.1K |
| 4.87M | d = 4 | 104K |
| 5.62M | d = 4 | 115K |
| 6.49M | d = 4 | 129K |
| 7.5M | d = 4 | 143K |
| 8.66M | d = 4 | 160K |
| 10M | d = 4 | 178K |
Worked numbers. U = 10⁶ units.
d = 2: no single-dimension query can touch fewer than10⁶ ^ (1/2) = 10³units.d = 4: the floor is10⁶ ^ (3/4) = 10^4.5 ≈ 31,600units. That is about 3% of the table, as a minimum, forever.
Z-order degrades sharply with d, and the damage is in the exponent.
Parcelo's version. With U = 3,125 and d = 2 (depot and time), the floor is √3,125 ≈ 56 files for any single-dimension query. With d = 3 it is 3,125^(2/3) ≈ 214 files.
The skip-budget rule
Now the main result. A single sort key on dimension 1 gives a skip factor of U on dimension 1 and 1 on the others. The product of the factors is U. Z-order over d dimensions gives a skip factor of U^(1/d) on each dimension. The product is again U.
(It is derived from this one calculation. Treat it as a useful rule of thumb, not as an established law.)
When should you use Z-order?
The product is fixed, and you pay the sum of the query costs. By the AM-GM inequality (the arithmetic mean of positive numbers is never less than their geometric mean), a sum with a fixed product is smallest when the terms are equal. So Z-order wins exactly when queries are spread across dimensions and the unclustered case would otherwise scan everything.
Check with two workloads: half of the queries filter dimension 1, half filter dimension 2.
- Sort key on dimension 1: cost =
0.5 × 1 + 0.5 × U ≈ 0.5·U(units read, in the relative scale of "1 means the best case"). - Z-order: cost =
0.5·√U + 0.5·√U = √U.
Z-order wins by a factor of about √U / 2. It is not better at anything. It wins because it eliminates the total-scan case, and the total-scan case dominates the sum.
So Z-order is insurance, not optimization. If 90% of your scanned bytes come through one predicate column, Z-ordering is a loss: you gave up U on the dimension that mattered to buy U^(1/d) on dimensions that rarely matter. Implementations that let you weight columns (giving one dimension the high-order bits) are doing the weighted version of the same AM-GM argument.
Two practical notes
- Morton versus Hilbert. Morton order has large jumps at bit-carry boundaries. Hilbert has none, and measurements usually show Hilbert touching fewer units. Both are
U^(1−1/d)asymptotically, so the gain is a constant factor whose size depends on the workload. This is engineering reality, not a permanent constraint. The price: Hilbert encoding is more expensive and harder to compute incrementally. - Different value ranges. Bit interleaving assumes the columns have comparable value ranges. Interleaving a column with 10 distinct values with a column of 10⁹ distinct values wastes most of the bit positions of the small column. Rank-transform or quantile-bucket the columns before interleaving.