Benchmarking Pandas, Polars, and PyArrow for Fare-File Ingestion

Choosing an ingestion engine for nightly settlement and tap-event CSVs is a throughput, memory, and correctness decision all at once — and the correctness half is the one most benchmarks quietly get wrong. This page compares pandas, Polars, and PyArrow reading the same fare file across three volume tiers (100K, 1M, 10M rows), measuring read throughput and peak resident memory while insisting that the amount_cents column survive the trip as an exact integer or Decimal, never a lossy float64. It sits under CSV Batch Parsing Workflows within Fare Data Ingestion & GTFS-RT Sync, and it exists to answer one operational question: which engine do you reach for at each file size, and what does it cost you at the settlement ledger if you pick the fast one and let money become a float.

Engine selection flow

The three engines are not interchangeable — each wins a different tier, and the money-handling rule cuts across all of them. The flow below routes a fare file to an engine by volume and by whether the pipeline needs streaming, then funnels every path through the same money-parsing gate before a row reaches the ledger.

Selecting a fare-file ingestion engine by volume and streaming need A fare file is sized into three tiers. Files near one hundred thousand rows route to pandas for its rich ecosystem. Files near one million rows route to Polars for multi-threaded throughput. Files near ten million rows split: a bounded-memory pipeline routes to PyArrow streaming batches, while an in-memory analytical pass routes to Polars lazy scanning. Every engine path then passes through a shared money gate that parses the amount column as string to Decimal or scaled integer, never float64, before rows land in the reconciliation ledger. Fare CSV in size the row count ~100K rows fits RAM easily ~1M rows multi-core payoff ~10M rows memory-bound pandas rich ecosystem Polars threaded / lazy PyArrow batches bounded memory streaming Money gate string → Decimal / int cents Reconciliation ledger

Step 1 — A common fare file and the money trap

Every engine reads the same canonical AFC export: a transaction_id, a card_uid, a tap_timestamp, a route_id, and an amount_cents column. The single most consequential decision is how amount_cents is typed. If the vendor writes minor units as bare integers you are safe, but many settlement files carry a decimal major-unit column like 2.75, and the instant a reader parses that as float64 the value becomes the nearest IEEE-754 double, not 2.75. Summed across ten million taps, that rounding error is a real settlement variance an auditor will find.

The rule is uniform across pandas, Polars, and PyArrow: read the money column as a string, then convert deterministically to a scaled integer or decimal.Decimal. Never let the CSV reader infer a floating dtype for money.

from decimal import Decimal, ROUND_HALF_UP

def major_units_to_cents(raw: str) -> int:
    """Parse a major-unit money string to exact integer cents, or raise."""
    if raw is None or raw.strip() == "":
        raise ValueError("Empty money value; refuse to default to zero.")
    # Decimal parses the *text* exactly; float would parse the nearest double.
    cents = (Decimal(raw) * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
    return int(cents)

assert major_units_to_cents("2.75") == 275
assert major_units_to_cents("0.10") == 10   # float64 would carry 0.1000000000000000055

The 0.10 case is the whole argument in one line: Decimal("0.10") is exactly one tenth, float("0.10") is not. Applying major_units_to_cents after the string read makes all three engines produce byte-identical ledgers.

Step 2 — Reading the same file in each engine

Each snippet reads the identical file, forces the money column to arrive as text, and defers the cents conversion to the shared function above. The differences are in the dtype API and the execution model, not in the correctness contract.

import pandas as pd

def read_with_pandas(path: str) -> pd.DataFrame:
    """Eager, single-threaded read; money column pinned to string dtype."""
    df = pd.read_csv(
        path,
        dtype={"transaction_id": "string", "card_uid": "string",
               "route_id": "string", "amount_major": "string"},
        parse_dates=["tap_timestamp"],
    )
    df["amount_cents"] = df["amount_major"].map(major_units_to_cents).astype("Int64")
    return df.drop(columns=["amount_major"])

Polars reads the same file with a multi-threaded CSV parser. Using scan_csv instead of read_csv returns a lazy frame, so the money cast is pushed into the query plan and the file is streamed rather than fully materialized:

import polars as pl

def read_with_polars(path: str) -> pl.DataFrame:
    """Multi-threaded lazy scan; amount read as Utf8, cast to Int64 cents."""
    lazy = pl.scan_csv(
        path,
        schema_overrides={"amount_major": pl.Utf8, "transaction_id": pl.Utf8},
        try_parse_dates=True,
    ).with_columns(
        (pl.col("amount_major").cast(pl.Decimal(scale=2)) * 100)
        .cast(pl.Int64).alias("amount_cents")
    ).drop("amount_major")
    return lazy.collect(streaming=True)

PyArrow reads the file as record batches, which is the bounded-memory option: you iterate batches instead of holding the whole table. Forcing the column type to string in ConvertOptions keeps money out of a float column:

import pyarrow as pa
from pyarrow import csv as pacsv

def read_with_pyarrow_batches(path: str, block_size: int = 1 << 24):
    """Stream Arrow record batches; yield (batch, cents_list) pairs."""
    convert = pacsv.ConvertOptions(column_types={"amount_major": pa.string()})
    read_opts = pacsv.ReadOptions(block_size=block_size)
    with pacsv.open_csv(path, read_options=read_opts, convert_options=convert) as reader:
        for batch in reader:
            raw = batch.column("amount_major").to_pylist()
            yield batch, [major_units_to_cents(v) for v in raw]

Benchmark results by volume tier

The table below reports what these three readers typically look like relative to one another on a warm local NVMe disk. These figures are illustrative, not measured guarantees — throughput and memory depend entirely on your CPU core count, disk, column mix, and library versions. Treat the shape of the comparison as the takeaway, not the absolute numbers.

Tier Engine Relative read throughput Peak memory profile Money correctness
100K rows pandas baseline (1.0×) low, whole file in RAM exact via string→Int64
100K rows Polars ~2–3× low exact via Decimal cast
100K rows PyArrow ~2× lowest exact via string column
1M rows pandas 1.0× moderate, single copy exact
1M rows Polars ~4–6× moderate, multi-threaded exact
1M rows PyArrow ~3–4× low, batched exact
10M rows pandas (eager) 1.0×, memory-risky high, may exceed RAM exact but costly
10M rows Polars (streaming) ~5–8× bounded by stream exact
10M rows PyArrow (batches) ~4–6× lowest, constant exact

The relative throughput bars below restate the 1M-row row of that table. They are drawn to illustrate the typical ordering — pandas slowest, Polars fastest, PyArrow between — and are deliberately labelled as illustrative rather than presented as precise measured values.

Illustrative relative read throughput at the one-million-row tier Three horizontal bars compare illustrative relative read throughput at one million rows. Pandas is the baseline at one times. PyArrow is roughly three to four times faster. Polars is roughly four to six times faster and the longest bar. The chart is labelled illustrative because real numbers depend on hardware and library versions. Illustrative only · relative to pandas baseline · not measured pandas ~1.0× baseline PyArrow ~3–4× Polars ~4–6× longer bar = faster read · ordering is typical, magnitudes vary by machine

Step 3 — Choosing an engine per tier

The recommendation follows the tiers, with money correctness held constant because it is non-negotiable regardless of engine:

  • ~100K rows — pandas. At this size throughput differences are milliseconds and irrelevant. Pick pandas for its ecosystem: it hands directly to the validation and reconciliation code in CSV Batch Parsing Workflows, and the extra speed of the others buys nothing you can feel.
  • ~1M rows — Polars. This is where multi-threaded parsing earns its keep on a nightly close. Polars reads several times faster than eager pandas and its Decimal cast keeps money exact without a Python-level map.
  • ~10M rows — PyArrow batches or Polars streaming. Here the constraint is memory, not speed. If you need a strict, constant memory ceiling — for example a container with a hard limit — iterate PyArrow record batches. If you can afford a larger but still bounded working set and want the richer expression API, use pl.scan_csv(...).collect(streaming=True). Eager pandas at 10M rows is the one combination to avoid; if you must use pandas here, switch to the chunked reader in Optimizing Pandas Chunksize for 10M-Row Fare Files.

Validation & Test Cases

The test that matters most is not “which is fastest” but “do all three produce the same ledger total from the same file.” Cross-engine agreement on the summed cents is the correctness gate; a mismatch means one reader let money become a float.

import csv
import tempfile
from pathlib import Path

def _write_sample(path: Path) -> None:
    rows = [("T1", "C1", "2026-07-03T08:00:00Z", "R12", "2.75"),
            ("T2", "C2", "2026-07-03T08:01:00Z", "R12", "0.10"),
            ("T3", "C3", "2026-07-03T08:02:00Z", "R44", "10.05")]
    with path.open("w", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["transaction_id", "card_uid", "tap_timestamp", "route_id", "amount_major"])
        w.writerows(rows)

with tempfile.TemporaryDirectory() as d:
    fp = Path(d) / "fares.csv"
    _write_sample(fp)

    # Expected exact total: 275 + 10 + 1005 = 1290 cents
    pd_total = int(read_with_pandas(str(fp))["amount_cents"].sum())
    pl_total = int(read_with_polars(str(fp))["amount_cents"].sum())
    pa_total = sum(c for _, cents in read_with_pyarrow_batches(str(fp)) for c in cents)

    assert pd_total == pl_total == pa_total == 1290

# Edge case: a money string a float would corrupt still round-trips exactly.
assert major_units_to_cents("70.07") == 7007
# Edge case: empty money is refused, never silently zeroed.
try:
    major_units_to_cents("")
    raise AssertionError("expected ValueError on empty money")
except ValueError:
    pass

The normal case asserts all three engines agree on 1290 cents. The edge cases prove the money gate refuses an empty value instead of defaulting to zero, and that 70.07 — a value float64 cannot hold exactly — survives as 7007. If you ever see the three totals diverge, the first suspect is a reader that inferred a float dtype before the string cast could run.

Edge Cases & Honest Benchmarking

A few gotchas separate a defensible benchmark from a misleading one:

  1. Warm vs cold cache. The first read of a file pays disk I/O; the second reads from the OS page cache and can look several times faster. State which one you measured, and measure both — a nightly job hits a cold file.
  2. Dtype inference skews everything. If you let one engine infer float64 for money and force another to string, you are benchmarking dtype code paths, not the engines. Pin the money column to text in all three, as shown.
  3. Peak memory needs a real probe. Report peak resident set via tracemalloc or /usr/bin/time -v, not df.memory_usage(), which misses transient copies during the read.
  4. Version drift. Polars and PyArrow move fast; a result on polars 1.0 may not hold on a later release. Always pin and print versions alongside numbers, and never quote a magnitude as if it were universal.

Integration Note

This benchmark chooses the engine; the parent CSV Batch Parsing Workflows is what runs downstream of that choice, applying schema gates, idempotency guards, and the running SHA-256 checksum regardless of which reader produced the rows. Its closest sibling is Optimizing Pandas Chunksize for 10M-Row Fare Files: if this page steers you to pandas at a volume where memory is tight, that guide is how you keep pandas within a fixed ceiling by sizing chunks rather than switching engines. The two together answer “which engine” and “how big a bite,” and both defer money correctness to the same string-to-Decimal rule.

FAQ

Why not just read money as float64 and round at the end?
Because the error is already baked in before you round. A value like 0.10 becomes the nearest IEEE-754 double the instant the CSV reader parses it, and summing millions of those doubles accumulates a drift that a final round cannot recover. Read the money column as a string and convert to integer cents or Decimal with an explicit rounding mode, so the exact text is preserved at parse time.
Are the throughput numbers on this page measured benchmarks?
No. They are illustrative and describe the typical ordering of the three engines, not precise figures from a specific machine. Real throughput and peak memory depend on core count, disk speed, column mix, cache warmth, and library versions. Reproduce them on your own hardware with pinned versions and a real memory probe before quoting any number in a capacity plan.
When is PyArrow the right choice over Polars at 10M rows?
When you need a strict, constant memory ceiling. Iterating PyArrow record batches holds only one block in memory at a time, which suits a container with a hard limit or a step that streams straight to a broker. Polars streaming is faster and has a richer expression API but keeps a larger bounded working set. If speed matters more than a hard cap, pick Polars; if the cap is inviolable, pick PyArrow batches.
Does the engine choice change the reconciliation ledger?
It must not. All three engines are held to the same money-parsing contract, so a correct pipeline produces byte-identical cent totals from the same file regardless of reader. The cross-engine equality assertion in the validation section is exactly this guarantee. If the totals differ, one reader silently used a float dtype for money and needs its money column pinned to string.

Part of CSV Batch Parsing Workflows, within Fare Data Ingestion & GTFS-RT Sync.