Skip to content
About
Main content

The composite-key rule (C_a ≤ U)

Measure your machineNeeds Python with PyArrow, pandas

Problem

Sorting by a composite key (a, b) clusters b only within each value of a. If the leading has fewer distinct values than there are files (C_a < U), each file holds one or a few values of a, and b's ranges are tight inside them. If C_a is far above U, every file contains thousands of values of a, each with its own full range of b, so b's min/max in every file covers everything and a filter on b alone prunes nothing.

Use 20 million rows with depot_id (500 values), user_id (5 million values) and a day number. Write two copies as 20 files: sorted by (depot_id, scan_time) and sorted by (user_id, scan_time). For each copy, count how many files a query for one day must open, from the scan_time statistics, alone and with a depot given. Compute C_a ÷ U.

The question. For each sort key, how many files does a one-day query open, and how does that follow C_a ÷ U?


Predict

Sorted by (depot_id, scan_time), how many of the 20 files does a query on one day alone open?

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.

    the-composite-key-rule-ca-u.py50 linesDownload
    View the code
    the-composite-key-rule-ca-u.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["numpy", "pandas", "pyarrow"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """The composite-key rule: C_a against U.
    
    20 million rows with depot_id (500 values), user_id (5 million values) and
    scan_time (730 days), written as 20 files sorted by (depot_id, scan_time) and
    again sorted by (user_id, scan_time). From the scan_time statistics, counts
    the files a one-day query must open, alone and with a depot given. When the
    leading column's cardinality C_a is far above the number of files U, the
    second column's ranges cover everything in every file.
    """
    import argparse, json, os, shutil, tempfile
    import numpy as np, pandas as pd, pyarrow as pa, pyarrow.parquet as pq
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--rows", type=int, default=20_000_000, help="Rows.")
    p.add_argument("--files", type=int, default=20, help="Files U.")
    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); base = tempfile.mkdtemp(); n = args.rows; per = n // args.files
    df = pd.DataFrame({"depot_id": rng.integers(0, 500, n), "user_id": rng.integers(0, 5_000_000, n), "scan_time": rng.integers(0, 730, n)})
    def stats(frame, folder):
        os.makedirs(folder, exist_ok=True); out = []
        for i in range(args.files):
            part = frame.iloc[i * per:(i + 1) * per]; path = f"{folder}/f{i}.parquet"
            pq.write_table(pa.Table.from_pandas(part, preserve_index=False), path, row_group_size=per)
            md = pq.ParquetFile(path).metadata.row_group(0)
            col = {md.column(j).path_in_schema: md.column(j).statistics for j in range(md.num_columns)}
            out.append(col)
        return out
    day, depot = 365, 17
    rows = []
    for label, key, ca in (("sorted by (depot_id, scan_time)", ["depot_id", "scan_time"], 500), ("sorted by (user_id, scan_time)", ["user_id", "scan_time"], 5_000_000)):
        st = stats(df.sort_values(key), f"{base}/{key[0]}")
        day_only = sum(1 for s in st if s["scan_time"].min <= day <= s["scan_time"].max)
        with_depot = sum(1 for s in st if s["scan_time"].min <= day <= s["scan_time"].max and s["depot_id"].min <= depot <= s["depot_id"].max)
        rows.append({"layout": label, "C_a": ca, "U": args.files, "C_a_over_U": ca / args.files, "files_day_only": day_only, "files_day_and_depot": with_depot})
    shutil.rmtree(base, ignore_errors=True)
    for r in rows: print(f"{r['layout']:<32} C_a/U = {r['C_a_over_U']:>9,.0f}: one day opens {r['files_day_only']:>2} of {r['U']} files; one day + one depot opens {r['files_day_and_depot']}")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"layouts": rows}}, 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 the-composite-key-rule-ca-u.py
    uv run the-composite-key-rule-ca-u.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--rows20,000,000 Rows.
--files20 Files U.
--seed0 Random seed.

Our run

Files opened (of 20)

Files opened (of 20)
Sort keyC_a ÷ UOne dayOne day + one depot
sorted by (depot_id, scan_time)25201
sorted by (user_id, scan_time)250K2020
Reference runApple M2 · 8 cores · 8 GB
Output · uv run the-composite-key-rule-ca-u.py
sorted by (depot_id, scan_time)  C_a/U =        25: one day opens 20 of 20 files; one day + one depot opens 1
sorted by (user_id, scan_time)   C_a/U =   250,000: one day opens 20 of 20 files; one day + one depot opens 20
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · pandas 3.0.6 · pyarrow 26.0.0 · run 2026-10-20

What we found

Sorted by (depot_id, scan_time), with C_a ÷ U = 25, a one-day query alone still opened 20 of 20 files, since every file holds a couple of dozen depots each with all their days; given a depot as well, it opened 1. Sorted by (user_id, scan_time), with C_a ÷ U = 250K, it opened 20 either way: the day filter pruned nothing.


Read it

The second column of a sort key helps only when the first leaves room for it: C_a ÷ U must be small. Put the low-cardinality column first when queries filter on the second; a high-cardinality leading column (a user id) makes the rest of the key decorative for pruning.