Skip to content
About
Main content

Measure the cost of encryption on columnar scans

Measure your machineNeeds Python with PyArrow, DuckDB and cryptography

Problem

Columnar files store min/max statistics for each column in each row group, and a range query reads only the row groups whose range overlaps the filter. Encrypting a column makes its values look random: the statistics no longer bound anything, so every row group must be read and decrypted, and random-looking bytes do not compress.

Write three Parquet files of the same 20 million rows (ts, value) in row groups of 500,000: ts sorted in plaintext, ts replaced by a random permutation of itself (a stand-in with the same effect on statistics), and ts encrypted with AES-CTR. Time SELECT sum(value) WHERE ts BETWEEN a AND b over about 1% of the rows on each (best of five after a warm-up), decrypting the encrypted column in our own code, and count the row groups each must read. Compare the size of the ts column with no compression, Snappy and Zstd.

The question. How much slower is the range query when the column is encrypted, and how well does the encrypted column compress?


Predict

With the ts column encrypted and decrypted at query time, how much slower is the range query than on plaintext?

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.

    measure-the-cost-of-encryption-on.py125 linesDownload
    View the code
    measure-the-cost-of-encryption-on.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["pyarrow", "duckdb", "numpy", "cryptography"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Measure the cost of encryption on columnar scans.
    
    See how encryption removes the statistics that make queries fast. Three Parquet files of the
    same rows: ts in plaintext (sorted), ts as a random permutation (a stand-in with the same effect
    on statistics), and ts encrypted with AES-CTR and decrypted in our own code at query time.
    Each query is timed as the best of several runs, and the row groups each must read are counted."""
    
    import argparse
    import time
    import os
    import tempfile
    import json
    import pyarrow as pa
    import pyarrow.parquet as pq
    import duckdb
    import numpy as np
    from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
    
    def best(fn, repeats):
        fn()                                               # warm-up: file open, page cache
        times = []
        for _ in range(repeats):
            t = time.perf_counter(); out = fn(); times.append(time.perf_counter() - t)
        return min(times), out
    
    def groups_read(path, col, a, b):
        """Row groups whose min/max statistics overlap [a, b]; None when the column has no usable order."""
        md = pq.ParquetFile(path).metadata
        i = md.schema.to_arrow_schema().get_field_index(col)
        st = [md.row_group(g).column(i).statistics for g in range(md.num_row_groups)]
        if any(s is None or not s.has_min_max or isinstance(s.min, bytes) for s in st):
            return md.num_row_groups, md.num_row_groups    # ciphertext: nothing can be skipped
        return sum(1 for s in st if not (s.max < a or s.min > b)), md.num_row_groups
    
    def column_mb(path, col):
        md = pq.ParquetFile(path).metadata
        i = md.schema.to_arrow_schema().get_field_index(col)
        return sum(md.row_group(g).column(i).total_compressed_size for g in range(md.num_row_groups)) / 2**20
    
    def main():
        parser = argparse.ArgumentParser(description=__doc__.split('\n')[0])
        parser.add_argument("--rows", type=int, default=20_000_000, help="Number of rows to generate.")
        parser.add_argument("--row-group", type=int, default=500_000, help="Rows per row group.")
        parser.add_argument("--repeats", type=int, default=5, help="Timed runs per query (the best is kept).")
        parser.add_argument("--seed", type=int, default=42, help="Random seed for reproducibility.")
        parser.add_argument("--json", type=str, help="Also write the results to this JSON file.")
        args = parser.parse_args()
    
        rng = np.random.default_rng(args.seed)
        print(f"Generating {args.rows} rows...")
        ts = np.arange(args.rows, dtype=np.int64)
        value = rng.random(args.rows)
        ts_perm = rng.permutation(ts)                      # stand-in for ciphertext
    
        key, nonce = os.urandom(32), os.urandom(16)
        def ctr():
            return Cipher(algorithms.AES(key), modes.CTR(nonce))
        enc = ctr().encryptor().update(ts.tobytes())       # AES-CTR over the whole column: every 8-byte value looks random
        ts_cipher = pa.FixedSizeBinaryArray.from_buffers(pa.binary(8), args.rows, [None, pa.py_buffer(enc)])
    
        a, b = int(args.rows * 0.5), int(args.rows * 0.51)   # about 1% of the rows
        queries, sizes = [], []
        with tempfile.TemporaryDirectory() as d:
            paths = {}
            for label, col, arr in (("plaintext", "ts", ts), ("permuted (stand-in)", "ts", ts_perm), ("AES-CTR encrypted", "ts_enc", ts_cipher)):
                for comp in ("none", "snappy", "zstd"):
                    p = os.path.join(d, f"{label[:4]}-{comp}.parquet")
                    pq.write_table(pa.table({col: arr, "value": value}), p, compression=comp, row_group_size=args.row_group)
                    sizes.append({"file": label, "compression": comp, "ts_mb": column_mb(p, col), "file_mb": os.path.getsize(p) / 2**20})
                    if comp == "snappy":
                        paths[label] = (p, col)
    
            con = duckdb.connect()
            for label in ("plaintext", "permuted (stand-in)"):
                p, col = paths[label]
                q = f"SELECT sum(value) FROM '{p}' WHERE {col} BETWEEN {a} AND {b}"
                t, res = best(lambda: con.execute(q).fetchone()[0], args.repeats)
                g, total = groups_read(p, col, a, b)
                queries.append({"file": label, "seconds": t, "row_groups_read": g, "row_groups": total, "result": float(res)})
    
            p, col = paths["AES-CTR encrypted"]
            def decrypt_and_sum():                         # read everything, decrypt in our own code, then filter
                t = pq.read_table(p, columns=[col, "value"])
                raw = t.column(col).combine_chunks().buffers()[1].to_pybytes()
                plain = np.frombuffer(ctr().decryptor().update(raw), dtype=np.int64)
                v = t.column("value").to_numpy()
                return float(v[(plain >= a) & (plain <= b)].sum())
            t, res = best(decrypt_and_sum, args.repeats)
            g, total = groups_read(p, col, a, b)
            queries.append({"file": "AES-CTR encrypted", "seconds": t, "row_groups_read": g, "row_groups": total, "result": res})
    
        base = queries[0]["seconds"]
        for q in queries:
            q["slowdown"] = q["seconds"] / base
            print(f"{q['file']:<22} {q['seconds']*1000:8.1f} ms  {q['slowdown']:5.1f}x  row groups read {q['row_groups_read']}/{q['row_groups']}  sum={q['result']:.2f}")
        print()
        for s in sizes:
            print(f"{s['file']:<22} {s['compression']:<7} ts column {s['ts_mb']:7.1f} MB   file {s['file_mb']:7.1f} MB")
    
        size = {(s["file"], s["compression"]): s["ts_mb"] for s in sizes}
        results = {
            "summary": {
                "rows": args.rows,
                "plain_s": queries[0]["seconds"], "perm_s": queries[1]["seconds"], "enc_s": queries[2]["seconds"],
                "perm_slowdown": queries[1]["slowdown"], "enc_slowdown": queries[2]["slowdown"],
                "plain_groups": queries[0]["row_groups_read"], "row_groups": queries[0]["row_groups"],
                "ts_raw_mb": size[("plaintext", "none")],
                "plain_zstd_mb": size[("plaintext", "zstd")], "enc_zstd_mb": size[("AES-CTR encrypted", "zstd")],
                "enc_zstd_ratio": size[("AES-CTR encrypted", "zstd")] / size[("AES-CTR encrypted", "none")],
            },
            "tables": {"queries": queries, "sizes": sizes},
        }
        if args.json:
            with open(args.json, "w") as f:
                json.dump(results, f, indent=2)
    
    if __name__ == "__main__":
        main()
    
  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 measure-the-cost-of-encryption-on.py
    uv run measure-the-cost-of-encryption-on.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--rows20,000,000 Number of rows to generate.
--row-group500,000 Rows per row group.
--repeats5 Timed runs per query (the best is kept).
--seed42 Random seed for reproducibility.

Our run

1% range query, 20 million rows

1% range query, 20 million rows
ts columnQuery timeSlowdownRow groups read
plaintext4.35 ms1×1
permuted (stand-in)38.7 ms8.88×40
AES-CTR encrypted123 ms28.3×40

Size of the ts column

Size of the ts column
ts columnCompressionts column (MB)
plaintextnone163
plaintextsnappy86.7
plaintextzstd29.3
permuted (stand-in)none163
permuted (stand-in)snappy114
permuted (stand-in)zstd77
AES-CTR encryptednone163
AES-CTR encryptedsnappy163
AES-CTR encryptedzstd163
Reference runApple M2 · 8 cores · 8 GB
Output · uv run measure-the-cost-of-encryption-on.py
Generating 20000000 rows...
plaintext                   4.4 ms    1.0x  row groups read 1/40  sum=100074.99
permuted (stand-in)        38.7 ms    8.9x  row groups read 40/40  sum=99960.53
AES-CTR encrypted         123.1 ms   28.3x  row groups read 40/40  sum=100074.99

plaintext              none    ts column   163.0 MB   file   326.0 MB
plaintext              snappy  ts column    86.7 MB   file   249.8 MB
plaintext              zstd    ts column    29.3 MB   file   182.5 MB
permuted (stand-in)    none    ts column   163.0 MB   file   326.0 MB
permuted (stand-in)    snappy  ts column   114.2 MB   file   277.2 MB
permuted (stand-in)    zstd    ts column    77.0 MB   file   230.2 MB
AES-CTR encrypted      none    ts column   163.0 MB   file   326.0 MB
AES-CTR encrypted      snappy  ts column   163.0 MB   file   326.0 MB
AES-CTR encrypted      zstd    ts column   162.5 MB   file   315.7 MB
macOS 26.6.2 · Python 3.13.3 · pyarrow 26.0.0 · duckdb 1.5.6 · numpy 2.5.4 · cryptography 50.0.2 · run 2026-10-20

What we found

The plaintext query read 1 of 40 row groups and took 4.35 ms. With ts permuted, the statistics skipped nothing: all 40 row groups were read and the query took 38.7 ms, 8.88× slower. With ts encrypted and decrypted at query time it took 123 ms, 28.3× slower: "many times", as the chapter expects. Part of that gap is the query path (PyArrow and NumPy in our own code against DuckDB's native scan), not the cipher alone.

Zstd shrank the sorted column from 163 MB to 29.3 MB; the encrypted column stayed at 163 MB, 0.997× its raw size.


Read it

with a random turns an ordered column into noise as far as the file format is concerned: statistics stop pruning and compression stops working. The cost is paid by every query that filtered on that column, not at write time, which is why encrypting a sort or partition key costs far more than encrypting a payload column.