Skip to content
About
Main content

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 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:

the number of distinct values of a
number of units
rows per unit

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 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.

The second column prunes only if the first fills files

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 (: you rewrite a whole unit to change a few rows), because reclustering is copy-on-write with a different motive.

This is the principle , 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. . Vertica's (extra sorted copies of a table) are an honest implementation of that idea. This is the : the additional bet is paid for in 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 (explained below) prune nothing. Only inside a file are the 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 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 ) 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. 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
d = 2d = 3d = 4
Data table
Fewest units a one-column query can touch, by table size
Units UseriesFloor on units touched
100d = 210
115d = 210.7
133d = 211.5
154d = 212.4
178d = 213.3
205d = 214.3
237d = 215.4
274d = 216.5
316d = 217.8
365d = 219.1
422d = 220.5
487d = 222.1
562d = 223.7
649d = 225.5
750d = 227.4
866d = 229.4
1Kd = 231.6
1.15Kd = 234
1.33Kd = 236.5
1.54Kd = 239.2
1.78Kd = 242.2
2.05Kd = 245.3
2.37Kd = 248.7
2.74Kd = 252.3
3.16Kd = 256.2
3.65Kd = 260.4
4.22Kd = 264.9
4.87Kd = 269.8
5.62Kd = 275
6.49Kd = 280.6
7.5Kd = 286.6
8.66Kd = 293.1
10Kd = 2100
11.5Kd = 2107
13.3Kd = 2115
15.4Kd = 2124
17.8Kd = 2133
20.5Kd = 2143
23.7Kd = 2154
27.4Kd = 2165
31.6Kd = 2178
36.5Kd = 2191
42.2Kd = 2205
48.7Kd = 2221
56.2Kd = 2237
64.9Kd = 2255
75Kd = 2274
86.6Kd = 2294
100Kd = 2316
115Kd = 2340
133Kd = 2365
154Kd = 2392
178Kd = 2422
205Kd = 2453
237Kd = 2487
274Kd = 2523
316Kd = 2562
365Kd = 2604
422Kd = 2649
487Kd = 2698
562Kd = 2750
649Kd = 2806
750Kd = 2866
866Kd = 2931
1Md = 21K
1.15Md = 21.07K
1.33Md = 21.15K
1.54Md = 21.24K
1.78Md = 21.33K
2.05Md = 21.43K
2.37Md = 21.54K
2.74Md = 21.65K
3.16Md = 21.78K
3.65Md = 21.91K
4.22Md = 22.05K
4.87Md = 22.21K
5.62Md = 22.37K
6.49Md = 22.55K
7.5Md = 22.74K
8.66Md = 22.94K
10Md = 23.16K
100d = 321.5
115d = 323.7
133d = 326.1
154d = 328.7
178d = 331.6
205d = 334.8
237d = 338.3
274d = 342.2
316d = 346.4
365d = 351.1
422d = 356.2
487d = 361.9
562d = 368.1
649d = 375
750d = 382.5
866d = 390.9
1Kd = 3100
1.15Kd = 3110
1.33Kd = 3121
1.54Kd = 3133
1.78Kd = 3147
2.05Kd = 3162
2.37Kd = 3178
2.74Kd = 3196
3.16Kd = 3215
3.65Kd = 3237
4.22Kd = 3261
4.87Kd = 3287
5.62Kd = 3316
6.49Kd = 3348
7.5Kd = 3383
8.66Kd = 3422
10Kd = 3464
11.5Kd = 3511
13.3Kd = 3562
15.4Kd = 3619
17.8Kd = 3681
20.5Kd = 3750
23.7Kd = 3825
27.4Kd = 3909
31.6Kd = 31K
36.5Kd = 31.1K
42.2Kd = 31.21K
48.7Kd = 31.33K
56.2Kd = 31.47K
64.9Kd = 31.62K
75Kd = 31.78K
86.6Kd = 31.96K
100Kd = 32.15K
115Kd = 32.37K
133Kd = 32.61K
154Kd = 32.87K
178Kd = 33.16K
205Kd = 33.48K
237Kd = 33.83K
274Kd = 34.22K
316Kd = 34.64K
365Kd = 35.11K
422Kd = 35.62K
487Kd = 36.19K
562Kd = 36.81K
649Kd = 37.5K
750Kd = 38.25K
866Kd = 39.09K
1Md = 310K
1.15Md = 311K
1.33Md = 312.1K
1.54Md = 313.3K
1.78Md = 314.7K
2.05Md = 316.2K
2.37Md = 317.8K
2.74Md = 319.6K
3.16Md = 321.5K
3.65Md = 323.7K
4.22Md = 326.1K
4.87Md = 328.7K
5.62Md = 331.6K
6.49Md = 334.8K
7.5Md = 338.3K
8.66Md = 342.2K
10Md = 346.4K
100d = 431.6
115d = 435.2
133d = 439.2
154d = 443.7
178d = 448.7
205d = 454.2
237d = 460.4
274d = 467.3
316d = 475
365d = 483.5
422d = 493.1
487d = 4104
562d = 4115
649d = 4129
750d = 4143
866d = 4160
1Kd = 4178
1.15Kd = 4198
1.33Kd = 4221
1.54Kd = 4246
1.78Kd = 4274
2.05Kd = 4305
2.37Kd = 4340
2.74Kd = 4379
3.16Kd = 4422
3.65Kd = 4470
4.22Kd = 4523
4.87Kd = 4583
5.62Kd = 4649
6.49Kd = 4723
7.5Kd = 4806
8.66Kd = 4898
10Kd = 41K
11.5Kd = 41.11K
13.3Kd = 41.24K
15.4Kd = 41.38K
17.8Kd = 41.54K
20.5Kd = 41.72K
23.7Kd = 41.91K
27.4Kd = 42.13K
31.6Kd = 42.37K
36.5Kd = 42.64K
42.2Kd = 42.94K
48.7Kd = 43.28K
56.2Kd = 43.65K
64.9Kd = 44.07K
75Kd = 44.53K
86.6Kd = 45.05K
100Kd = 45.62K
115Kd = 46.26K
133Kd = 46.98K
154Kd = 47.77K
178Kd = 48.66K
205Kd = 49.65K
237Kd = 410.7K
274Kd = 412K
316Kd = 413.3K
365Kd = 414.9K
422Kd = 416.5K
487Kd = 418.4K
562Kd = 420.5K
649Kd = 422.9K
750Kd = 425.5K
866Kd = 428.4K
1Md = 431.6K
1.15Md = 435.2K
1.33Md = 439.2K
1.54Md = 443.7K
1.78Md = 448.7K
2.05Md = 454.2K
2.37Md = 460.4K
2.74Md = 467.3K
3.16Md = 475K
3.65Md = 483.5K
4.22Md = 493.1K
4.87Md = 4104K
5.62Md = 4115K
6.49Md = 4129K
7.5Md = 4143K
8.66Md = 4160K
10Md = 4178K

Worked numbers. U = 10⁶ units.

  • d = 2: no single-dimension query can touch fewer than 10⁶ ^ (1/2) = 10³ units.
  • d = 4: the floor is 10⁶ ^ (3/4) = 10^4.5 ≈ 31,600 units. 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.

A fixed skip budget, spent two ways16 units

(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 (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 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 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.