Por Qué las Columnas Le Ganan a las Filas para Analytics

Considerá una tabla con 100 columnas y 1 billón de filas. Tu query necesita 3 de esas columnas.

Almacenamiento orientado a filas (PostgreSQL, MySQL):

Fila 1: [col1, col2, col3, ..., col100] ← lee 100 columnas para obtener 3
Fila 2: [col1, col2, col3, ..., col100] ← lee 100 columnas para obtener 3
...

Para leer 3 columnas, leés las 100 de cada fila. Si cada columna son 8 bytes, eso es 800GB de I/O para 24GB de datos útiles.

Almacenamiento columnar (Parquet):

Column chunk 3: [fila1_val, fila2_val, ..., fila1B_val] ← LEER
Column chunk 47: [fila1_val, fila2_val, ..., fila1B_val] ← LEER
Column chunk 92: [fila1_val, fila2_val, ..., fila1B_val] ← LEER

Leés exactamente los 3 column chunks que se necesita: 24GB. Es una reducción de 33x en I/O, y en S3 donde pagás por byte transferido, también es una reducción de 33x en costo.

Internals de Apache Parquet

Apache Parquet es el formato columnar estándar de facto. Entender su estructura interna es esencial para optimización.

Layout del Archivo

┌──────────────────────────────────┐
│ Magic: PAR1 │
├──────────────────────────────────┤
│ Row Group 0 │
│ ├─ Column Chunk: user_id │
│ │ ├─ Page 0 (data page) │
│ │ └─ Page 1 (data page) │
│ ├─ Column Chunk: timestamp │
│ └─ Column Chunk: amount │
├──────────────────────────────────┤
│ Row Group 1 │
│ ├─ Column Chunk: user_id │
│ ├─ Column Chunk: timestamp │
│ └─ Column Chunk: amount │
├──────────────────────────────────┤
│ Footer (metadata) │
│ ├─ Schema │
│ ├─ Metadata de row group │
│ │ ├─ Offsets de column chunks │
│ │ ├─ Estadísticas Min/Max │
│ │ └─ Conteo de nulls │
│ └─ Metadata key-value │
├──────────────────────────────────┤
│ Footer length (4 bytes) │
│ Magic: PAR1 │
└──────────────────────────────────┘

Row Groups son la unidad de paralelismo. Cada row group contiene un slice horizontal de filas (típicamente 128MB). Los motores de query procesan row groups en paralelo entre cores.

Column Chunks son la unidad de I/O. Cuando hacés SELECT amount, el motor lee solo los column chunks de amount, salteando todo lo demás.

Pages son la unidad de encoding y compresión. Cada page (típicamente 1MB) se comprime y codifica independientemente.

Predicate Pushdown: La Verdadera Ganancia de Performance

El footer contiene estadísticas min/max para cada column chunk. Cuando escribirs WHERE timestamp > '2025-01-01', el motor de query chequea el min/max de timestamp de cada row group. Si el max timestamp de un row group es 2024-06-15, el row group entero se saltea, sin descompresión, sin I/O para ese es datos.

def should_read_row_group(row_group_metadata, predicate):
 col_stats = row_group_metadata.column('timestamp').statistics
 if predicate.op == '>':
 if col_stats.max < predicate.value:
 return False # Saltear ~1M filas con cero I/O
 return True

Pero aquí viene el catch que me costó meses aprender: predicate pushdown solo funciona si tus datos están ordenados (o al menos clusterizados) por la columna filtrada. Si tus datos están en orden random, cada row group contiene el rango completo de timestamps, y ningún row group se saltea.

# Malo: orden random → cada row group tiene min=2020, max=2026
df_random = spark.read.parquet("s3://data/random-order/")
df_random.filter("timestamp > '2025-01-01'").count()
# Lee: 12TB (todos los row groups)

# Bueno: ordenado por timestamp → row groups con rangos min/max ajustados
df_sorted = spark.read.parquet("s3://data/sorted-by-time/")
df_sorted.filter("timestamp > '2025-01-01'").count()
# Lee: 1.2TB (solo row groups recientes)

Esa es una diferencia de 10x solo por ordenar antes de escribir. No puedo exagerar cuán importante es esto.

Esquemas de Encoding

Dictionary Encoding:
 Original: ["USD", "EUR", "USD", "USD", "GBP", "EUR", "USD"]
 Diccionario: {0: "USD", 1: "EUR", 2: "GBP"}
 Codificado: [0, 1, 0, 0, 2, 1, 0]
 → Strings reducidos a integers. Ahorro enorme para columnas de baja cardinalidad.

Run-Length Encoding (RLE):
 Original: [1, 1, 1, 1, 1, 2, 2, 2, 3, 3]
 Codificado: [(1, 5), (2, 3), (3, 2)]

Delta Encoding:
 Original: [1000, 1002, 1005, 1007, 1010]
 Codificado: [1000, 2, 3, 2, 3]
 → Almacenar diferencias. Genial para timestamps e IDs incrementales.

Delta Lake e Iceberg: Tablas Sobre Parquet

Los archivos Parquet crudos son inmutables. Delta Lake e Iceberg agregan una capa de metadata que provee transacciones ACID, time travel, evolución de schema, y evolución de particiones.

Transaction Log de Delta Lake

my_table/
├── _delta_log/
│ ├── 00000000000000000000.json ← commit inicial
│ ├── 00000000000000000001.json ← agregar 3 archivos
│ ├── 00000000000000000002.json ← update: remover 1, agregar 1
│ └── 00000000000000000003.json ← delete: remover 2
├── part-00000-abc123.parquet
├── part-00001-def456.parquet
└── part-00002-ghi789.parquet

Metadata de Apache Iceberg

Iceberg toma un approach diferente con una jerarquía de metadata estructurada en árbol:

Catálogo → Archivo Metadata Tabla → Lista Manifiestos → Archivos Manifiesto → Archivos Datos

Esta estructura de árbol significa que Iceberg puede podar en tres niveles antes de leer datos: saltear manifiestos enteros (partition pruning), saltear archivos dentro de manifiestos (min/max pruning), después saltear row groups dentro de archivos (pushdown a nivel Parquet).

Lance: Almacenamiento Columnar para ML

Lance es el formato más nuevo con el que vengo trabajando para pipelines de ML. Es columnar como Parquet pero optimizado para patrones de acceso diferentes:

import lance

ds = lance.write_dataset(
 pa.table({
 "id": range(1_000_000),
 "text": texts,
 "embedding": embeddings, # vectores float32 de 768 dimensiones
 "metadata": metadata_dicts,
 }),
 "s3://ml-data/documents.lance",
)

# Búsqueda vectorial rápida, aquí es donde Lance brilla
results = ds.to_table(
 nearest={"column": "embedding", "q": query_vector, "k": 10},
 filter="metadata.category = 'technical'"
)

Optimización Práctica: Queries Reales

Una optimización real que hice en un dataset de clickstream de 4TB:

-- Antes de optimización: 47 segundos, escanea 4TB
SELECT
 page_category,
 COUNT(*) as views,
 COUNT(DISTINCT user_id) as unique_users,
 AVG(time_on_page_seconds) as avg_time
FROM clickstream
WHERE event_date BETWEEN '2025-01-01' AND '2025-01-31'
GROUP BY page_category;

Optimizaciones aplicadas:

  1. Particionar por fecha: Redujo scan de 4TB a 340GB (solo enero)
  2. Ordenar dentro de particiones por page_category: Pushdown en columna GROUP BY
  3. Dictionary encode page_category: 50 valores únicos → integers
  4. ZSTD nivel 3: Archivos 20% más chicos que default Snappy
import pyarrow.parquet as pq

writer = pq.ParquetWriter(
 'clickstream.parquet',
 schema=schema,
 compression='zstd',
 compression_level=3,
 use_dictionary=['page_category', 'browser', 'country'],
 row_group_size=64 * 1024 * 1024,
 write_statistics=True,
)

df_sorted = df.sort_values(['event_date', 'page_category'])
writer.write_table(pa.Table.from_pandas(df_sorted))
writer.close()

Después de optimización: 2.1 segundos. Mismos datos, misma query, 22x más rápido.

Eligiendo Entre Formatos

Mi árbol de decisión después de trabajar con los cuatro:

Parquet puro cuando teners datos de analytics read-only, sin escritores concurrentes, y se desea máxima compatibilidad.

Delta Lake cuando se necesita transacciones ACID y se está en el ecosistema Databricks/Spark.

Apache Iceberg cuando teners múltiples motores de query (Trino + Spark + Flink) y se necesita evolución de particiones.

Lance cuando tus datos incluyen embeddings vectoriales y tu patrón de acceso mezcla queries de analytics con búsqueda de similaridad.

La realidad es que para la mayoría de los equipos, la elección entre Delta e Iceberg importa menos que un correcto ordenamiento de datos, tuning de compresión, y estrategia de particionamiento. Hacé ese es fundamentales bien en cualquier formato y se va a ver mejoras de 10-50x sobre uso naive. Vi equipos agonizar sobre Delta vs. Iceberg durante meses mientras sus queries escaneaban terabytes de Parquet sin ordenar y sin particionar. Arreglá el layout de datos primero. El formato de tabla es una nota al pie. En serio, te lo digo como alguien que perdió tiempo en la decisión equivocada.