Part 7 · Scientific Computing & Data Science · Module 52

Polars, Arrow & Columnar Data

Modern analytics engines are fast for the same few reasons: they store data by column, they share a standard in-memory format (Apache Arrow) instead of copying, they read only the bytes a query needs (Parquet), and they plan a whole query before running it (Polars' lazy API). Understanding those ideas makes you faster in every tool, not just one.

⏱ ~2.5 hoursPrerequisites: M51 pandas, M48 NumPy

Row storage vs column storage

A table is two-dimensional, but memory and disk are one long line of bytes. Something has to decide the order. There are two classic choices:

table id city amt 1 Oslo 5 2 Lima 9 3 Pune 4 row store 1 Oslo 5 2 Lima 9 3 Pune 4 sum(amt) must hop over id and city: strided, cache-unfriendly column store 1 2 3 Oslo Lima Pune 5 9 4 sum(amt) reads one contiguous run: SIMD-friendly, compresses well Row stores win at "get/update one whole record"; column stores win at "scan a few columns of many rows". Same-typed neighbors also compress far better (similar values sit together).
The same table, two byte orders. Analytics usually reads few columns and many rows, so it favors columns.

Column storage has three big payoffs for analytics. Less I/O: a query that uses 3 of 40 columns reads about 3/40 of the data. Faster CPU loops: one column is a dense array of one type, which is exactly what CPU caches and SIMD (single instruction, multiple data) instructions like. Better compression: values of one column resemble each other (many repeated city names, slowly increasing timestamps), so encodings like dictionary and run-length shrink them dramatically.

You already know a column store: a pandas DataFrame keeps each column as its own array, and NumPy arrays are contiguous. Let's measure the effect in pure Python with the array module, which stores raw machine numbers contiguously.

Remember: row stores are for transactions on whole records; column stores are for analytics over a few columns of many rows. Columns give less I/O, faster loops, and better compression.

Apache Arrow: a shared in-memory format

Before Arrow, every tool had its own in-memory layout. Moving a table from a database driver to pandas to a machine-learning library meant serializing (converting to bytes) and deserializing at each step, which often cost more time than the analysis itself. Apache Arrow is a language-independent specification for how columnar data sits in memory. If two libraries both use Arrow, one can hand the other a pointer to the same buffers: zero-copy sharing. Polars, DuckDB, PyArrow, pandas' Arrow-backed dtypes (including the pandas 3.0 str dtype), Spark, and many database drivers speak Arrow.

How an Arrow array is laid out

An Arrow array is a few flat buffers plus metadata (type, length, null count):

["Oslo", null, "Lima", "Pune"] as an Arrow string array validity 1 0 1 1 offsets 0 4 4 8 12 data O s l o L i m a P u n e value i = data[offsets[i] : offsets[i+1]] null at index 1: offsets 4..4 is empty Three flat buffers and no per-string Python objects. Any Arrow-aware library can read them in place.
Variable-length strings become offsets plus one byte buffer; nulls live in a separate bitmap.

Arrow groups columns of equal length into a RecordBatch, and a sequence of batches with one schema forms a Table. Batches let engines process large data in chunks and in parallel. The same layout is also used on the wire (Arrow IPC, Arrow Flight) and on disk (the Feather format), so data can move between processes without conversion.

Remember: Arrow is a standard columnar memory layout (validity bitmap, offsets, data buffers). Shared layout means tools can pass tables without copying or converting.

Parquet: columnar files that skip work

Arrow is the in-memory format; Parquet is its on-disk partner. A Parquet file is organized for reading as little as possible:

sales.parquet row group 0 date chunk city chunk amt chunk row group 1 date chunk city chunk amt chunk footer: schema + per-chunk stats RG0 amt min=1 max=80 RG1 amt min=95 max=400 Query: SELECT city WHERE amt > 100 projection pushdown: read only city, amt predicate pushdown: RG0 max=80 <= 100, skip RG0 entirely
Readers check the footer first, then fetch only the needed column chunks of row groups that might match.

Two optimizations follow directly from this layout:

Encodings you can see

Dictionary encoding replaces repeated values with small integer codes plus a lookup table (exactly what the pandas category dtype does). Run-length encoding (RLE) stores (value, count) pairs, which is tiny for sorted or repetitive columns. Parquet typically dictionary-encodes a column and then run-length encodes the codes.

Remember: Parquet = row groups of column chunks, encoded and compressed, with min/max stats in the footer. Readers skip columns (projection pushdown) and row groups (predicate pushdown). Sorting by common filter columns makes skipping effective.

Polars: DataFrames built from expressions

Polars is a DataFrame library written in Rust, built on the Arrow memory model, multi-threaded by default, and designed around a query optimizer. Its API looks different from pandas in a few deliberate ways:

TaskpandasPolars
new columndf.assign(t=df.a * 2)df.with_columns((pl.col("a") * 2).alias("t"))
filter rowsdf[df.a > 1]df.filter(pl.col("a") > 1)
pick columnsdf[["a", "b"]]df.select("a", "b")
group summarydf.groupby("k").agg(total=("a", "sum"))df.group_by("k").agg(pl.col("a").sum().alias("total"))
group value per rowdf.groupby("k")["a"].transform("sum")pl.col("a").sum().over("k")
if/elsenp.where(cond, x, y)pl.when(cond).then(x).otherwise(y)

To build intuition in the browser, here is the same query in pandas. Notice how each Polars expression maps to a pandas operation, and how Polars needs no lambda to refer to columns created earlier in a chain:

Remember: Polars has no index; you write expressions (pl.col, pl.when, .over) and give them to contexts (select, with_columns, filter, group_by().agg).

The lazy API and the query optimizer

Everything above ran eagerly: each method executed immediately. Polars' lazy API instead builds a query plan (a tree of operations) and runs nothing until you call .collect(). Seeing the whole plan first lets the optimizer rewrite it:

Start a lazy query with pl.scan_csv, pl.scan_parquet, or df.lazy(), chain the same methods as before, and finish with .collect() (or .sink_parquet(path) to stream results to a file without holding them all in memory). .explain() prints the optimized plan; .explain(optimized=False) prints the plan as you wrote it. Read plans bottom-up: the scan is at the bottom.

Running that locally prints two plans. The unoptimized plan filters after computing temp_f for every row and reads all three columns. In the optimized plan, the scan line shows SELECTION: col("city") == "Oslo" (the filter moved into the reader) and a projection of 2 of 3 columns (humidity is never read).

To make the idea concrete without Polars, here is a toy lazy frame: it records operations, then a tiny "optimizer" moves filters ahead of computed columns when the filter does not depend on them.

Remember: lazy = build a plan, optimize, then execute. explain() shows what will actually run; look for filters and column lists pushed into the scan.

Why Polars is fast, and measuring it honestly

Polars is often several times faster than pandas on group-bys, joins, and scans of large files, and uses less memory. The reasons are architectural, not magic:

Parallel by default

Expressions and partitions run on all CPU cores. pandas operations mostly use one core.

Query optimization

Lazy plans skip unneeded columns, rows, and row groups before any work is done.

Arrow memory

Compact strings, bitmaps for nulls, and zero-copy interop with other Arrow tools.

No Python in the loop

Expressions compile to Rust kernels. A Python lambda via map_elements brings back Python speed, so avoid it.

Benchmarks are easy to get wrong. Measure on realistic data sizes (tiny data is dominated by overhead), run several repetitions and take the median or best, include I/O if your real job includes I/O, make sure both tools compute the same answer, and remember pandas 3.0 with Arrow-backed strings narrowed some gaps.

Remember: Polars is fast because of parallelism, optimization, Arrow memory, and keeping Python out of inner loops. Benchmark with realistic sizes, repetitions, and equal answers.

Interoperability: pandas, Polars, PyArrow, DuckDB

Because they share Arrow, these tools convert cheaply, often without copying numeric data:

FromToCall
Polarspandaspl_df.to_pandas() (add use_pyarrow_extension_array=True to keep Arrow-backed columns)
pandasPolarspl.from_pandas(pd_df)
PolarsPyArrowpl_df.to_arrow() (zero-copy for most types)
PyArrowPolarspl.from_arrow(table)
pandasPyArrowpa.Table.from_pandas(pd_df); back with table.to_pandas()
any of themDuckDB SQLduckdb.sql("SELECT ... FROM pl_df") reads DataFrames in scope by name

Remember: Arrow is the common language; to_pandas, from_pandas, to_arrow, from_arrow are cheap bridges, and DuckDB can query any of them with SQL.

Choosing pandas, Polars, or DuckDB

All three are excellent; the right one depends on data size, team skills, and the surrounding ecosystem.

pandasPolarsDuckDB
InterfacePython methods, row indexPython expressions, no indexSQL (plus a Python relational API)
Executioneager, mostly single-threadedeager or lazy, multi-threaded, optimizedlazy, multi-threaded, optimized, out-of-core
Sweet spotsmall to medium data, exploration, the widest library ecosystem (statsmodels, scikit-learn, plotting)medium to large data in Python pipelines; complex transformationslarge data, joins and aggregations, querying Parquet/CSV files directly, SQL-fluent teams
Larger than memoryno (chunk manually)yes, with the streaming engineyes, spills to disk
Time series and index tricksstrongestgood (group_by_dynamic, rolling)good via SQL window functions

A practical rule: start in pandas for small exploratory work and anything that hands a DataFrame to another library that expects pandas; reach for Polars when pandas becomes slow or memory-hungry inside Python code; reach for DuckDB when the natural expression is SQL or the data lives in many Parquet files. They interoperate, so you can mix them in one project. M61 Analytical SQL & DuckDB covers DuckDB in depth.

Remember: choose by data size, interface, and ecosystem; they share Arrow, so mixing is cheap.

Practice

These exercises build the core columnar ideas in plain Python, so they run and grade in the browser.

Write to_columns(rows) that converts a list of dict records into a column store: a dict mapping each column name to a list of values. Columns appear in the order they are first seen. A record missing a key contributes None for that column, so every list has length len(rows).

Hint

First collect column names in order (a dict used as an ordered set works well), then build each list with row.get(name).

Show solution
def to_columns(rows):
    names = {}
    for row in rows:
        for key in row:
            names.setdefault(key, None)
    return {name: [row.get(name) for row in rows] for name in names}

Write dict_encode(values) returning (dictionary, codes): dictionary is the list of distinct non-None values in first-seen order, and codes gives each value's position in the dictionary, with -1 for None (as pandas categoricals do). Also write dict_decode(dictionary, codes) that reverses it.

Hint

Keep a dict from value to code while you scan; append to the dictionary the first time you see a value.

Show solution
def dict_encode(values):
    index = {}
    dictionary, codes = [], []
    for v in values:
        if v is None:
            codes.append(-1)
            continue
        if v not in index:
            index[v] = len(dictionary)
            dictionary.append(v)
        codes.append(index[v])
    return dictionary, codes

def dict_decode(dictionary, codes):
    return [None if c == -1 else dictionary[c] for c in codes]

Write rle_encode(values) that returns a list of (value, run_length) tuples for consecutive equal values, and rle_decode(runs) that expands them back into a list.

Hint

itertools.groupby groups consecutive equal items, or loop and compare with the last run.

Show solution
from itertools import groupby

def rle_encode(values):
    return [(key, sum(1 for _ in group)) for key, group in groupby(values)]

def rle_decode(runs):
    return [value for value, count in runs for _ in range(count)]

A file is a list of row groups; each is a list of numbers. Write build_stats(row_groups) returning a list of (min, max) per group, and scan(row_groups, stats, lo, hi) returning (matches, groups_read): all values with lo <= v <= hi in file order, and how many row groups had to be read. Skip a group without reading it when its stats prove it cannot contain a match.

Hint

A group can be skipped when max < lo or min > hi.

Show solution
def build_stats(row_groups):
    return [(min(rg), max(rg)) for rg in row_groups]

def scan(row_groups, stats, lo, hi):
    matches, read = [], 0
    for rg, (mn, mx) in zip(row_groups, stats):
        if mx < lo or mn > hi:
            continue
        read += 1
        matches.extend(v for v in rg if lo <= v <= hi)
    return matches, read

Build a miniature version of Polars expressions over a column store (a dict of equal-length lists). Implement a class Expr and a function col(name) so that:

  • col("a").eval(table) returns the list table["a"].
  • +, -, *, / between two expressions, or an expression and a number (on either side), produce a new Expr that computes element-wise.
  • >, <, == produce boolean expressions, and & combines two boolean expressions element-wise.
  • filter_table(table, predicate) returns a new column store keeping rows where the predicate is True.

Nothing is computed until eval is called: an expression is a description.

Hint

Write one helper _binary(self, other, op) that wraps a number in a constant expression, then returns Expr(lambda t: [op(a, b) for a, b in zip(self.eval(t), other.eval(t))]). Each dunder method calls it with an operator function. Defining __eq__ means you should also set __hash__ = None or leave it; tests do not hash expressions.

Show solution
import operator

class Expr:
    def __init__(self, fn):
        self.fn = fn

    def eval(self, table):
        return self.fn(table)

    def _binary(self, other, op, reverse=False):
        if not isinstance(other, Expr):
            value = other
            other = Expr(lambda t: [value] * len(next(iter(t.values()))))
        left, right = (other, self) if reverse else (self, other)
        return Expr(lambda t: [op(a, b) for a, b in zip(left.eval(t), right.eval(t))])

    def __add__(self, other): return self._binary(other, operator.add)
    def __radd__(self, other): return self._binary(other, operator.add, reverse=True)
    def __sub__(self, other): return self._binary(other, operator.sub)
    def __mul__(self, other): return self._binary(other, operator.mul)
    def __rmul__(self, other): return self._binary(other, operator.mul, reverse=True)
    def __truediv__(self, other): return self._binary(other, operator.truediv)
    def __gt__(self, other): return self._binary(other, operator.gt)
    def __lt__(self, other): return self._binary(other, operator.lt)
    def __eq__(self, other): return self._binary(other, operator.eq)
    def __and__(self, other): return self._binary(other, lambda a, b: a and b)
    __hash__ = None

def col(name):
    return Expr(lambda t: list(t[name]))

def filter_table(table, predicate):
    keep = predicate.eval(table)
    return {name: [v for v, k in zip(values, keep) if k] for name, values in table.items()}

Mini-project: port a pandas analysis to Polars lazy

Mini-projectpandas vs Polars on the same question

Take an analysis you would write in pandas, port it to the Polars lazy API, prove both give the same answer, and measure the difference on data large enough to matter.

pixi init columnar-bench && cd columnar-bench
pixi add python=3.14 pandas polars pyarrow numpy
pixi run python make_data.py     # writes trips.parquet (10M rows, 20 row groups) and trips.csv
pixi run python bench.py

Data: generate ~10 million synthetic taxi trips with NumPy: pickup_time, zone (260 zones), distance_km, fare, tip, payment_type. Sort by pickup_time and write Parquet with row_group_size=500_000, plus a CSV copy.

Question: for trips in March paid by card, what are the median fare per km and the average tip percentage per zone, for the 10 busiest zones?

  • A pandas version reads Parquet with columns= and answers the question
  • A Polars version uses scan_parquet and a single lazy chain ending in collect()
  • explain() output shows the date filter and column projection pushed into the scan (paste it in a comment)
  • An assertion proves both versions return the same zones and values (within float tolerance)
  • Timings use the best of 5 runs, for both Parquet and CSV inputs, printed as a small table
  • A short README explains which tool you would pick for this job and why

Stretch goals: answer the same question with DuckDB SQL on the Parquet file; try collect(engine="streaming") with 50M rows; compare file sizes for Snappy vs Zstandard compression.

Quiz

Recap

Going further