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 column 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 Parquet. 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 index 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
Expect Parquet several times smaller, because of dictionary and bit-packing. Expect the column format to win the scan by a large factor and lose (or tie) the single-row read.
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
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)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
| Knob · default | What it changes |
|---|---|
| --rows10,000,000 | Rows. |
Our run
10 million rows × 25 columns
| Format | Size (MB) | 2 of 25 columns | One row (ms) |
|---|---|---|---|
| CSV (rows) | 843 | 5.12 s | 0.097 |
| Parquet (columns) | 250 | 2.05 s | 29.2 |
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 msWhat we found
Parquet was 250 MB 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 row groups 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.