03 Python Datos
03 Python Datos
Datos
pandas, ETL, fidelidad de datos y pipelines reproducibles
Documento técnico
Tabla de contenido
1. El ecosistema y cuándo usar cada herramienta
1.1 El mapa
1.2 Regla de decisión práctica
1.3 El caso que motiva este documento
1.4 Instalación y versiones
7. Selección e indexación
7.1 Los tres accesores
7.2 Filtrado booleano
7.3 SettingWithCopyWarning
7.4 Índices
7.5 Operaciones frecuentes
8. Valores faltantes
8.1 Los tres nulos
8.2 Detectar
8.3 Rellenar
8.4 Eliminar
8.5 Nulo frente a cadena vacía
Cierre
Tabla de contenido
1. El ecosistema y cuándo usar cada herramienta
2. Leer CSV: el problema de la inferencia de tipos
3. dtype=str y la fidelidad de los datos
4. Codificaciones y el desastre del mojibake
5. Archivos grandes: fragmentos y memoria
6. Tipos de pandas y consumo de memoria
7. Selección e indexación
8. Valores faltantes
9. Vectorización frente a apply
10. Fechas y horas
11. Texto y expresiones regulares
12. groupby y agregación
13. Combinar tablas: merge , join , concat
14. Remodelado: pivot , melt , stack
15. Escribir Excel con openpyxl
16. Excel avanzado: formato y varias hojas
17. Validación de datos
18. Arquitectura de un pipeline ETL
19. Logging y trazabilidad
20. Pruebas de transformaciones
21. Bases de datos: SQLAlchemy y MySQL
22. Alternativas modernas: Polars y DuckDB
23. Parquet y formatos columnares
24. Rendimiento y perfilado
25. Antipatrones y lista de verificación
1. El ecosistema y cuándo usar cada
herramienta
1.1 El mapa
Herramienta Fuerte en Débil en
No hay una respuesta única. Un pipeline maduro suele usar tres o cuatro.
Parece trivial. No lo es. Este código, que es lo primero que escribe todo el mundo, está mal:
import pandas as pd
df = pd.read_csv("[Link]")
df.to_excel("[Link]", index=False)
En un reporte de facturación clínica, cualquiera de estas conversiones es un error de datos que llega al
usuario final. El capítulo 3 resuelve esto de forma definitiva.
pandas==2.2.2
openpyxl==3.1.5
pyarrow==17.0.0
SQLAlchemy==2.0.32
PyMySQL==1.1.1
python-dotenv==1.0.1
polars==1.5.0
duckdb==1.0.0
import pandas as pd
from io import StringIO
datos = StringIO(
"codigo,telefono,fecha,monto,notas\n"
"007,0551234567,2026-07-15,1234.50,NA\n"
"008,0559876543,2026-07-16,890.00,sin observaciones\n"
)
df = pd.read_csv(datos)
print([Link])
print(df)
codigo int64
telefono int64
fecha object
monto float64
notas object
Tres daños en dos filas: 007 → 7 , el teléfono perdió su cero inicial, y la cadena NA se convirtió en nulo.
Si tu columna estado tiene el valor legítimo "NA" (Nuevo Álamo, No Aplica, Norteamérica…), pandas lo
destruye silenciosamente.
Desactivarlo:
Ahora ninguna cadena se convierte en nulo. Los campos vacíos quedan como cadena vacía "" .
O ser selectivo:
df = pd.read_csv(
"[Link]",
keep_default_na=False,
na_values=["", "NULL"], # solo estos dos
)
df = pd.read_csv(
"[Link]",
dtype=str, # todo como texto (capítulo 3)
keep_default_na=False, # no convertir cadenas a NaN
na_values=[], # ninguna cadena es nula
encoding="utf-8-sig", # tolera el BOM de Windows
sep=",", # separador; usa ";" en CSV europeos
quotechar='"',
escapechar=None,
skipinitialspace=False,
engine="c", # "c" es rápido; "python" es más flexible
on_bad_lines="error", # "warn" o "skip" para tolerar errores
low_memory=False, # evita inferencia por bloques inconsistente
)
inspeccionar("[Link]")
import csv
print("Separador:", repr([Link]))
print("Comillas:", repr([Link]))
print("¿Cabecera?:", tiene_cabecera)
El Sniffer no es infalible (falla con archivos de una sola columna o con datos que contienen muchos
puntos y comas), pero es un buen primer paso automático.
2.5 Archivos mal formados
# Registrar y continuar
def manejar_mala_linea(linea):
print(f"Línea descartada: {linea}")
return None # descartar
# Excel
df = pd.read_excel("[Link]", sheet_name="Datos", dtype=str, engine="openpyxl")
hojas = pd.read_excel("[Link]", sheet_name=None, dtype=str) # dict de DataFrames
# Ancho fijo
df = pd.read_fwf("[Link]", colspecs=[(0,10),(10,30),(30,40)],
names=["codigo","nombre","monto"], dtype=str)
# JSON
df = pd.read_json("[Link]", dtype=str)
df = pd.read_json("[Link]", lines=True, dtype=str) # JSON Lines
3.1 El principio
Cuando el objetivo es conservar los datos, no analizarlos, léelo todo como texto.
Un CSV es un archivo de texto. Cada campo es una cadena de caracteres. Si la tarea es “convertir de
CSV a Excel sin transformar”, la representación correcta durante todo el proceso es la cadena original,
byte a byte.
Cualquier conversión a número, fecha o booleano es una interpretación, y toda interpretación puede
equivocarse.
import pandas as pd
df = pd.read_csv(
"reporte_clinicas.csv",
dtype=str, # cada columna es texto
keep_default_na=False, # "NA", "NULL", "None" se conservan literalmente
na_values=[], # ninguna cadena adicional es nula
encoding="utf-8-sig", # tolera BOM
)
No hay ninguna pérdida de información. El DataFrame es una representación exacta del archivo.
def csv_a_excel_fiel(ruta_csv: str, ruta_xlsx: str, nombre_hoja: str = "Datos") -> None:
"""Convierte CSV a XLSX sin ninguna transformación de datos."""
df = pd.read_csv(
ruta_csv,
dtype=str,
keep_default_na=False,
na_values=[],
encoding="utf-8-sig",
)
wb = Workbook()
ws = [Link]
[Link] = nombre_hoja
# Cabecera
[Link](list([Link]))
ws.freeze_panes = "A2"
[Link](ruta_xlsx)
csv_a_excel_fiel("reporte_clinicas.csv", "reporte_clinicas.xlsx")
El number_format = "@" es la pieza crítica. Sin él, Excel aplicará su propia inferencia al abrir el archivo y
volverás al punto de partida.
if list([Link]) != list([Link]):
print("[FALLO] Las columnas difieren")
print(" CSV :", list([Link]))
print(" XLSX:", list([Link]))
return False
if [Link] != [Link]:
print(f"[FALLO] Dimensiones: {[Link]} vs {[Link]}")
return False
verificar_fidelidad("reporte_clinicas.csv", "reporte_clinicas.xlsx")
Ejecutar esta verificación en cada corrida convierte un script frágil en un proceso confiable.
El patrón profesional separa las dos fases: leer como texto, validar, y convertir explícitamente solo las
columnas que vas a analizar.
Nota importante: mantén la columna original junto a la convertida. Así el reporte siempre puede
mostrar el dato tal como llegó.
df["monto_num"] = pd.to_numeric(
df["monto"].[Link](".", "", regex=False) # quitar separador de miles
.[Link](",", ".", regex=False), # coma decimal a punto
errors="coerce"
)
Cuidado con el orden de los reemplazos: quitar los puntos antes de convertir la coma. Al revés se
destruye el número.
4. Codificaciones y el desastre del mojibake
4.1 El problema
Un archivo de texto es una secuencia de bytes. Interpretarlos como caracteres requiere conocer la
codificación. Si adivinas mal, obtienes mojibake:
Esto es especialmente frecuente en entornos mixtos Windows/Mac, que es exactamente donde ocurre en
la práctica.
Truco importante: latin-1 mapea los 256 bytes posibles a caracteres, así que nunca lanza error de
decodificación. Esto lo hace útil como último recurso, pero también significa que “funcionó sin error”
no prueba que fuera la codificación correcta.
4.3 El BOM
El Byte Order Mark es una secuencia de 3 bytes ( EF BB BF ) al inicio de algunos archivos UTF-8. Excel
para Windows lo añade al exportar CSV.
Si lees con encoding="utf-8" en vez de utf-8-sig , el BOM aparece pegado al primer nombre de
columna:
df = pd.read_csv("desde_excel.csv", encoding="utf-8")
print([Link]())
# ['\ufeffcodigo', 'nombre', 'monto'] ← el primero está corrupto
df["codigo"] # KeyError
4.4 Detección
def detectar_codificacion(ruta: str) -> str:
"""Prueba codificaciones en orden de probabilidad."""
candidatas = ["utf-8-sig", "utf-8", "cp1252", "latin-1"]
with open(ruta, "rb") as f:
crudo = [Link](200_000)
Con la librería charset-normalizer (dependencia de requests , así que suele estar instalada):
resultado = from_path("[Link]").best()
print([Link], [Link])
print(reparar_mojibake("José GarcÃ
a")) # José García
df["nombre"] = df["nombre"].map(reparar_mojibake)
Advertencia: solo funciona si la corrupción es de un solo paso. Si el texto pasó por dos conversiones
erróneas, la información puede haberse perdido definitivamente (varios caracteres distintos colapsan al
mismo ? ).
El BOM es la diferencia entre que un usuario de Windows vea José o José al hacer doble clic en el
archivo. Como los CSV generados casi siempre los abre alguien en Excel, utf-8-sig es el valor por
defecto correcto en la práctica.
Sin newline="" , Python en Windows escribe \r\r\n y aparecen filas vacías intercaladas en Excel. Es un
error clásico y muy desconcertante.
En pandas:
estimar_memoria("reporte_grande.csv")
El factor depende del contenido: columnas de texto corto con muchos valores repetidos se expanden
mucho (cada cadena de Python tiene ~50 bytes de sobrecarga); columnas numéricas se comprimen.
df = pd.read_csv("[Link]", dtype=str)
print([Link](memory_usage="deep"))
print(f"{df.memory_usage(deep=True).sum() / 1024**2:.1f} MB")
deep=True es imprescindible: sin él, pandas reporta solo el tamaño de los punteros, no el de las cadenas
apuntadas. La diferencia puede ser de 20×.
import pandas as pd
TAMANO = 100_000
lector = pd.read_csv(
"reporte_grande.csv",
dtype=str,
keep_default_na=False,
na_values=[],
chunksize=TAMANO,
)
total_filas = 0
acumulado = {}
# Procesar el fragmento
for clave, valor in [Link]("sucursal")["monto"].apply(
lambda s: pd.to_numeric(s, errors="coerce").sum()
).items():
acumulado[clave] = [Link](clave, 0) + valor
print(acumulado)
fragmentos_filtrados = []
df = [Link](fragmentos_filtrados, ignore_index=True)
Patrón clave: acumula en una lista y haz un solo [Link] al final. Concatenar dentro del bucle es
O(n²) porque copia todo el acumulado en cada iteración.
En un archivo de 60 columnas donde usas 4, esto reduce la memoria y el tiempo de lectura en más del
90%. Es la optimización más rentable y la más olvidada.
import csv
lector = [Link](fe)
escritor = [Link](fs, fieldnames=[Link] + ["monto_iva"])
[Link]()
[Link]("[Link]")
Limitaciones del modo write-only: no puedes leer celdas ya escritas, ni acceder a [Link]() , ni congelar
paneles después. El formato debe aplicarse con objetos WriteOnlyCell al momento de escribir.
Y un límite duro que hay que conocer: una hoja de Excel admite 1.048.576 filas y 16.384
columnas. Si tu CSV tiene 3 millones de filas, Excel no es el formato de destino adecuado. Considera
Parquet, o dividir en varias hojas.
LIMITE = 1_000_000
6.1 El catálogo
dtype Descripción Soporta nulos
timedelta64[ns] Duración Sí
Nótese la convención: los tipos con inicial mayúscula ( Int64 , Float64 , Boolean ) son los nullable de
pandas.
if [Link].is_integer_dtype(tipo):
out[col] = pd.to_numeric(out[col], downcast="integer")
elif [Link].is_float_dtype(tipo):
out[col] = pd.to_numeric(out[col], downcast="float")
En datasets reales con muchas columnas de baja cardinalidad (sucursal, estado, tipo de servicio), la
reducción típica está entre el 60% y el 90%.
6.3 category : el mayor ahorro
import pandas as pd
c = [Link]("category")
print(f"category: {c.memory_usage(deep=True) / 1024**2:.1f} MB")
object: 88.4 MB
category: 1.4 MB
Internamente, category guarda un array de códigos enteros pequeños más un diccionario de valores
únicos. Con 3 valores distintos en 1,5 millones de filas, el ahorro es de 60×.
orden = CategoricalDtype(
categories=["bajo", "medio", "alto", "urgente"], ordered=True
)
df["prioridad"] = df["prioridad"].astype(orden)
import pandas as pd
import numpy as np
Esto importa muchísimo al escribir a Excel o a una base de datos: un identificador 4821 que se convierte
en 4821.0 genera errores de JOIN y de presentación.
df["paciente_id"] = df["paciente_id"].astype("Int64")
Ventajas: menor consumo de memoria en cadenas, operaciones de texto más rápidas, y conversión sin
copia a/desde Parquet.
Limitación actual: no todas las operaciones de pandas están implementadas sobre Arrow, y algunas caen
de vuelta a NumPy silenciosamente. En 2026 es una opción sólida pero conviene probar el pipeline
completo antes de adoptarla.
# Numérico
df["monto"] = pd.to_numeric(df["monto"], errors="coerce")
df["monto"] = pd.to_numeric(df["monto"], errors="raise") # falla ruidosamente
.map() con diccionario es preferible a una cadena de replace : los valores no contemplados quedan
como NA en vez de conservarse silenciosamente con un valor incorrecto.
7. Selección e indexación
Diferencia que causa errores constantemente: loc incluye el límite superior; iloc no. [Link][0:5]
da 6 filas, [Link][0:5] da 5.
Reglas obligatorias: - Usa & , | , ~ — no and , or , not (estos operan sobre el valor de verdad del array
completo y lanzan ValueError ). - Paréntesis alrededor de cada condición: la precedencia de & es
mayor que la de == .
df[df["sucursal"].isin(["MATRIZ", "NORTE"])]
df[df["nombre"].[Link]("García", na=False)]
df[df["fecha"].between("2026-01-01", "2026-06-30")]
df[df["monto"].notna()]
7.3 SettingWithCopyWarning
Regla: una sola llamada a .loc para asignar; nunca df[...][...] = valor .
pandas 3.0 adopta Copy-on-Write por defecto, lo que elimina la ambigüedad. Puedes activarlo ya:
[Link].copy_on_write = True
Con CoW, el encadenamiento nunca modifica el original (falla de forma predecible en vez de a veces sí y
a veces no). Merece la pena activarlo en proyectos nuevos.
7.4 Índices
df = df.set_index("codigo")
df = df.reset_index()
df = df.reset_index(drop=True) # descartar el índice viejo
[Link] = "id"
# Índice múltiple
df = df.set_index(["sucursal", "fecha"])
[Link]["MATRIZ"]
[Link][("MATRIZ", "2026-07-15")]
[Link]("MATRIZ", level="sucursal")
Un índice bien elegido acelera enormemente las búsquedas repetidas (pandas usa una tabla hash), pero
complica el código. En pipelines ETL, suele ser más simple trabajar con RangeIndex y columnas
normales, y usar índices solo cuando hay una necesidad de rendimiento medida.
df["sucursal"].unique()
df["sucursal"].nunique()
df["sucursal"].value_counts()
df["sucursal"].value_counts(normalize=True) # proporciones
df.sort_values("monto", ascending=False)
df.sort_values(["sucursal", "fecha"], ascending=[True, False])
[Link](10, "monto")
[Link](10, "monto")
df.drop_duplicates()
df.drop_duplicates(subset=["codigo"], keep="last")
[Link](subset=["codigo"]).sum()
import numpy as np
import pandas as pd
8.2 Detectar
# Resumen legible
resumen = [Link]({
"nulos": [Link]().sum(),
"pct": ([Link]().mean() * 100).round(2),
"tipo": [Link](str),
})
print(resumen[resumen["nulos"] > 0].sort_values("nulos", ascending=False))
8.3 Rellenar
df["monto"] = df["monto"].fillna(0)
df["estado"] = df["estado"].fillna("desconocido")
# Con estadísticos
df["edad"] = df["edad"].fillna(df["edad"].median())
# Interpolación
df["temperatura"] = df["temperatura"].interpolate(method="linear")
Advertencia metodológica: rellenar nulos es una decisión analítica con consecuencias, no un paso de
limpieza rutinario. Sustituir montos faltantes por la media inventa datos y sesga cualquier conclusión
posterior. En un pipeline de fidelidad, no rellenes nada.
df["monto_era_nulo"] = df["monto"].isna()
df["monto"] = df["monto"].fillna(0)
8.4 Eliminar
Al escribir a Excel, NaN aparece como celda vacía y "" también. Pero al escribir a una base de datos,
NULL y '' son valores diferentes con semántica diferente.
n = 1_000_000
df = [Link]({
"a": [Link](n),
"b": [Link](n),
})
Resultados típicos:
vectorizado 0.008 s
apply sobre serie 0.312 s
apply axis=1 14.203 s
iterrows 52.841 s
iterrows es 6.600 veces más lento que la versión vectorizada. Y no es un caso artificial: es
exactamente lo que la gente escribe cuando viene de programación imperativa.
# BIEN: [Link]
condiciones = [df["monto"] > 5000, df["monto"] > 1000]
opciones = ["alto", "medio"]
df["categoria"] = [Link](condiciones, opciones, default="bajo")
# Anidado
df["tipo"] = [Link](
df["monto"] > 5000, "alto",
[Link](df["monto"] > 1000, "medio", "bajo")
)
[Link] evalúa las condiciones en orden y toma la primera verdadera, igual que un if/elif .
# MAL
df["nombre_sucursal"] = df["sucursal_id"].apply(lambda x: [Link](x, "?"))
# MAL
df["nombre"] = df["nombre"].apply(lambda s: [Link]().upper())
# Encadenado
df["email_dominio"] = (
df["email"].[Link]().[Link]().[Link]("@").str[-1]
)
En esos casos, sigue prefiriendo apply sobre una Serie antes que axis=1 :
# Preferible
df["x"] = df["col"].apply(mi_funcion)
# iterrows: crea una Serie por fila. Muy lento y pierde los dtypes.
for indice, fila in [Link]():
print(fila["codigo"])
El último patrón, zip sobre columnas, es la forma más rápida de iterar en pandas y la que deberías usar
cuando la iteración es inevitable.
10. Fechas y horas
10.1 Conversión
# Formato día/mes/año
df["fecha"] = pd.to_datetime(df["fecha"], format="%d/%m/%Y")
# Tolerar errores
df["fecha"] = pd.to_datetime(df["fecha"], errors="coerce") # inválidas -> NaT
# Desde epoch
df["fecha"] = pd.to_datetime(df["ts"], unit="s")
df["fecha"] = pd.to_datetime(df["ts_ms"], unit="ms")
Sobre dayfirst : 01/02/2026 es ambiguo. Sin format ni dayfirst , pandas usará una heurística que
puede cambiar entre versiones y, peor, interpretar de forma distinta filas distintas del mismo archivo.
Especifica siempre el formato cuando lo conozcas.
%y Año 2 dígitos 26
%m Mes 01–12 07
%d Día 01–31 15
%H Hora 00–23 14
%M Minuto 32
%S Segundo 11
%f Microsegundos 482913
df["formateada"] = df["fecha"].[Link]("%d/%m/%Y")
10.3 Aritmética
df["fecha"] + [Link](days=30)
df["fecha"] - [Link](months=1) # respeta la longitud del mes
df["fecha"] + [Link](5) # 5 días hábiles
df["fecha"].dt.tz_localize(
"America/Mexico_City",
ambiguous="NaT", # hora repetida al retrasar el reloj
nonexistent="NaT", # hora inexistente al adelantarlo
)
Recomendación firme: guarda todo en UTC, convierte solo para mostrar. Mezclar zonas horarias en
el almacenamiento es una fuente inagotable de errores difíciles de reproducir.
df["monto"].resample("D").sum() # diario
df["monto"].resample("W").mean() # semanal
df["monto"].resample("ME").sum() # fin de mes
df["monto"].resample("QE").sum() # fin de trimestre
# Ventanas móviles
df["media_movil_7"] = df["monto"].rolling(window=7, min_periods=1).mean()
df["acumulado"] = df["monto"].expanding().sum()
df["ema"] = df["monto"].ewm(span=30).mean()
Nota de versión: los alias M , Q , Y están deprecados en pandas 2.2+ en favor de ME , QE , YE (month
end, etc.). Si mantienes código antiguo, verás avisos.
df["nombre"].[Link]()
df["nombre"].[Link]()
df["nombre"].[Link]()
df["nombre"].[Link]()
df["nombre"].[Link]()
df["nombre"].[Link](" ", " ", regex=False)
df["nombre"].[Link]("García", case=False, na=False)
df["nombre"].[Link]("Dr.")
df["nombre"].[Link](" ")
df["nombre"].[Link](" ", expand=True) # devuelve un DataFrame
df["nombre"].[Link](df["apellido"], sep=" ")
df["codigo"].[Link](6) # "7" -> "000007"
df["codigo"].[Link](10, side="left", fillchar="0")
df["texto"].[Link](0, 10)
df["texto"].str[0:10] # equivalente
na=False en contains es importante: sin él, los nulos producen NaN en la máscara booleana, lo que
rompe el filtrado con un error poco claro.
df["nombre"] = limpiar_texto(df["nombre"])
Los caracteres \u00a0 (espacio duro) y \u200b (espacio de ancho cero) son la causa número uno de
“este texto se ve igual pero no coincide”. Vienen de copiar y pegar desde páginas web o Word.
import unicodedata
df["nombre_busqueda"] = sin_acentos(df["nombre"].[Link]())
Guarda el original y añade la versión normalizada como columna aparte. Nunca destruyas el dato de
origen.
# Validar formato
patron_rfc = r"^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$"
df["rfc_valido"] = df["rfc"].[Link](patron_rfc, na=False)
PATRONES = {
"email": r"^[\w\.\-\+]+@[\w\-]+(\.[\w\-]+)+$",
"rfc_pf": r"^[A-ZÑ&]{4}\d{6}[A-Z0-9]{3}$", # persona física
"rfc_pm": r"^[A-ZÑ&]{3}\d{6}[A-Z0-9]{3}$", # persona moral
"curp": r"^[A-Z]{4}\d{6}[HM][A-Z]{5}[A-Z0-9]\d$",
"cp_mx": r"^\d{5}$",
"tel_mx": r"^\+?52?\s?\d{10}$",
}
# Lento
df["tiene_dr"] = df["nombre"].[Link](r"^Dr\.", regex=True)
# Rápido
df["tiene_dr"] = df["nombre"].[Link]("Dr.")
En pandas 2, regex por defecto es False en [Link] . En versiones anteriores era True . Especifícalo
siempre de forma explícita para no depender de la versión.
emparejados = df["sucursal_texto"].map(emparejar)
df["sucursal_norm"] = [x[0] for x in emparejados]
df["similitud"] = [x[1] for x in emparejados]
12.1 Básico
[Link]("sucursal")["monto"].sum()
[Link]("sucursal")["monto"].agg(["sum", "mean", "count", "std"])
[Link](["sucursal", "doctor"])["monto"].sum()
reset_index() al final convierte el índice de grupo en columna normal, que suele ser lo que quieres para
exportar.
Como siempre: apply sobre grupos es cómodo pero lento. Con miles de grupos, la diferencia es de
minutos a segundos.
observed=True importa mucho: con dos columnas categóricas de 50 y 200 valores, observed=False genera
10.000 filas, la mayoría vacías. En pandas 3 el valor por defecto pasa a ser True .
13.1 merge
r = [Link](
citas, pacientes,
left_on="paciente_id",
right_on="id",
how="left",
suffixes=("_cita", "_paciente"),
validate="many_to_one",
indicator=True,
)
Tipos de how :
how Resultado
Si la relación no se cumple, pandas lanza MergeError inmediatamente en vez de multiplicar las filas
silenciosamente.
Este es el error más caro de todo el trabajo con datos: un merge que duplica filas porque la tabla de la
derecha tenía duplicados en la clave. El total del reporte sale inflado, nadie lo nota, y se toman
decisiones sobre números falsos.
both 48213
left_only 142 ← citas con paciente_id inexistente: datos huérfanos
right_only 1023 ← pacientes sin ninguna cita
Esos 142 registros huérfanos son un problema de calidad de datos que hay que reportar, no ignorar.
13.5 concat
# Horizontalmente
ancho = [Link]([df1, df2], axis=1)
archivos = sorted(Path("datos/").glob("reporte_*.csv"))
partes = []
for a in archivos:
d = pd.read_csv(a, dtype=str, keep_default_na=False, na_values=[])
d["archivo_origen"] = [Link]
[Link](d)
df = [Link](partes, ignore_index=True)
print(f"{len(archivos)} archivos, {len(df):,} filas")
La columna archivo_origen es oro puro para depurar. Cuando aparezca una fila anómala, sabrás
exactamente de dónde vino.
Formato ancho: una fila por entidad, una columna por periodo.
El largo es mejor para procesar y almacenar; el ancho es mejor para que un humano lo lea en Excel. Un
pipeline típico procesa en largo y exporta en ancho.
pivot falla si hay duplicados en (index, columns) . pivot_table los agrega según aggfunc . En datos
reales, casi siempre quieres pivot_table .
largo = ancho.reset_index().melt(
id_vars=["sucursal"],
var_name="mes",
value_name="monto",
)
melt es la operación que más se necesita al recibir hojas de Excel diseñadas por humanos: una columna
por mes, una fila por sucursal.
14.4 stack y unstack
df = [Link]({
"cita_id": [1, 2],
"servicios": ["limpieza,radiografia", "extraccion"],
})
df["servicios"] = df["servicios"].[Link](",")
df = [Link]("servicios").reset_index(drop=True)
df["servicios"] = df["servicios"].[Link]()
cita_id servicios
0 1 limpieza
1 1 radiografia
2 2 extraccion
Normalizar una columna con valores delimitados es un paso frecuente al recibir exportaciones de
sistemas mal diseñados.
import json
registros = [Link](Path("[Link]").read_text(encoding="utf-8"))
df = pd.json_normalize(
registros,
record_path=["citas"], # el array a expandir
meta=["id", ["paciente", "nombre"]], # campos del nivel superior
meta_prefix="pac_",
sep="_",
errors="ignore",
)
# Una hoja
df.to_excel("[Link]", sheet_name="Datos", index=False)
# Varias hojas
with [Link]("[Link]", engine="openpyxl") as writer:
resumen.to_excel(writer, sheet_name="Resumen", index=False)
detalle.to_excel(writer, sheet_name="Detalle", index=False)
for suc, grupo in [Link]("sucursal"):
# Los nombres de hoja: máx. 31 caracteres, sin : \ / ? * [ ]
nombre = str(suc)[:31].replace("/", "-").replace("\\", "-")
grupo.to_excel(writer, sheet_name=nombre, index=False)
wb = Workbook()
ws = [Link]
[Link] = "Reporte"
# Cabecera
encabezados = list([Link])
[Link](encabezados)
# Datos
for fila in [Link](index=False, name=None):
[Link](list(fila))
# Bordes finos
borde = Border(*[Side(style="thin", color="D5DAE0")] * 4)
for fila in ws.iter_rows(min_row=1, max_row=ws.max_row, max_col=ws.max_column):
for celda in fila:
[Link] = borde
# Anchos
for i, col in enumerate([Link], start=1):
ancho = max(len(str(col)), int(df[col].astype(str).[Link]().max() or 0))
ws.column_dimensions[get_column_letter(i)].width = min(ancho + 3, 55)
[Link]("reporte_formateado.xlsx")
FORMATOS = {
"texto": "@",
"entero": "#,##0",
"decimal": "#,##0.00",
"moneda_mx": '"$"#,##0.00',
"porcentaje": "0.0%",
"fecha": "dd/mm/yyyy",
"fecha_hora": "dd/mm/yyyy hh:mm",
"contable": '_-"$"* #,##0.00_-;-"$"* #,##0.00_-;_-"$"* "-"??_-;_-@_-',
}
MAPA_COLUMNAS = {
"codigo": "texto",
"telefono": "texto",
"monto": "moneda_mx",
"fecha": "fecha",
"margen": "porcentaje",
}
# Escala de color
ws.conditional_formatting.add(
"E2:E1000",
ColorScaleRule(
start_type="min", start_color="F8696B",
mid_type="percentile", mid_value=50, mid_color="FFEB84",
end_type="max", end_color="63BE7B",
),
)
# Resaltar valores
ws.conditional_formatting.add(
"E2:E1000",
CellIsRule(operator="lessThan", formula=["0"],
fill=PatternFill("solid", fgColor="FFC7CE"),
font=Font(color="9C0006")),
)
# Barras de datos
ws.conditional_formatting.add("F2:F1000", DataBarRule(color="638EC6"))
# Fórmulas
ws["G2"] = "=E2*1.16"
ws[f"E{ws.max_row + 1}"] = f"=SUM(E2:E{ws.max_row})"
# Lista desplegable
from [Link] import DataValidation
dv = DataValidation(type="list", formula1='"activo,inactivo,suspendido"',
allow_blank=True, showDropDown=False)
ws.add_data_validation(dv)
[Link]("H2:H1000")
# Gráfico de barras
from [Link] import BarChart, Reference
grafico = BarChart()
[Link] = "Ingresos por sucursal"
grafico.y_axis.title = "Monto (MXN)"
datos = Reference(ws, min_col=5, min_row=1, max_row=ws.max_row)
categorias = Reference(ws, min_col=1, min_row=2, max_row=ws.max_row)
grafico.add_data(datos, titles_from_data=True)
grafico.set_categories(categorias)
[Link], [Link] = 10, 20
ws.add_chart(grafico, "J2")
Nota sobre las tablas: el displayName debe ser único en el libro y no puede contener espacios.
Esa limitación de data_only=True sorprende a mucha gente: openpyxl no evalúa fórmulas. Si necesitas
los resultados, el archivo debe haber pasado por Excel o LibreOffice.
16. Excel avanzado: formato y varias hojas
@dataclass
class EstiloReporte:
color_cabecera: str = "0B3D5C"
color_texto_cab: str = "FFFFFF"
color_bordes: str = "D5DAE0"
ancho_max: int = 55
congelar: str = "A2"
autofiltro: bool = True
class GeneradorReporte:
def __init__(self, ruta: str | Path, estilo: EstiloReporte | None = None):
[Link] = Path(ruta)
[Link] = estilo or EstiloReporte()
[Link] = Workbook()
[Link]([Link]) # quitar la hoja vacía inicial
[Link](list([Link]))
for fila in [Link](index=False, name=None):
[Link](list(fila))
self._estilar_cabecera(ws)
self._aplicar_bordes(ws)
self._ajustar_anchos(ws, df)
if formatos:
self._aplicar_formatos(ws, df, formatos)
if [Link]:
ws.freeze_panes = [Link]
if [Link] and ws.max_row > 1:
ws.auto_filter.ref = [Link]
@staticmethod
def _limpiar_nombre(nombre: str) -> str:
for c in r"[]:*?/\\":
nombre = [Link](c, "-")
return nombre[:31] or "Hoja"
Uso:
gen = GeneradorReporte("salida/reporte_julio.xlsx")
print(f"Generado: {[Link]()}")
ws["B2"] = titulo
ws["B2"].font = Font(size=22, bold=True, color="0B3D5C")
fila = 4
for clave, valor in [Link]():
ws[f"B{fila}"] = clave
ws[f"B{fila}"].font = Font(bold=True)
ws[f"C{fila}"] = str(valor)
fila += 1
ws.column_dimensions["B"].width = 28
ws.column_dimensions["C"].width = 55
Una portada con la procedencia y el hash del archivo de origen convierte un Excel anónimo en un
documento auditable. Cuando alguien pregunte “¿de dónde salió este número?”, la respuesta está en la
hoja 1.
[Link] = True
[Link] = "clave" # disuasorio, NO seguridad real
[Link] = False # permitir filtrar aunque esté protegida
[Link] = False
Que quede claro: la protección de hojas de Excel es trivialmente removible. Sirve para evitar ediciones
accidentales, no para proteger información. Si los datos son confidenciales, la solución es el control de
acceso al archivo, no una contraseña de hoja.
17. Validación de datos
@dataclass
class Regla:
nombre: str
columna: str
prueba: Callable[[[Link]], [Link]] # devuelve máscara de VÁLIDOS
critica: bool = True
@dataclass
class ResultadoValidacion:
errores: list[str] = field(default_factory=list)
avisos: list[str] = field(default_factory=list)
detalle: dict[str, [Link]] = field(default_factory=dict)
@property
def valido(self) -> bool:
return not [Link]
mascara_ok = [Link](df[[Link]])
fallos = [Link][~mascara_ok]
if not [Link]:
msg = f"{[Link]}: {len(fallos):,} filas incumplen"
([Link] if [Link] else [Link]).append(msg)
[Link][[Link]] = fallos
return r
Uso:
REGLAS = [
Regla("codigo no vacío", "codigo",
lambda s: [Link]().ne("")),
Regla("codigo único", "codigo",
lambda s: ~[Link](keep=False)),
Regla("monto numérico", "monto",
lambda s: pd.to_numeric(s, errors="coerce").notna()),
Regla("monto no negativo", "monto",
lambda s: pd.to_numeric(s, errors="coerce").fillna(0) >= 0),
Regla("fecha válida", "fecha",
lambda s: pd.to_datetime(s, format="%Y-%m-%d", errors="coerce").notna()),
Regla("email con formato", "email",
lambda s: [Link]("") | [Link](r"^[\w\.\-\+]+@[\w\-]+\.\w+$", na=False),
critica=False),
Regla("sucursal conocida", "sucursal",
lambda s: [Link](["MATRIZ", "NORTE", "SUR", "CENTRO"])),
]
if not [Link]:
for e in [Link]:
print(f"[ERROR] {e}")
for a in [Link]:
print(f"[AVISO] {a}")
Ese ExcelWriter con los registros que fallaron es la diferencia entre un pipeline que dice “hubo errores”
y uno que le da al operador exactamente las filas que debe corregir.
class EsquemaCitas([Link]):
codigo: Series[str] = [Link](str_matches=r"^\d{6}$", unique=True)
paciente_id: Series[int] = [Link](gt=0)
sucursal: Series[str] = [Link](isin=["MATRIZ", "NORTE", "SUR", "CENTRO"])
fecha: Series[[Link]] = [Link](
ge=[Link]("2020-01-01"),
le=[Link]("2030-12-31"),
)
monto: Series[float] = [Link](ge=0, le=1_000_000)
email: Series[str] = [Link](
str_matches=r"^[\w\.\-\+]+@[\w\-]+\.\w+$", nullable=True
)
class Config:
strict = True # rechazar columnas no declaradas
coerce = True # convertir tipos automáticamente
@[Link]("monto", name="monto_razonable")
def monto_no_atipico(cls, s: Series[float]) -> Series[bool]:
return s < [Link](0.999) * 10
try:
validado = [Link](df, lazy=True) # lazy: reporta TODOS los fallos
except [Link] as e:
print(e.failure_cases) # DataFrame con columna, check, valor e índice
raise
lazy=True es fundamental: sin él, pandera aborta en el primer fallo y tienes que iterar corrida a corrida.
Con él, obtienes el informe completo.
print(perfilar(df).to_string(index=False))
Este perfil, ejecutado sobre cada archivo nuevo antes de procesarlo, detecta la mayoría de los problemas
de origen: columnas que cambiaron de nombre, valores que ahora vienen vacíos, longitudes anómalas.
if len(anterior):
variacion = abs(len(actual) - len(anterior)) / len(anterior)
if variacion > tolerancia:
[Link](
f"Conteo de filas varió {variacion:.0%}: "
f"{len(anterior):,} -> {len(actual):,}"
)
return alertas
Los datos de origen cambian sin avisar. Un sistema que compara cada carga con la anterior detecta el
problema el día que ocurre, no el mes siguiente cuando alguien revisa un total.
18. Arquitectura de un pipeline ETL
proyecto_etl/
├── src/
│ └── etl/
│ ├── __init__.py
│ ├── [Link] # configuración desde entorno
│ ├── [Link] # lectura de fuentes
│ ├── [Link] # funciones puras de transformación
│ ├── [Link] # esquemas y reglas
│ ├── [Link] # escritura de destinos
│ └── [Link] # orquestación
├── tests/
│ ├── [Link]
│ ├── test_transformar.py
│ └── fixtures/
│ └── [Link]
├── datos/
│ ├── entrada/
│ ├── intermedio/
│ └── salida/
├── logs/
├── .[Link]
├── .gitignore
├── [Link]
├── [Link]
└── [Link]
18.2 Configuración
# src/etl/[Link]
from dataclasses import dataclass
from pathlib import Path
import os
from dotenv import load_dotenv
load_dotenv()
@dataclass(frozen=True)
class Config:
dir_entrada: Path = Path([Link]("DIR_ENTRADA", "datos/entrada"))
dir_salida: Path = Path([Link]("DIR_SALIDA", "datos/salida"))
dir_logs: Path = Path([Link]("DIR_LOGS", "logs"))
CONFIG = Config()
.[Link] versionado, .env en .gitignore . Ver el documento 1, capítulo 14, para el manejo correcto
de secretos en Git.
resultado = (
df
.pipe(normalizar_columnas)
.pipe(limpiar_espacios, columnas=["nombre", "sucursal"])
.pipe(agregar_columnas_derivadas)
.pipe(lambda d: d[d["monto_num"].notna()])
)
.pipe() mantiene el encadenamiento legible y evita las variables intermedias df1 , df2 , df_temp que
hacen imposible saber en qué estado está cada una.
18.5 Orquestación
# src/etl/[Link]
import logging
import time
from pathlib import Path
import pandas as pd
log = [Link](__name__)
try:
[Link]("Extrayendo de %s", ruta_entrada)
df = extraer.leer_csv_fiel(ruta_entrada)
metricas["filas_leidas"] = len(df)
[Link]("Validando estructura")
resultado = [Link](df, [Link])
if not [Link]:
raise ValueError(f"Validación fallida: {[Link]}")
metricas["avisos"] = len([Link])
[Link]("Transformando")
df = (df
.pipe(transformar.normalizar_columnas)
.pipe(transformar.limpiar_espacios,
columnas=["nombre", "sucursal"])
.pipe(transformar.agregar_columnas_derivadas))
metricas["filas_transformadas"] = len(df)
[Link]("Cargando")
salida = CONFIG.dir_salida / f"{ruta_entrada.stem}.xlsx"
cargar.a_excel(df, salida)
metricas["salida"] = str(salida)
metricas["estado"] = "OK"
except Exception:
metricas["estado"] = "ERROR"
[Link]("Fallo procesando %s", ruta_entrada)
raise
finally:
metricas["segundos"] = round(time.perf_counter() - inicio, 2)
[Link]("Métricas: %s", metricas)
return metricas
18.6 Idempotencia
Un pipeline debe poder reejecutarse sin duplicar ni corromper nada.
import hashlib
import json
from pathlib import Path
class RegistroProcesados:
"""Evita reprocesar archivos ya tratados con el mismo contenido."""
Uso:
import os
from pathlib import Path
Si el proceso muere a mitad de la escritura, el archivo de destino queda intacto con su versión anterior
en vez de convertirse en un .xlsx corrupto que alguien intentará abrir.
19. Logging y trazabilidad
19.1 Configuración
# src/etl/logging_config.py
import logging
import [Link]
from pathlib import Path
formato = [Link](
"%(asctime)s | %(levelname)-8s | %(name)s:%(lineno)d | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
consola = [Link]()
[Link](formato)
[Link](nivel)
archivo = [Link](
dir_logs / "[Link]",
maxBytes=10 * 1024 * 1024,
backupCount=10,
encoding="utf-8",
)
[Link](formato)
[Link]([Link])
raiz = [Link]()
[Link]([Link])
[Link]()
for h in (consola, archivo, errores):
[Link](h)
En cada módulo:
import logging
log = [Link](__name__)
Usa %s en vez de f-strings en las llamadas de logging. El formateo se difiere: si el nivel DEBUG está
desactivado, la cadena nunca se construye. Con muchos mensajes en bucles, la diferencia es medible.
class FormateadorJSON([Link]):
def format(self, record: [Link]) -> str:
datos = {
"ts": [Link](record, "%Y-%m-%dT%H:%M:%S"),
"nivel": [Link],
"logger": [Link],
"mensaje": [Link](),
"modulo": [Link],
"linea": [Link],
}
if record.exc_info:
datos["excepcion"] = [Link](record.exc_info)
if hasattr(record, "extra_datos"):
[Link](record.extra_datos)
return [Link](datos, ensure_ascii=False)
[Link]("Pipeline completado",
extra={"extra_datos": {
"filas": 48213,
"segundos": 12.4,
"archivo": "reporte_202607.csv",
}})
Con logs en JSON puedes consultar “todas las corridas que tardaron más de 60 segundos” sin escribir
expresiones regulares sobre texto libre.
@dataclass
class Metricas:
archivo: str
inicio: str = field(default_factory=lambda: [Link]().isoformat())
fin: str = ""
segundos: float = 0.0
filas_leidas: int = 0
filas_escritas: int = 0
filas_rechazadas: int = 0
errores: list[str] = field(default_factory=list)
avisos: list[str] = field(default_factory=list)
estado: str = "en_curso"
hash_origen: str = ""
registros = [
[Link](p.read_text(encoding="utf-8"))
for p in sorted(Path("logs/metricas").glob("metricas_*.json"))
]
hist = [Link](registros)
print([Link]("estado").size())
print(hist["segundos"].describe())
print([Link](5, "segundos")[["archivo", "segundos", "filas_leidas"]])
df["_origen_archivo"] = ruta_entrada.name
df["_origen_fila"] = [Link] + 2 # +2: cabecera y base 1
df["_procesado_en"] = [Link]().isoformat()
df["_version_etl"] = "1.4.2"
df["_hash_origen"] = hash_archivo(ruta_entrada)[:16]
Cuando aparezca un número raro en el reporte de dirección, estas cinco columnas convierten una
investigación de horas en una consulta de segundos.
Si no quieres exponerlas al usuario final, escríbelas en una hoja oculta o en un archivo Parquet paralelo.
20. Pruebas de transformaciones
# tests/[Link]
import pytest
import pandas as pd
from io import StringIO
@[Link]
def csv_muestra() -> StringIO:
return StringIO(
"Código,Nombre Completo ,SUCURSAL,fecha,monto\n"
"007, José García ,MATRIZ,2026-07-15,1234.50\n"
"008,María López,NORTE,2026-07-16,890.00\n"
"009,NA,SUR,2026-07-17,\n"
)
@[Link]
def df_muestra(csv_muestra) -> [Link]:
return pd.read_csv(csv_muestra, dtype=str,
keep_default_na=False, na_values=[])
# tests/test_transformar.py
import pandas as pd
import pytest
from [Link] import normalizar_columnas, limpiar_espacios
class TestNormalizarColumnas:
def test_pasa_a_snake_case(self, df_muestra):
r = normalizar_columnas(df_muestra)
assert list([Link]) == ["codigo", "nombre_completo",
"sucursal", "fecha", "monto"]
def test_dataframe_vacio(self):
vacio = [Link](columns=["Código", "MONTO"])
r = normalizar_columnas(vacio)
assert list([Link]) == ["codigo", "monto"]
class TestFidelidad:
def test_conserva_ceros_iniciales(self, df_muestra):
assert df_muestra["Código"].iloc[0] == "007"
def test_transformacion_completa(df_muestra):
resultado = transformar_todo(df_muestra)
esperado = [Link]({
"codigo": ["007", "008", "009"],
"nombre": ["José García", "María López", "NA"],
"sucursal": ["MATRIZ", "NORTE", "SUR"],
})
assert_frame_equal(
resultado[[Link]],
esperado,
check_dtype=False,
check_like=False, # el orden de columnas SÍ importa
)
@[Link]("entrada,esperado", [
("1234.50", 1234.50),
("1,234.50", 1234.50),
("$1,234.50", 1234.50),
("1.234,50", 1234.50),
("", None),
("N/A", None),
("abc", None),
("-500", -500.0),
("0", 0.0),
])
def test_parsear_monto(entrada, esperado):
resultado = parsear_monto(entrada)
if esperado is None:
assert [Link](resultado)
else:
assert resultado == [Link](esperado)
Cada caso de esta tabla representa un formato que apareció alguna vez en datos reales. La tabla es la
documentación del comportamiento esperado.
Hypothesis genera cientos de entradas, incluyendo casos límite (cadenas vacías, caracteres de control,
valores extremos) que nunca se te ocurriría escribir a mano.
# tests/test_regresion.py
from pathlib import Path
import pandas as pd
import pytest
@[Link]
def test_muestra_produce_salida_conocida(tmp_path):
"""Un archivo real anonimizado debe producir siempre el mismo resultado."""
entrada = FIXTURES / "muestra_1000_filas.csv"
salida = tmp_path / "[Link]"
ejecutar_pipeline(entrada, salida)
[Link].assert_frame_equal(obtenido, esperado)
20.7 Cobertura
21.1 Conexión
def crear_motor():
usuario = [Link]["DB_USUARIO"]
password = quote_plus([Link]["DB_PASSWORD"]) # escapa caracteres especiales
host = [Link]["DB_HOST"]
puerto = [Link]("DB_PUERTO", "3306")
base = [Link]["DB_NOMBRE"]
url = f"mysql+pymysql://{usuario}:{password}@{host}:{puerto}/{base}?charset=utf8mb4"
return create_engine(
url,
pool_size=5,
max_overflow=10,
pool_pre_ping=True, # verifica la conexión antes de usarla
pool_recycle=3600, # recicla antes del wait_timeout de MySQL
echo=False,
future=True,
)
motor = crear_motor()
pool_pre_ping=True y pool_recycle resuelven el error “MySQL server has gone away” que aparece
cuando una conexión del pool lleva más tiempo inactiva que el wait_timeout del servidor.
21.2 Leer
import pandas as pd
# Consulta simple
df = pd.read_sql("SELECT * FROM citas LIMIT 1000", motor)
# MAL
pd.read_sql(f"SELECT * FROM citas WHERE sucursal = '{valor}'", motor)
# BIEN
pd.read_sql(text("SELECT * FROM citas WHERE sucursal = :s"), motor,
params={"s": valor})
21.3 Escribir
df.to_sql(
"citas_staging",
motor,
if_exists="replace", # "fail", "replace", "append"
index=False,
chunksize=10_000,
method="multi", # INSERT multi-fila: mucho más rápido
dtype={
"codigo": [Link](20),
"monto": [Link](10, 2),
"fecha": [Link](),
},
)
El parámetro dtype importa: sin él, pandas crea columnas TEXT para todas las cadenas, lo que produce
tablas enormes y sin índices utilizables.
Para volúmenes grandes, to_sql es lento. LOAD DATA es un orden de magnitud más rápido:
Las variables @fecha y @monto permiten transformar durante la carga: NULLIF(@monto, '') convierte el
vacío en NULL en vez de en 0 .
RENAME TABLE con dos pares es atómico en MySQL: los usuarios nunca ven la tabla vacía ni a medio
llenar.
21.6 Upsert
Requiere que la tabla tenga una clave primaria o única sobre la que detectar el duplicado.
import socket
def probar_conexion(host: str, puerto: int = 3306, timeout: int = 5) -> bool:
try:
with socket.create_connection((host, puerto), timeout=timeout):
print(f"[OK] TCP alcanzable en {host}:{puerto}")
return True
except [Link]:
print(f"[FALLO] Timeout. Revisa el Security Group: "
f"¿permite tu IP en el puerto {puerto}?")
except [Link]:
print(f"[FALLO] No se resuelve el nombre '{host}'")
except ConnectionRefusedError:
print(f"[FALLO] Conexión rechazada: el puerto está cerrado")
return False
Distinguir los síntomas ahorra horas: timeout = red o security group; “Access denied” = ya llegaste al
servidor, es un problema de credenciales o del host del usuario MySQL.
22. Alternativas modernas: Polars y DuckDB
22.1 Polars
Escrito en Rust, con ejecución multinúcleo y un modo perezoso que optimiza el plan antes de ejecutar.
import polars as pl
# Lectura ansiosa
df = pl.read_csv("[Link]", infer_schema_length=0) # todo como texto
resultado = (
lf
.filter([Link]("sucursal") == "MATRIZ")
.filter([Link]("fecha") >= "2026-01-01")
.group_by("doctor")
.agg([
[Link]("monto").sum().alias("total"),
[Link]("monto").mean().alias("promedio"),
[Link]().alias("citas"),
])
.sort("total", descending=True)
.collect() # aquí se ejecuta, con el plan ya optimizado
)
La ventaja del modo perezoso: Polars empuja los filtros hacia la lectura y solo carga las columnas y
filas que hacen falta. En un archivo de 60 columnas donde usas 4, nunca lee las otras 56.
Índice Sí No existe
df_pandas = df_polars.to_pandas()
df_polars = pl.from_pandas(df_pandas)
22.2 DuckDB
Base de datos analítica embebida. Consulta archivos directamente con SQL, sin cargarlos.
import duckdb
r = [Link]("""
SELECT [Link], COUNT(*) AS n, SUM([Link]) AS total
FROM citas c
JOIN pacientes p ON [Link] = c.paciente_id
GROUP BY [Link]
HAVING SUM([Link]) > 10000
ORDER BY total DESC
""").df()
DuckDB encuentra las variables citas y pacientes en el ámbito de Python automáticamente. Es una
integración notablemente cómoda.
Persistencia:
con = [Link]("[Link]")
[Link]("CREATE TABLE citas AS SELECT * FROM 'datos/*.csv'")
[Link]("CREATE INDEX idx_fecha ON citas(fecha)")
[Link]()
pandas 42 s 8,2 GB
DuckDB 3s 0,6 GB
Polars — pipelines de producción con volúmenes grandes, cuando la velocidad importa y quieres una
API estricta que evite errores silenciosos.
DuckDB — cuando la lógica se expresa naturalmente en SQL, cuando hay joins y agregaciones grandes,
cuando quieres consultar archivos sin cargarlos.
Estrategia híbrida y muy práctica: DuckDB para filtrar y agregar el volumen grande, pandas para el
análisis final del resultado reducido.
resumen = [Link]("""
SELECT sucursal, DATE_TRUNC('month', fecha) AS mes, SUM(monto) AS total
FROM 'datos/*.parquet'
GROUP BY 1, 2
""").df() # de 50M de filas a 300
Soporte en Excel Sí No
23.2 Uso
# Escribir
df.to_parquet("[Link]", engine="pyarrow", compression="snappy", index=False)
# Leer
df = pd.read_parquet("[Link]")
Opciones de compresión: snappy (rápida, por defecto), gzip (más pequeña), zstd (buen equilibrio,
recomendada), brotli , lz4 , none .
df.to_parquet(
"datos/",
partition_cols=["anio", "mes"],
engine="pyarrow",
)
Estructura resultante:
datos/
├── anio=2026/
│ ├── mes=1/[Link]
│ ├── mes=2/[Link]
│ └── mes=7/[Link]
└── anio=2025/
└── ...
import [Link] as pq
archivo = [Link]("[Link]")
print([Link])
print([Link])
print(f"Filas: {[Link].num_rows:,}")
print(f"Grupos de filas: {[Link].num_row_groups}")
import pyarrow as pa
esquema = [Link]([
("codigo", [Link]()),
("paciente", pa.int64()),
("fecha", pa.date32()),
("monto", pa.decimal128(10, 2)),
("sucursal", [Link](pa.int8(), [Link]())), # equivale a category
])
pa.decimal128 es la forma correcta de guardar dinero: precisión decimal exacta, no punto flotante.
Ventaja decisiva: si descubres un error en la lógica de limpieza, reprocesas desde la capa cruda sin
necesitar los CSV originales, que quizá ya nadie tenga.
24. Rendimiento y perfilado
import time
from contextlib import contextmanager
@contextmanager
def cronometro(nombre: str):
t = time.perf_counter()
try:
yield
finally:
print(f"{nombre:<40} {time.perf_counter() - t:>8.3f} s")
with cronometro("transformación"):
df = transformar(df)
En Jupyter:
%time df = pd.read_csv("[Link]")
%timeit df["monto"].sum()
%%time
# toda la celda
perfil = LineProfiler()
perfil.add_function(mi_transformacion)
perfil.enable_by_count()
resultado = mi_transformacion(df)
perfil.disable_by_count()
perfil.print_stats()
Memoria:
@profile
def procesar(ruta):
df = pd.read_csv(ruta)
return [Link]("sucursal").sum()
Visualizar:
1. Leer menos.
2. Usar Parquet.
# Encadena en su lugar
df = [Link](f1).pipe(f2).pipe(f3)
# MAL: O(n²)
r = [Link]()
for parte in partes:
r = [Link]([r, parte])
# BIEN: O(n)
r = [Link](partes, ignore_index=True)
pacientes = pacientes.set_index("id")
[Link][4821] # búsqueda hash, no escaneo
24.4 Paralelismo
Para tareas independientes por archivo, el paralelismo a nivel de proceso es simple y efectivo:
from [Link] import ProcessPoolExecutor, as_completed
from pathlib import Path
archivos = sorted(Path("datos/").glob("*.csv"))
Nota: el GIL de Python hace que ThreadPoolExecutor no ayude en trabajo intensivo de CPU. Para eso
hace falta ProcessPoolExecutor . Los hilos sí ayudan en E/S (descargas, consultas a base de datos).
Para paralelizar operaciones sobre un solo DataFrame, en 2026 la respuesta correcta suele ser cambiar
de herramienta: Polars y DuckDB paralelizan automáticamente sin que escribas nada.
24.5 Caché
@lru_cache(maxsize=32)
def cargar_catalogo(ruta: str) -> [Link]:
return pd.read_csv(ruta, dtype=str)
if [Link]():
return pd.read_parquet(cache)
Incluir el hash en el nombre del archivo de caché resuelve el problema de invalidación: si el CSV cambia,
el nombre cambia y se regenera automáticamente.
25. Antipatrones y lista de verificación
Antipatrón Corrección
Antipatrón Corrección
== [Link] .isna()
Validación - [ ] Columnas esperadas presentes, con los nombres esperados - [ ] Conteo de filas dentro
del rango razonable - [ ] Reglas de negocio codificadas y ejecutadas - [ ] Registros rechazados exportados
para revisión humana - [ ] Comparación contra el perfil de la corrida anterior
Salida - [ ] Escritura atómica (temporal + rename) - [ ] Formato de celda correcto en Excel ( "@" para
códigos) - [ ] Verificación de fidelidad ejecutada y registrada - [ ] Metadatos de procedencia incluidos - [ ]
Límite de 1.048.576 filas de Excel considerado
#!/usr/bin/env python3
"""Convierte reportes CSV a Excel conservando los datos sin transformar."""
from __future__ import annotations
import argparse
import logging
import sys
from pathlib import Path
import pandas as pd
from openpyxl import Workbook
from [Link] import get_column_letter
log = [Link]("csv2xlsx")
def escribir(df: [Link], destino: Path, hoja: str = "Datos") -> None:
wb = Workbook()
ws = [Link]
[Link] = hoja
[Link](list([Link]))
for fila in [Link](index=False, name=None):
[Link](list(fila))
ws.freeze_panes = "A2"
[Link](
level=[Link] if [Link] else [Link],
format="%(asctime)s | %(levelname)-7s | %(message)s",
datefmt="%H:%M:%S",
)
archivos = (sorted([Link]("*.csv"))
if [Link].is_dir() else [[Link]])
if not archivos:
[Link]("Sin archivos CSV en %s", [Link])
return 1
fallos = 0
for csv in archivos:
try:
destino = [Link] / f"{[Link]}.xlsx"
escribir(leer(csv), destino)
if not args.sin_verificar and not verificar(csv, destino):
fallos += 1
except Exception:
[Link]("Fallo procesando %s", [Link])
fallos += 1
if __name__ == "__main__":
[Link](main())
Cierre
Cinco ideas que sostienen todo lo anterior:
2. Vectoriza o pierde dos órdenes de magnitud. apply(axis=1) e iterrows() son entre 100 y 6.000
veces más lentos que la alternativa vectorizada. No es una micro-optimización; es la diferencia entre
segundos y horas.
3. validate= en cada merge. El error más caro del trabajo con datos es el merge que duplica filas
silenciosamente. Veinticinco caracteres lo previenen.
4. El origen es sagrado. Nunca sobrescribas el archivo de entrada, conserva siempre las columnas
originales junto a las derivadas, y guarda una capa cruda inmutable en Parquet para poder
reprocesar.
5. Un pipeline sin logging ni pruebas no es un pipeline, es un script con suerte. Los datos de
origen cambian sin avisar; la única defensa es validar cada corrida y comparar con la anterior.
Y si el volumen crece más allá de lo cómodo, la respuesta no es más RAM: es cambiar de herramienta.
DuckDB sobre Parquet resuelve en menos de un segundo lo que a pandas le cuesta cuarenta.