Z-order floor
Simulate a modelNeeds Python with NumPy
Problem
With U units and min/max statistics, sorting on dimension 1 makes a query on dimension 1 touch about one unit and a query on dimension 2 touch all U. Z-ordering two dimensions gives each unit a square-ish box, so a thin query on either dimension touches about √U units: worse than the best case for each, far better than the worst. For a workload split evenly between the two, the sorted layout costs about 0.5·U per query and Z-order about √U.
Simulate U = 4,096 units over 2 dimensions with random points. Compare three layouts: sorted on dimension 1, Z-order (interleave the bits of the two integer coordinates) and random. For a query on only dimension 2, count the units that overlap the range; then run a dimension-1 query. Compute the cost of a 50/50 workload and check it against √U versus 0.5·U.
The question. How many units does each layout touch for each dimension, and what does a 50/50 mix cost?
Predict
Expect about 4,096 for the dimension-1 sort, about √4,096 = 64 for Z-order (for a very thin slab), and 4,096 for random if you only have min/max statistics.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
z-order-floor.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """The Z-order floor. A million random 2-D points split into U = 4,096 units under three layouts: sorted on dimension 1, Z-order (bits of the two coordinates interleaved) and random. Counts the units whose min/max box overlaps a thin slab query on dimension 2 alone, and on dimension 1 alone; then the cost of a 50/50 mix, against √U and 0.5·U. """ import argparse, json import numpy as np p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--units", type=int, default=4096, help="Units U.") p.add_argument("--points", type=int, default=1 << 20, help="Points.") p.add_argument("--queries", type=int, default=50, help="Queries per dimension.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() rng = np.random.default_rng(args.seed); pts = rng.integers(0, 1 << 16, (args.points, 2)).astype(np.uint64) def z(x): out = np.zeros(len(x), dtype=np.uint64) for b in range(16): out |= ((x[:, 0] >> np.uint64(b)) & np.uint64(1)) << np.uint64(2 * b); out |= ((x[:, 1] >> np.uint64(b)) & np.uint64(1)) << np.uint64(2 * b + 1) return out orders = {"sorted on dimension 1": np.lexsort((pts[:, 1], pts[:, 0])), "Z-order": np.argsort(z(pts), kind="stable"), "random": rng.permutation(args.points)} rows = [] for name, o in orders.items(): u = pts[o].reshape(args.units, -1, 2); mn, mx = u.min(axis=1), u.max(axis=1) cost = {} for dim in (0, 1): vals = rng.integers(0, 1 << 16, args.queries) cost[dim] = float(np.mean([((mn[:, dim] <= v) & (v <= mx[:, dim])).sum() for v in vals])) # a very thin slab: one value rows.append({"layout": name, "dim1_query": cost[0], "dim2_query": cost[1], "mix_50_50": (cost[0] + cost[1]) / 2}) summary = {"sqrt_u": args.units ** 0.5, "half_u": args.units / 2} for r in rows: print(f"{r['layout']:<22} dimension-1 query {r['dim1_query']:7.1f} units dimension-2 query {r['dim2_query']:7.1f} 50/50 mix {r['mix_50_50']:7.1f}") print(f"√U = {summary['sqrt_u']:.0f}, 0.5·U = {summary['half_u']:.0f}") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "tables": {"layouts": rows}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run z-order-floor.py uv run z-order-floor.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --units4096 | Units U. |
| --points1 << 20 | Points. |
| --queries50 | Queries per dimension. |
| --seed0 | Random seed. |
Our run
Units touched per query (of 4,096)
| Layout | Dimension-1 query | Dimension-2 query | 50/50 mix |
|---|---|---|---|
| sorted on dimension 1 | 1.06 | 4,090 | 2,050 |
| Z-order | 133 | 90.9 | 112 |
| random | 4,090 | 4,010 | 4,050 |
sorted on dimension 1 dimension-1 query 1.1 units dimension-2 query 4093.4 50/50 mix 2047.2
Z-order dimension-1 query 133.1 units dimension-2 query 90.9 50/50 mix 112.0
random dimension-1 query 4094.3 units dimension-2 query 4005.7 50/50 mix 4050.0
√U = 64, 0.5·U = 2048What we found
Sorted on dimension 1, a dimension-1 query touched 1.06 units and a dimension-2 query 4.09K, a 50/50 mix 2.05K per query (0.5·U = 2.05K). Z-order touched 133 and 90.9, a mix of 112, within a factor of two of √U = 64. Random order touched nearly everything for both.
Read it
Z-order trades the best case for the average: no query is as cheap as on its own sort key, and none is as expensive as on the other's. When the workload is split, √U beats 0.5·U by a wide margin; when one dimension dominates, sort on it.