Ubuntu TechHive
rust-and-data-processing-with-polars.md
Rust and Data Processing with Polars
article.detalle

Rust and Data Processing with Polars

reading.progreso 13 min de lectura

Una introducción rápida a los fundamentos de Rust junto con el procesamiento de datos usando Polars

Procesamiento de datos con Rust y Polars

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). i32 es 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/prestado
  • String — 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:

  1. Cada valor tiene un propietario
  2. Solo hay un propietario a la vez
  3. 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 es Some(x) o None
  • Result — es Ok(x) o Err(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.toml

Las dependencias (llamadas "crates") se declaran en Cargo.toml y se obtienen de crates.io.

A tener en cuenta:

  • Propiedad / préstamo — la danza de & y mut. Espera luchar con esto al principio; se vuelve natural con la práctica.
  • Dos tipos de cadena (String vs &str) — convierte con .to_string() o String::from(...).
  • Inmutable por defecto — olvidar mut es 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 funcionesprintln!, 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 tipo
  • DataFrame — 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 expresionescol(...) 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 x
  • lit("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 ambos
  • Left — todas las filas de la izquierda, nulos donde no hay coincidencia
  • Anti — 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

  • PolarsResult y ? = Result + operador ?
  • &customers en un join = préstamo (leer sin tomar la propiedad)
  • &mut df al escribir Parquet = un préstamo mutable
  • df!, 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,shipped

La 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() devuelve PolarsResult. 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 NaN y 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
  • PolarsResult hace 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