Skip to content
About
Main content

Row versus column on the same table

Measure your machineNeeds Python with pandas, PyArrow

Problem

A row layout stores each record's fields together, so reading one record is one contiguous read and reading one field of every record touches everything. A layout stores each field's values together, so a scan of a few columns reads only those, and each column compresses well because its values are alike; reading one record means visiting every column.

Generate a table of 10 million rows and 25 columns and write it twice: as CSV (a stand-in for rows) and as . Compare file sizes and the time to read 2 of 25 columns. Then time a point read of one row by position in each (for the CSV, with an of row offsets, as a row store keeps).

The question. How do the two formats compare on size, on a two-column scan and on a single-row read?


Predict

Reading 2 of 25 columns from 10 million rows: how many times faster is Parquet than CSV?

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.

    row-versus-column-on-the-same.py43 linesDownload
    View the code
    row-versus-column-on-the-same.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
    """Row against column on the same table.
    
    10 million rows and 25 columns written as CSV (a stand-in for a row format)
    and as Parquet. File sizes, the time to read 2 of the 25 columns, and the time
    to read one row by position.
    """
    import argparse, json, os, shutil, tempfile, time
    import numpy as np, pandas as pd, pyarrow.parquet as pq
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--rows", type=int, default=10_000_000, help="Rows.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    d = tempfile.mkdtemp(); rng = np.random.default_rng(0); n = args.rows
    df = pd.DataFrame({f"c{i}": rng.integers(0, 200, n) for i in range(24)}); df["weight_g"] = rng.integers(100, 30000, n)
    csv, pqf = os.path.join(d, "t.csv"), os.path.join(d, "t.parquet")
    df.to_csv(csv, index=False); df.to_parquet(pqf, row_group_size=640_000); del df
    row = n // 2
    t = time.perf_counter(); pd.read_parquet(pqf, columns=["c0", "weight_g"]).mean(); pq_scan = time.perf_counter() - t
    t = time.perf_counter(); pd.read_csv(csv, usecols=["c0", "weight_g"]).mean(); csv_scan = time.perf_counter() - t
    t = time.perf_counter(); f = pq.ParquetFile(pqf); rg = row // 640_000; f.read_row_group(rg).slice(row % 640_000, 1).to_pylist(); pq_point = time.perf_counter() - t
    offsets = [0]                                                             # a row store knows where each row starts: build the offsets once
    with open(csv, "rb") as fh:
        for line in fh: offsets.append(offsets[-1] + len(line))
    t = time.perf_counter()
    with open(csv, "rb") as fh: fh.seek(offsets[row + 1]); fh.readline()
    csv_point = time.perf_counter() - t
    rows = [{"format": "CSV (rows)", "size_mb": os.path.getsize(csv) / 2**20, "two_columns_s": csv_scan, "one_row_ms": csv_point * 1000},
            {"format": "Parquet (columns)", "size_mb": os.path.getsize(pqf) / 2**20, "two_columns_s": pq_scan, "one_row_ms": pq_point * 1000}]
    shutil.rmtree(d, ignore_errors=True)
    for r in rows: print(f"{r['format']:<18} {r['size_mb']:7.0f} MB   2 of 25 columns {r['two_columns_s']:6.2f} s   one row by position {r['one_row_ms']:8.3f} ms")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"formats": 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 row-versus-column-on-the-same.py
    uv run row-versus-column-on-the-same.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--rows10,000,000 Rows.

Our run

10 million rows × 25 columns

10 million rows × 25 columns
FormatSize (MB)2 of 25 columnsOne row (ms)
CSV (rows)8435.12 s0.097
Parquet (columns)2502.05 s29.2
Reference runApple M2 · 8 cores · 8 GB
Output · uv run row-versus-column-on-the-same.py
CSV (rows)             843 MB   2 of 25 columns   5.12 s   one row by position    0.097 ms
Parquet (columns)      250 MB   2 of 25 columns   2.05 s   one row by position   29.232 ms
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

Parquet was 250 against 843 MB for CSV. Reading 2 of the 25 columns took 2.05 s from Parquet and 5.12 s from CSV, a smaller gap than expected: Parquet still decodes whole of the two columns into pandas, and the CSV reader is fast C code. One row by position took 0.097 ms from the CSV with an offset index and 29.2 ms from Parquet, which must decode a whole row group to return one row.


Read it

Layout is a bet on the access pattern: column formats for scans over many rows and few columns, row formats for whole-record access. A system that serves both keeps both, or accepts that one of them is the slow path.