Procesamiento de datos con Rust y Polars
- Qué hace diferente a Rust
- Variables y mutabilidad
- Tipos de datos básicos
- Cadenas: Dos tipos
- Funciones
- Flujo de control
- Propiedad (Ownership): La gran idea
- Structs: Tipos de datos personalizados
- Enums y coincidencia de patrones (Pattern Matching)
- Option y Result: Sin nulos, sin errores silenciosos
- El operador ?: Abreviatura para manejo de errores
- Colecciones comunes
- Cargo: Herramienta de compilación y gestor de paquetes de Rust
- A tener en cuenta:
- Qué es Polars
- Los dos tipos principales
- Todo devuelve un Result
- Lectura y escritura de datos
- Esquemas: El contrato
- Selección y filtrado
- Joins: Combinando tablas
- Eager vs Lazy: La gran distinción
- Hoja de trucos de operaciones comunes
- Por qué Polars (vs pandas / Spark)
- Cómo se conecta con los fundamentos de Rust
- Demo
- Un CSV deliberadamente imperfecto
- Lectura Eager con esquema inferido (el camino fácil y peligroso)
- Esquema explícito (la lección de fiabilidad)
- Este es el punto de Rust + Polars
- Manejo de errores como una preocupación de primera clase
- Bloquéalo con una prueba
- Manejo de nulos a propósito (no por accidente)
- Conclusiones de la sección
Qué hace diferente a Rust
- Compilado y rápido: se compila a código máquina nativo, sin tiempo de ejecución ni recolector de basura (GC).
- Seguridad de memoria: el compilador evita clases enteras de errores (errores de puntero nulo, condiciones de carrera) antes de que se ejecute el programa.
- Fuertemente tipado y estático: cada valor tiene un tipo conocido en tiempo de compilación; el compilador detecta discrepancias tempranamente.
Variables y mutabilidad
Las variables son inmutables por defecto. Optas por la mutabilidad con mut.
let x = 5; // inmutable -- no puede ser reasignada
let mut y = 10; // mutable
y = 20; // OK gracias a `mut`
// x = 6; // ERROR DE COMPILACIÓN: no se puede asignar dos veces a `x`
const MAX: u32 = 100_000; // constante: siempre inmutable, requiere tipo
Este valor por defecto invierte la expectativa habitual: declaras de antemano qué puede cambiar, lo que hace que el código sea más fácil de razonar.
Tipos de datos básicos
Tipos escalares
- Enteros:
i32,i64,u32,u64… (i = con signo, u = sin signo; número = bits).i32es el predeterminado. - Flotantes:
f64(predeterminado),f32 - Booleano:
bool->true/false - Carácter:
char-> un solo carácter Unicode, entre comillas simples
let count: i64 = 42;
let price: f64 = 19.99;
let is_ready: bool = true;
let letter: char = 'A';Tipos compuestos
- Tupla: grupo de tamaño fijo con tipos mixtos
- Array: tamaño fijo, todos del mismo tipo
let person: (i32, f64, char) = (30, 5.9, 'M');
let height = person.1; // acceso por índice -> 5.9
let nums: [i32; 3] = [1, 2, 3]; // array de 3 i32s
let first = nums[0]; // -> 1
Cadenas: Dos tipos
&str— un "slice de cadena", usualmente un literal de cadena fijo/prestadoString— una cadena propia, de tamaño variable que puedes modificar
let literal: &str = "hello"; // texto fijo
let mut owned: String = String::from("hello");
owned.push_str(", world"); // puede crecer porque es propia
Funciones
- Declaradas con
fn - Los tipos de los parámetros son obligatorios; el tipo de retorno va después de ->
- La última expresión (sin punto y coma) es el valor de retorno
fn add(a: i32, b: i32) -> i32 {
a + b // sin punto y coma = este es el valor de retorno
}
fn greet(name: &str) { // sin `->` significa que no devuelve nada
println!("Hello, {name}!");
}
fn main() {
let sum = add(2, 3); // todo programa comienza en main()
println!("Sum: {sum}");
greet("Aziz");
}Nota: println! es una macro (el ! lo delata), no una función.
Flujo de control
if / else (¡es una expresión!)
let n = 7;
if n % 2 == 0 {
println!("even");
} else {
println!("odd");
}
// Como `if` devuelve un valor, puedes asignar con él:
let label = if n > 5 { "big" } else { "small" };Bucles
// loop: se ejecuta para siempre hasta que haces `break`
let mut i = 0;
loop {
if i >= 3 { break; }
i += 1;
}
// while
let mut c = 3;
while c > 0 {
println!("{c}");
c -= 1;
}
// for: el más común -- itera sobre un rango o colección
for k in 0..3 { // 0, 1, 2 (excluyendo el final)
println!("k = {k}");
}Propiedad (Ownership): La gran idea
La característica estrella de Rust. Tres reglas:
- Cada valor tiene un propietario
- Solo hay un propietario a la vez
- Cuando el propietario sale del ámbito, el valor se limpia
let s1 = String::from("hi");
let s2 = s1; // la propiedad SE MUEVE a s2
// println!("{s1}"); // ERROR: s1 ya no es válido
// Para permitir que otra función use un valor SIN tomar la propiedad,
// lo *tomas prestado* con & (una referencia):
fn length(s: &String) -> usize {
s.len() // lee s, no es dueño de él
}
let word = String::from("rust");
let n = length(&word); // lo prestas; `word` sigue siendo utilizable después
Esto es lo que permite a Rust garantizar la seguridad de memoria sin un recolector de basura. Es la parte que más cuesta acostumbrarse.
Structs: Tipos de datos personalizados
struct Order {
id: i64,
amount: f64,
shipped: bool,
}
let o = Order { id: 1, amount: 42.5, shipped: true };
println!("Order {} costs {}", o.id, o.amount);Enums y coincidencia de patrones (Pattern Matching)
Los Enums permiten que un valor sea una de varias variantes; match maneja cada una.
enum Status {
Pending,
Shipped,
Cancelled,
}
let s = Status::Shipped;
match s {
Status::Pending => println!("waiting"),
Status::Shipped => println!("on the way"),
Status::Cancelled => println!("nope"),
}match debe ser exhaustivo: maneja cada caso o el código no compilará. Otra forma en que el compilador evita que olvides cosas.
Option y Result: Sin nulos, sin errores silenciosos
Rust no tiene null. En su lugar:
Option— un valor que esSome(x)oNoneResult— esOk(x)oErr(e)(esta es la base de todo el manejo de errores en los ejemplos de Polars)
fn divide(a: f64, b: f64) -> Option<f64> {
if b == 0.0 { None } else { Some(a / b) }
}
match divide(10.0, 2.0) {
Some(result) => println!("Got {result}"),
None => println!("Can't divide by zero"),
}El operador ?: Abreviatura para manejo de errores
En un Result, ? significa
"dame el valor, o devuelve el error de esta función."
use std::num::ParseIntError;
fn parse_and_double(text: &str) -> Result<i32, ParseIntError> {
let n = text.parse::<i32>()?; // si el parseo falla, devuelve el Err
Ok(n * 2) // de lo contrario, continúa
}Es por esto que read_orders(...)? se lee limpiamente: el ? propaga silenciosamente cualquier fallo en lugar de forzar un gran bloque de coincidencia.
Colecciones comunes
Vec— lista de tamaño variable (como una lista de Python)HashMap— mapa de clave/valor (como un diccionario de Python)
let mut v: Vec<i32> = Vec::new();
v.push(1);
v.push(2);
for item in &v { println!("{item}"); }
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert("alice", 10);
scores.insert("bob", 7);Cargo: Herramienta de compilación y gestor de paquetes de Rust
Lo esencial:
cargo new my_project # crear un nuevo proyecto
cargo build # compilar
cargo run # compilar + ejecutar
cargo test # ejecutar pruebas
cargo add polars # añadir una dependencia a Cargo.tomlLas dependencias (llamadas "crates") se declaran en Cargo.toml y se obtienen de crates.io.
A tener en cuenta:
- Propiedad / préstamo — la danza de
&ymut. Espera luchar con esto al principio; se vuelve natural con la práctica. - Dos tipos de cadena (
Stringvs&str) — convierte con.to_string()oString::from(...). - Inmutable por defecto — olvidar
mutes el error inicial más común. - El compilador es tu amigo — los mensajes de error de Rust son inusualmente buenos. Léelos; a menudo te dicen la solución exacta.
- Macros vs funciones —
println!,vec!,df!terminan en!y se comportan un poco diferente a las funciones normales.
Qué es Polars
- Una biblioteca de DataFrame para trabajar con datos tabulares (filas y columnas) — piensa en hojas de cálculo o tablas de base de datos, en código
- Escrita en Rust, construida sobre Apache Arrow (un formato de memoria columnar)
- Columnar: almacena datos por columna, no por fila — por eso las operaciones de columna y analíticas son rápidas
- Multihilo por defecto: usa todos tus núcleos de CPU sin que se lo pidas
- Disponible desde Rust directamente, y desde Python mediante enlaces
Los dos tipos principales
Series— una sola columna de datos, todos del mismo tipoDataFrame— una colección de Series; la tabla en sí
use polars::prelude::*;
// Una Series es una columna con nombre.
let s = Series::new("amount".into(), &[42.5, 17.0, 9.99]);
// Un DataFrame se construye a partir de columnas. La macro df! es la forma fácil.
let df = df!(
"order_id" => &[1, 2, 3],
"amount" => &[42.5, 17.0, 9.99],
)?;
println!("{df}");Nota: df! termina en ! — es una macro, como println! y vec!.
Todo devuelve un Result
Casi todas las operaciones de Polars pueden fallar (tipos incorrectos, columnas faltantes, archivos dañados), por lo que devuelve PolarsResult. Por eso ves ?
por todas partes en el taller: propaga errores en lugar de dejar que
pasen silenciosamente.
fn build() -> PolarsResult<DataFrame> {
let df = df!("a" => &[1, 2, 3])?; // ? desenvuelve o devuelve el error
Ok(df)
}Esto se vincula directamente con Result y ? de Rust: los datos incorrectos se convierten en un error que debes manejar, no en un NaN silencioso.
Lectura y escritura de datos
Los cuatro formatos de la agenda:
// CSV de entrada
let df = CsvReadOptions::default()
.with_has_header(true)
.try_into_reader_with_file_path(Some("orders.csv".into()))?
.finish()?;
// Parquet de salida
let mut file = std::fs::File::create("orders.parquet")?;
ParquetWriter::new(&mut file).finish(&mut df)?;
// Parquet de entrada
let mut f = std::fs::File::open("orders.parquet")?;
let df = ParquetReader::new(&mut f).finish()?;Idea clave: Parquet almacena el esquema y los tipos dentro del archivo, por lo que leerlo de vuelta no requiere adivinanzas. CSV es texto y debe inferirse o proporcionarse un esquema explícito.
Esquemas: El contrato
Un Schema declara el nombre y tipo de cada columna de antemano. Dale uno a
un lector y los datos incorrectos fallarán ruidosamente en lugar de corromper una columna.
let mut schema = Schema::default();
schema.with_column("order_id".into(), DataType::Int64);
schema.with_column("amount".into(), DataType::Float64);DataType=s comunes: =Int64, Float64, String, Boolean, Date.
Selección y filtrado
Describes las operaciones con expresiones — col(...) se refiere a una
columna, y encadenas transformaciones.
let result = df
.clone()
.lazy()
.filter(col("status").eq(lit("shipped"))) // mantener filas coincidentes
.select([col("order_id"), col("amount")]) // elegir columnas
.collect()?; // ejecutarlo
col("x")— referirse a la columna xlit("shipped")— un valor literal para comparar.eq,.gt,.lt— operadores de comparación en expresiones
Joins: Combinando tablas
Coincidir filas de dos DataFrames en una clave compartida.
let joined = orders.join(
&customers,
["customer_id"], // clave en la tabla izquierda
["customer_id"], // clave en la tabla derecha
JoinArgs::new(JoinType::Inner), // Inner / Left / Anti / ...
None,
)?;Tipos de Join que vale la pena conocer:
Inner— solo filas que coinciden en ambosLeft— todas las filas de la izquierda, nulos donde no hay coincidenciaAnti— filas de la izquierda sin coincidencia (genial como verificación de calidad de datos)
Eager vs Lazy: La gran distinción
- Eager — cada operación se ejecuta inmediatamente (
DataFrame). Simple, bueno para datos pequeños y exploración. - Lazy — construyes un plan de consulta, y nada se ejecuta hasta
.collect(). Polars entonces optimiza todo el plan (empujando filtros hacia abajo, leyendo solo las columnas necesarias).
// Lazy: scan_* y .lazy() devuelven un LazyFrame -- un plan, aún no datos.
let plan = LazyCsvReader::new(PlPath::new("orders.csv"))
.with_has_header(true)
.finish()?
.filter(col("status").eq(lit("shipped")))
.select([col("order_id"), col("amount")]);
println!("{}", plan.clone().explain(true)?); // inspeccionar el plan
let df = plan.collect()?; // AHORA se ejecuta
explain(true) imprime el plan optimizado — puedes ver lo que el
motor decidió hacer antes de gastar cualquier cómputo.
Hoja de trucos de operaciones comunes
df.height(); // número de filas
df.width(); // número de columnas
df.column("amount")?; // obtener una columna (Series)
df.head(Some(5)); // primeras 5 filas
df.get_column_names(); // nombres de columnas
df.column("amount")?.dtype();// el tipo de datos de la columna
Por qué Polars (vs pandas / Spark)
- vs pandas — mucho más rápido, multihilo, optimización lazy, mucho mejor comportamiento de memoria; los tipos son más estrictos (menos sorpresas silenciosas)
- vs Spark — no se necesita clúster para cargas de trabajo de una sola máquina; muchos trabajos de "necesitamos Spark" son realmente "pandas era demasiado lento en una sola máquina"
- Polars te da rendimiento más corrección sin la sobrecarga de sistemas distribuidos
Cómo se conecta con los fundamentos de Rust
PolarsResulty?=Result+ operador?&customersen un join = préstamo (leer sin tomar la propiedad)&mut dfal escribir Parquet = un préstamo mutabledf!,col!macros de estilo = la sintaxis de macro!- Esquemas y
DataType= la idea de Rust de "todo tiene un tipo conocido", aplicada a columnas de tabla
Demo
Un CSV deliberadamente imperfecto
Usa un archivo con una columna de tipo mixto, un nulo y una fila incorrecta:
order_id,customer_id,amount,status
1,100,42.50,shipped
2,101,,pending
3,102,17.00,shipped
4,bad_id,9.99,shippedLa fila 4 tiene un customer_id no numérico. En una tubería flexible esto
se convierte en un NaN silencioso o una columna de objetos. Queremos que sea ruidoso.
Lectura Eager con esquema inferido (el camino fácil y peligroso)
use polars::prelude::*;
fn main() -> PolarsResult<()> {
let df = CsvReadOptions::default()
.with_has_header(true)
.try_into_reader_with_file_path(Some("orders.csv".into()))?
.finish()?;
println!("{df}");
Ok(())
}Esto funciona, pero la inferencia miró una muestra y adivinó los tipos. En un archivo diferente, o más filas, la suposición puede cambiar. La inferencia es conveniente y no determinista; esa combinación es la que te muerde en producción.
Esquema explícito (la lección de fiabilidad)
Deja de adivinar. Declara el contrato:
use polars::prelude::*;
use std::sync::Arc;
fn read_orders(path: &str) -> PolarsResult<DataFrame> {
let mut schema = Schema::default();
schema.with_column("order_id".into(), DataType::Int64);
schema.with_column("customer_id".into(), DataType::Int64);
schema.with_column("amount".into(), DataType::Float64);
schema.with_column("status".into(), DataType::String);
CsvReadOptions::default()
.with_has_header(true)
.with_schema(Some(Arc::new(schema)))
.try_into_reader_with_file_path(Some(path.into()))?
.finish()
}Ahora customer_id se declara Int64. La fila incorrecta (bad_id) ya no puede
colarse como texto — Polars devuelve un Err, no una columna silenciosamente
corrupta. El fallo ocurre en el momento de la lectura, con una causa clara,
en lugar de tres transformaciones después.
Este es el punto de Rust + Polars
- El esquema es código — está versionado, revisado y probado como cualquier otro contrato
finish()devuelvePolarsResult. No hay forma de ignorar un fallo de parseo por accidente — el?te obliga a manejarlo o propagarlo- Compara con una tubería tipada dinámicamente donde un mal parseo se convierte en
NaNy fluye aguas abajo silenciosamente. Aquí, el sistema de tipos y el tipo de error hacen que el silencio sea imposible.
Manejo de errores como una preocupación de primera clase
Muestra ambos comportamientos para que la audiencia sienta la diferencia:
fn main() {
match read_orders("orders.csv") {
Ok(df) => println!("Loaded {} rows\n{df}", df.height()),
Err(e) => eprintln!("CSV failed its contract: {e}"),
}
}En una tubería, Err significa que el trabajo se detiene aquí, ruidosamente, con un
mensaje — no a las 3 a.m., con cuarenta millones de filas procesadas.
Bloquéalo con una prueba
El tema de la fiabilidad hecho concreto — una prueba que afirma el contrato, para que un archivo upstream mal formado falle en CI, no en prod:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_is_enforced() {
let df = read_orders("tests/data/orders_good.csv").unwrap();
assert_eq!(df.height(), 3);
assert_eq!(
df.column("amount").unwrap().dtype(),
&DataType::Float64
);
}
#[test]
fn bad_types_are_rejected() {
// El archivo con `bad_id` NO debe cargarse silenciosamente.
assert!(read_orders("tests/data/orders_bad.csv").is_err());
}
}bad_types_are_rejected es toda la filosofía en una prueba: afirmamos que los datos incorrectos fallan. La mayoría de las tuberías nunca escriben esa prueba
porque en su stack, los datos incorrectos no fallan — se propagan.
Manejo de nulos a propósito (no por accidente)
El amount vacío en la fila 2 es un nulo real. Decide qué significa
en lugar de dejar que una suposición decida:
use polars::prelude::*;
fn parse_options() -> CsvParseOptions {
CsvParseOptions::default()
.with_null_values(Some(NullValues::AllColumns(
vec!["".into(), "NA".into(), "null".into()].into(),
)))
}Claridad operativa: los nulos son una decisión documentada en el código, no un artefacto de lo que al parser le dio la gana hacer.
Conclusiones de la sección
- CSV no tiene tipos y es inseguro por defecto — trata cada lectura como un límite que debe ser validado
- Los esquemas explícitos convierten "espero que se parseé" en "se parsea o da error" — determinismo sobre conveniencia
PolarsResulthace que ignorar un fallo sea una imposibilidad en tiempo de compilación- Una prueba (
bad_types_are_rejected) demuestra toda la tesis de fiabilidad - Rust + Polars importa aquí no porque sea más rápido, sino porque hace que la corrupción silenciosa de datos sea estructuralmente difícil

