---
title: "0.5.4 Polars: API lazy, expresiones y rendimiento"
description: "Polars con los datos de SINT: expresiones, with_columns, group_by y ventanas; API lazy con scan_parquet y explain; cuándo elegirlo frente a pandas."
url: "https://pythonparatrading.com/curso-python-trading/nivel-0/polars-lazy-y-expresiones/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-0/polars-lazy-y-expresiones.png"
---

# 0.5.4 · Polars: API lazy frente a eager, expresiones, rendimiento y cuándo elegirlo frente a pandas

> Polars es una librería de tablas escrita en Rust que describe los cálculos como expresiones y deja que un optimizador decida cómo ejecutarlos. Esta lección repite con Polars los cálculos de las lecciones 0.5.2 y 0.5.3 sobre SINT, enseña a leer un plan de consulta y termina con una regla práctica para elegir entre Polars y pandas.

- Curso: [Nivel 0: Python desde cero y mentalidad de datos](https://pythonparatrading.com/curso-python-trading/nivel-0/)
- Módulo: 0.5 · Computación numérica y datos
- Duración: 45 min
- Requisitos: [0.5.3 · Selección, filtrado, groupby, merge, pivot, resample y ventanas móviles](https://pythonparatrading.com/curso-python-trading/nivel-0/pandas-groupby-merge-resample-y-rolling/)

## Al terminar esta lección

- Leer CSV y Parquet con Polars y crear columnas derivadas con `pl.col`, `with_columns`, `select` y `filter`.
- Agregar con `group_by` y calcular ventanas móviles y valores por grupo con `rolling_std`, `cum_max` y `over`.
- Construir una consulta lazy con `scan_parquet`, leer su plan con `explain()` y reconocer el filtro y la proyección empujados al fichero.
- Convertir tablas entre Polars y pandas y decidir cuál usar según el tamaño de los datos y el resto del código.

## Otra forma de trabajar con tablas

pandas nació en 2008 sobre NumPy y conserva decisiones de entonces: un índice en cada tabla, operaciones que se
ejecutan una a una en el orden en que se escriben y, en buena parte, un solo hilo. Polars, escrito en Rust y con el
formato en memoria de Apache Arrow, parte de otras ideas:

| | pandas | Polars |
|---|---|---|
| Índice | Sí (`DatetimeIndex`, alineación) | No: las fechas son una columna |
| Forma de escribir | Métodos sobre `Series` y `DataFrame` | Expresiones dentro de contextos |
| Ejecución | Paso a paso (*eager*) | *Eager* o *lazy* (consulta optimizada) |
| Paralelismo | Mayoritariamente un hilo | Multihilo por defecto |
| Ausentes | `NaN` (y `NA` en algunos tipos) | `null` en todos los tipos |

El curso usa Polars 2.0, publicado en octubre de 2026[^1] (versión fijada en `curso-code/uv.lock`). Las funciones que
aparecen en esta lección ya existían en la serie 1.x: lo aprendido aquí sirve también para código algo más antiguo.

```python
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)
```
```output
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` reconoce la columna de fechas, que pasa a ser de tipo `Date` (un día sin hora). No hay índice:
la tabla tiene seis columnas, y `fecha` es una de ellas. Las filas se seleccionan por posición o con filtros, nunca por
etiqueta.

## Expresiones y contextos

La pieza central de Polars es la **expresión**: una descripción de un cálculo sobre columnas que todavía no se ha
ejecutado. `pl.col("cierre")` es «la columna cierre», y `pl.col("cierre").pct_change()` es «la variación de la columna
cierre respecto a la fila anterior». Una expresión se evalúa en un **contexto**:

| Contexto | Qué hace | Equivalente en pandas |
|---|---|---|
| `select(...)` | Devuelve solo las columnas de las expresiones | `df[[...]]`, `agg` |
| `with_columns(...)` | Añade o sustituye columnas | `assign`, `pd.col()` |
| `filter(...)` | Conserva las filas en que la expresión es verdadera | máscara de `bool` |
| `group_by(...).agg(...)` | Evalúa las expresiones dentro de cada grupo | `groupby().agg` |

```python
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)
```
```output
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 │
└────────────┴────────┴───────────┴──────────┘
```

Impresa, `r` es solo la descripción del cálculo. Se puede guardar en una variable y reutilizar en varias consultas:
es la misma idea que `pd.col()` de pandas 3.0 (lección 0.5.2). La primera rentabilidad sale
`null`, el valor ausente de Polars, que existe igual en columnas de números, texto y fechas.

Dentro de un contexto, las expresiones se evalúan **en paralelo** y cada una produce una columna. Por eso cada
resultado necesita un nombre propio: dos expresiones sobre `r` sin `alias` darían un error de nombre duplicado.

```python
caidas = barras.filter(
    (pl.col("r") < -0.03)
    & (pl.col("volumen") > pl.col("volumen").median())
)
print(caidas.height, "caídas fuertes con volumen alto")

barras.select(
    media=pl.col("r").mean(),
    vol_anual=pl.col("r").std() * 252 ** 0.5,
    sesiones=pl.col("r").count(),
)
```
```output
10 caídas fuertes con volumen alto
shape: (1, 3)
┌──────────┬───────────┬──────────┐
│ media    ┆ vol_anual ┆ sesiones │
│ ---      ┆ ---       ┆ ---      │
│ f64      ┆ f64       ┆ u32      │
╞══════════╪═══════════╪══════════╡
│ 0.000342 ┆ 0.180962  ┆ 1302     │
└──────────┴───────────┴──────────┘
```

Las diez sesiones son las mismas que en la lección 0.5.3, y la mediana del volumen se calcula dentro del filtro sin
variables auxiliares. `count()` no cuenta los `null`: 1.302 rentabilidades de 1.303 cierres.

## group_by y ventanas

`group_by` funciona como en pandas, con la clave escrita también como expresión. El resultado de un `group_by` no
conserva el orden de los grupos (se calculan en paralelo): para una tabla legible se ordena con `sort` o se pide
`maintain_order=True`.

```python
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))
```
```output
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 │
└──────┴─────┴────────┴────────┘
```

Las cifras coinciden con las de pandas en la lección 0.5.3: −8,5 % en 2022 con una volatilidad del 24,8 %. `pl.col`
admite varios nombres a la vez, y la expresión se aplica a cada columna.

Las ventanas móviles y acumuladas son métodos de las expresiones. La volatilidad de 21 sesiones y el drawdown, en un
solo contexto:

```python
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()),
)
```
```output
shape: (1, 4)
┌──────────┬─────────────┬───────────┬─────────────┐
│ vol_max  ┆ sin_ventana ┆ peor_dd   ┆ fecha_fondo │
│ ---      ┆ ---         ┆ ---       ┆ ---         │
│ f64      ┆ u32         ┆ f64       ┆ date        │
╞══════════╪═════════════╪═══════════╪═════════════╡
│ 0.339454 ┆ 21          ┆ -0.250278 ┆ 2022-10-20  │
└──────────┴─────────────┴───────────┴─────────────┘
```

Volatilidad máxima del 33,9 %, 21 valores sin ventana completa y un drawdown del 25 % con fondo el 20 de octubre de
2022: los mismos números que en pandas. Polars no tiene índice, así que «la fecha del mínimo» se escribe como una
expresión: la fecha en la posición del mínimo del drawdown.

Los cálculos **por grupo que conservan todas las filas** usan `over`, el equivalente de las funciones de ventana de SQL
(lección 0.5.5). En el libro de operaciones, el peso de cada operación en el resultado de su símbolo:

```python
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)
```
```output
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(...)` es el `np.where` de las expresiones. Con `over("simbolo")`, la suma se calcula
dentro de cada símbolo y se repite en todas sus filas: la operación 1, de `ORO`, ve el total de las tres operaciones
de `ORO`. Con `group_by` habría una fila por símbolo; con `over`, se conservan las 40.

## Lazy frente a eager

Todo lo anterior es **eager**: cada `with_columns` o `filter` se ejecuta al momento y devuelve un `DataFrame`. La API
**lazy** cambia el orden: `scan_parquet` (o `scan_csv`, o `df.lazy()`) devuelve un `LazyFrame`, que solo acumula la
consulta. Nada se lee ni se calcula hasta `collect()`, y antes de ejecutar, el optimizador reescribe el plan completo:

```mermaid
flowchart LR
  A[scan_parquet] --> B[filter]
  B --> C[with_columns]
  C --> D[collect]
```

```python
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())
```
```output
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: 1303
```

El plan se lee de abajo arriba. El optimizador ha hecho dos cosas que en eager no son posibles:

- **Proyección empujada** (`PROJECT 2/6 COLUMNS`): la consulta solo usa `fecha` y `cierre`, así que el lector de
  Parquet no carga las otras cuatro columnas del disco.
- **Filtro empujado** (`SELECTION`): la condición del año se aplica mientras se lee el fichero, y Parquet puede saltarse
  bloques enteros que no cumplen la condición gracias a las estadísticas que guarda de cada uno.

Con un fichero de 50 KB no se nota. Con años de barras de un minuto de cientos de símbolos, la diferencia está entre
leer unos cientos de megas y leer decenas de gigas. Este plan, sin embargo, tiene un problema que no es de rendimiento:

```python
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))
```
```output
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 │
└─────────────────────┴───────────┘
```

En la primera consulta, el filtro se aplica antes de `pct_change`: el 3 de enero de 2022 ya no tiene el cierre del 31
de diciembre y su rentabilidad sale `null`. En la segunda, la rentabilidad se calcula con toda la serie y después se
filtra: el 3 de enero tiene su 0,03 %. El optimizador no cambia el orden en este caso, porque `pct_change` depende de
la fila anterior y el resultado sería otro; la responsabilidad de escribir el orden correcto es de quien escribe la
consulta. El error, en sentido contrario, es más grave: cualquier cálculo que mire hacia atrás (`shift`, ventanas,
máximos acumulados) debe hacerse con la historia completa antes de recortar el periodo de un backtest.

¿Cuándo usar cada modo? Eager es cómodo para explorar en un notebook: cada paso muestra su resultado. Lazy conviene
en cuanto los datos vienen de ficheros grandes o la consulta tiene varios pasos, y es el modo natural de los scripts que
procesan datos de forma periódica (la descarga diaria de barras, el recálculo nocturno de señales). Una consulta lazy
termina en `collect()`, que devuelve un `DataFrame`, o en `sink_parquet("salida.parquet")`, que escribe el resultado
en disco sin reunirlo entero en memoria. Y `df.lazy()` convierte un `DataFrame` ya cargado en un `LazyFrame`, para
aprovechar el optimizador en una cadena larga de operaciones.

`fecha` aparece como `datetime[μs]` y no como `Date`: el Parquet de los datos lo guardó pandas, que escribe las fechas
con hora. Leer el mismo dato de dos fuentes y comparar los tipos es un control que evita uniones que no casan.

## Rendimiento sin cronómetro

Esta lección no da tiempos: dependen de la máquina, de la versión y del tamaño de los datos, y los de un fichero de
1.303 filas no dicen nada. Lo que sí se puede afirmar es **por qué** Polars suele ser más rápido con datos grandes, y
cuándo deja de serlo:

- Las expresiones se ejecutan en Rust, en varios hilos y sobre columnas contiguas de Arrow, sin un objeto de Python
  por valor.
- En lazy, el optimizador elimina columnas y filas innecesarias antes de leerlas y reutiliza subexpresiones comunes.
- Con el motor de *streaming*, una consulta lazy procesa ficheros más grandes que la memoria por bloques.
- Una función de Python dentro de una expresión (`map_elements`) devuelve el cálculo a Python fila a fila y anula casi
  todas las ventajas. Si una operación no existe como expresión, suele haber una combinación de expresiones que la hace.

<aside class="callout" data-kind="nota">

Para medir en tu máquina, compara la misma consulta en pandas y en Polars con datos del tamaño real de tu research y con
`timeit` (lección 0.5.1). Un fichero de juguete favorece al que tenga menos coste fijo, no al más rápido.

</aside>

## Polars y pandas juntos

La mayoría de los proyectos usan las dos. Las conversiones son directas, con dos detalles: pandas necesita un índice y
los tipos de fecha cambian de resolución.

```python
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)
```
```output
DataFrame datetime64[ms] (1303, 5)
Datetime(time_unit='ms', time_zone=None) (1303, 6)
```

El `Date` de Polars llega a pandas como `datetime64[ms]` y vuelve como `Datetime`, no como `Date`. Si el resto del
código compara fechas entre tablas, conviene normalizar el tipo en un único sitio (por ejemplo, con
`pl.col("fecha").cast(pl.Date)`). La conversión usa Arrow por debajo y, para tipos numéricos, es rápida; no es gratis
con tablas grandes, así que se hace una vez, en la frontera entre las dos partes del código.

| Situación | Elección | Por qué |
|---|---|---|
| Ficheros Parquet grandes, filtros y agregaciones | Polars lazy | Proyección y filtro empujados, multihilo |
| Barras intradía de muchos símbolos por grupo | Polars | `group_by` y `over` en paralelo |
| Series temporales con índice, `resample`, alineación | pandas | El índice hace el trabajo |
| statsmodels, scikit-learn, librerías de backtesting | pandas (o NumPy) | Es lo que esperan |
| Tablas de cientos o miles de filas | La que ya use el proyecto | La diferencia no se nota |

La lección 0.5.5 añade una tercera opción para las mismas consultas: SQL sobre Parquet con DuckDB.

## Ejercicios

1. Con `group_by_dynamic("fecha", every="1mo")`, calcula el último cierre de cada mes de SINT y su rentabilidad
   mensual. Compara la etiqueta de cada mes con la de `resample("ME")` de pandas (lección 0.5.3). ¿Qué riesgo tiene esa
   etiqueta?

**Solución.**

```python
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)
```
```output
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 │
└────────────┴────────┴──────────┘
```

Los valores coinciden con los de pandas (1,73 % en febrero de 2021), pero la etiqueta es el **inicio** del mes
(`2021-02-01`) y el valor es el cierre del **final**. Si esa tabla se cruza por fecha con datos diarios, el
1 de febrero «conocería» el cierre del 26 de febrero: anticipación. Lo seguro es desplazar la etiqueta al final del
periodo o usar el resultado solo a partir del mes siguiente.

2. Escribe una consulta lazy sobre `sint_diario.parquet` que devuelva la rentabilidad anual de cada año (compuesta a
   partir de las rentabilidades diarias, calculadas con toda la serie) y comprueba con `explain()` cuántas columnas
   se leen del fichero.

**Solución.**

```python
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))
```
```output
True
shape: (5, 2)
┌──────┬────────┐
│ ano  ┆ anual  │
│ ---  ┆ ---    │
│ i32  ┆ f64    │
╞══════╪════════╡
│ 2021 ┆ 0.152  │
│ 2022 ┆ -0.085 │
│ 2023 ┆ 0.1421 │
│ 2024 ┆ 0.1432 │
│ 2025 ┆ 0.0424 │
└──────┴────────┘
```

Solo se leen `fecha` y `cierre`. `product()` ignora el `null` de la primera fila, así que 2021 compone sus 259
rentabilidades. Las cifras son las mismas que con pandas y que en el `group_by` eager.

3. Con `ops` (que ya tiene `pnl`), calcula para cada operación la diferencia entre su resultado y la media de las
   operaciones de su mismo símbolo, sin perder filas. Muestra las tres operaciones de `TECH`.

**Solución.**

```python
(
    ops.with_columns(
        frente_media=pl.col("pnl")
        - pl.col("pnl").mean().over("simbolo"),
    )
    .filter(pl.col("simbolo") == "TECH")
    .select("id", "pnl", "frente_media")
)
```
```output
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` calcula la media dentro de cada símbolo y la repite en sus filas; la resta se hace fila a fila. Es el patrón de
cualquier normalización por grupo (rentabilidad frente a la media del sector, volumen frente a la media del símbolo).

[^1]: Polars 2.0.0 en PyPI (pypi.org/project/polars/2.0.0), publicado el 6 de octubre de 2026; consultado el 21 de
    octubre de 2026.

## Autoevaluación

1. ¿Qué devuelve `pl.col("cierre").pct_change()` si se evalúa sola, fuera de un `DataFrame`?

   - A. Una `Series` con las rentabilidades de SINT
   - B. Una expresión: la descripción de un cálculo que se ejecutará cuando se use dentro de `select`, `with_columns` o `filter`
   - C. Un error, porque no se ha leído ningún fichero
   - D. Un número: la rentabilidad media

   *Respuesta B: Una expresión: la descripción de un cálculo que se ejecutará cuando se use dentro de `select`, `with_columns` o `filter`.* Las expresiones de Polars no contienen datos. Se pueden guardar en variables y combinar, y solo se evalúan en un contexto (`select`, `with_columns`, `filter`, `group_by().agg`).

2. En una consulta lazy, ¿qué significa `PROJECT 2/6 COLUMNS` dentro de `Parquet SCAN` en la salida de `explain()`?

   - A. Que el fichero tiene dos columnas dañadas
   - B. Que solo se leerán del fichero las 2 columnas que la consulta necesita, de 6
   - C. Que la consulta devolverá 2 filas de cada 6
   - D. Que se usarán 2 hilos de 6

   *Respuesta B: Que solo se leerán del fichero las 2 columnas que la consulta necesita, de 6.* Es la proyección empujada al lector (*projection pushdown*): Polars analiza la consulta completa y no lee del disco las columnas que no se usan. Con ficheros grandes, es la mayor parte del ahorro.

3. Se quiere la rentabilidad diaria de SINT en 2022. ¿Por qué `filtrar 2022 → pct_change` y `pct_change → filtrar 2022` dan resultados distintos?

   - A. No dan resultados distintos: el optimizador los iguala
   - B. Porque en la primera, la rentabilidad del primer día de 2022 no tiene el cierre del 31 de diciembre de 2021 y sale nula
   - C. Porque la segunda lee el fichero dos veces
   - D. Porque `pct_change` no funciona en modo lazy

   *Respuesta B: Porque en la primera, la rentabilidad del primer día de 2022 no tiene el cierre del 31 de diciembre de 2021 y sale nula.* `pct_change` depende de la fila anterior. Si el filtro va antes, la fila anterior al 3 de enero de 2022 ha desaparecido. El optimizador respeta el orden cuando cambiar el orden cambiaría el resultado.

4. ¿En cuál de estos casos tiene más sentido Polars que pandas?

   - A. Un notebook que usa statsmodels y una librería de backtesting que espera un `DataFrame` de pandas con `DatetimeIndex`
   - B. Agregar barras de un minuto de cientos de símbolos guardadas en decenas de ficheros Parquet
   - C. Una tabla de 40 operaciones
   - D. Cualquier caso: Polars siempre es mejor

   *Respuesta B: Agregar barras de un minuto de cientos de símbolos guardadas en decenas de ficheros Parquet.* Polars gana con datos grandes y consultas que filtran y agregan, sobre todo en lazy y sobre Parquet. Con tablas pequeñas o código que depende del ecosistema de pandas, la conversión y el cambio de API no compensan.

## Ideas clave

- Polars no tiene índice: las fechas son una columna más. Los cálculos se escriben como expresiones (`pl.col(...)`) dentro de `select`, `with_columns`, `filter` y `group_by().agg`.
- Eager (`read_*`) ejecuta cada paso al momento; lazy (`scan_*`) acumula la consulta y la ejecuta entera con `collect()`, después de optimizarla.
- `explain()` muestra el plan: el filtro y las columnas que se empujan al lector de Parquet son lo que más ahorra en ficheros grandes.
- El orden de las operaciones importa con cálculos que miran a la fila anterior (`pct_change`, `shift`, ventanas): filtrar antes puede romperlos.
- Polars para datos grandes y consultas de agregación; pandas cuando el resto del código lo espera. `to_pandas()` y `pl.from_pandas()` conectan los dos.

## Recursos

### Research

- [Big Data para trading con ArcticDB](https://pythonparatrading.com/big-data-para-trading/) · Research-0019 · 16 min

### Documentación

- [Polars: guía de usuario](https://docs.pola.rs/user-guide/getting-started/) · docs.pola.rs
- [Polars: expresiones y contextos](https://docs.pola.rs/user-guide/concepts/expressions-and-contexts/) · docs.pola.rs
- [Polars: API lazy](https://docs.pola.rs/user-guide/concepts/lazy-api/) · docs.pola.rs
- [Polars: migrar desde pandas](https://docs.pola.rs/user-guide/migration/pandas/) · docs.pola.rs

### Datos

- [Datos del nivel 0: barras diarias de SINT (Parquet)](https://pythonparatrading.com/curso/datos/nivel-0/sint_diario.parquet) · Descarga · sint_diario.parquet

Lección anterior: [0.5.3 · Selección, filtrado, groupby, merge, pivot, resample y ventanas móviles](https://pythonparatrading.com/curso-python-trading/nivel-0/pandas-groupby-merge-resample-y-rolling/)

Lección siguiente: [0.5.5 · DuckDB y SQL esencial: SELECT, JOIN, GROUP BY y funciones de ventana sobre Parquet](https://pythonparatrading.com/curso-python-trading/nivel-0/duckdb-y-sql-sobre-parquet/)
