Skip to content
About
Main content

As-of join versus range join

Measure your machineNeeds Python with DuckDB

Problem

"The value in effect at time t" is a common question with an expensive naive answer: each order to every earlier price of its product, then keep the latest with ROW_NUMBER(). Each order meets on average half of its product's price history, so the intermediate result is orders × changes ÷ 2. An walks both inputs in time order and matches each order to the single latest price directly.

In DuckDB create prices(product_id, valid_from, price) with 1,000 products × 1,000 changes, and orders. Compute the price at order time (a) with JOIN ... ON valid_from <= order_ts plus ROW_NUMBER() = 1, and (b) with ASOF JOIN. Run (a) on 50,000 orders and extrapolate; run (b) on 5 million. Record time and the intermediate row count of (a) from EXPLAIN ANALYZE, and check that both give the same prices.

The question. How many intermediate rows does the range join build, and how do the two approaches compare in time?


Predict

On 50,000 orders, how many intermediate rows does the range join build before ROW_NUMBER keeps one per order?

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.

    as-of-join-versus-range-join.py53 linesDownload
    View the code
    as-of-join-versus-range-join.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["duckdb"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """As-of join against a range join.
    
    DuckDB: prices(product_id, valid_from, price) with 1,000 products × 1,000
    changes, and orders. The price at order time computed (a) with a join on
    valid_from <= order_ts plus ROW_NUMBER() = 1, and (b) with ASOF JOIN: time,
    the intermediate row count of (a), and whether both agree. (a) runs on a
    smaller order table first and is extrapolated; (b) runs on 5 million orders.
    """
    import argparse, json, re, time
    import duckdb
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--orders", type=int, default=5_000_000, help="Orders for the ASOF join.")
    p.add_argument("--range-orders", type=int, default=50_000, help="Orders for the range join (it is quadratic).")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    c = duckdb.connect(); c.execute("SELECT setseed(0.3)")
    c.execute("""CREATE TABLE prices AS SELECT p AS product_id, TIMESTAMP '2025-01-01' + (ch * 1000 + p % 997) * INTERVAL '1 minute' AS valid_from,
                 (random() * 100)::DECIMAL(10, 2) AS price FROM range(1000) a(p), range(1000) b(ch)""")
    c.execute(f"""CREATE TABLE orders AS SELECT i AS order_id, (random() * 999)::INT AS product_id,
                  TIMESTAMP '2025-01-01' + (random() * 1000 * 1000)::INT * INTERVAL '1 minute' AS order_ts FROM range({args.orders}) t(i)""")
    RANGE = """SELECT order_id, price FROM (SELECT o.order_id, p.price, ROW_NUMBER() OVER (PARTITION BY o.order_id ORDER BY p.valid_from DESC) rn
               FROM (SELECT * FROM orders WHERE order_id < {n}) o JOIN prices p ON p.product_id = o.product_id AND p.valid_from <= o.order_ts) WHERE rn = 1"""
    ASOF = """SELECT o.order_id, p.price FROM (SELECT * FROM orders WHERE order_id < {n}) o
              ASOF JOIN prices p ON p.product_id = o.product_id AND o.order_ts >= p.valid_from"""
    def timed(q):
        t = time.perf_counter(); r = c.execute(q).fetchall(); return time.perf_counter() - t, r
    rows = []
    t_range, r_range = timed(RANGE.format(n=args.range_orders))
    prof = c.execute("EXPLAIN ANALYZE " + RANGE.format(n=args.range_orders)).fetchall()[0][1]
    pairs = max(int(x.replace(",", "")) for x in re.findall(r"([\d,]+)\s+[Rr]ows", prof))
    t_asof_small, r_asof = timed(ASOF.format(n=args.range_orders))
    agree = "yes" if sorted(r_range) == sorted(r_asof) else "no"
    t_asof, _ = timed(ASOF.format(n=args.orders))
    rows.append({"method": "(a) range join + ROW_NUMBER", "orders": args.range_orders, "seconds": t_range, "intermediate_rows": pairs})
    rows.append({"method": "(b) ASOF JOIN", "orders": args.range_orders, "seconds": t_asof_small, "intermediate_rows": None})
    rows.append({"method": "(b) ASOF JOIN", "orders": args.orders, "seconds": t_asof, "intermediate_rows": None})
    scale = args.orders / args.range_orders
    summary = {"agree": agree, "pairs_per_order": pairs / args.range_orders, "pairs_extrapolated": pairs * scale, "range_extrapolated_s": t_range * scale}
    for r in rows: print(f"{r['method']:<30} {r['orders']:>9,} orders: {r['seconds']:7.2f} s" + (f"   intermediate rows {r['intermediate_rows']:,}" if r["intermediate_rows"] else ""))
    print(f"same prices on the small run: {agree}; (a) at {args.orders:,} orders would build ~{summary['pairs_extrapolated']:,.0f} pairs and take ~{summary['range_extrapolated_s']:,.0f} s")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"summary": summary, "tables": {"methods": 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 as-of-join-versus-range-join.py
    uv run as-of-join-versus-range-join.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--orders5,000,000 Orders for the ASOF join.
--range-orders50,000 Orders for the range join (it is quadratic).

Our run

Two ways to find the price at order time

Two ways to find the price at order time
MethodOrdersTimeIntermediate rows
(a) range join + ROW_NUMBER50K213 ms25.1M
(b) ASOF JOIN50K59.3 ms—
(b) ASOF JOIN5M3.54 s—
Reference runApple M2 · 8 cores · 8 GB
Output · uv run as-of-join-versus-range-join.py
(a) range join + ROW_NUMBER       50,000 orders:    0.21 s   intermediate rows 25,126,253
(b) ASOF JOIN                     50,000 orders:    0.06 s
(b) ASOF JOIN                  5,000,000 orders:    3.54 s
same prices on the small run: yes; (a) at 5,000,000 orders would build ~2,512,625,300 pairs and take ~21 s
macOS 26.6.2 · Python 3.13.3 · duckdb 1.5.6 · run 2026-10-20

What we found

On 50,000 orders the range join built 25.1M intermediate rows (503 per order) in 213 ms; at 5 million orders that is about 2.51B pairs. The as-of join gave the same prices (yes) and took 3.54 s for all 5 million orders. The range join's extrapolated time (21.3 s) is a lower bound: billions of rows would not stay in memory.


Read it

A temporal lookup written as a range join pays for every pair it discards. Use an as-of join (or a sorted merge, or a lookup on validity intervals) whenever the question is "the latest value at or before t"; the cost then grows with the inputs, not with their product.