Skip to content
About
Main content

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

In Python, how many times faster is sorting by normalized byte-string keys than with a typed comparison?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    normalized-keys-and-sorting-a-pointer.py45 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat it changes
--n300,000 Rows.
--seed0 Random seed.

Our run

300,000 rows

300,000 rows
MethodTime
typed comparison652 ms
normalized keys238 ms
prefix + index pairs135 ms
Reference runApple M2 · 8 cores · 8 GB
Output · uv run normalized-keys-and-sorting-a-pointer.py
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 MB
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

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