Normalized keys and sorting a pointer array
Measure your machineNeeds Python
Problem
Sorting multi-column rows with a comparison function runs a chain of type-specific comparisons and branches for every pair. Encoding each row's key as bytes whose order equals the tuple order (a normalized key) replaces that chain with one byte-string comparison. Engines go further and sort small fixed-size entries, a key prefix plus a pointer to the row, instead of the rows themselves.
Compare sorting multi-column records with a typed comparison against sorting normalized byte-string keys (300,000 rows of region, integer, float). Then sort (prefix, index) pairs and compare their memory with the full rows, and check that the result gives the same order.
The question. How much faster is the normalized-key sort, and does sorting prefix + pointer pairs help further?
Predict
Expect the normalized-key sort to be several times faster. The gain comes from replacing many branches with one byte-string comparison.
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
normalized-keys-and-sorting-a-pointer.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Normalized keys, and sorting a pointer. 300,000 three-column rows sorted with a typed comparison function, and by normalized byte-string keys whose byte order equals the tuple order. Then sorts (8-byte prefix, index) pairs instead of whole rows, compares the memory of the sort inputs, and checks that all orders agree. """ import argparse, functools, json, random, struct, sys, time p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--n", type=int, default=300_000, help="Rows.") 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() random.seed(args.seed) rows = [(random.choice(["north", "south", "east", "west"]), random.randrange(10**6), random.random()) for _ in range(args.n)] def cmp(a, b): # typed, branchy comparison if a[0] != b[0]: return -1 if a[0] < b[0] else 1 if a[1] != b[1]: return -1 if a[1] < b[1] else 1 return (a[2] > b[2]) - (a[2] < b[2]) def norm(r): # bytes whose order equals the tuple order return r[0].encode().ljust(8, b"\0") + struct.pack(">I", r[1]) + struct.pack(">d", r[2]) t = time.perf_counter(); a = sorted(rows, key=functools.cmp_to_key(cmp)); t1 = time.perf_counter() - t t = time.perf_counter(); keys = sorted((norm(r), i) for i, r in enumerate(rows)); t2 = time.perf_counter() - t prefixes = [(norm(r)[:8] + struct.pack(">I", r[1]), i) for i, r in enumerate(rows)] t = time.perf_counter(); pref = sorted(prefixes); t3 = time.perf_counter() - t size = lambda xs, inner: sys.getsizeof(xs) + sum(sys.getsizeof(x) + sum(sys.getsizeof(y) for y in inner(x)) for x in xs) mem_rows = size(rows, lambda r: r); mem_pref = size(prefixes, lambda r: r) same = [rows[i] for _, i in keys] == a results = [{"method": "typed comparison", "seconds": t1}, {"method": "normalized keys", "seconds": t2}, {"method": "prefix + index pairs", "seconds": t3}] summary = {"ratio": t1 / t2, "same_order": "yes" if same else "no", "rows_mb": mem_rows / 2**20, "prefix_mb": mem_pref / 2**20} for r in results: print(f"{r['method']:<22} {r['seconds']:.3f} s") print(f"typed / normalized = {summary['ratio']:.1f}x; same order: {summary['same_order']}; sort input: rows {summary['rows_mb']:.0f} MB, prefix pairs {summary['prefix_mb']:.0f} MB") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "tables": {"sorts": results}}, 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 normalized-keys-and-sorting-a-pointer.py uv run normalized-keys-and-sorting-a-pointer.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --n300,000 | Rows. |
| --seed0 | Random seed. |
Our run
300,000 rows
| Method | Time |
|---|---|
| typed comparison | 652 ms |
| normalized keys | 238 ms |
| prefix + index pairs | 135 ms |
typed comparison 0.652 s
normalized keys 0.238 s
prefix + index pairs 0.135 s
typed / normalized = 2.7x; same order: yes; sort input: rows 49 MB, prefix pairs 39 MBWhat we found
The typed comparison took 652 ms, normalized keys 238 ms (2.75× faster), and prefix + index pairs 135 ms, all in the same order (yes). In Python the memory saving was small (39.4 MB of pairs against 48.7 MB of rows), because every Python object has a large fixed overhead; in an engine with packed rows, the pairs are a fraction of the data.
Read it
The comparison, not the data movement, dominates sorting with typed keys. Normalizing keys once turns every later comparison into a byte comparison, which is why sort-heavy engines encode keys before sorting, and sort small (prefix, pointer) entries so that most comparisons never touch the row.