Apache Parquet, explicado
Cómo el almacenamiento columnar, la compresión por columna y el pushdown de predicados convirtieron a Parquet en el formato de facto para analítica a escala.
Durante años, la analítica de datos se construyó sobre formatos pensados para otra cosa: CSV y JSON son cómodos de generar y leer línea por línea, pero una consulta analítica típica no lee líneas, lee columnas. Un SELECT avg(monto) FROM ventas WHERE fecha = '2026-08-01' no necesita ver el 90% de los campos de cada fila, pero un formato orientado a filas te obliga a leerlos igual. Apache Parquet nació para resolver exactamente ese desajuste: un formato de almacenamiento columnar, binario, con metadatos embebidos, diseñado para que un motor de consultas lea solo lo que necesita y descarte el resto sin siquiera abrirlo. Esa propiedad —combinada con compresión agresiva y compatibilidad multi-engine— es la razón por la que hoy es el formato por defecto en cualquier lago de datos o lakehouse serio, desde Spark hasta Trino, Athena o DuckDB.
La anatomía de un archivo Parquet
Un archivo Parquet no es un blob plano: tiene una jerarquía interna pensada para permitir lectura selectiva a distintos niveles de granularidad. En el nivel más alto, un archivo se divide en row groups, que son porciones horizontales del dataset (por defecto, del orden de 128 MB). Dentro de cada row group, los datos se reorganizan por columna en column chunks: todos los valores de una misma columna, para ese subconjunto de filas, quedan contiguos en disco. Y dentro de cada column chunk, los valores se subdividen en pages (por defecto, alrededor de 1 MB), que son la unidad mínima de lectura, decodificación y compresión.
Cada page trae, además de los valores codificados, los niveles de repetición y definición que Parquet usa para representar campos anidados y opcionales sin necesidad de aplanar la estructura. Y todo el archivo cierra con un footer que contiene el schema completo, la versión de formato y estadísticas (mínimo, máximo, conteo de nulos) por column chunk. Ese footer es lo que hace que un archivo Parquet sea autodescriptivo: cualquier motor puede leerlo sin un catálogo externo para saber qué columnas tiene y qué rango de valores contiene cada bloque.
| Nivel | Qué contiene | Tamaño típico | Para qué sirve |
|---|---|---|---|
| Archivo | Row groups + footer | Variable | Unidad física de almacenamiento y de asignación a tareas de lectura |
| Row group | Un column chunk por columna | ~128 MB (configurable) | Unidad de paralelismo entre tareas/executors |
| Column chunk | Pages de una sola columna | Depende del row group | Permite leer una columna sin tocar las demás (projection pushdown) |
| Page | Valores codificados + niveles rep/def | ~1 MB (configurable) | Unidad mínima de compresión y filtrado por estadísticas |
| Footer | Schema, versión, min/max/count por chunk | Pequeño | Hace el archivo autodescriptivo y habilita predicate pushdown |
Por qué el modelo columnar habilita compresión y pushdown
Agrupar valores de la misma columna, y del mismo tipo, tiene dos consecuencias directas. La primera es que la compresión funciona mejor: una columna de estados ("activo", "activo", "inactivo"...) o de fechas tiene mucha menos entropía que una fila completa con tipos mezclados, así que técnicas como run-length encoding o codificación por diccionario reducen el tamaño de forma agresiva antes incluso de aplicar un compresor genérico como Snappy, Gzip o ZSTD. La segunda consecuencia es que el motor puede decidir, columna por columna, si necesita leerla o no. Si tu consulta solo toca tres columnas de una tabla de cuarenta, el resto de los column chunks ni se descomprimen.
Sobre esa base se construye el predicate pushdown: como el footer guarda el mínimo y el máximo de cada column chunk, el motor puede comparar el predicado de la consulta contra esos rangos y saltarse row groups enteros sin leer un solo byte de datos. Si filtras por x > 5 y un row group tiene x entre 0 y 4, ese bloque se descarta antes de abrirlo. Cuando la columna usa codificación por diccionario, el filtrado puede ser todavía más fino: si el valor buscado ni siquiera aparece en el diccionario de esa página, se descarta sin comparar fila por fila.
Esta optimización tiene límites que conviene conocer. No funciona bien sobre datos desordenados, porque si los valores de una columna están dispersos de forma aleatoria entre row groups, casi ningún rango de min/max permite descartar nada; para que el pushdown sea efectivo hay que escribir los datos ordenados (o clusterizados) por las columnas que más se filtran. Tampoco es igual de eficaz según el tipo de dato: comparaciones sobre enteros, strings (solo igualdad) o booleanos son directas, pero sobre floats o decimals la precisión de la representación binaria puede introducir imprecisiones. Y hay que usar predicados del mismo tipo que la columna —comparar una columna long contra un literal int puede impedir que el motor aplique el pushdown.
Parquet no acelera las consultas por sí solo: acelera las consultas que están escritas y organizadas para evitar leer lo que no hace falta.
Complementario al pushdown por estadísticas está el partition pruning: organizar los datos en subdirectorios del tipo columna=valor para que el motor descarte particiones completas antes de listar archivos. Es potente, pero tiene contrapartidas: conviene usar columnas de cardinalidad baja o media (fecha, región, tenant), evitar particiones por debajo de 1 GB y evitar anidar demasiadas columnas de partición, porque cada combinación adicional multiplica subdirectorios y termina generando el clásico "problema de los archivos pequeños".
Evolución de esquema sin reescribir el histórico
Como cada archivo lleva su propio schema en el footer, Parquet tolera bien que el esquema de una tabla cambie con el tiempo: se pueden agregar columnas nuevas, y los archivos antiguos que no las tienen simplemente devuelven nulo en esos campos al leerse; se pueden reordenar columnas sin que eso rompa la lectura, porque el mapeo es por nombre, no por posición; y tipos anidados (structs, arrays, maps) se representan de forma nativa gracias al modelo de niveles de repetición y definición, sin necesidad de aplanar la estructura ni de usar columnas serializadas como JSON dentro de una columna.
Esto no es magia sin reglas: el motor de consultas tiene que estar configurado para hacer merge de esquemas entre archivos con distintas versiones (muchos frameworks lo dejan desactivado por defecto por costo de listado), y cambiar el tipo de una columna existente —de int a string, por ejemplo— sigue siendo una operación delicada que puede romper lectores más antiguos. La evolución de esquema en Parquet resuelve el caso común (agregar/quitar/reordenar columnas) de forma barata; los cambios de tipo siguen requiriendo una migración deliberada.
La base de los lakehouses: Delta Lake, Iceberg y compañía
Parquet resuelve el formato de archivo, pero no resuelve la tabla. Un directorio lleno de archivos Parquet no tiene transacciones atómicas, no coordina escritores concurrentes, no ofrece time travel y, con cada micro-batch de un pipeline incremental, tiende a acumular archivos pequeños que degradan el rendimiento de lectura con el tiempo. Ahí es donde entran los table formats: Delta Lake, Apache Iceberg y Apache Hudi añaden una capa de metadatos y un log transaccional sobre archivos Parquet planos, aportando transacciones ACID, versionado, estadísticas de skipping a nivel de archivo y operaciones de mantenimiento como compactación automática (OPTIMIZE) o clustering por columna (Z-order).
Lo importante es entender la división de responsabilidades: el dato en disco sigue siendo Parquet —column chunks, pages, min/max por chunk— y lo que agrega el table format es un catálogo transaccional que sabe qué archivos Parquet componen cada versión de la tabla y cómo reemplazarlos de forma atómica. Parquet no compite con Delta Lake o Iceberg; es la capa física sobre la que están construidos.
Si tu pipeline escribe en streaming o por micro-batches, no combatas el problema de archivos pequeños con jobs manuales de coalesce/repartition corriendo aparte: apóyate en la compactación automática de tu table format (Delta, Iceberg, Hudi) y déjalo integrado al ciclo de escritura.
Cuándo usarlo en la práctica
- Tamaño de row group: para cargas típicas en Spark u otros motores distribuidos, apunta a row groups de 128 MB a 1 GB. Row groups demasiado pequeños multiplican overhead de metadatos por archivo; demasiado grandes reducen el paralelismo entre tareas.
- Columnas de partición: elige columnas de cardinalidad baja o media que aparezcan en tus cláusulas
WHEREmás frecuentes (fecha, región, tenant). Evita particionar por columnas casi únicas (IDs, timestamps con precisión de segundo) y evita anidar más de una o dos columnas de partición. - Tamaño de archivo objetivo: evita archivos por debajo de ~50-100 MB (el overhead de listar y parsear metadatos de miles de archivos pequeños puede hacer que una consulta tarde más leyendo footers que datos) y evita archivos gigantes de cientos de gigabytes en una sola partición, que eliminan el paralelismo y ralentizan hasta un
count(*)simple. - Ordena antes de escribir: si sabes qué columnas se van a filtrar más, ordénalas o clusterízalas al escribir. Sin eso, las estadísticas min/max del footer no sirven para descartar row groups.
- Compacta con regularidad: en pipelines incrementales o streaming, programa compactación (manual o automática vía tu table format) para evitar la acumulación de archivos pequeños.
- Cuándo NO usarlo: para cargas transaccionales con escrituras y actualizaciones fila por fila (OLTP), para acceso aleatorio a registros individuales, o para datasets pequeños donde el overhead de metadatos columnar no se amortiza. En esos casos, una base de datos relacional o un almacén orientado a filas siguen siendo la opción correcta; y si de todos modos operas sobre un lakehouse, es mejor no escribir Parquet plano sino envolverlo en Delta Lake o Iceberg desde el día uno.