Skip to content
About
Main content

Elias-Fano and the posting-list size

Measure your machineNeeds Python with NumPy

Problem

A is a sorted list of document IDs. Stored raw, each ID costs 4 bytes. stores the gaps instead, which are small, and varint packs small numbers into fewer bytes, but reading the i-th ID then means decoding from the last checkpoint. splits each ID into low bits, stored packed, and high bits, stored as a unary bit vector; it uses about 2 + ⌈log₂(u/n)⌉ bits per ID and finds the i-th ID directly with a over the high bits.

Generate 1,000,000 sorted random doc IDs from a universe of 10⁹. Compute the size of raw int32, delta plus varint, and Elias-Fano. Then implement get(i) for Elias-Fano using a simple select over the high-bits vector. Time 10⁶ random-access reads for each format.

The question. How small is each format, and how fast does each answer "give me the i-th ID"?


Predict

How large is delta + varint encoding of the million IDs? (Raw int32 is 4 MB.)

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.

    elias-fano-and-the-posting-list.py76 linesDownload
    View the code
    elias-fano-and-the-posting-list.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["numpy"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Elias-Fano and the posting list.
    
    1,000,000 sorted random doc ids from a universe of 10^9: size as raw int32,
    as delta + varint, and as Elias-Fano; then 10^6 random-access reads of
    element i from each (varint decodes from a checkpoint every 128 values;
    Elias-Fano answers with a select over its high bits).
    """
    import argparse, json, math, time
    import numpy as np
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--n", type=int, default=1_000_000, help="Doc ids.")
    p.add_argument("--universe", type=int, default=10**9, help="Universe of doc ids.")
    p.add_argument("--reads", type=int, default=1_000_000, help="Random-access reads (the varint path uses a tenth, then scales).")
    p.add_argument("--checkpoint", type=int, default=128, help="Values between varint checkpoints.")
    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)
    ids = np.unique(rng.integers(0, args.universe, int(args.n * 1.01)))[: args.n].astype(np.int64)
    n, U = len(ids), args.universe
    
    # delta + varint, with checkpoints
    gaps = np.diff(ids, prepend=0)
    def varint(x):
        out = bytearray()
        while True:
            b = x & 0x7F; x >>= 7
            if x: out.append(b | 0x80)
            else: out.append(b); return out
    blob, offsets = bytearray(), []
    for i, g in enumerate(gaps.tolist()):
        if i % args.checkpoint == 0: offsets.append(len(blob)); g = int(ids[i])     # restart from the absolute value
        blob += varint(g)
    
    # Elias-Fano
    l = max(0, int(math.floor(math.log2(U / n))))
    low = ids & ((1 << l) - 1)
    high = ids >> l
    hb = np.zeros(n + (U >> l) + 1, dtype=bool); hb[high + np.arange(n)] = True
    ef_bits = n * l + len(hb)
    ones = np.flatnonzero(hb)                                            # select(i) = position of the i-th one
    def ef_get(i): return ((int(ones[i]) - i) << l) | int(low[i])
    
    def varint_get(i):
        c = i // args.checkpoint; pos = offsets[c]; val = 0
        for j in range(c * args.checkpoint, i + 1):
            x, shift = 0, 0
            while True:
                b = blob[pos]; pos += 1; x |= (b & 0x7F) << shift; shift += 7
                if b < 0x80: break
            val = x if j == c * args.checkpoint else val + x
        return val
    
    q = rng.integers(0, n, args.reads)
    t = time.perf_counter(); r_ef = [ef_get(int(i)) for i in q]; t_ef = (time.perf_counter() - t) / len(q)
    qv = q[: len(q) // 10]
    t = time.perf_counter(); r_v = [varint_get(int(i)) for i in qv]; t_v = (time.perf_counter() - t) / len(qv)
    t = time.perf_counter(); r_raw = [int(ids[i]) for i in q]; t_raw = (time.perf_counter() - t) / len(q)
    assert r_ef[: len(qv)] == r_v == r_raw[: len(qv)]
    rows = [{"format": "raw int32", "mb": n * 4 / 1e6, "ns_per_get": t_raw * 1e9},
            {"format": "delta + varint", "mb": len(blob) / 1e6, "ns_per_get": t_v * 1e9},
            {"format": "Elias-Fano", "mb": ef_bits / 8 / 1e6, "ns_per_get": t_ef * 1e9}]
    for r in rows: print(f"{r['format']:<16} {r['mb']:6.2f} MB   get(i): {r['ns_per_get']:9.0f} ns")
    print(f"Elias-Fano: {l} low bits per id + {len(hb) / n:.2f} high bits per id = {ef_bits / n:.2f} bits per id")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"formats": rows, "summary": [{"low_bits": l, "bits_per_id": ef_bits / n}]}}, 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 elias-fano-and-the-posting-list.py
    uv run elias-fano-and-the-posting-list.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--n1,000,000 Doc ids.
--universe10 ** 9 Universe of doc ids.
--reads1,000,000 Random-access reads (the varint path uses a tenth, then scales).
--checkpoint128 Values between varint checkpoints.
--seed0 Random seed.

Our run

One million doc IDs

One million doc IDs
FormatSize (MB)get(i), ns
raw int324128
delta + varint1.913K
Elias-Fano1.49314
Reference runApple M2 · 8 cores · 8 GB
Output · uv run elias-fano-and-the-posting-list.py
raw int32          4.00 MB   get(i):       128 ns
delta + varint     1.90 MB   get(i):     12992 ns
Elias-Fano         1.49 MB   get(i):       314 ns
Elias-Fano: 9 low bits per id + 2.95 high bits per id = 11.95 bits per id
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · run 2026-10-20

What we found

Elias-Fano came to 1.49 MB (12 bits per ID), as computed. Delta plus varint came to 1.9 MB, larger than the expected 1.2 to 1.5: with a mean gap of 1,000, most gaps are above 127 and take 2 bytes, so nearly every ID costs 2 bytes. Random access cost 128 ns raw, 314 ns with Elias-Fano and 13K ns with delta plus varint, which decodes on average 64 gaps from its checkpoint, in Python.


Read it

Elias-Fano is smaller than varint and still random-access, because its size depends only on the universe and the count, not on the gaps being byte-aligned. Varint is the simplest compressed format and the worst for random access: its cost per read grows with the distance between checkpoints.

Search engines use the property that matters here: they skip into posting lists during intersection, so a format that answers the i-th element (or the next element ≥ x) without decoding wins even at equal size.