Elias-Fano and the posting-list size
Measure your machineNeeds Python with NumPy
Problem
A posting list is a sorted list of document IDs. Stored raw, each ID costs 4 bytes. Delta encoding 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. Elias-Fano 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 select 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
Raw int32 is 4 MB. Delta plus varint: expect about 1.2 to 1.5 MB, because mean gap is 1,000 and fits in 2 bytes mostly. Elias-Fano: 1,000,000 × (2 + ⌈log₂ 1000⌉) = 12,000,000 bits = 1.5 MB. Delta plus varint needs decoding from a checkpoint, while Elias-Fano answers directly.
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
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)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
| Knob · default | What 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
| Format | Size (MB) | get(i), ns |
|---|---|---|
| raw int32 | 4 | 128 |
| delta + varint | 1.9 | 13K |
| Elias-Fano | 1.49 | 314 |
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 idWhat 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.