Pandera é uma biblioteca para declarar e executar contratos sobre dados tabulares. Em vez de espalhar verificações como if "preco" in df.columns pelo código, você descreve colunas, tipos, valores permitidos e regras entre campos em um schema reutilizável. A resposta curta é: use Pandera na entrada e na saída das etapas críticas para transformar erros silenciosos de dados em falhas claras, localizadas e testáveis.

Ela complementa o pandas, não o substitui. O pandas carrega, transforma e agrega; Pandera confirma se o DataFrame recebido ou produzido corresponde ao contrato esperado. Isso é útil em ETL, análises, notebooks que viraram produção, APIs e treinamento de modelos. Se DataFrames ainda são novidade, comece pelo guia de pandas para análise de dados.

Instalação e primeiro schema

Crie um ambiente virtual com venv e instale Pandera com suporte a pandas. O extra evita depender de componentes opcionais por acaso:

python -m pip install "pandera[pandas]" pandas pytest

Há duas APIs comuns. DataFrameSchema monta o contrato com objetos em tempo de execução; DataFrameModel usa anotações de tipo e costuma ficar mais legível em projetos. A documentação oficial do Pandera apresenta os backends e recursos disponíveis. Neste tutorial usaremos o modelo tipado:

import pandas as pd
import pandera.pandas as pa
from pandera.typing import Series

class VendaSchema(pa.DataFrameModel):
    pedido_id: Series[int] = pa.Field(unique=True, ge=1)
    produto: Series[str] = pa.Field(str_length={"min_value": 1})
    quantidade: Series[int] = pa.Field(gt=0)
    preco_unitario: Series[float] = pa.Field(ge=0)
    status: Series[str] = pa.Field(isin=["pago", "enviado", "cancelado"])

    class Config:
        strict = True
        coerce = True

dados = pd.DataFrame({
    "pedido_id": [101, 102],
    "produto": ["Teclado", "Mouse"],
    "quantidade": [1, 2],
    "preco_unitario": [199.90, 79.50],
    "status": ["pago", "enviado"],
})

validado = VendaSchema.validate(dados)
print(validado.dtypes)

strict = True rejeita colunas desconhecidas. Isso é adequado quando uma mudança inesperada deve interromper o pipeline; remova a opção se colunas extras forem aceitáveis. coerce = True tenta converter valores para os tipos declarados. Coerção é conveniência, não correção: a string "abc" continua inválida como inteiro, e uma conversão bem-sucedida não garante que o significado esteja certo.

Colunas, checks e valores nulos

Field cobre regras frequentes: gt, ge, lt, le, isin, unique, limites de tamanho e correspondência por expressão regular. Uma coluna não aceita nulos por padrão. Para permitir ausência, declare nullable=True; isso é diferente de tornar a coluna opcional. nullable aceita valores vazios dentro de uma coluna presente, enquanto required=False na API de schemas permite que a coluna nem exista.

Regras que envolvem mais de uma coluna pertencem ao DataFrame inteiro. Por exemplo, pedidos cancelados podem ter total zero, mas os demais precisam gerar receita positiva:

class VendaComTotal(VendaSchema):
    total: Series[float] = pa.Field(ge=0)

    @pa.dataframe_check
    def total_coerente(cls, df: pd.DataFrame) -> Series[bool]:
        calculado = df["quantidade"] * df["preco_unitario"]
        return (df["status"] == "cancelado") | df["total"].eq(calculado)

O método devolve uma série booleana, uma resposta por linha. Prefira operações vetorizadas a loops: elas expressam melhor a regra e aproveitam o funcionamento do pandas. Anotações também ajudam editores e revisores; aprofunde esse tema no artigo de type hints em Python.

Projeto prático: pipeline de vendas

Vamos separar leitura, transformação e validação. O arquivo vendas.py recebe um CSV, normaliza texto, calcula o total e entrega apenas dados aprovados:

from pathlib import Path

import pandas as pd
import pandera.pandas as pa
from pandera.typing import DataFrame, Series

class VendaEntrada(pa.DataFrameModel):
    pedido_id: Series[int] = pa.Field(unique=True, ge=1)
    produto: Series[str] = pa.Field(str_length={"min_value": 1})
    quantidade: Series[int] = pa.Field(gt=0)
    preco_unitario: Series[float] = pa.Field(ge=0)
    status: Series[str] = pa.Field(isin=["pago", "enviado", "cancelado"])

    class Config:
        strict = True
        coerce = True

class VendaSaida(VendaEntrada):
    total: Series[float] = pa.Field(ge=0)

@pa.check_types
def transformar(df: DataFrame[VendaEntrada]) -> DataFrame[VendaSaida]:
    resultado = df.copy()
    resultado["produto"] = resultado["produto"].str.strip()
    resultado["total"] = (
        resultado["quantidade"] * resultado["preco_unitario"]
    ).where(resultado["status"] != "cancelado", 0.0)
    return resultado

def carregar(caminho: str | Path) -> DataFrame[VendaEntrada]:
    bruto = pd.read_csv(caminho)
    return VendaEntrada.validate(bruto, lazy=True)

O decorator check_types valida argumentos e retorno anotados. Assim, uma transformação não pode prometer VendaSaida e esquecer total. Ainda vale manter funções pequenas: veja como organizar essa lógica no guia de funções em Python. Em conjuntos enormes, validar tudo pode custar tempo; amostragem serve para exploração, mas contratos críticos normalmente devem examinar o conjunto completo.

Testes de dados válidos e inválidos

Schema também é código e merece testes. O primeiro cenário protege o caminho feliz; o segundo comprova que uma quantidade inválida não passa:

import pandas as pd
import pandera.errors
import pytest

from vendas import VendaEntrada, transformar

def venda_valida() -> pd.DataFrame:
    return pd.DataFrame({
        "pedido_id": [1],
        "produto": ["Caderno"],
        "quantidade": [3],
        "preco_unitario": [12.5],
        "status": ["pago"],
    })

def test_transformar_calcula_total():
    saida = transformar(VendaEntrada.validate(venda_valida()))
    assert saida.loc[0, "total"] == 37.5

def test_quantidade_zero_falha():
    df = venda_valida()
    df.loc[0, "quantidade"] = 0

    with pytest.raises(pandera.errors.SchemaError):
        VendaEntrada.validate(df)

Execute com pytest -q. Para ampliar a suíte, consulte o tutorial de testes automatizados com pytest. Teste limites, nulos, duplicidade, categoria desconhecida, coluna ausente e coluna adicional, não apenas um exemplo genérico.

Erros úteis com validação lazy

Sem lazy=True, Pandera interrompe na primeira violação. Isso é rápido, porém ruim para um arquivo enviado por usuário, que teria de corrigir um erro por vez. O modo lazy reúne as falhas em SchemaErrors:

import pandera.errors

try:
    vendas = VendaEntrada.validate(dados, lazy=True)
except pandera.errors.SchemaErrors as exc:
    falhas = exc.failure_cases[
        ["schema_context", "column", "check", "failure_case", "index"]
    ]
    falhas.to_csv("vendas_rejeitadas.csv", index=False)
    raise SystemExit("Arquivo inválido; consulte vendas_rejeitadas.csv") from exc

Capture a exceção específica, registre contexto e encerre a etapa. Não continue com o DataFrame bruto depois da falha, pois isso anula o contrato. Para estratégias de exceção e mensagens, leia tratamento de erros com try/except. Evite também registrar linhas completas quando houver dados pessoais.

Boas práticas e armadilhas

Coloque schemas perto do domínio, versionados junto ao código. Dê nomes de negócio às classes, valide cedo na fronteira e novamente após transformações que alterem a estrutura. Separe regras estruturais de decisões que exigem banco de dados ou serviços externos. Um schema gigantesco e acoplado a tudo fica difícil de evoluir.

Não confunda tipo com semântica. Um CPF pode ser str e ainda ser inválido; uma data pode ser parseável e estar fora do período aceito. Tampouco use coerção para esconder uma fonte inconsistente. Observe as falhas e corrija o produtor quando possível. Em pipelines contínuos, acompanhe quantidade e motivo das rejeições para detectar mudanças antes que usuários percebam.

Perguntas frequentes

Pandera substitui pandas?

Não. Pandas manipula DataFrames; Pandera declara e verifica contratos para esses objetos. As bibliotecas trabalham juntas.

Pandera substitui testes com pytest?

Não. O schema executa validações; pytest confirma que schemas e transformações se comportam corretamente em cenários conhecidos.

Devo usar DataFrameSchema ou DataFrameModel?

Use o modelo tipado quando anotações melhorarem a API do projeto. Use DataFrameSchema quando precisar montar regras dinamicamente. Ambos são válidos.

Quando usar lazy=True?

Use quando o consumidor precisa receber uma lista completa de problemas, como na importação de CSV. Para falhar imediatamente em etapas internas, o padrão pode bastar.

Conclusão

Pandera torna explícito aquilo que um pipeline espera: nomes, tipos, limites e relações entre colunas. Comece validando uma fronteira importante, adicione testes para casos válidos e inválidos e trate relatórios lazy sem deixar dados rejeitados avançarem. Esse pequeno contrato reduz depuração, documenta decisões e dá segurança para alterar transformações sem aceitar regressões silenciosas.