Use ←/→, space, Home/End. Press P to print.
Polars Work-Along

Build Polars capability by building a pipeline

This is a web presentation version of the tutorial. It is not a syntax tour. It is a guided build: generate data, scan lazily, transform with expressions, validate joins, test the output, and sink a production-shaped report.

Outcome: one repeatable Polars pipeline you can run, inspect, and test.
Why Polars

Think query engine, not spreadsheet

Polars is easiest to use when you stop thinking row-by-row. You describe column operations as expressions, Polars builds a logical plan, optimizes it, then executes.

The win comes from avoiding unnecessary reads, avoiding Python loops, and letting the engine push work down into the scan.

sourceexpressionslogical planoptimized planexecutionresult / sink
Mental model

The default pipeline shape

scan
select
filter
transform
group / join
validate
sink / collect

Use scan_* plus chained expressions, then call collect() once at the edge — or use sink_parquet() when writing output.

Essential syntax

The 12 patterns you need first

NeedPattern
Lazy file readpl.scan_parquet(...), pl.scan_csv(...)
Column referencepl.col("name")
Keep columns.select(...)
Filter rows.filter(...)
Create columns.with_columns(...)
Rename expression.alias("new_name")
Aggregate.group_by(...).agg(...)
Join.join(other, on="id", how="left")
Inspect plan.explain()
Finish.collect() or .sink_parquet(...)
Project

What you will build

polars-workalong/
├── data/
│   ├── raw/
│   └── output/
├── src/polars_workalong/
│   ├── generate_data.py
│   └── pipeline.py
├── tests/test_pipeline.py
├── pyproject.toml
└── POLARS_WORKALONG_TUTORIAL.md
Deliverable: monthly_region_plan_report.parquet grouped by month, region, and plan.
Setup

Start from a clean environment

python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e '.[dev]'

python - <<'PY'
import polars as pl
print(pl.__version__)
print(pl.show_versions())
PY

Do not skip environment verification. Version drift creates fake learning problems.

Data generator

Generate realistic input data

python -m polars_workalong.generate_data
FilePurpose
customers.parquetCustomer dimension: user, region, plan, active flag
events.parquetEvent fact table: date, type, amount, device, tags
events_sample.csvCSV comparison input for scanning practice

The point is not fake demo data. The point is a controlled fixture that forces joins, nulls, dates, strings, and nested values.

Lesson 1

DataFrame, LazyFrame, Series, expressions

sales = pl.DataFrame({
    "order_id": [1, 2, 3, 4],
    "region": ["north", "south", "north", "west"],
    "amount": [100.0, 75.5, 210.0, 40.0],
    "is_returned": [False, False, True, False],
})

result = sales.select(
    "order_id",
    (pl.col("amount") * 1.1).round(2).alias("amount_plus_10pct"),
)
Rule: an expression is a recipe. It is evaluated only inside a context like select, with_columns, filter, or agg.
Exercise

Replace row logic with expression logic

answer = sales.with_columns(
    pl.when(pl.col("is_returned"))
      .then(0.0)
      .otherwise(pl.col("amount"))
      .alias("net_amount"),

    pl.when(pl.col("amount") < 75).then(pl.lit("small"))
      .when(pl.col("amount") < 175).then(pl.lit("medium"))
      .otherwise(pl.lit("large"))
      .alias("amount_band"),
)
assert answer["net_amount"].to_list() == [100.0, 75.5, 0.0, 40.0]
assert answer["amount_band"].to_list() == ["medium", "medium", "large", "small"]
Lesson 2

Schemas are contracts

customers = pl.scan_parquet("data/raw/customers.parquet")
events = pl.scan_parquet("data/raw/events.parquet")

print(customers.collect_schema())
print(events.collect_schema())

Types are not decoration. They decide what operations are legal, how values are parsed, whether joins work, and how memory is represented.

Boundary rule: cast explicitly when data enters your pipeline.
Lesson 3

Lazy scanning and optimizer pushdown

query = (
    pl.scan_parquet("data/raw/events.parquet")
      .select("user_id", "event_date", "event_type", "amount")
      .filter(pl.col("event_date") >= pl.date(2026, 1, 1))
      .filter(pl.col("amount") > 0)
)

print(query.explain())

If you select only four columns and filter early, Polars can avoid reading and processing work you never needed.

Anti-pattern

Do not collect in the middle

Weak

df = pl.scan_parquet(path).collect()
df = df.select(...)
df = df.filter(...)

You forced a full read before Polars could optimize the plan.

Strong

df = (
    pl.scan_parquet(path)
      .select(...)
      .filter(...)
      .collect()
)

One plan. One materialization point.

Lesson 4

select vs with_columns

select

Changes the output column set. Use it to keep only what the next step needs.

events.select(
    "user_id",
    "amount",
    (pl.col("amount") * 1.0825).alias("with_tax"),
)

with_columns

Preserves existing columns and adds or replaces columns.

events.with_columns(
    pl.col("device").fill_null("unknown"),
    pl.col("event_date").dt.truncate("1mo").alias("month"),
)
Lesson 5

Filtering correctly

filtered = events.filter(
    (pl.col("event_type") == "purchase")
    & (pl.col("amount") > 0)
    & (pl.col("event_date").is_between(pl.date(2026, 1, 1), pl.date(2026, 6, 30)))
)
Rule: use &, |, and parentheses. Do not use Python and / or with Polars expressions.
Lesson 6

Null and NaN are not the same

ValueMeaningCommon fix
nullMissing valuefill_null(...)
NaNFloating-point invalid numberfill_nan(...)
clean = df.with_columns(
    pl.col("device").fill_null("unknown"),
    pl.col("amount").fill_null(0.0).fill_nan(0.0),
)

Do not pretend missing data and invalid numeric output are the same problem.

Lessons 7–8

Strings, dates, and time buckets

events.with_columns(
    pl.col("event_type").str.to_lowercase().alias("event_type_clean"),
    pl.col("event_date").dt.truncate("1mo").alias("month"),
    pl.col("event_date").dt.weekday().alias("weekday"),
)

String and date work should stay inside expressions. Do not parse dates or clean strings row-by-row in Python unless you have no real alternative.

Lesson 9

Group and aggregate without losing meaning

monthly = (
    events
      .group_by("month", "region", "plan")
      .agg(
          pl.len().alias("events"),
          pl.col("user_id").n_unique().alias("users"),
          pl.col("net_amount").sum().round(2).alias("net_revenue"),
      )
      .with_columns(
          (pl.col("net_revenue") / pl.col("users")).round(2).alias("revenue_per_user")
      )
)
Practical check: every metric name should tell you its grain and meaning.
Lesson 10

Join with cardinality validation

events.join(
    customers,
    on="user_id",
    how="inner",
    validate="m:1",
)

A join is not just syntax. It is a claim about the relationship between tables.

ValidationClaim
1:1Each side has at most one matching key
1:mLeft key is unique, right may repeat
m:1Left may repeat, right key is unique
m:mBoth sides may repeat
Lesson 11

Windows answer “within each group” questions

ranked = events.with_columns(
    pl.col("amount")
      .rank("dense", descending=True)
      .over("user_id")
      .alias("purchase_rank_for_user")
)

A group-by collapses rows. A window expression keeps rows and adds group-aware calculations.

Lessons 12–14

Duplicate policy, nested data, reshaping

Duplicates

Use unique(...) only after defining the business key and keep policy.

Nested

Use list and struct expressions instead of exploding too early.

Reshape

Use unpivot for wide-to-long. Use pivot only after aggregation grain is clear.

events.select(
    "event_id",
    pl.col("tags").list.first().alias("primary_tag"),
)
Lessons 15–16

Concatenation, plans, profiling, streaming

query = build_event_report(events_path, customers_path)
print(query.explain())

# In-memory result
report = query.collect()

# Disk output without forcing an intermediate DataFrame
query.sink_parquet("data/output/monthly_region_plan_report.parquet")

Use collect() when you need a DataFrame in memory. Use a sink when the real output is a file.

Lesson 17

Why map_elements is usually the wrong move

Weak

df.with_columns(
    pl.col("amount").map_elements(lambda x: x * 1.0825)
)

This pushes work into Python and hides logic from Polars.

Strong

df.with_columns(
    (pl.col("amount") * 1.0825).alias("amount_with_tax")
)

This keeps work native, parallel, and optimizer-visible.

Lesson 18

Test transformations like business logic

from polars.testing import assert_frame_equal

actual = build_event_report(events_path, customers_path).collect().sort("region")
expected = pl.DataFrame({...}).sort("region")

assert_frame_equal(actual, expected)
TestWhy
Schema testCatches broken contracts
Tiny known inputProves math and branching
Quality assertionCatches impossible output
Lesson 19–20

SQL interface and ecosystem boundaries

ctx = pl.SQLContext()
ctx.register("events", events)
result = ctx.execute("""
    SELECT event_type, COUNT(*) AS events
    FROM events
    GROUP BY event_type
    ORDER BY events DESC
""").collect()

Use SQL when it makes the transformation clearer. Use Polars expressions when you need composable, typed Python pipelines. Convert to pandas only at the boundary when another library requires it.

Capstone

Monthly region and plan report

def build_event_report(events_path: Path, customers_path: Path) -> pl.LazyFrame:
    events = (
        pl.scan_parquet(events_path)
          .select("event_id", "user_id", "event_date", "event_type", "amount", "device")
          .filter(pl.col("event_date").is_between(pl.date(2026, 1, 1), pl.date(2026, 6, 30)))
          .with_columns(
              pl.col("device").fill_null("unknown"),
              pl.when(pl.col("event_type").is_in(["purchase", "refund"]))
                .then(pl.col("amount"))
                .otherwise(0.0)
                .fill_null(0.0)
                .alias("net_amount"),
          )
          .with_columns(
              (pl.col("net_amount") * 1.0825).round(2).alias("amount_with_tax"),
              pl.col("event_date").dt.truncate("1mo").alias("month"),
          )
    )
Capstone continued

Join, aggregate, derive, sort

customers = (
    pl.scan_parquet(customers_path)
      .select("user_id", "region", "plan", "is_active")
      .filter(pl.col("is_active"))
)

return (
    events.join(customers, on="user_id", how="inner", validate="m:1")
      .group_by("month", "region", "plan")
      .agg(
          pl.len().alias("events"),
          pl.col("user_id").n_unique().alias("users"),
          pl.col("net_amount").sum().round(2).alias("net_revenue"),
          pl.col("amount_with_tax").sum().round(2).alias("revenue_with_tax"),
      )
      .with_columns((pl.col("net_revenue") / pl.col("users")).round(2).alias("revenue_per_user"))
      .sort("month", "revenue_with_tax", descending=[False, True])
)
Validation

Done means tested, not “looks right”

python -m polars_workalong.generate_data
python -m polars_workalong.pipeline
python -m pytest -q
Practice plan

Deliberate practice sequence

  1. Rewrite one pandas transformation as Polars expressions.
  2. Add one schema boundary and one tiny known-output test.
  3. Inspect the lazy plan before and after moving select and filter.
  4. Add a join with validate=... and intentionally break the cardinality.
  5. Sink output to Parquet and read it back with scan_parquet.

The goal is not to know every method. The goal is to build reliable pipelines without falling back to row loops.

Final checklist

What capability looks like

Core

Expressions, lazy scans, select/filter/with_columns, group-by, joins, collect/sink.

Intermediate

Schemas, nulls, temporal logic, windows, nested values, reshaping, query plans.

Production-shaped

Join validation, data-quality checks, tests, repeatable outputs, clear boundaries.

Hard truth: if you are writing Python loops over rows, you are probably not using Polars yet. You are using Python with a DataFrame nearby.