{"schemaVersion":1,"id":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta","url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta","title":"Formatos de Almacenamiento Columnar: Arquitectura y Optimización de Parquet, Lance, Delta Lake e Iceberg","description":"Entendiendo formatos de almacenamiento columnar desde adentro: row groups, predicate pushdown, dictionary encoding, y optimización práctica de queries en Apache Parquet, Lance, Delta Lake, y Apache Iceberg.","language":"es","datePublished":"2025-12-12","dateModified":null,"contentFormat":"text/markdown","content":"Data Engineering\n\n# Formatos de Almacenamiento Columnar: Arquitectura y Optimización de Parquet, Lance, Delta Lake e Iceberg\n\n2025-12-127 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/column-oriented-storage-parquet-lance-delta>)\n\n## Por Qué las Columnas Le Ganan a las Filas para Analytics\n\nConsiderá una tabla con 100 columnas y 1 billón de filas. Tu query necesita 3 de esas columnas.\n\nAlmacenamiento orientado a filas (PostgreSQL, MySQL):\n\n```\nFila 1: [col1, col2, col3, ..., col100] ← lee 100 columnas para obtener 3\nFila 2: [col1, col2, col3, ..., col100] ← lee 100 columnas para obtener 3\n...\n```\n\nPara 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.\n\nAlmacenamiento columnar (Parquet):\n\n```\nColumn chunk 3: [fila1_val, fila2_val, ..., fila1B_val] ← LEER\nColumn chunk 47: [fila1_val, fila2_val, ..., fila1B_val] ← LEER\nColumn chunk 92: [fila1_val, fila2_val, ..., fila1B_val] ← LEER\n```\n\nLeé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.\n\n## Internals de Apache Parquet\n\n[Apache Parquet](<https://parquet.apache.org/>) es el formato columnar estándar de facto. Entender su estructura interna es esencial para optimización.\n\n### Layout del Archivo\n\n```\n┌──────────────────────────────────┐\n│ Magic: PAR1 │\n├──────────────────────────────────┤\n│ Row Group 0 │\n│ ├─ Column Chunk: user_id │\n│ │ ├─ Page 0 (data page) │\n│ │ └─ Page 1 (data page) │\n│ ├─ Column Chunk: timestamp │\n│ └─ Column Chunk: amount │\n├──────────────────────────────────┤\n│ Row Group 1 │\n│ ├─ Column Chunk: user_id │\n│ ├─ Column Chunk: timestamp │\n│ └─ Column Chunk: amount │\n├──────────────────────────────────┤\n│ Footer (metadata) │\n│ ├─ Schema │\n│ ├─ Metadata de row group │\n│ │ ├─ Offsets de column chunks │\n│ │ ├─ Estadísticas Min/Max │\n│ │ └─ Conteo de nulls │\n│ └─ Metadata key-value │\n├──────────────────────────────────┤\n│ Footer length (4 bytes) │\n│ Magic: PAR1 │\n└──────────────────────────────────┘\n```\n\n**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.\n\n**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.\n\n**Pages** son la unidad de encoding y compresión. Cada page (típicamente 1MB) se comprime y codifica independientemente.\n\n### Predicate Pushdown: La Verdadera Ganancia de Performance\n\nEl 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.\n\n```python\ndef should_read_row_group(row_group_metadata, predicate):\n col_stats = row_group_metadata.column('timestamp').statistics\n if predicate.op == '>':\n if col_stats.max < predicate.value:\n return False # Saltear ~1M filas con cero I/O\n return True\n```\n\nPero 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.\n\n```python\n# Malo: orden random → cada row group tiene min=2020, max=2026\ndf_random = spark.read.parquet(\"s3://data/random-order/\")\ndf_random.filter(\"timestamp > '2025-01-01'\").count()\n# Lee: 12TB (todos los row groups)\n\n# Bueno: ordenado por timestamp → row groups con rangos min/max ajustados\ndf_sorted = spark.read.parquet(\"s3://data/sorted-by-time/\")\ndf_sorted.filter(\"timestamp > '2025-01-01'\").count()\n# Lee: 1.2TB (solo row groups recientes)\n```\n\nEsa es una diferencia de 10x solo por ordenar antes de escribir. No puedo exagerar cuán importante es esto.\n\n### Esquemas de Encoding\n\n```\nDictionary Encoding:\n Original: [\"USD\", \"EUR\", \"USD\", \"USD\", \"GBP\", \"EUR\", \"USD\"]\n Diccionario: {0: \"USD\", 1: \"EUR\", 2: \"GBP\"}\n Codificado: [0, 1, 0, 0, 2, 1, 0]\n → Strings reducidos a integers. Ahorro enorme para columnas de baja cardinalidad.\n\nRun-Length Encoding (RLE):\n Original: [1, 1, 1, 1, 1, 2, 2, 2, 3, 3]\n Codificado: [(1, 5), (2, 3), (3, 2)]\n\nDelta Encoding:\n Original: [1000, 1002, 1005, 1007, 1010]\n Codificado: [1000, 2, 3, 2, 3]\n → Almacenar diferencias. Genial para timestamps e IDs incrementales.\n```\n\n## Delta Lake e Iceberg: Tablas Sobre Parquet\n\nLos 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.\n\n### Transaction Log de Delta Lake\n\n```\nmy_table/\n├── _delta_log/\n│ ├── 00000000000000000000.json ← commit inicial\n│ ├── 00000000000000000001.json ← agregar 3 archivos\n│ ├── 00000000000000000002.json ← update: remover 1, agregar 1\n│ └── 00000000000000000003.json ← delete: remover 2\n├── part-00000-abc123.parquet\n├── part-00001-def456.parquet\n└── part-00002-ghi789.parquet\n```\n\n### Metadata de Apache Iceberg\n\nIceberg toma un approach diferente con una jerarquía de metadata estructurada en árbol:\n\n```\nCatálogo → Archivo Metadata Tabla → Lista Manifiestos → Archivos Manifiesto → Archivos Datos\n```\n\nEsta 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).\n\n## Lance: Almacenamiento Columnar para ML\n\nLance 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:\n\n```python\nimport lance\n\nds = lance.write_dataset(\n pa.table({\n \"id\": range(1_000_000),\n \"text\": texts,\n \"embedding\": embeddings, # vectores float32 de 768 dimensiones\n \"metadata\": metadata_dicts,\n }),\n \"s3://ml-data/documents.lance\",\n)\n\n# Búsqueda vectorial rápida, aquí es donde Lance brilla\nresults = ds.to_table(\n nearest={\"column\": \"embedding\", \"q\": query_vector, \"k\": 10},\n filter=\"metadata.category = 'technical'\"\n)\n```\n\n## Optimización Práctica: Queries Reales\n\nUna optimización real que hice en un dataset de clickstream de 4TB:\n\n```sql\n-- Antes de optimización: 47 segundos, escanea 4TB\nSELECT\n page_category,\n COUNT(*) as views,\n COUNT(DISTINCT user_id) as unique_users,\n AVG(time_on_page_seconds) as avg_time\nFROM clickstream\nWHERE event_date BETWEEN '2025-01-01' AND '2025-01-31'\nGROUP BY page_category;\n```\n\nOptimizaciones aplicadas:\n\n1. **Particionar por fecha**: Redujo scan de 4TB a 340GB (solo enero)\n2. **Ordenar dentro de particiones por page\\_category**: Pushdown en columna GROUP BY\n3. **Dictionary encode page\\_category**: 50 valores únicos → integers\n4. **ZSTD nivel 3**: Archivos 20% más chicos que default Snappy\n\n```python\nimport pyarrow.parquet as pq\n\nwriter = pq.ParquetWriter(\n 'clickstream.parquet',\n schema=schema,\n compression='zstd',\n compression_level=3,\n use_dictionary=['page_category', 'browser', 'country'],\n row_group_size=64 * 1024 * 1024,\n write_statistics=True,\n)\n\ndf_sorted = df.sort_values(['event_date', 'page_category'])\nwriter.write_table(pa.Table.from_pandas(df_sorted))\nwriter.close()\n```\n\nDespués de optimización: 2.1 segundos. Mismos datos, misma query, 22x más rápido.\n\n## Eligiendo Entre Formatos\n\nMi árbol de decisión después de trabajar con los cuatro:\n\n**Parquet puro** cuando teners datos de analytics read-only, sin escritores concurrentes, y se desea máxima compatibilidad.\n\n**Delta Lake** cuando se necesita transacciones ACID y se está en el ecosistema Databricks/Spark.\n\n**Apache Iceberg** cuando teners múltiples motores de query (Trino + Spark + Flink) y se necesita evolución de particiones.\n\n**Lance** cuando tus datos incluyen embeddings vectoriales y tu patrón de acceso mezcla queries de analytics con búsqueda de similaridad.\n\nLa 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.\n\n## Preguntas frecuentes\n\n### ¿Por qué el almacenamiento columnar es más rápido para analytics que el orientado a filas?\n\nLas 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é.\n\n### ¿Cuál es la diferencia entre Delta Lake y Apache Iceberg?\n\nAmbos 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).\n\n### ¿Cuándo debería usar Lance en vez de Parquet?\n\nLance 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.\n\n## Tips\n\n1. **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.\n2. **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.\n3. **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.\n4. **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.\n\n- parquet\n- lance\n- delta-lake\n- iceberg\n- almacenamiento-columnar\n- data-engineering\n- analytics\n- optimización-queries\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)","headings":[{"level":1,"text":"Formatos de Almacenamiento Columnar: Arquitectura y Optimización de Parquet, Lance, Delta Lake e Iceberg","url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#title"},{"level":2,"text":"Por Qué las Columnas Le Ganan a las Filas para Analytics","url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#por-qu-las-columnas-le-ganan-a-las-filas-para-analytics"},{"level":2,"text":"Internals de Apache Parquet","url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#internals-de-apache-parquet"},{"level":3,"text":"Layout del Archivo","url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#layout-del-archivo"},{"level":3,"text":"Predicate Pushdown: La Verdadera Ganancia de Performance","url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#predicate-pushdown-la-verdadera-ganancia-de-performance"},{"level":3,"text":"Esquemas de Encoding","url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#esquemas-de-encoding"},{"level":2,"text":"Delta Lake e Iceberg: Tablas Sobre Parquet","url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#delta-lake-e-iceberg-tablas-sobre-parquet"},{"level":3,"text":"Transaction Log de Delta Lake","url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#transaction-log-de-delta-lake"},{"level":3,"text":"Metadata de Apache Iceberg","url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#metadata-de-apache-iceberg"},{"level":2,"text":"Lance: Almacenamiento Columnar para ML","url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#lance-almacenamiento-columnar-para-ml"},{"level":2,"text":"Optimización Práctica: Queries Reales","url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#optimizacin-prctica-queries-reales"},{"level":2,"text":"Eligiendo Entre Formatos","url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#eligiendo-entre-formatos"},{"level":2,"text":"Preguntas frecuentes","url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#questions"},{"level":3,"text":"¿Por qué el almacenamiento columnar es más rápido para analytics que el orientado a filas?","url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#question-1"},{"level":3,"text":"¿Cuál es la diferencia entre Delta Lake y Apache Iceberg?","url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#question-2"},{"level":3,"text":"¿Cuándo debería usar Lance en vez de Parquet?","url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#article-tips"}],"sections":[{"heading":"Formatos de Almacenamiento Columnar: Arquitectura y Optimización de Parquet, Lance, Delta Lake e Iceberg","level":1,"url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta","content":"Data Engineering"},{"heading":"Formatos de Almacenamiento Columnar: Arquitectura y Optimización de Parquet, Lance, Delta Lake e Iceberg","level":1,"url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#title","content":"2025-12-127 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/column-oriented-storage-parquet-lance-delta>)"},{"heading":"Por Qué las Columnas Le Ganan a las Filas para Analytics","level":2,"url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#por-qu-las-columnas-le-ganan-a-las-filas-para-analytics","content":"Considerá una tabla con 100 columnas y 1 billón de filas. Tu query necesita 3 de esas columnas.\n\nAlmacenamiento orientado a filas (PostgreSQL, MySQL):\n\n```\nFila 1: [col1, col2, col3, ..., col100] ← lee 100 columnas para obtener 3\nFila 2: [col1, col2, col3, ..., col100] ← lee 100 columnas para obtener 3\n...\n```\n\nPara 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.\n\nAlmacenamiento columnar (Parquet):\n\n```\nColumn chunk 3: [fila1_val, fila2_val, ..., fila1B_val] ← LEER\nColumn chunk 47: [fila1_val, fila2_val, ..., fila1B_val] ← LEER\nColumn chunk 92: [fila1_val, fila2_val, ..., fila1B_val] ← LEER\n```\n\nLeé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."},{"heading":"Internals de Apache Parquet","level":2,"url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#internals-de-apache-parquet","content":"[Apache Parquet](<https://parquet.apache.org/>) es el formato columnar estándar de facto. Entender su estructura interna es esencial para optimización."},{"heading":"Layout del Archivo","level":3,"url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#layout-del-archivo","content":"```\n┌──────────────────────────────────┐\n│ Magic: PAR1 │\n├──────────────────────────────────┤\n│ Row Group 0 │\n│ ├─ Column Chunk: user_id │\n│ │ ├─ Page 0 (data page) │\n│ │ └─ Page 1 (data page) │\n│ ├─ Column Chunk: timestamp │\n│ └─ Column Chunk: amount │\n├──────────────────────────────────┤\n│ Row Group 1 │\n│ ├─ Column Chunk: user_id │\n│ ├─ Column Chunk: timestamp │\n│ └─ Column Chunk: amount │\n├──────────────────────────────────┤\n│ Footer (metadata) │\n│ ├─ Schema │\n│ ├─ Metadata de row group │\n│ │ ├─ Offsets de column chunks │\n│ │ ├─ Estadísticas Min/Max │\n│ │ └─ Conteo de nulls │\n│ └─ Metadata key-value │\n├──────────────────────────────────┤\n│ Footer length (4 bytes) │\n│ Magic: PAR1 │\n└──────────────────────────────────┘\n```\n\n**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.\n\n**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.\n\n**Pages** son la unidad de encoding y compresión. Cada page (típicamente 1MB) se comprime y codifica independientemente."},{"heading":"Predicate Pushdown: La Verdadera Ganancia de Performance","level":3,"url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#predicate-pushdown-la-verdadera-ganancia-de-performance","content":"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.\n\n```python\ndef should_read_row_group(row_group_metadata, predicate):\n col_stats = row_group_metadata.column('timestamp').statistics\n if predicate.op == '>':\n if col_stats.max < predicate.value:\n return False # Saltear ~1M filas con cero I/O\n return True\n```\n\nPero 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.\n\n```python\n# Malo: orden random → cada row group tiene min=2020, max=2026\ndf_random = spark.read.parquet(\"s3://data/random-order/\")\ndf_random.filter(\"timestamp > '2025-01-01'\").count()\n# Lee: 12TB (todos los row groups)\n\n# Bueno: ordenado por timestamp → row groups con rangos min/max ajustados\ndf_sorted = spark.read.parquet(\"s3://data/sorted-by-time/\")\ndf_sorted.filter(\"timestamp > '2025-01-01'\").count()\n# Lee: 1.2TB (solo row groups recientes)\n```\n\nEsa es una diferencia de 10x solo por ordenar antes de escribir. No puedo exagerar cuán importante es esto."},{"heading":"Esquemas de Encoding","level":3,"url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#esquemas-de-encoding","content":"```\nDictionary Encoding:\n Original: [\"USD\", \"EUR\", \"USD\", \"USD\", \"GBP\", \"EUR\", \"USD\"]\n Diccionario: {0: \"USD\", 1: \"EUR\", 2: \"GBP\"}\n Codificado: [0, 1, 0, 0, 2, 1, 0]\n → Strings reducidos a integers. Ahorro enorme para columnas de baja cardinalidad.\n\nRun-Length Encoding (RLE):\n Original: [1, 1, 1, 1, 1, 2, 2, 2, 3, 3]\n Codificado: [(1, 5), (2, 3), (3, 2)]\n\nDelta Encoding:\n Original: [1000, 1002, 1005, 1007, 1010]\n Codificado: [1000, 2, 3, 2, 3]\n → Almacenar diferencias. Genial para timestamps e IDs incrementales.\n```"},{"heading":"Delta Lake e Iceberg: Tablas Sobre Parquet","level":2,"url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#delta-lake-e-iceberg-tablas-sobre-parquet","content":"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."},{"heading":"Transaction Log de Delta Lake","level":3,"url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#transaction-log-de-delta-lake","content":"```\nmy_table/\n├── _delta_log/\n│ ├── 00000000000000000000.json ← commit inicial\n│ ├── 00000000000000000001.json ← agregar 3 archivos\n│ ├── 00000000000000000002.json ← update: remover 1, agregar 1\n│ └── 00000000000000000003.json ← delete: remover 2\n├── part-00000-abc123.parquet\n├── part-00001-def456.parquet\n└── part-00002-ghi789.parquet\n```"},{"heading":"Metadata de Apache Iceberg","level":3,"url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#metadata-de-apache-iceberg","content":"Iceberg toma un approach diferente con una jerarquía de metadata estructurada en árbol:\n\n```\nCatálogo → Archivo Metadata Tabla → Lista Manifiestos → Archivos Manifiesto → Archivos Datos\n```\n\nEsta 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)."},{"heading":"Lance: Almacenamiento Columnar para ML","level":2,"url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#lance-almacenamiento-columnar-para-ml","content":"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:\n\n```python\nimport lance\n\nds = lance.write_dataset(\n pa.table({\n \"id\": range(1_000_000),\n \"text\": texts,\n \"embedding\": embeddings, # vectores float32 de 768 dimensiones\n \"metadata\": metadata_dicts,\n }),\n \"s3://ml-data/documents.lance\",\n)\n\n# Búsqueda vectorial rápida, aquí es donde Lance brilla\nresults = ds.to_table(\n nearest={\"column\": \"embedding\", \"q\": query_vector, \"k\": 10},\n filter=\"metadata.category = 'technical'\"\n)\n```"},{"heading":"Optimización Práctica: Queries Reales","level":2,"url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#optimizacin-prctica-queries-reales","content":"Una optimización real que hice en un dataset de clickstream de 4TB:\n\n```sql\n-- Antes de optimización: 47 segundos, escanea 4TB\nSELECT\n page_category,\n COUNT(*) as views,\n COUNT(DISTINCT user_id) as unique_users,\n AVG(time_on_page_seconds) as avg_time\nFROM clickstream\nWHERE event_date BETWEEN '2025-01-01' AND '2025-01-31'\nGROUP BY page_category;\n```\n\nOptimizaciones aplicadas:\n\n1. **Particionar por fecha**: Redujo scan de 4TB a 340GB (solo enero)\n2. **Ordenar dentro de particiones por page\\_category**: Pushdown en columna GROUP BY\n3. **Dictionary encode page\\_category**: 50 valores únicos → integers\n4. **ZSTD nivel 3**: Archivos 20% más chicos que default Snappy\n\n```python\nimport pyarrow.parquet as pq\n\nwriter = pq.ParquetWriter(\n 'clickstream.parquet',\n schema=schema,\n compression='zstd',\n compression_level=3,\n use_dictionary=['page_category', 'browser', 'country'],\n row_group_size=64 * 1024 * 1024,\n write_statistics=True,\n)\n\ndf_sorted = df.sort_values(['event_date', 'page_category'])\nwriter.write_table(pa.Table.from_pandas(df_sorted))\nwriter.close()\n```\n\nDespués de optimización: 2.1 segundos. Mismos datos, misma query, 22x más rápido."},{"heading":"Eligiendo Entre Formatos","level":2,"url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#eligiendo-entre-formatos","content":"Mi árbol de decisión después de trabajar con los cuatro:\n\n**Parquet puro** cuando teners datos de analytics read-only, sin escritores concurrentes, y se desea máxima compatibilidad.\n\n**Delta Lake** cuando se necesita transacciones ACID y se está en el ecosistema Databricks/Spark.\n\n**Apache Iceberg** cuando teners múltiples motores de query (Trino + Spark + Flink) y se necesita evolución de particiones.\n\n**Lance** cuando tus datos incluyen embeddings vectoriales y tu patrón de acceso mezcla queries de analytics con búsqueda de similaridad.\n\nLa 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."},{"heading":"Preguntas frecuentes","level":2,"url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#questions","content":""},{"heading":"¿Por qué el almacenamiento columnar es más rápido para analytics que el orientado a filas?","level":3,"url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#question-1","content":"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é."},{"heading":"¿Cuál es la diferencia entre Delta Lake y Apache Iceberg?","level":3,"url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#question-2","content":"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)."},{"heading":"¿Cuándo debería usar Lance en vez de Parquet?","level":3,"url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#question-3","content":"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."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#article-tips","content":"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.\n2. **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.\n3. **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.\n4. **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.\n\n- parquet\n- lance\n- delta-lake\n- iceberg\n- almacenamiento-columnar\n- data-engineering\n- analytics\n- optimización-queries\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)"}],"links":[{"text":"English","url":"https://lucioduran.com/blog/column-oriented-storage-parquet-lance-delta"},{"text":"Apache Parquet","url":"https://parquet.apache.org/"},{"text":"Volver al Blog","url":"https://lucioduran.com/es/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta"},"headline":"Formatos de Almacenamiento Columnar: Arquitectura y Optimización de Parquet, Lance, Delta Lake e Iceberg","description":"Entendiendo formatos de almacenamiento columnar desde adentro: row groups, predicate pushdown, dictionary encoding, y optimización práctica de queries en Apache Parquet, Lance, Delta Lake, y Apache Iceberg.","datePublished":"2025-12-12","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://statics.lucioduran.com/images/lucioduran-social-card.jpg","url":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta","inLanguage":"es","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Data Engineering","keywords":"parquet, lance, delta-lake, iceberg, almacenamiento-columnar, data-engineering, analytics, optimización-queries","wordCount":1190,"timeRequired":"PT7M","workTranslation":[{"@type":"BlogPosting","inLanguage":"en","url":"https://lucioduran.com/blog/column-oriented-storage-parquet-lance-delta"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Inicio","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/es/blog"},{"@type":"ListItem","position":3,"name":"Formatos de Almacenamiento Columnar: Arquitectura y Optimización de Parquet, Lance, Delta Lake e Iceberg","item":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"¿Por qué el almacenamiento columnar es más rápido para analytics que el orientado a filas?","acceptedAnswer":{"@type":"Answer","text":"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é."}},{"@type":"Question","name":"¿Cuál es la diferencia entre Delta Lake y Apache Iceberg?","acceptedAnswer":{"@type":"Answer","text":"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)."}},{"@type":"Question","name":"¿Cuándo debería usar Lance en vez de Parquet?","acceptedAnswer":{"@type":"Answer","text":"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."}}]}]}],"contentHash":"f2cec9a8b91232dc9530e3610f05a65d06038189923facff70491873ee35506a","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/es/blog/column-oriented-storage-parquet-lance-delta","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":66678,"contentBytes":12518}}
