<!-- Canonical: https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta
Language: es
Content-SHA256: f2cec9a8b91232dc9530e3610f05a65d06038189923facff70491873ee35506a
-->

Data Engineering

# Formatos de Almacenamiento Columnar: Arquitectura y Optimización de Parquet, Lance, Delta Lake e Iceberg

2025-12-127 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/column-oriented-storage-parquet-lance-delta>)

## 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](<https://parquet.apache.org/>) 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.

```python
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.

```python
# 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:

```python
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:

```sql
-- 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

```python
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.

## Preguntas frecuentes

### ¿Por qué el almacenamiento columnar es más rápido para analytics que el orientado a filas?

Las queries de analytics típicamente leen unas pocas columnas a través de millones de filas (ej: SELECT AVG(price) WHERE date \> X). El almacenamiento columnar pone todos los valores de una columna contiguamente en disco, así leer una columna hace I/O secuencial solo sobre ese es datos. El almacenamiento por filas te obliga a leer filas enteras incluyendo columnas que no se necesita, desperdiciando ancho de banda y caché.

### ¿Cuál es la diferencia entre Delta Lake y Apache Iceberg?

Ambos son formatos de tabla que agregan transacciones ACID, time travel, y evolución de schema sobre archivos Parquet. Delta Lake usa un log de transacciones basado en JSON, fue creado por Databricks, y tiene la integración más profunda con Spark. Iceberg usa metadata basada en Avro con una abstracción de catálogo, fue creado en Netflix, y tiene soporte multi-motor más amplio (Trino, Flink, Spark, Athena).

### ¿Cuándo debería usar Lance en vez de Parquet?

Lance está optimizado para workloads de ML con datos vectoriales de alta dimensionalidad y patrones de acceso random. Si se necesita búsqueda de similaridad vectorial rápida junto con datos tabulares, o tu patrón de acceso es lookups random de filas en vez de scans columnares, Lance supera a Parquet significativamente. Para queries de analytics puras, Parquet sigue siendo el estándar.

## Tips

1. **Tip:** Configurar el tamaño de row group de Parquet para que matchee tus patrones de query. Para point lookups, row groups más chicos (8-16MB) reducen I/O. Para scans completos, row groups más grandes (128-256MB) reducen overhead de metadata. El default de 128MB es un buen punto de partida, pero vi speedups de 4x tuneando esto en workloads específicos.
2. **Tip:** Siempre ordená tus datos por la columna más filtrada antes de escribir a Parquet. Esto hace que las estadísticas min/max en la metadata del row group sirvan de verdad para predicate pushdown. Datos desordenados significan que el min/max de cada row group abarca todo el rango de valores, y el motor de query lee todo igual.
3. **Tip:** Utilizar compresión ` ZSTD ` nivel 3 para Parquet en producción, no el nivel 1 por defecto. Nivel 3 da 15-20% mejor compresión con overhead de encoding despreciable. Nivel 19 da retornos decrecientes y mata el throughput de escritura. Benchmarkée los 22 niveles, 3 es el punto óptimo.
4. **Tip:** Cuando eligas entre Delta Lake e Iceberg, verificar tu ecosistema existente. Si se está en Databricks, Delta es nativo. Si se está en AWS Athena/Glue o Snowflake, Iceberg tiene mejor integración. Los formatos están convergiendo, no elijas por features, elegir por lo que tu motor de query soporta nativamente.

- parquet
- lance
- delta-lake
- iceberg
- almacenamiento-columnar
- data-engineering
- analytics
- optimización-queries

[Volver al Blog](<https://lucioduran.com/es/blog>)
