Lesson 0.5.4 · 45 min
Polars: lazy versus eager API, expressions, performance and when to pick it over pandas
Polars is a table library written in Rust that describes computations as expressions and lets an optimizer decide how to run them. This lesson repeats with Polars the computations from lessons 0.5.2 and 0.5.3 on SINT, shows how to read a query plan and ends with a practical rule for choosing between Polars and pandas.
- Module
- 0.5 · Numerical computing and data
- Duration
- 45 min
- Author
- Jesús Cuesta
- Updated
Level 0 lessons
0.1 · Welcome and expectations
0.2 · Modern working environment
- 0.2.1 Installing Python with uv: versions and support cycle
- 0.2.2 Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles
- 0.2.3 Editors (VS Code, Cursor, PyCharm) and notebooks (Jupyter, JupyterLab, marimo)
- 0.2.4 Basic terminal, paths, environment variables and .env files
- 0.2.5 Git and GitHub: commits, branches, pull requests and .gitignore
- 0.2.6 Introduction to AI coding assistants: prompts, review and tests
0.3 · Language fundamentals
- 0.3.1 Variables, types and operators; floating point and decimal for prices and ticks
- 0.3.2 Strings, f-strings and formatting numbers and dates
- 0.3.3 Lists, tuples, dictionaries, sets and comprehensions
- 0.3.4 Control flow: if, elif, else, for, while and match-case
- 0.3.5 Functions: arguments, *args and **kwargs, lambdas and scope
- 0.3.6 Exceptions; modules and packages, with Python 3.15 lazy imports
0.4 · Intermediate Python
- 0.4.1 Object-oriented programming: classes, inheritance, composition and dataclasses
- 0.4.2 Typing: type hints, mypy or ty, and Pydantic for configurations
- 0.4.3 Iterators, generators, decorators and context managers
- 0.4.4 Files (CSV, JSON, YAML, TOML) and dates with time zones
- 0.4.5 Professional logging and testing with pytest
- 0.4.6 Concurrency: threading, multiprocessing, asyncio and free-threading
0.5 · Numerical computing and data
- 0.5.1 NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators
- 0.5.2 pandas 3.0: Series, DataFrame, time indexes, Copy-on-Write, the str type and pd.col()
- 0.5.3 Selection, filtering, groupby, merge, pivot, resample and moving windows
- 0.5.4 Polars: lazy versus eager API, expressions, performance and when to pick it over pandas
- 0.5.5 DuckDB and essential SQL: SELECT, JOIN, GROUP BY and window functions on Parquet
0.6 · Visualisation
0.7 · Minimum mathematics and statistics
Another way to work with tables
pandas was born in 2008 on top of NumPy and keeps decisions from that time: an index on every table, operations that run one by one in the order they are written and, for the most part, a single thread. Polars, written in Rust and using the Apache Arrow in-memory format, starts from different ideas:
| pandas | Polars | |
|---|---|---|
| Index | Yes (DatetimeIndex, alignment) | No: dates are a column |
| Way of writing | Methods on Series and DataFrame | Expressions inside contexts |
| Execution | Step by step (eager) | Eager or lazy (optimized query) |
| Parallelism | Mostly a single thread | Multithreaded by default |
| Missing values | NaN (and NA in some types) | null in all types |
The course uses Polars 2.0, released in October 20261Note 1 · Polars 2.0.0 on PyPI (pypi.org/project/polars/2.0.0), released on October 6, 2026; accessed on October 21,
2026. (version pinned in curso-code/uv.lock). The functions that
appear in this lesson already existed in the 1.x series: what you learn here also applies to somewhat older code.
import polars as pl
df = pl.read_csv("sint_diario.csv", try_parse_dates=True)
print(pl.__version__, df.shape)
print(df.schema)
df.select("fecha", "cierre", "volumen").head(3)2.0.0 (1303, 6)
Schema({'fecha': Date, 'apertura': Float64, 'maximo': Float64, 'minimo': Float64, 'cierre': Float64, 'volumen': Int64})
shape: (3, 3)
┌────────────┬────────┬─────────┐
│ fecha ┆ cierre ┆ volumen │
│ --- ┆ --- ┆ --- │
│ date ┆ f64 ┆ i64 │
╞════════════╪════════╪═════════╡
│ 2021-01-04 ┆ 99.14 ┆ 2322609 │
│ 2021-01-05 ┆ 99.25 ┆ 1517954 │
│ 2021-01-06 ┆ 97.38 ┆ 3245658 │
└────────────┴────────┴─────────┘try_parse_dates=True recognizes the date column, which becomes of type Date (a day with no time). There is no
index: the table has six columns, and fecha is one of them. Rows are selected by position or with filters, never by
label.
Expressions and contexts
The central piece of Polars is the expression: a description of a computation on columns that has not run yet.
pl.col("cierre") is "the cierre column", and pl.col("cierre").pct_change() is "the change of the cierre column
relative to the previous row". An expression is evaluated in a context:
| Context | What it does | pandas equivalent |
|---|---|---|
select(...) | Returns only the columns of the expressions | df[[...]], agg |
with_columns(...) | Adds or replaces columns | assign, pd.col() |
filter(...) | Keeps the rows where the expression is true | bool mask |
group_by(...).agg(...) | Evaluates the expressions within each group | groupby().agg |
r = pl.col("cierre").pct_change()
print(r)
barras = df.with_columns(
r=r,
rango=(pl.col("maximo") - pl.col("minimo")) / pl.col("cierre"),
)
barras.select("fecha", "cierre", "r", "rango").head(3)col("cierre").pct_change([dyn int: 1])
shape: (3, 4)
┌────────────┬────────┬───────────┬──────────┐
│ fecha ┆ cierre ┆ r ┆ rango │
│ --- ┆ --- ┆ --- ┆ --- │
│ date ┆ f64 ┆ f64 ┆ f64 │
╞════════════╪════════╪═══════════╪══════════╡
│ 2021-01-04 ┆ 99.14 ┆ null ┆ 0.015836 │
│ 2021-01-05 ┆ 99.25 ┆ 0.00111 ┆ 0.011688 │
│ 2021-01-06 ┆ 97.38 ┆ -0.018841 ┆ 0.019203 │
└────────────┴────────┴───────────┴──────────┘Printed, r is only the description of the computation. It can be stored in a variable and reused in several
queries: it is the same idea as pandas 3.0's pd.col() (lesson 0.5.2). The first return comes out null, the Polars
missing value, which exists in the same way in columns of numbers, text and dates.
Inside a context, expressions are evaluated in parallel and each one produces a column. That is why each result
needs its own name: two expressions on r without alias would raise a duplicate name error.
caidas = barras.filter(
(pl.col("r") < -0.03)
& (pl.col("volumen") > pl.col("volumen").median())
)
print(caidas.height, "sharp drops on high volume")
barras.select(
media=pl.col("r").mean(),
vol_anual=pl.col("r").std() * 252 ** 0.5,
sesiones=pl.col("r").count(),
)10 sharp drops on high volume
shape: (1, 3)
┌──────────┬───────────┬──────────┐
│ media ┆ vol_anual ┆ sesiones │
│ --- ┆ --- ┆ --- │
│ f64 ┆ f64 ┆ u32 │
╞══════════╪═══════════╪══════════╡
│ 0.000342 ┆ 0.180962 ┆ 1302 │
└──────────┴───────────┴──────────┘The ten sessions are the same as in lesson 0.5.3, and the volume median is computed inside the filter without
auxiliary variables. count() does not count null values: 1,302 returns from 1,303 closes.
group_by and windows
group_by works as in pandas, with the key also written as an expression. The result of a group_by does not keep the
order of the groups (they are computed in parallel): for a readable table, sort it with sort or ask for
maintain_order=True.
por_ano = (
barras.group_by(pl.col("fecha").dt.year().alias("ano"))
.agg(
n=pl.col("r").count(),
anual=(1 + pl.col("r")).product() - 1,
vol=pl.col("r").std() * 252 ** 0.5,
)
.sort("ano")
)
por_ano.with_columns(pl.col("anual", "vol").round(4))shape: (5, 4)
┌──────┬─────┬────────┬────────┐
│ ano ┆ n ┆ anual ┆ vol │
│ --- ┆ --- ┆ --- ┆ --- │
│ i32 ┆ u32 ┆ f64 ┆ f64 │
╞══════╪═════╪════════╪════════╡
│ 2021 ┆ 259 ┆ 0.152 ┆ 0.1599 │
│ 2022 ┆ 260 ┆ -0.085 ┆ 0.2483 │
│ 2023 ┆ 260 ┆ 0.1421 ┆ 0.1495 │
│ 2024 ┆ 262 ┆ 0.1432 ┆ 0.1499 │
│ 2025 ┆ 261 ┆ 0.0424 ┆ 0.1792 │
└──────┴─────┴────────┴────────┘The figures match the pandas ones in lesson 0.5.3: −8.5 % in 2022 with a volatility of 24.8 %. pl.col accepts
several names at once, and the expression is applied to each column.
Rolling and cumulative windows are methods of expressions. The 21-session volatility and the drawdown, in a single context:
riesgo = barras.with_columns(
vol_21=pl.col("r").rolling_std(21) * 252 ** 0.5,
dd=pl.col("cierre") / pl.col("cierre").cum_max() - 1,
)
riesgo.select(
vol_max=pl.col("vol_21").max(),
sin_ventana=pl.col("vol_21").null_count(),
peor_dd=pl.col("dd").min(),
fecha_fondo=pl.col("fecha").get(pl.col("dd").arg_min()),
)shape: (1, 4)
┌──────────┬─────────────┬───────────┬─────────────┐
│ vol_max ┆ sin_ventana ┆ peor_dd ┆ fecha_fondo │
│ --- ┆ --- ┆ --- ┆ --- │
│ f64 ┆ u32 ┆ f64 ┆ date │
╞══════════╪═════════════╪═══════════╪═════════════╡
│ 0.339454 ┆ 21 ┆ -0.250278 ┆ 2022-10-20 │
└──────────┴─────────────┴───────────┴─────────────┘Maximum volatility of 33.9 %, 21 values without a full window and a 25 % drawdown with its trough on October 20, 2022: the same numbers as in pandas. Polars has no index, so "the date of the minimum" is written as an expression: the date at the position of the drawdown minimum.
Per-group computations that keep all rows use over, the equivalent of SQL window functions (lesson 0.5.5). In
the trade log, the weight of each trade in the result of its symbol:
ops = pl.read_csv("operaciones_sint.csv", try_parse_dates=True)
signo = pl.when(pl.col("lado") == "largo").then(1).otherwise(-1)
pnl = (signo * (pl.col("precio_salida") - pl.col("precio_entrada"))
* pl.col("cantidad") - pl.col("comision"))
ops = ops.with_columns(pnl=pnl).with_columns(
n_simbolo=pl.len().over("simbolo"),
total_simbolo=pl.col("pnl").sum().over("simbolo"),
)
ops.select("id", "simbolo", "pnl", "n_simbolo", "total_simbolo").head(4)shape: (4, 5)
┌─────┬─────────┬──────────┬───────────┬───────────────┐
│ id ┆ simbolo ┆ pnl ┆ n_simbolo ┆ total_simbolo │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ i64 ┆ str ┆ f64 ┆ u32 ┆ f64 │
╞═════╪═════════╪══════════╪═══════════╪═══════════════╡
│ 1 ┆ ORO ┆ -3123.56 ┆ 3 ┆ -3237.96 │
│ 2 ┆ SINT ┆ 7.43 ┆ 30 ┆ 4405.08 │
│ 3 ┆ SINT ┆ -31.5 ┆ 30 ┆ 4405.08 │
│ 4 ┆ SINT ┆ -306.72 ┆ 30 ┆ 4405.08 │
└─────┴─────────┴──────────┴───────────┴───────────────┘pl.when(...).then(...).otherwise(...) is the np.where of expressions. With over("simbolo"), the sum is computed
within each symbol and repeated on all its rows: trade 1, on ORO, sees the total of the three ORO trades. With
group_by there would be one row per symbol; with over, all 40 are kept.
Lazy versus eager
Everything above is eager: each with_columns or filter runs immediately and returns a DataFrame. The
lazy API changes the order: scan_parquet (or scan_csv, or df.lazy()) returns a LazyFrame, which only
accumulates the query. Nothing is read or computed until collect(), and before running, the optimizer rewrites the
whole plan:
Mermaid source
flowchart LR A[scan_parquet] --> B[filter] B --> C[with_columns] C --> D[collect]
consulta = (
pl.scan_parquet("sint_diario.parquet")
.filter(pl.col("fecha").dt.year() == 2022)
.with_columns(r=pl.col("cierre").pct_change())
.select("fecha", "r")
)
print(type(consulta).__name__)
print(consulta.explain())LazyFrame
simple π 2/2 ["fecha", "r"]
WITH_COLUMNS:
[col("cierre").pct_change([dyn int: 1]).alias("r")]
Parquet SCAN [sint_diario.parquet]
PROJECT 2/6 COLUMNS
SELECTION: (col("fecha").dt.year() == 2022)
ESTIMATED ROWS: 1303The plan is read from bottom to top. The optimizer has done two things that are not possible in eager mode:
- Projection pushdown (
PROJECT 2/6 COLUMNS): the query only usesfechaandcierre, so the Parquet reader does not load the other four columns from disk. - Predicate pushdown (
SELECTION): the year condition is applied while the file is read, and Parquet can skip whole blocks that do not meet the condition thanks to the statistics it stores for each one.
With a 50 KB file you will not notice. With years of one-minute bars for hundreds of symbols, the difference is between reading a few hundred megabytes and reading tens of gigabytes. This plan, however, has a problem that is not about performance:
antes = consulta.collect()
despues = (
pl.scan_parquet("sint_diario.parquet")
.with_columns(r=pl.col("cierre").pct_change())
.filter(pl.col("fecha").dt.year() == 2022)
.select("fecha", "r")
.collect()
)
print(antes.head(2))
print(despues.head(2))shape: (2, 2)
┌─────────────────────┬───────────┐
│ fecha ┆ r │
│ --- ┆ --- │
│ datetime[μs] ┆ f64 │
╞═════════════════════╪═══════════╡
│ 2022-01-03 00:00:00 ┆ null │
│ 2022-01-04 00:00:00 ┆ -0.017069 │
└─────────────────────┴───────────┘
shape: (2, 2)
┌─────────────────────┬───────────┐
│ fecha ┆ r │
│ --- ┆ --- │
│ datetime[μs] ┆ f64 │
╞═════════════════════╪═══════════╡
│ 2022-01-03 00:00:00 ┆ 0.000263 │
│ 2022-01-04 00:00:00 ┆ -0.017069 │
└─────────────────────┴───────────┘In the first query, the filter is applied before pct_change: January 3, 2022 no longer has the December 31 close
and its return comes out null. In the second, the return is computed with the whole series and filtered afterwards:
January 3 has its 0.03 %. The optimizer does not change the order in this case, because pct_change depends on the
previous row and the result would be different; writing the correct order is the responsibility of whoever writes the
query. The mistake in the opposite direction is more serious: any computation that looks backwards (shift, windows,
cumulative maxima) must be done with the full history before trimming the period of a backtest.
When should you use each mode? Eager is convenient for exploring in a notebook: each step shows its result. Lazy pays
off as soon as the data comes from large files or the query has several steps, and it is the natural mode for scripts
that process data periodically (the daily bar download, the nightly signal recalculation). A lazy query ends in
collect(), which returns a DataFrame, or in sink_parquet("salida.parquet"), which writes the result to disk
without gathering it all in memory. And df.lazy() turns an already loaded DataFrame into a LazyFrame, to take
advantage of the optimizer in a long chain of operations.
fecha appears as datetime[μs] and not as Date: the data's Parquet file was saved by pandas, which writes dates
with a time. Reading the same data from two sources and comparing the types is a check that prevents joins that do not
match.
Performance without a stopwatch
This lesson gives no timings: they depend on the machine, the version and the data size, and those of a 1,303-row file say nothing. What can be stated is why Polars is usually faster with large data, and when it stops being so:
- Expressions run in Rust, on several threads and on contiguous Arrow columns, without a Python object per value.
- In lazy mode, the optimizer removes unnecessary columns and rows before reading them and reuses common subexpressions.
- With the streaming engine, a lazy query processes files larger than memory in chunks.
- A Python function inside an expression (
map_elements) hands the computation back to Python row by row and cancels almost all the advantages. If an operation does not exist as an expression, there is usually a combination of expressions that does it.
Polars and pandas together
Most projects use both. Conversions are direct, with two details: pandas needs an index and date types change resolution.
pdf = df.to_pandas().set_index("fecha")
print(type(pdf).__name__, pdf.index.dtype, pdf.shape)
de_vuelta = pl.from_pandas(pdf, include_index=True)
print(de_vuelta.schema["fecha"], de_vuelta.shape)DataFrame datetime64[ms] (1303, 5)
Datetime(time_unit='ms', time_zone=None) (1303, 6)The Polars Date reaches pandas as datetime64[ms] and comes back as Datetime, not as Date. If the rest of the
code compares dates across tables, it is worth normalizing the type in a single place (for example, with
pl.col("fecha").cast(pl.Date)). The conversion uses Arrow under the hood and, for numeric types, it is fast; it is not
free with large tables, so it is done once, at the boundary between the two parts of the code.
| Situation | Choice | Why |
|---|---|---|
| Large Parquet files, filters and aggregations | Polars lazy | Projection and predicate pushdown, multithreaded |
| Intraday bars for many symbols by group | Polars | group_by and over in parallel |
Indexed time series, resample, alignment | pandas | The index does the work |
| statsmodels, scikit-learn, backtesting libraries | pandas (or NumPy) | It is what they expect |
| Tables of hundreds or thousands of rows | Whichever the project already uses | The difference is not noticeable |
Lesson 0.5.5 adds a third option for the same queries: SQL on Parquet with DuckDB.
Exercises
- With
group_by_dynamic("fecha", every="1mo"), compute the last close of each month for SINT and its monthly return. Compare the label of each month with that of pandasresample("ME")(lesson 0.5.3). What risk does that label carry?
Solution
mensual = (
df.group_by_dynamic("fecha", every="1mo")
.agg(pl.col("cierre").last())
.with_columns(r=pl.col("cierre").pct_change())
)
print(mensual.height)
mensual.head(3)60
shape: (3, 3)
┌────────────┬────────┬──────────┐
│ fecha ┆ cierre ┆ r │
│ --- ┆ --- ┆ --- │
│ date ┆ f64 ┆ f64 │
╞════════════╪════════╪══════════╡
│ 2021-01-01 ┆ 95.99 ┆ null │
│ 2021-02-01 ┆ 97.65 ┆ 0.017293 │
│ 2021-03-01 ┆ 103.27 ┆ 0.057552 │
└────────────┴────────┴──────────┘The values match the pandas ones (1.73 % in February 2021), but the label is the start of the month
(2021-02-01) and the value is the close at the end. If that table is joined by date with daily data, February 1
would "know" the February 26 close: look-ahead bias. The safe option is to shift the label to the end of the period or
to use the result only from the following month on.
- Write a lazy query on
sint_diario.parquetthat returns the annual return of each year (compounded from the daily returns, computed with the whole series) and check withexplain()how many columns are read from the file.
Solution
anual = (
pl.scan_parquet("sint_diario.parquet")
.with_columns(r=pl.col("cierre").pct_change())
.group_by(pl.col("fecha").dt.year().alias("ano"))
.agg(anual=(1 + pl.col("r")).product() - 1)
.sort("ano")
)
print("PROJECT 2/6" in anual.explain())
anual.collect().with_columns(pl.col("anual").round(4))True
shape: (5, 2)
┌──────┬────────┐
│ ano ┆ anual │
│ --- ┆ --- │
│ i32 ┆ f64 │
╞══════╪════════╡
│ 2021 ┆ 0.152 │
│ 2022 ┆ -0.085 │
│ 2023 ┆ 0.1421 │
│ 2024 ┆ 0.1432 │
│ 2025 ┆ 0.0424 │
└──────┴────────┘Only fecha and cierre are read. product() ignores the null in the first row, so 2021 compounds its 259
returns. The figures are the same as with pandas and as in the eager group_by.
- With
ops(which already haspnl), compute for each trade the difference between its result and the mean of the trades on the same symbol, without losing rows. Show the threeTECHtrades.
Solution
(
ops.with_columns(
frente_media=pl.col("pnl")
- pl.col("pnl").mean().over("simbolo"),
)
.filter(pl.col("simbolo") == "TECH")
.select("id", "pnl", "frente_media")
)shape: (3, 3)
┌─────┬─────────┬──────────────┐
│ id ┆ pnl ┆ frente_media │
│ --- ┆ --- ┆ --- │
│ i64 ┆ f64 ┆ f64 │
╞═════╪═════════╪══════════════╡
│ 10 ┆ 239.14 ┆ 250.813333 │
│ 18 ┆ -47.0 ┆ -35.326667 │
│ 38 ┆ -227.16 ┆ -215.486667 │
└─────┴─────────┴──────────────┘over computes the mean within each symbol and repeats it on its rows; the subtraction is done row by row. It is the
pattern of any per-group normalization (return versus the sector mean, volume versus the symbol mean).
Footnotes
-
Polars 2.0.0 on PyPI (pypi.org/project/polars/2.0.0), released on October 6, 2026; accessed on October 21, 2026. ↩
Each answer is folded under its question
Self-assessment
What does
pl.col("cierre").pct_change()return when evaluated on its own, outside aDataFrame?- AA
Serieswith the SINT returns - BAn error, because no file has been read
- CA number: the mean return
- DAn expression: the description of a computation that will run when used inside
select,with_columnsorfilter
Show answer
Answer D. An expression: the description of a computation that will run when used inside
select,with_columnsorfilterPolars expressions hold no data. They can be stored in variables and combined, and they are only evaluated in a context (
select,with_columns,filter,group_by().agg).- AA
In a lazy query, what does
PROJECT 2/6 COLUMNSinsideParquet SCANmean in the output ofexplain()?- AThat the file has two corrupted columns
- BThat only the 2 columns the query needs, out of 6, will be read from the file
- CThat the query will return 2 rows out of every 6
- DThat 2 threads out of 6 will be used
Show answer
Answer B. That only the 2 columns the query needs, out of 6, will be read from the file
It is the projection pushed down to the reader (*projection pushdown*): Polars analyzes the whole query and does not read from disk the columns that are not used. With large files, that is most of the savings.
You want the daily return of SINT in 2022. Why do
filter 2022 → pct_changeandpct_change → filter 2022give different results?- AThey do not give different results: the optimizer makes them equal
- BBecause the second one reads the file twice
- CBecause in the first one, the return of the first day of 2022 lacks the close of December 31, 2021 and comes out null
- DBecause
pct_changedoes not work in lazy mode
Show answer
Answer C. Because in the first one, the return of the first day of 2022 lacks the close of December 31, 2021 and comes out null
pct_changedepends on the previous row. If the filter goes first, the row before January 3, 2022 is gone. The optimizer keeps the order when changing it would change the result.In which of these cases does Polars make more sense than pandas?
- AAggregating one-minute bars for hundreds of symbols stored in dozens of Parquet files
- BA notebook that uses statsmodels and a backtesting library that expects a pandas
DataFramewith aDatetimeIndex - CA table of 40 trades
- DAny case: Polars is always better
Show answer
Answer A. Aggregating one-minute bars for hundreds of symbols stored in dozens of Parquet files
Polars wins with large data and queries that filter and aggregate, especially in lazy mode and on Parquet. With small tables or code that depends on the pandas ecosystem, the conversion and the API change do not pay off.
Key points
- Polars has no index: dates are just another column. Computations are written as expressions (
pl.col(...)) insideselect,with_columns,filterandgroup_by().agg. - Eager (
read_*) runs each step immediately; lazy (scan_*) accumulates the query and runs it whole withcollect(), after optimizing it. explain()shows the plan: the filter and the columns pushed down to the Parquet reader are what saves the most on large files.- The order of operations matters with computations that look at the previous row (
pct_change,shift, windows): filtering first can break them. - Polars for large data and aggregation queries; pandas when the rest of the code expects it.
to_pandas()andpl.from_pandas()connect the two.