Skip to content
StackPractices
intermediate Por Mathias Paulenko

Construir un Pipeline ETL con pandas y Parquet

Cómo construir un pipeline extract-transform-load usando pandas para procesamiento de datos y Parquet para almacenamiento columnar con coerción de tipos y validación.

Temas: data

Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.

Overview

pandas es la herramienta estándar para procesamiento de datos tabulares en Python. Parquet es un formato de almacenamiento columnar que comprime mejor que CSV y preserva los tipos de datos (enteros, floats, datetimes, categoricals). Combinarlos en un pipeline ETL te da procesamiento de datos type-safe con almacenamiento compacto. Aqui se explica como extracción de múltiples fuentes, transformación con coerción de tipos y validación, y carga a archivos Parquet particionados.

When to Use

  • Jobs de procesamiento de datos batch que corren en un schedule (horario, diario)
  • Transformar exports CSV/JSON en Parquet tipado para analytics downstream
  • Pipelines de datos donde los archivos intermedios necesitan preservación de tipos
  • Construir features para modelos ML a partir de data sources raw
  • Cualquier escenario donde necesitas transformaciones de datos reproducibles y auditables

When NOT to Use

  • Pipelines streaming/real-time — usa Spark Structured Streaming o Flink
  • Datasets más grandes que la memoria — usa Polars, Dask o PySpark
  • Transformaciones one-off simples — un solo pd.read_csv().to_parquet() es suficiente
  • Data warehouses productivos — usa dbt para transformaciones basadas en SQL

Solution

Estructura básica de pipeline ETL

import pandas as pd
from pathlib import Path
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


def extract_csv(path: str) -> pd.DataFrame:
    """Extraer data de un archivo CSV."""
    logger.info(f"Extracting from {path}")
    df = pd.read_csv(path)
    logger.info(f"Extracted {len(df)} rows, {len(df.columns)} columns")
    return df


def extract_json(path: str) -> pd.DataFrame:
    """Extraer data de un archivo JSON."""
    logger.info(f"Extracting from {path}")
    df = pd.read_json(path, lines=True)
    logger.info(f"Extracted {len(df)} rows")
    return df


def transform(df: pd.DataFrame) -> pd.DataFrame:
    """Aplicar transformaciones: coerción de tipos, limpieza, columnas derivadas."""
    logger.info("Starting transformation")

    # Coerción de tipos
    df["order_date"] = pd.to_datetime(df["order_date"], errors="coerce")
    df["amount"] = pd.to_numeric(df["amount"], errors="coerce")
    df["quantity"] = pd.to_numeric(df["quantity"], errors="coerce").astype("Int64")

    # Drop filas con fechas o amounts inválidos
    df = df.dropna(subset=["order_date", "amount"])

    # Columnas derivadas
    df["year"] = df["order_date"].dt.year
    df["month"] = df["order_date"].dt.month
    df["revenue"] = df["amount"] * df["quantity"]

    # Normalizar columnas de texto
    df["customer_name"] = df["customer_name"].str.strip().str.title()

    # Categorical para columnas de baja cardinalidad
    df["status"] = df["status"].astype("category")

    logger.info(f"Transformed to {len(df)} rows")
    return df


def load_parquet(df: pd.DataFrame, path: str, partition_cols: list[str] | None = None) -> None:
    """Cargar DataFrame a Parquet, opcionalmente particionado."""
    logger.info(f"Loading to {path}")
    if partition_cols:
        df.to_parquet(path, partition_cols=partition_cols, index=False)
    else:
        df.to_parquet(path, index=False)
    logger.info(f"Loaded {len(df)} rows")


def run_pipeline(source_path: str, destination_path: str) -> None:
    """Ejecutar el pipeline ETL completo."""
    df = extract_csv(source_path)
    df = transform(df)
    load_parquet(df, destination_path, partition_cols=["year", "month"])


if __name__ == "__main__":
    run_pipeline("data/raw/orders.csv", "data/processed/orders")

Extraer de múltiples fuentes y mergear

def extract_and_merge(orders_path: str, customers_path: str) -> pd.DataFrame:
    """Extraer de múltiples fuentes y mergear."""
    orders = pd.read_csv(orders_path)
    customers = pd.read_csv(customers_path)

    # Estandarizar join keys
    orders["customer_id"] = orders["customer_id"].astype(str).str.strip()
    customers["customer_id"] = customers["customer_id"].astype(str).str.strip()

    merged = orders.merge(customers, on="customer_id", how="left")

    logger.info(f"Merged: {len(orders)} orders + {len(customers)} customers = {len(merged)} rows")
    return merged

Transformar con validación

def transform_with_validation(df: pd.DataFrame) -> pd.DataFrame:
    """Transformar con checks de calidad de datos."""
    # Coerción de tipos
    df["order_date"] = pd.to_datetime(df["order_date"], errors="coerce")
    df["amount"] = pd.to_numeric(df["amount"], errors="coerce")

    # Validación: no amounts negativos
    negative_count = (df["amount"] < 0).sum()
    if negative_count > 0:
        logger.warning(f"Found {negative_count} negative amounts, filtering out")
        df = df[df["amount"] >= 0]

    # Validación: no order IDs duplicados
    dup_count = df.duplicated(subset=["order_id"]).sum()
    if dup_count > 0:
        logger.warning(f"Found {dup_count} duplicate order IDs, dropping duplicates")
        df = df.drop_duplicates(subset=["order_id"], keep="last")

    # Validación: columnas requeridas presentes
    required_cols = ["order_id", "customer_id", "order_date", "amount"]
    missing = [c for c in required_cols if c not in df.columns]
    if missing:
        raise ValueError(f"Missing required columns: {missing}")

    # Columnas derivadas
    df["year"] = df["order_date"].dt.year
    df["month"] = df["order_date"].dt.month
    df["quarter"] = df["order_date"].dt.quarter

    return df

Output Parquet particionado

def load_partitioned(df: pd.DataFrame, base_path: str) -> None:
    """Cargar a Parquet particionado por year y month."""
    # Asegurar que las columnas de partición sean strings (requerimiento de Parquet)
    df["year"] = df["year"].astype(str)
    df["month"] = df["month"].astype(str).str.zfill(2)

    df.to_parquet(
        base_path,
        partition_cols=["year", "month"],
        index=False,
        engine="pyarrow",
        compression="snappy",
    )
    logger.info(f"Partitioned output at {base_path}/year=*/month=*")


def read_partitioned(base_path: str, year: str, month: str | None = None) -> pd.DataFrame:
    """Leer particiones específicas."""
    if month:
        path = f"{base_path}/year={year}/month={month}"
    else:
        path = f"{base_path}/year={year}"
    return pd.read_parquet(path)

Carga incremental (append a Parquet existente)

def load_incremental(df: pd.DataFrame, path: str) -> None:
    """Appendear nueva data a dataset Parquet existente."""
    from pathlib import Path

    if Path(path).exists():
        existing = pd.read_parquet(path)
        combined = pd.concat([existing, df], ignore_index=True)
        combined = combined.drop_duplicates(subset=["order_id"], keep="last")
    else:
        combined = df

    combined.to_parquet(path, index=False)
    logger.info(f"Incremental load: {len(df)} new rows, {len(combined)} total")

Pipeline con error handling y retries

import time

def extract_with_retry(path: str, retries: int = 3, delay: int = 5) -> pd.DataFrame:
    """Extraer con lógica de retry para sources de red."""
    for attempt in range(retries):
        try:
            if path.startswith("http"):
                df = pd.read_csv(path)
            else:
                df = pd.read_csv(path)
            return df
        except Exception as e:
            logger.warning(f"Attempt {attempt + 1}/{retries} failed: {e}")
            if attempt < retries - 1:
                time.sleep(delay * (attempt + 1))
            raise


def run_pipeline_safe(source: str, destination: str) -> bool:
    """Ejecutar pipeline con error handling completo."""
    try:
        df = extract_with_retry(source)
        df = transform_with_validation(df)
        load_partitioned(df, destination)
        logger.info("Pipeline completed successfully")
        return True
    except Exception as e:
        logger.error(f"Pipeline failed: {e}")
        return False

Enforcement de schema

EXPECTED_SCHEMA = {
    "order_id": "int64",
    "customer_id": "object",
    "order_date": "datetime64[ns]",
    "amount": "float64",
    "quantity": "Int64",
    "status": "category",
}

def enforce_schema(df: pd.DataFrame) -> pd.DataFrame:
    """Enforcear el schema esperado en el DataFrame."""
    for col, dtype in EXPECTED_SCHEMA.items():
        if col not in df.columns:
            raise ValueError(f"Missing column: {col}")
        if df[col].dtype != dtype:
            logger.info(f"Converting {col} from {df[col].dtype} to {dtype}")
            if dtype == "datetime64[ns]":
                df[col] = pd.to_datetime(df[col], errors="coerce")
            elif dtype == "category":
                df[col] = df[col].astype("category")
            else:
                df[col] = df[col].astype(dtype)
    return df

Variants

Usar PyArrow directamente para archivos grandes

import pyarrow.parquet as pq
import pyarrow as pa

def load_with_pyarrow(df: pd.DataFrame, path: str) -> None:
    """Escribir Parquet usando PyArrow para más control."""
    table = pa.Table.from_pandas(df, preserve_index=False)
    pq.write_table(
        table,
        path,
        compression="zstd",
        compression_level=3,
        use_dictionary=True,
        write_statistics=True,
    )

Pipeline con logging a archivo

import logging.handlers

def setup_logging(log_path: str = "logs/etl.log") -> None:
    """Set up logging a archivo + consola."""
    handler = logging.handlers.RotatingFileHandler(
        log_path, maxBytes=10_000_000, backupCount=5
    )
    handler.setFormatter(logging.Formatter(
        "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
    ))
    logging.basicConfig(
        level=logging.INFO,
        handlers=[handler, logging.StreamHandler()],
    )

Orquestación de pipeline con config

import yaml

def load_config(config_path: str) -> dict:
    with open(config_path) as f:
        return yaml.safe_load(f)

def run_pipeline_from_config(config_path: str) -> None:
    config = load_config(config_path)

    df = pd.read_csv(config["source"])
    for transform_config in config.get("transforms", []):
        if transform_config["type"] == "rename":
            df = df.rename(columns=transform_config["mapping"])
        elif transform_config["type"] == "filter":
            df = df.query(transform_config["condition"])
        elif transform_config["type"] == "cast":
            df[transform_config["column"]] = df[transform_config["column"]].astype(
                transform_config["dtype"]
            )

    df.to_parquet(
        config["destination"],
        partition_cols=config.get("partition_cols"),
        index=False,
    )

Best Practices

  • For a deeper guide, see Schedule and Monitor DAGs with Apache Airflow.

  • Usa errors="coerce" en pd.to_numeric y pd.to_datetime — convierte valores inválidos a NaN en lugar de lanzar error

  • Particiona por columnas de fecha (year, month) — habilita reads eficientes de rangos de tiempo específicos

  • Usa compresión snappy para velocidad, zstd para mejor ratio de compresión

  • Loggea row counts en cada stage — hace el debugging de issues del pipeline más fácil

  • Valida data antes de escribir — atrapa issues temprano, no propagues data mala

  • Usa Int64 (nullable integer) en lugar de int64 cuando la data puede tener missing values

  • Escribe statistics en Parquet — habilita predicate pushdown para queries más rápidas

Common Mistakes

  • No manejar missing values: pd.to_numeric sin errors="coerce" lanza error en data inválida. Usa errors="coerce" y maneja NaN downstream.
  • Usar CSV como formato intermedio: CSV pierde información de tipos. Usa Parquet para almacenamiento intermedio.
  • No particionar datasets grandes: un solo archivo Parquet de 10GB es lento de leer. Particiona por fecha.
  • Ignorar dtypes después de leer: pd.read_csv infiere tipos, que pueden ser incorrectos. Cast explícitamente las columnas después de leer.
  • No deduplicar en cargas incrementales: appendear sin deduplicación crea filas duplicadas. Usa drop_duplicates.

FAQ

¿Por qué usar Parquet en lugar de CSV?

Parquet preserva tipos de datos (enteros quedan enteros, fechas quedan fechas), comprime 3-10x mejor que CSV y soporta reads columnares (solo leer las columnas que necesitas). CSV requiere re-parsing de tipos en cada read.

¿Cómo manejo datasets más grandes que la memoria?

Usa el parámetro chunksize en pd.read_csv para procesar en batches, o cambia a Polars/Dask que manejan out-of-core computation nativamente.

¿Qué compresión debería usar?

snappy para read/write rápido (bueno para archivos intermedios). zstd para mejor ratio de compresión (bueno para archival). gzip para compatibilidad pero más lento que ambos.

¿Cómo leo particiones específicas?

df = pd.read_parquet("data/orders/year=2025/month=01")

El particionado de Parquet crea estructuras de directorios que pandas puede leer directamente.

¿Debería usar pandas o Polars para ETL?

Usa pandas para datasets bajo 1GB y cuando necesitas compatibilidad con el ecosistema. Usa Polars para datasets más grandes o cuando la velocidad es crítica — es 5-30x más rápido para la mayoría de operaciones.