lección 4
Particionado: organizar terabytes para encontrar lo que necesitas
Hive-style partitioning, partition pruning, el small file problem y estrategias para organizar datos masivos de forma eficiente.
⏱ 60 min
Imagina una biblioteca con 10 millones de libros. Si todos estuvieran en una sola estantería gigante sin organización, encontrar uno específico requeriría recorrer la estantería entera. Pero si los organizas por género, luego por autor, luego por año — puedes ir directamente a la sección correcta. Eso es exactamente lo que hace el particionado: organizar archivos en carpetas jerárquicas para que los motores de consulta solo lean las carpetas relevantes.
## Qué es particionar y por qué importa
Particionar un dataset es dividirlo en subdirectorios basados en los valores de una o más columnas. En vez de un archivo enorme con todos los datos, tienes miles de archivos pequeños organizados en carpetas. El truco está en que el motor de consultas puede SALTAR carpetas enteras sin abrirlas — esto se llama "partition pruning" y es la razón por la que queries sobre 10 TB tardan segundos en vez de horas.
El estándar de facto es el "Hive-style partitioning": cada nivel de partición es una carpeta cuyo nombre tiene formato columna=valor. Es una convención que todos los motores entienden: Spark, Athena, Presto, DuckDB, PyArrow.
1# Hive-style partitioning: carpetas con nombre columna=valor2s3://lake/silver/ventas/3 year=2023/4 month=01/5 part-00000.parquet6 part-00001.parquet7 month=02/8 part-00000.parquet9 ...10 year=2024/11 month=01/12 part-00000.parquet13 ...1415# Query: SELECT * FROM ventas WHERE year=2024 AND month=0116# → Solo lee: silver/ventas/year=2024/month=01/*.parquet17# → Ignora TODOS los demás directorios
Hive-style partitioning: la convención que todos los motores entienden
## Elegir la columna de partición: la decisión más importante
Elegir mal la columna de partición es un error que he visto destruir el rendimiento de data lakes enteros. La regla es simple: particiona por la columna que MÁS se filtra en las queries. En el 90% de los casos, esa columna es una fecha (día, mes o año). Pero hay criterios más específicos:
- 01.Cardinalidad media: no demasiados valores (no particionar por user_id con 50M de usuarios) ni muy pocos (no particionar por is_active con solo true/false).
- 02.Usado en filtros WHERE: si nadie filtra por esa columna, particionarla no ahorra nada.
- 03.Distribución uniforme: cada partición debería tener un tamaño similar. Si el 90% de los datos cae en una sola partición, no has ganado mucho.
- 04.Crece con el tiempo: la fecha es ideal porque naturalmente crea nuevas particiones cada día/mes sin rebalanceo.
Mi regla de oro para elegir partición: si el 80% de las queries incluyen "WHERE fecha >=..." o "WHERE year =...", particiona por fecha. Punto. No te compliques. Solo añade un segundo nivel si el volumen diario es enorme (+1GB/día por partición).
## El Small File Problem: cuando particionas demasiado
Aquí viene la trampa que atrapa a todo principiante: si particionas con demasiada granularidad, acabas con miles de archivos diminutos. Y muchos archivos pequeños son PEOR que pocos archivos grandes. ¿Por qué? Porque cada archivo tiene un coste fijo de apertura (overhead de filesystem, handshake con S3, lectura del footer Parquet). Si tienes 100.000 archivos de 1KB cada uno, el overhead de abrirlos es mayor que el tiempo de leer los datos.
La regla de la industria: cada archivo Parquet debería tener entre 128MB y 1GB. Si tus archivos son menores a 100MB, tienes un small file problem. Causas comunes: particionar por hora cuando el volumen es bajo, particionar por múltiples columnas simultáneamente (fecha + país + canal = miles de combinaciones), o escrituras frecuentes que generan un archivo por micro-batch.
Y aquí va el número que convierte el consejo en una decisión. Medido sobre 200.000 registros, leyendo siempre lo mismo (un mes, dos columnas):
1Estrategia Ficheros Tamaño total Leer enero2───────────────────── ──────── ──────────── ──────────3Sin particionar 1 1,25 MB 8,6 ms4Por mes (12 part.) 12 2,01 MB 1,9 ms ← 4,5× más rápido5Por mes+día (336) 336 3,68 MB 18,8 ms ← ya es PEOR6Por sensor (900) 900 7,88 MB 764,3 ms ← 89× más lento
Pasarse de particiones es peor que no particionar. Y el disco también se paga: los mismos datos ocupan 6× más repartidos en 900 ficheros.
Además, pyarrow tiene un techo duro: se niega a escribir más de 1.024 particiones en una sola llamada (ArrowInvalid, parámetro max_partitions). O sea que el consejo de "no particiones por sensor_id con 10.000 valores" no es solo una buena práctica — es que la librería no te deja.
He visto un lake con 2 millones de archivos de 50KB cada uno. Las queries que deberían tardar 10 segundos tardaban 20 minutos porque el motor pasaba más tiempo LISTANDO y ABRIENDO archivos que leyendo datos. La solución fue compactar: un job nocturno que fusiona archivos pequeños en otros más grandes.
## Particionado con PyArrow: demo completa
1import pyarrow as pa2import pyarrow.parquet as pq3import pyarrow.dataset as ds4import datetime5import random67# Generar datos de ventas de 3 meses8n = 100_0009fechas = [datetime.date(2024, random.randint(1,3), random.randint(1,28)) for _ in range(n)]10data = pa.table({11 "fecha": pa.array(fechas),12 "producto": pa.array([f"P{random.randint(1,50):03d}" for _ in range(n)]),13 "importe": pa.array([round(random.uniform(10, 500), 2) for _ in range(n)]),14 "pais": pa.array([random.choice(["ES","FR","IT","DE"]) for _ in range(n)]),15})1617# Escribir con particionado Hive-style por país18pq.write_to_dataset(19 data,20 root_path="lake/ventas_particionadas",21 partition_cols=["pais"], # → carpetas pais=ES/, pais=FR/, pais=IT/, pais=DE/22)2324print("Estructura creada:")25import os26for root, dirs, files in os.walk("lake/ventas_particionadas"):27 level = root.replace("lake/ventas_particionadas", "").count(os.sep)28 indent = " " * level29 print(f"{indent}{os.path.basename(root)}/")30 for f in files:31 print(f"{indent} {f}")
Escribir datos particionados con PyArrow — genera carpetas Hive-style automáticamente
Aquí se ha particionado por país porque es la columna que ya existe en la tabla, y eso es justo lo que hay que entender: partition_cols solo acepta columnas que estén en los datos. Si quieres particionar por año y mes —que es lo habitual, porque la fecha es lo que más se filtra— no basta con tener una columna fecha: tienes que derivar las columnas que van a dar nombre a las carpetas antes de escribir. En PyArrow eso lo hacen pyarrow.compute.year() y pyarrow.compute.month() sobre la columna de fecha, y el resultado se añade a la tabla como dos columnas más. Es el primer ejercicio de esta lección.
## Partition pruning en acción
1import pyarrow.dataset as ds2import time34# El dataset se construye FUERA de los dos cronómetros: abrirlo es lo que lista5# el árbol de carpetas, y solo pasa una vez. Si lo metes dentro del primero,6# estarías comparando "listar + leer todo" contra "leer una partición", que no7# es lo que se quiere medir.8dataset = ds.dataset("lake/ventas_particionadas", format="parquet", partitioning="hive")910# Leer TODO el dataset particionado11start = time.time()12df_all = dataset.to_table().to_pandas()13t_all = time.time() - start14print(f"Todo: {len(df_all):,} filas en {t_all:.3f}s")1516# Leer SOLO España (partition pruning)17start = time.time()18df_es = dataset.to_table(filter=ds.field("pais") == "ES").to_pandas()19t_es = time.time() - start20print(f"Solo ES: {len(df_es):,} filas en {t_es:.3f}s")21print(f"Pruning saltó {(1 - len(df_es)/len(df_all))*100:.0f}% de los datos")
Partition pruning: el motor solo abre las carpetas que coinciden con el filtro
## Estrategias de particionado por caso de uso
- Datos de eventos/logs: particionar por fecha (year/month/day). Queries típicas filtran por rango temporal.
- Datos multi-tenant: particionar por tenant_id (empresa). Cada query solo toca un tenant.
- Datos geográficos: particionar por país o región. Las queries suelen filtrarse por zona.
- Datos de sensores IoT: particionar por device_id + fecha. Alto volumen por dispositivo.
Cuando no sepas por qué columna particionar, mira las queries más frecuentes de tu equipo. Si el 80% tiene "WHERE fecha BETWEEN..." ahí está tu respuesta. Los datos te dicen cómo quieren ser organizados.
## Compactación: la solución al small file problem
Si ya tienes un small file problem, la solución es compactar: leer todos los archivos pequeños de una partición y reescribirlos como un archivo grande. Es un job que típicamente se ejecuta diariamente o semanalmente. Con PyArrow son quince líneas, pero una de ellas es la que separa un job de mantenimiento de un incidente de datos:
1from pathlib import Path23import pyarrow.dataset as ds4import pyarrow.parquet as pq56def compactar_particion(raiz_lake: str, filtro, output: str):7 """Compacta UNA partición en un solo fichero, sin perder columnas.89 raiz_lake: la RAÍZ del dataset, no la carpeta de la partición.10 filtro: qué partición compactar, como expresión de PyArrow.11 """12 # Se abre por la RAÍZ y con partitioning="hive" a propósito. Las columnas de13 # partición se reconstruyen a partir de los nombres de carpeta que hay POR14 # DEBAJO de la ruta que abres: si apuntas directamente a .../pais=ES, PyArrow15 # no tiene de dónde sacar la columna pais y la pierdes sin ningún error.16 dataset = ds.dataset(raiz_lake, format="parquet", partitioning="hive")1718 ficheros = [f.path for f in dataset.get_fragments(filtro)]19 table = dataset.to_table(filter=filtro)2021 # pq.write_table tampoco crea las carpetas intermedias22 Path(output).parent.mkdir(parents=True, exist_ok=True)2324 # Reescribir como un solo archivo con row_group_size óptimo25 pq.write_table(26 table,27 output,28 row_group_size=1_000_000, # ~128MB por row group29 compression="snappy",30 )31 print(f"Compactado: {len(ficheros)} archivos → 1 archivo")32 print(f"Filas: {table.num_rows:,} | columnas: {table.schema.names}")3334# Ejemplo de uso35compactar_particion(36 "lake/ventas_particionadas",37 ds.field("pais") == "ES",38 "lake/compactado/pais=ES/data.parquet",39)
Compactar archivos pequeños en uno grande — job típico de mantenimiento del lake
El fallo clásico de la compactación es silencioso y se come el lake entero. Las columnas de partición (pais=ES) no están dentro de los ficheros Parquet: solo existen en el nombre de la carpeta. Si abres el dataset apuntando a la carpeta de la partición en vez de a la raíz, esa columna no se reconstruye, y el job no da error: escribe su fichero, imprime "Compactado" y se lleva por delante la columna pais de cada partición que toca. Como corre en cron cada noche, cuando alguien lo note el original ya no estará. Norma: todo job de compactación imprime el esquema resultante, y lo primero que se compara al probarlo es la lista de columnas antes y después.
Y cuidado al comprobarlo, porque la trampa se repite un nivel más arriba: si verificas el resultado leyendo el lake desde su raíz — pd.read_parquet("lake/compactado") o pq.read_table("lake/compactado") — verás la columna pais aunque el fichero NO la tenga. La reconstruye el lector a partir del nombre de la carpeta, igual que hará Spark o Athena, así que el lake "parece" correcto mientras el dato ya se ha perdido. Lo que pasa si apuntas al fichero suelto es peor todavía: depende de la versión. Con pyarrow 17, pq.read_table("lake/compactado/pais=ES/data.parquet") te devuelve pais; con pyarrow 25, no. Comprobado en las dos. Así que no uses eso como prueba de nada. La única comprobación que dice la verdad en cualquier versión es mirar el esquema escrito dentro del fichero: pq.ParquetFile(ruta).schema_arrow.names.
## comentarios
Reporta erratas, ayuda a otros o comparte tu opinión. Sé constructivo.
Inicia sesión para comentar y responder.
cargando comentarios...
Regístrate para guardar tu progreso.