Processamento de Dados em Rust com Polars
- O que torna o Rust diferente
- Variáveis e Mutabilidade
- Tipos de Dados Básicos
- Strings: Dois Tipos
- Funções
- Fluxo de Controle
- Ownership (Propriedade): A Grande Ideia
- Structs: Tipos de Dados Personalizados
- Enums e Pattern Matching
- Option e Result: Sem Nulos, Sem Erros Silenciosos
- O Operador ?: Atalho para Tratamento de Erros
- Coleções Comuns
- Cargo: Ferramenta de Build e Gerenciador de Pacotes do Rust
- O que observar:
- O que é o Polars
- Os Dois Tipos Principais
- Tudo Retorna um Result
- Lendo e Escrevendo Dados
- Esquemas: O Contrato
- Selecionando e Filtrando
- Joins: Combinando Tabelas
- Eager vs Lazy: A Grande Distinção
- Folha de Dicas de Operações Comuns
- Por que Polars (vs pandas / Spark)
- Como se conecta aos fundamentos do Rust
- Demonstração
- Um CSV Deliberadamente Imperfeito
- Leitura Eager Com Esquema Inferido (o caminho fácil e perigoso)
- Esquema Explícito (a lição de confiabilidade)
- Este é o ponto do Rust + Polars
- Tratamento de Erros como uma Preocupação de Primeira Classe
- Bloqueie com um Teste
- Lidando com Nulos de Propósito (não por acidente)
- Conclusões da Seção
O que torna o Rust diferente
- Compilado e rápido — compila para código de máquina nativo, sem runtime/GC
- Seguro quanto à memória — o compilador evita classes inteiras de bugs (erros de ponteiro nulo, condições de corrida) antes que seu programa seja executado
- Fortemente e estaticamente tipado — cada valor tem um tipo conhecido em tempo de compilação; o compilador detecta incompatibilidades precocemente
Variáveis e Mutabilidade
Variáveis são imutáveis por padrão. Você opta pela mutabilidade com mut.
let x = 5; // imutável -- não pode ser reatribuído
let mut y = 10; // mutável
y = 20; // OK por causa do `mut`
// x = 6; // ERRO DE COMPILAÇÃO: não é possível atribuir duas vezes a `x`
const MAX: u32 = 100_000; // constante: sempre imutável, tipo obrigatório
Este padrão inverte a expectativa usual: você declara antecipadamente o que pode mudar, o que torna o código mais fácil de raciocinar.
Tipos de Dados Básicos
Tipos escalares
- Inteiros:
i32,i64,u32,u64… (i = com sinal, u = sem sinal; número = bits).i32é o padrão. - Ponto flutuante:
f64(padrão),f32 - Booleano:
bool->true/false - Caractere:
char-> um único caractere Unicode, entre aspas simples
let count: i64 = 42;
let price: f64 = 19.99;
let is_ready: bool = true;
let letter: char = 'A';Tipos compostos
- Tupla: grupo de tamanho fixo com tipos mistos
- Array: tamanho fixo, todos do mesmo tipo
let person: (i32, f64, char) = (30, 5.9, 'M');
let height = person.1; // acesso por índice -> 5.9
let nums: [i32; 3] = [1, 2, 3]; // array de 3 i32s
let first = nums[0]; // -> 1
Strings: Dois Tipos
&str— uma "fatia de string", geralmente um literal de string fixo/emprestadoString— uma string proprietária e expansível que você pode modificar
let literal: &str = "hello"; // texto fixo
let mut owned: String = String::from("hello");
owned.push_str(", world"); // pode crescer porque é proprietária
Funções
- Declaradas com
fn - Tipos de parâmetros são obrigatórios; o tipo de retorno vem após
-> - A última expressão (sem ponto e vírgula) é o valor de retorno
fn add(a: i32, b: i32) -> i32 {
a + b // sem ponto e vírgula = este é o valor de retorno
}
fn greet(name: &str) { // sem `->` significa que não retorna nada
println!("Hello, {name}!");
}
fn main() {
let sum = add(2, 3); // todo programa começa em main()
println!("Sum: {sum}");
greet("Aziz");
}Nota: println! é uma macro (o ! entrega isso), não uma função.
Fluxo de Controle
if / else (é uma expressão!)
let n = 7;
if n % 2 == 0 {
println!("even");
} else {
println!("odd");
}
// Como `if` retorna um valor, você pode atribuir com ele:
let label = if n > 5 { "big" } else { "small" };Loops
// loop: executa para sempre até você usar `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: o mais comum -- itera sobre um intervalo ou coleção
for k in 0..3 { // 0, 1, 2 (fim exclusivo)
println!("k = {k}");
}Ownership (Propriedade): A Grande Ideia
O recurso principal do Rust. Três regras:
- Cada valor tem um proprietário
- Existe apenas um proprietário por vez
- Quando o proprietário sai de escopo, o valor é limpo
let s1 = String::from("hi");
let s2 = s1; // a propriedade MOVE para s2
// println!("{s1}"); // ERRO: s1 não é mais válido
// Para permitir que outra função use um valor SEM assumir a propriedade,
// você o *empresta* com & (uma referência):
fn length(s: &String) -> usize {
s.len() // lê s, não é proprietário
}
let word = String::from("rust");
let n = length(&word); // empresta; `word` ainda utilizável depois
É isso que permite ao Rust garantir a segurança de memória sem um coletor de lixo. É a parte que leva mais tempo para se acostumar.
Structs: Tipos de Dados 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 e Pattern Matching
Enums permitem que um valor seja uma de várias variantes; match lida com cada uma.
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 deve ser exaustivo — lide com todos os casos ou o código não compilará. Outra forma pela qual o compilador impede que você esqueça coisas.
Option e Result: Sem Nulos, Sem Erros Silenciosos
Rust não tem null. Em vez disso:
Option— um valor que éSome(x)ouNoneResult— ouOk(x)ouErr(e)(esta é a base de todo o tratamento de erros nos exemplos do 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"),
}O Operador ?: Atalho para Tratamento de Erros
Em um Result, ? significa
"me dê o valor, ou retorne o erro desta função."
use std::num::ParseIntError;
fn parse_and_double(text: &str) -> Result<i32, ParseIntError> {
let n = text.parse::<i32>()?; // se o parse falhar, retorna o Err
Ok(n * 2) // caso contrário, continua
}É por isso que read_orders(...)? é lido de forma limpa: o ? propaga silenciosamente qualquer falha em vez de forçar um grande bloco de match.
Coleções Comuns
Vec— lista expansível (como uma lista Python)HashMap— mapa chave/valor (como um dicionário 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: Ferramenta de Build e Gerenciador de Pacotes do Rust
O essencial:
cargo new my_project # cria um novo projeto
cargo build # compila
cargo run # compila + executa
cargo test # executa testes
cargo add polars # adiciona uma dependência ao Cargo.tomlDependências (chamadas de "crates") são declaradas em Cargo.toml e baixadas do crates.io.
O que observar:
- Ownership / borrowing — a dança do
&emut. Espere lutar com isso no início; torna-se natural com a prática. - Dois tipos de string (
Stringvs&str) — converta com.to_string()ouString::from(...). - Imutável por padrão — esquecer
muté o erro inicial mais comum. - O compilador é seu amigo — as mensagens de erro do Rust são excepcionalmente boas. Leia-as; elas geralmente indicam a correção exata.
- Macros vs funções —
println!,vec!,df!terminam em!e se comportam de forma um pouco diferente das funções normais.
O que é o Polars
- Uma biblioteca de DataFrame para trabalhar com dados tabulares (linhas e colunas) — pense em planilhas ou tabelas de banco de dados, em código
- Escrito em Rust, construído sobre o Apache Arrow (um formato de memória colunar)
- Colunar: armazena dados por coluna, não por linha — por isso operações de coluna e análises são rápidas
- Multithreaded por padrão: usa todos os seus núcleos de CPU sem você pedir
- Disponível diretamente no Rust e no Python via bindings
Os Dois Tipos Principais
Series— uma única coluna de dados, todos do mesmo tipoDataFrame— uma coleção de Series; a tabela em si
use polars::prelude::*;
// Uma Series é uma coluna nomeada.
let s = Series::new("amount".into(), &[42.5, 17.0, 9.99]);
// Um DataFrame é construído a partir de colunas. A macro df! é a maneira fácil.
let df = df!(
"order_id" => &[1, 2, 3],
"amount" => &[42.5, 17.0, 9.99],
)?;
println!("{df}");Note que df! termina em ! — é uma macro, como println! e vec!.
Tudo Retorna um Result
Quase toda operação do Polars pode falhar (tipos incorretos, colunas ausentes,
arquivos ruins), então ela retorna PolarsResult. É por isso que você vê ?
em toda parte no workshop — ele propaga erros em vez de deixá-los
passar silenciosamente.
fn build() -> PolarsResult<DataFrame> {
let df = df!("a" => &[1, 2, 3])?; // ? desempacota ou retorna o erro
Ok(df)
}Isso se conecta diretamente ao Result e ? do Rust: dados ruins tornam-se
um erro que você deve tratar, não um NaN silencioso.
Lendo e Escrevendo Dados
Os quatro formatos da 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 saída
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()?;Ideia chave: Parquet armazena o esquema e os tipos dentro do arquivo, então lê-lo de volta não requer adivinhação. CSV é texto e deve ser inferido ou receber um esquema explícito.
Esquemas: O Contrato
Um Schema declara o nome e o tipo de cada coluna antecipadamente. Forneça um
a um leitor e dados ruins falharão ruidosamente em vez de corromper uma coluna.
let mut schema = Schema::default();
schema.with_column("order_id".into(), DataType::Int64);
schema.with_column("amount".into(), DataType::Float64);DataType=s comuns: =Int64, Float64, String, Boolean, Date.
Selecionando e Filtrando
Você descreve operações com expressões — col(...) refere-se a uma
coluna, e você encadeia transformações.
let result = df
.clone()
.lazy()
.filter(col("status").eq(lit("shipped"))) // mantém linhas correspondentes
.select([col("order_id"), col("amount")]) // escolhe colunas
.collect()?; // executa
col("x")— refere-se à coluna xlit("shipped")— um valor literal para comparar.eq,.gt,.lt— operadores de comparação em expressões
Joins: Combinando Tabelas
Combine linhas de dois DataFrames em uma chave compartilhada.
let joined = orders.join(
&customers,
["customer_id"], // chave na tabela esquerda
["customer_id"], // chave na tabela direita
JoinArgs::new(JoinType::Inner), // Inner / Left / Anti / ...
None,
)?;Tipos de Join que vale a pena conhecer:
Inner— apenas linhas que correspondem em ambosLeft— todas as linhas da esquerda, nulos onde não há correspondênciaAnti— linhas da esquerda sem correspondência (ótimo como verificação de qualidade de dados)
Eager vs Lazy: A Grande Distinção
- Eager — cada operação é executada imediatamente (
DataFrame). Simples, bom para dados pequenos e exploração. - Lazy — você constrói um plano de consulta, e nada é executado até
.collect(). O Polars então otimiza todo o plano (empurrando filtros para baixo, lendo apenas as colunas necessárias).
// Lazy: scan_* e .lazy() retornam um LazyFrame -- um plano, ainda não dados.
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)?); // inspeciona o plano
let df = plan.collect()?; // AGORA ele executa
explain(true) imprime o plano otimizado — você pode ver o que o
mecanismo decidiu fazer antes de gastar qualquer processamento.
Folha de Dicas de Operações Comuns
df.height(); // número de linhas
df.width(); // número de colunas
df.column("amount")?; // obtém uma coluna (Series)
df.head(Some(5)); // primeiras 5 linhas
df.get_column_names(); // nomes das colunas
df.column("amount")?.dtype();// o tipo de dado da coluna
Por que Polars (vs pandas / Spark)
- vs pandas — muito mais rápido, multithreaded, otimização lazy, comportamento de memória muito melhor; tipos são mais rigorosos (menos surpresas silenciosas)
- vs Spark — sem necessidade de cluster para cargas de trabalho em uma única máquina; muitos trabalhos de "precisamos do Spark" são na verdade "o pandas estava muito lento em uma máquina"
- Polars oferece desempenho mais correção sem a sobrecarga de sistemas distribuídos
Como se conecta aos fundamentos do Rust
PolarsResulte?=Result+ operador?&customersem um join = borrowing (lendo sem assumir a propriedade)&mut dfao escrever Parquet = um borrow mutáveldf!,col!macros de estilo = a sintaxe de macro!- Esquemas e
DataType= a ideia do Rust de que "tudo tem um tipo conhecido", aplicada a colunas de tabela
Demonstração
Um CSV Deliberadamente Imperfeito
Use um arquivo com uma coluna de tipo misto, um nulo e uma linha ruim:
order_id,customer_id,amount,status
1,100,42.50,shipped
2,101,,pending
3,102,17.00,shipped
4,bad_id,9.99,shippedA linha 4 tem um customer_id não numérico. Em um pipeline flexível, isso
se torna um NaN silencioso ou uma coluna de objeto. Queremos que seja ruidoso.
Leitura Eager Com Esquema Inferido (o caminho fácil e perigoso)
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(())
}Isso funciona — mas a inferência olhou para uma amostra e adivinhou os tipos. Em um arquivo diferente, ou com mais linhas, o palpite pode mudar. A inferência é conveniente e não determinística; essa combinação é o que te prejudica em produção.
Esquema Explícito (a lição de confiabilidade)
Pare de adivinhar. Declare o 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()
}Agora customer_id é declarado como Int64. A linha ruim (bad_id) não pode
mais passar como texto — o Polars retorna um Err, não uma coluna silenciosamente
corrompida. A falha ocorre no momento da leitura, com uma causa clara,
em vez de três transformações depois.
Este é o ponto do Rust + Polars
- O esquema é código — ele é versionado, revisado e testado como qualquer outro contrato
finish()retornaPolarsResult. Não há como ignorar uma falha de parse por acidente — o?força você a tratá-la ou propagá-la- Compare com um pipeline tipado dinamicamente onde um parse ruim se torna
NaNe flui downstream silenciosamente. Aqui, o sistema de tipos e o tipo de erro tornam o silêncio impossível.
Tratamento de Erros como uma Preocupação de Primeira Classe
Mostre ambos os comportamentos para que o público sinta a diferença:
fn main() {
match read_orders("orders.csv") {
Ok(df) => println!("Carregadas {} linhas\n{df}", df.height()),
Err(e) => eprintln!("CSV falhou em seu contrato: {e}"),
}
}Em um pipeline, Err significa que o trabalho para aqui, ruidosamente, com uma
mensagem — não às 3 da manhã, quarenta milhões de linhas depois.
Bloqueie com um Teste
O tema da confiabilidade tornado concreto — um teste que afirma o contrato, para que um arquivo upstream malformado falhe em CI, não em 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() {
// O arquivo com `bad_id` NÃO deve carregar silenciosamente.
assert!(read_orders("tests/data/orders_bad.csv").is_err());
}
}bad_types_are_rejected é toda a filosofia em um teste: afirmamos que dados ruins falham.
A maioria dos pipelines nunca escreve esse teste porque, em sua stack, dados ruins não falham — eles se espalham.
Lidando com Nulos de Propósito (não por acidente)
O amount vazio na linha 2 é um nulo real. Decida o que ele significa
em vez de deixar um palpite decidir:
use polars::prelude::*;
fn parse_options() -> CsvParseOptions {
CsvParseOptions::default()
.with_null_values(Some(NullValues::AllColumns(
vec!["".into(), "NA".into(), "null".into()].into(),
)))
}Clareza operacional: nulos são uma decisão documentada no código, não um artefato do que o parser decidiu fazer.
Conclusões da Seção
- CSV não é tipado e não é seguro por padrão — trate cada leitura como uma fronteira que deve ser validada
- Esquemas explícitos transformam "espero que faça o parse" em "faz o parse ou gera erro" — determinismo sobre conveniência
PolarsResulttorna ignorar falhas uma impossibilidade em tempo de compilação- Um teste (
bad_types_are_rejected) demonstra toda a tese de confiabilidade - Rust + Polars importa aqui não porque é mais rápido, mas porque torna a corrupção silenciosa de dados estruturalmente difícil

