~/bda/m01 $ cat 05-formatos-de-datos.md
- Big Data Aplicado
- M01
- lección 05/09
Formatos de datos
Guardar los mismos datos de distintas formas
Section titled “Guardar los mismos datos de distintas formas”Queremos guardar tres ventas y volver a leerlas con Python. Vamos a hacerlo primero en CSV, después en JSON Lines y finalmente en Avro, Parquet y ORC. En cada apartado estudiaremos cómo representa los datos el formato, generaremos un archivo y comprobaremos su lectura. Al terminar compararemos lo que hemos observado.
Guardar datos de un programa en un archivo se denomina serializar. Recuperarlos como valores del programa se denomina deserializar. El formato establece las reglas que permiten interpretar los bytes guardados. Por ejemplo, un lector debe saber dónde termina un campo y si su contenido representa texto o un número.
Filas y columnas
Section titled “Filas y columnas”Antes de crear los archivos, conviene distinguir dos maneras de organizar sus datos. Imagina una tabla de ventas con producto, categoría, unidades, precio e importe.
- Por filas o registros. Se guardan juntos los campos de una venta y después los de la siguiente. Para recuperar una venta completa recorremos su registro. CSV, JSONL y Avro siguen esta organización.
- Por columnas. Se guardan juntos los valores de un mismo campo dentro de un grupo de registros. Si solo necesitamos categoría e importe, el lector puede solicitar esas columnas. Parquet y ORC siguen esta organización.
En un archivo columnar también existen filas lógicas. Lo que cambia es cómo se almacenan sus valores. Parquet utiliza grupos de filas y ORC utiliza stripes; dentro de esas unidades, los datos se organizan por columnas.
La figura utiliza otro ejemplo pequeño, tres compras de fruta con fecha, producto, cantidad y precio. Sigue el recorrido de izquierda a derecha. El lector CSV recorre las líneas para recuperar fecha y precio; el lector Parquet puede solicitar esas columnas y omitir las otras.
Preparar el entorno
Section titled “Preparar el entorno”Usaremos el contenedor de formatos del repositorio BigDataAplicadoLab_2627. Es el mismo repositorio empleado en los bloques 03 y 04; aquí utilizaremos su entorno Python para formatos. En el anfitrión, entra en BigDataAplicadoLab_2627/modulo01/ y abre su shell.
make formatos-shellSin Make, la orden equivalente es la siguiente.
docker compose -f formatos/compose.yaml run --rm --build formatos bashLa imagen bda-m01-formatos:3.12-r3 incluye Python 3.12.8, pandas 2.2.3, NumPy 2.1.3, PyArrow 18.1.0 y fastavro 1.10.0, además de los editores nano y vi y las herramientas curl, wget y gzip. Estás dentro del contenedor, como alumno, en /lab. La carpeta modulo01/ del anfitrión está montada en /lab, por lo que los archivos que creemos allí se conservarán al salir.
Desde esa shell, prepara la carpeta de trabajo.
mkdir -p trabajo/formatoscd /lab/trabajo/formatospython --versionpython -c "import pyarrow, fastavro; print(pyarrow.__version__, fastavro.__version__)"Espera Python 3.12.8, PyArrow 18.1.0 y fastavro 1.10.0. Todos los scripts siguientes se crean y ejecutan en /lab/trabajo/formatos, dentro de este contenedor. Puedes editarlos también desde modulo01/trabajo/formatos/ en el anfitrión. Si sales y vuelves a abrir la shell, entra de nuevo en ese directorio.
Datos comunes en un archivo de entrada
Section titled “Datos comunes en un archivo de entrada”Crea ventas_entrada.csv en /lab/trabajo/formatos con este contenido. Es la fuente que leerán todos los ejemplos. Cada línea de datos representa una venta y la primera contiene los nombres de campo.
producto,categoria,unidades,precio,importeauriculares,audio,2,59.90,119.80altavoz,audio,1,45.00,45.00teclado,perifericos,1,75.00,75.00Los scripts abrirán este archivo y pedirán los registros a medida que los necesiten. Para reutilizar la lectura, crea datos_ventas.py con dos funciones pequeñas.
import csv
CAMPOS = ['producto', 'categoria', 'unidades', 'precio', 'importe']
# Leer el archivo de entrada y entregar una venta cada vezdef leer_ventas(): with open('ventas_entrada.csv', encoding='utf-8', newline='') as entrada: for venta in csv.DictReader(entrada): venta['unidades'] = int(venta['unidades']) venta['precio'] = float(venta['precio']) venta['importe'] = float(venta['importe']) yield venta
# Comprobar los registros sin guardarlos todos en memoriadef resumir(registros): cantidad = 0 total_centimos = 0 for venta in registros: cantidad += 1 total_centimos += round(venta['importe'] * 100) return cantidad, total_centimoscsv.DictReader entrega un diccionario por cada registro del CSV, utilizando la cabecera como nombres de campo. Sus valores llegan como texto, por eso convertimos unidades, precio e importe. En el apartado CSV veremos sus operaciones de escritura y lectura con más detalle.
La palabra yield entrega una venta (un registro cada vez) y deja la función en pausa. Cuando el bucle solicita la siguiente, la función continúa leyendo el archivo desde donde se quedó. A este tipo de función se le llama generador. Cada llamada a leer_ventas() crea un recorrido nuevo del archivo. El archivo permanece abierto mientras se recorre y se cierra al terminar el bloque with.
resumir recorre los registros que recibe y conserva solo dos acumuladores, el número de ventas y su importe total en céntimos. round(importe * 100) convierte cada importe de dos decimales en un entero antes de sumarlo. Así evitamos acumular millones de aproximaciones de coma flotante. Las comprobaciones de cantidad y total no garantizan la igualdad de todos los campos de texto.
from datos_ventas import leer_ventas, resumir permite utilizar esas funciones desde otro script situado en la misma carpeta. Importar el módulo define las funciones; la lectura empieza cuando recorremos el resultado de leer_ventas().
Comprueba la lectura con este script.
from datos_ventas import leer_ventas, resumir
print('Cantidad e importe en céntimos:', resumir(leer_ventas()))python comprobar_datos.pyCon la muestra debe mostrar (3, 23980), tres registros y 239,80 de importe total. Las mismas funciones pueden recorrer un archivo con más filas sin formar una lista con todas ellas. Para esta comparación usamos float y su equivalente double en los formatos binarios. Una aplicación contable definiría tipos decimales o unidades mínimas enteras desde la entrada.
CSV — formato por filas con campos separados por comas
Section titled “CSV — formato por filas con campos separados por comas”CSV es un formato de texto orientado a filas. Representa cada registro en una línea, con sus campos separados por un delimitador. Usaremos comas y una primera línea con los nombres de las columnas. La cabecera permite identificar los campos, pero no declara sus tipos. Si por ejemplo, leemos un 2 del archivo debemos decidir si representa texto o un entero.
La biblioteca estándar csv gestiona los delimitadores y los campos entrecomillados. Así, un texto que contenga una coma puede conservarse dentro de una única columna.
Crear y leer un archivo CSV
Section titled “Crear y leer un archivo CSV”En Python, cada venta es un diccionario con claves como producto, categoria o unidades. En el CSV necesitamos una cabecera con esos nombres y una línea de valores por venta. DictWriter y DictReader permiten pasar de una representación a la otra.
Para escribir usamos csv.DictWriter. Al crearlo le indicamos el archivo de salida y la lista de nombres de columna mediante fieldnames. Esa lista determina qué valor corresponde a cada columna y en qué orden se escribe.
La constante CAMPOS, definida en datos_ventas.py, contiene los cinco nombres de columna en el orden que vamos a escribir. La pasamos a fieldnames. Las llamadas siguientes realizan la escritura.
escritor.writeheader()escribe una primera línea con los nombres de columna.escritor.writerow(venta)escribe el diccionario recibido en la fila siguiente. La llamamos una vez por venta dentro del bucle.writerowstambién admite un iterable de registros, pero aquí hacemos visible cada paso.
Para leer usamos csv.DictReader. Recibe el archivo abierto y toma su primera línea como cabecera. Después, al recorrerlo, devuelve un diccionario por cada línea de datos. Por ejemplo, si la cabecera contiene producto,unidades y la siguiente línea contiene auriculares,2, devuelve {'producto': 'auriculares', 'unidades': '2'}.
Fíjate en las comillas de '2': el lector CSV devuelve los valores como texto. Por eso convertiremos las unidades a entero y los precios e importes a números. Recorremos csv.DictReader(entrada) con un bucle para procesar un diccionario cada vez. La función leer_csv entrega cada registro mediante yield para que resumir lo compruebe.
Crea el siguiente script para comprobar el recorrido completo.
import csvfrom datos_ventas import CAMPOS, leer_ventas, resumir
# Crear archivo escribiendo una venta cada vezwith open('ventas.csv', 'w', encoding='utf-8', newline='') as salida: escritor = csv.DictWriter(salida, fieldnames=CAMPOS) escritor.writeheader() for venta in leer_ventas(): escritor.writerow(venta)
# Leer archivo y recuperar los tipos registro a registrodef leer_csv(): with open('ventas.csv', encoding='utf-8', newline='') as entrada: for venta in csv.DictReader(entrada): venta['unidades'] = int(venta['unidades']) venta['precio'] = float(venta['precio']) venta['importe'] = float(venta['importe']) yield venta
resultado = resumir(leer_csv())assert resultado == resumir(leer_ventas())print('CSV:', resultado)python crear_csv.pycat ventas.csvnewline='' deja que la biblioteca gestione los finales de línea. Los modos w y r sirven para escribir y leer texto; al omitir el modo se utiliza lectura. assert comprueba que la cantidad y el importe total recuperados coinciden con la fuente y detiene el programa si no se cumple. Observa que hemos tenido que recuperar los tipos numéricos explícitamente.
JSON Lines — formato por registros con un objeto por línea
Section titled “JSON Lines — formato por registros con un objeto por línea”Queremos guardar eventos que llegan uno detrás de otro, por ejemplo accesos a una web, mensajes de una aplicación o lecturas de sensores. Cada evento puede representarse como un objeto JSON. La cuestión es cómo organizar muchos objetos dentro del archivo para ir escribiéndolos y leyéndolos.
En un documento JSON podemos guardar las ventas como una lista, delimitada por corchetes. Este fragmento muestra su estructura.
[ {"producto": "auriculares", "unidades": 2}, {"producto": "altavoz", "unidades": 1}]En JSON Lines, también llamado JSONL, cada línea es un objeto JSON completo. El archivo anterior se representaría así.
{"producto": "auriculares", "unidades": 2}{"producto": "altavoz", "unidades": 1}JSONL está orientado a registros. Conserva la sintaxis JSON de cada objeto, pero establece que el salto de línea separa un registro del siguiente. El archivo completo se lee como una secuencia de documentos JSON, uno por línea.
Esto resulta práctico para registros de aplicaciones, exportaciones de muchos documentos y eventos procesados en secuencia. Podemos añadir un evento al final escribiendo otra línea y procesar los eventos uno a uno con un bucle, manteniendo en memoria solo el actual. Para añadir un objeto a una lista JSON hay que respetar la coma y el corchete de cierre; JSONL facilita esa escritura secuencial.
Un documento JSON sigue siendo adecuado para una configuración, una respuesta de una API o un objeto con estructura anidada que se maneja como conjunto. JSONL ayuda cuando interesa recorrer numerosos registros de manera independiente. Existen lectores JSON que también procesan documentos grandes de forma incremental; la ventaja aquí es que basta con leer líneas y utilizar el lector JSON habitual para cada una.
Qué hacen las funciones de JSON
Section titled “Qué hacen las funciones de JSON”Python trabaja con diccionarios; un archivo de texto guarda caracteres. json.dumps(venta) toma un diccionario Python y devuelve una cadena con su representación JSON. Después, write guarda esa cadena en el archivo.
Al leer ocurre el recorrido inverso. json.loads(linea) toma la cadena leída y devuelve un objeto Python, en nuestro caso un diccionario cuyos campos podemos consultar de nuevo.
ensure_ascii=False permite que caracteres como ñ se escriban directamente en el texto, usando la codificación UTF-8 indicada en open. Con el valor predeterminado también se conservan, pero pueden representarse mediante escapes como \u00f1. La opción cambia cómo se representan esos caracteres en el archivo.
Crear y leer un archivo JSONL
Section titled “Crear y leer un archivo JSONL”import jsonfrom datos_ventas import leer_ventas, resumir
# Crear archivo escribiendo un objeto JSON por líneawith open('ventas.jsonl', 'w', encoding='utf-8') as salida: for venta in leer_ventas(): salida.write(json.dumps(venta, ensure_ascii=False) + '\n')
# Leer archivo interpretando un objeto cada vezdef leer_jsonl(): with open('ventas.jsonl', encoding='utf-8') as entrada: for linea in entrada: yield json.loads(linea)
resultado = resumir(leer_jsonl())assert resultado == resumir(leer_ventas())print('JSONL:', resultado)python crear_jsonl.pyhead ventas.jsonlEn la escritura añadimos \n después de cada objeto. Durante la lectura, json.loads interpreta una línea y yield entrega su diccionario para que resumir lo procese. Al continuar, leemos el siguiente registro. Conservamos los acumuladores, sin reunir todas las ventas en una lista.
Avro — formato por filas, binario y con esquema
Section titled “Avro — formato por filas, binario y con esquema”Avro es un formato binario orientado a filas. Representa registros mediante un esquema que declara el nombre y tipo de sus campos. El esquema se expresa en JSON, mientras que los valores se codifican en binario. En los archivos contenedores .avro que usaremos, la cabecera incluye el esquema de escritura y después se guardan bloques de registros.
Un registro completo es la unidad que queremos intercambiar. Esta organización por filas resulta adecuada para datos de entrada y eventos que los consumidores procesan registro a registro.
Crear y leer un archivo Avro
Section titled “Crear y leer un archivo Avro”Usaremos fastavro, ya instalado en el contenedor. string, long y double representan texto, enteros de 64 bits y números de coma flotante de doble precisión.
from fastavro import parse_schema, writer, readerfrom datos_ventas import leer_ventas, resumir
esquema = { 'type': 'record', 'name': 'Venta', 'fields': [ {'name': 'producto', 'type': 'string'}, {'name': 'categoria', 'type': 'string'}, {'name': 'unidades', 'type': 'long'}, {'name': 'precio', 'type': 'double'}, {'name': 'importe', 'type': 'double'}, ],}
# Crear archivowith open('ventas.avro', 'wb') as salida: writer(salida, parse_schema(esquema), leer_ventas(), codec='null')
# Leer archivowith open('ventas.avro', 'rb') as entrada: lector = reader(entrada) print(lector.writer_schema) resultado = resumir(lector)
assert resultado == resumir(leer_ventas())print('Avro:', resultado)python crear_avro.pyLa b de wb y rb indica escritura y lectura binarias. parse_schema prepara el esquema y writer guarda los registros. reader recupera el esquema de la cabecera y entrega registros al recorrerlo. writer recibe el generador leer_ventas() y solicita cada venta a medida que la escribe. codec='null' indica que esta primera prueba se escribe sin compresión.
Leer datos anteriores con un campo nuevo
Section titled “Leer datos anteriores con un campo nuevo”Avro permite resolver diferencias compatibles entre el esquema con el que se escribieron los datos y el que usa un nuevo lector. Por ejemplo, podemos añadir un campo canal cuyo valor por defecto sea desconocido para los registros antiguos.
from fastavro import reader
# Leer archivowith open('ventas.avro', 'rb') as entrada: esquema_anterior = reader(entrada).writer_schema
esquema_nuevo = { **esquema_anterior, 'fields': esquema_anterior['fields'] + [ {'name': 'canal', 'type': 'string', 'default': 'desconocido'}, ],}
# Leer archivowith open('ventas.avro', 'rb') as entrada: cantidad = 0 for venta in reader(entrada, reader_schema=esquema_nuevo): assert venta['canal'] == 'desconocido' cantidad += 1print('Registros con el campo nuevo:', cantidad)python leer_avro_evolucion.py**esquema_anterior copia sus entradas al nuevo diccionario; sustituimos fields por una lista con el campo adicional. El archivo anterior se conserva y el lector aporta el valor por defecto. Otros cambios, como sustituir arbitrariamente un tipo por otro incompatible, pueden impedir la lectura. La compatibilidad depende de las reglas de resolución de esquemas. Documentación de escritura de fastavro
Parquet — almacenar por columnas para consultar
Section titled “Parquet — almacenar por columnas para consultar”Parquet es un formato binario orientado a columnas, diseñado para consultas analíticas. Divide los registros en grupos de filas y, dentro de cada grupo, almacena juntos los valores de cada columna. Sus metadatos permiten localizar las columnas necesarias y pueden ayudar al motor a omitir grupos que no interesen a un filtro.
Si solo queremos sumar importe por categoria, el lector puede solicitar esas dos columnas. Esta lectura selectiva evita recuperar producto, unidades y precio.
Formar bloques de registros
Section titled “Formar bloques de registros”Parquet trabaja con grupos de filas. Para escribirlo, reuniremos como máximo 50.000 ventas en cada bloque y crearemos una tabla Arrow con ese bloque. islice permite tomar una cantidad limitada de registros de un iterador.
Prueba primero con bloques de dos ventas para observar el recorrido de la muestra.
from itertools import islicefrom datos_ventas import leer_ventas
entrada = leer_ventas()while True: bloque = list(islice(entrada, 2)) if not bloque: break print('Registros del bloque:', len(bloque))python practicar_bloques.pyLa primera vuelta recibe dos ventas, la siguiente una y la última una lista vacía que termina el bucle. list reúne únicamente los registros del bloque actual. En los scripts de escritura repetiremos el patrón con un límite de 50.000. El consumo también depende del tamaño de cada registro y de los buffers del escritor.
Crear y leer un archivo Parquet
Section titled “Crear y leer un archivo Parquet”PyArrow representa datos tipados en memoria mediante tablas Arrow y proporciona lectores y escritores de Parquet. Vamos a declarar los tipos y construir una tabla pequeña por cada bloque de ventas.
from itertools import isliceimport pyarrow as paimport pyarrow.parquet as pqfrom datos_ventas import leer_ventas, resumir
esquema = pa.schema([ ('producto', pa.string()), ('categoria', pa.string()), ('unidades', pa.int64()), ('precio', pa.float64()), ('importe', pa.float64()),])
# Crear archivo escribiendo bloques de hasta 50.000 ventasentrada = leer_ventas()with pq.ParquetWriter('ventas.parquet', esquema, compression='NONE') as escritor: while True: bloque = list(islice(entrada, 50000)) if not bloque: break tabla = pa.Table.from_pylist(bloque, schema=esquema) escritor.write_table(tabla)
# Leer archivo por lotes, completos o con columnas seleccionadasdef leer_parquet(columnas=None): with pq.ParquetFile('ventas.parquet') as archivo: for lote in archivo.iter_batches(batch_size=50000, columns=columnas): for venta in lote.to_pylist(): yield venta
resultado = resumir(leer_parquet())assert resultado == resumir(leer_ventas())assert resumir(leer_parquet(['categoria', 'importe'])) == resultadoprint('Parquet:', resultado)print('Esquema:', esquema)python crear_parquet.pyEstas son las operaciones utilizadas.
| Método o atributo | Qué hace |
|---|---|
pa.schema(...) |
Define nombres y tipos de las columnas |
pa.Table.from_pylist(bloque, schema=esquema) |
Convierte solo el bloque actual en una tabla Arrow |
pq.ParquetWriter(...) |
Mantiene abierto el archivo mientras añadimos tablas |
escritor.write_table(tabla) |
Escribe la tabla del bloque actual |
pq.ParquetFile(...) |
Abre un lector del archivo Parquet |
archivo.iter_batches(...) |
Entrega lotes de lectura de hasta batch_size filas |
lote.to_pylist() |
Convierte el lote actual en una lista de diccionarios Python |
columns=[...] |
Solicita únicamente las columnas indicadas |
columnas=None es el valor predeterminado de nuestra función y permite leer todas las columnas. La segunda comprobación solicita solo categoría e importe. Ambas lecturas deben recuperar la misma cantidad y el mismo importe total. Se desactiva la compresión para estudiar después su efecto por separado. Lectura y escritura de Parquet con Arrow 18.1
ORC — columnas organizadas en stripes
Section titled “ORC — columnas organizadas en stripes”ORC, Optimized Row Columnar, es un formato binario orientado a columnas utilizado en el ecosistema de Hive. Para organizar un archivo grande, divide sus registros en conjuntos llamados stripes.
Imagina seis ventas. Un primer stripe puede contener las ventas 1 a 3 y un segundo las ventas 4 a 6. Dentro de cada stripe se guardan por separado los valores de cada columna, por ejemplo los tres productos juntos y los tres importes juntos. Cada stripe incluye también índices y un pie con información que ayuda a interpretar sus datos. Al final del archivo hay metadatos para localizar los stripes y conocer el esquema.
El dibujo muestra dos stripes uno junto al otro. Dentro de cada uno, los valores de producto, unidades e importe aparecen en columnas verticales separadas. La consulta solicita importe, resaltado en verde, en ambos stripes; puede omitir los datos de las columnas producto y unidades. Los índices, los pies y los metadatos permiten localizar e interpretar esas columnas. Los límites de tres ventas del dibujo son ilustrativos; el tamaño real de los stripes lo determina el escritor y su configuración.
Crear y leer un archivo ORC
Section titled “Crear y leer un archivo ORC”La misma biblioteca PyArrow permite escribir ORC. Usaremos los mismos campos y tipos que en Parquet para comparar el almacenamiento sobre datos equivalentes.
from itertools import isliceimport pyarrow as paimport pyarrow.orc as orcfrom datos_ventas import leer_ventas, resumir
esquema = pa.schema([ ('producto', pa.string()), ('categoria', pa.string()), ('unidades', pa.int64()), ('precio', pa.float64()), ('importe', pa.float64()),])
# Crear archivo escribiendo bloques de hasta 50.000 ventasentrada = leer_ventas()with orc.ORCWriter('ventas.orc', compression='uncompressed', stripe_size=8 * 1024 * 1024) as escritor: while True: bloque = list(islice(entrada, 50000)) if not bloque: break tabla = pa.Table.from_pylist(bloque, schema=esquema) escritor.write(tabla)
# Leer archivo stripe a stripedef leer_orc(columnas=None): with open('ventas.orc', 'rb') as entrada: archivo = orc.ORCFile(entrada) for numero in range(archivo.nstripes): tabla = archivo.read_stripe(numero, columns=columnas) for venta in tabla.to_pylist(): yield venta
resultado = resumir(leer_orc())assert resultado == resumir(leer_ventas())assert resumir(leer_orc(['categoria', 'importe'])) == resultadoprint('ORC:', resultado)print('Esquema:', esquema)python crear_orc.pyORCWriter mantiene el archivo abierto y write añade los datos del bloque actual. stripe_size configura un tamaño objetivo de 8 MiB para los stripes del archivo. Un bloque de Python y un stripe son unidades diferentes; el escritor decide cómo organiza las tablas recibidas dentro de los stripes.
ORCFile.nstripes indica cuántos stripes hay. read_stripe(numero, columns=...) lee uno y devuelve una tabla Arrow con las columnas solicitadas. Convertimos solo esa tabla a registros con to_pylist() y continuamos con la siguiente. El tamaño en disco de un stripe no equivale a su consumo en memoria, por lo que hay que ajustar su tamaño y comprobarlo con los datos del laboratorio.
Las dos lecturas verifican la cantidad y el total recuperados. Avro, Parquet y ORC se inspeccionan mediante sus lectores, que interpretan los bytes. Lectura y escritura de ORC con Arrow 18.1
Comparar los formatos después de utilizarlos
Section titled “Comparar los formatos después de utilizarlos”Ahora tenemos los mismos tres registros guardados en cinco formatos. Hemos comprobado su relectura y podemos relacionar las diferencias observadas con su organización.
| Formato | Organización y tipos | Observación de la práctica | Uso orientativo |
|---|---|---|---|
| CSV | Por filas, campos de texto por registro; tipos definidos por el lector | Recuperamos enteros y decimales explícitamente | Intercambio tabular sencillo |
| JSONL | Por registros, un objeto por línea con sus nombres de campo y tipos JSON | Leímos cada objeto con json.loads |
Eventos y datos semiestructurados |
| Avro | Por filas, registros binarios con esquema de escritura | Consultamos el esquema y leímos datos antiguos con un campo nuevo | Ingesta e intercambio con esquemas versionados |
| Parquet | Grupos de filas almacenados por columnas | Solicitamos solo categoría e importe | Históricos y consultas analíticas |
| ORC | Columnas dentro de stripes, con índices y metadatos | Recuperamos los mismos tipos y seleccionamos columnas | Analítica, especialmente en ecosistemas Hive |
Medir los tamaños
Section titled “Medir los tamaños”Crea el siguiente script después de generar los cinco archivos.
from pathlib import Path
for extension in ['csv', 'jsonl', 'avro', 'parquet', 'orc']: archivo = Path(f'ventas.{extension}') print(f'{extension}: {archivo.stat().st_size} bytes')python comparar_tamanos.pyPara expresar los tamaños en MiB, divide los bytes entre 1024 ** 2.
La muestra tiene tres registros y cinco campos en todos los archivos. CSV y JSONL están sin compresión externa; en los tres binarios la hemos desactivado explícitamente. Aun así, la codificación, los índices y los metadatos son diferentes. Un formato binario puede ocupar más que el CSV en una muestra tan pequeña. Estas cifras describen el experimento y no permiten predecir porcentajes de ahorro ni velocidad con millones de filas.
Compresión y particionado
Section titled “Compresión y particionado”La compresión busca representar los datos con menos bytes. Reduce almacenamiento y transferencias, pero comprimir y descomprimir consume CPU. El resultado depende de los datos, el códec y la implementación.
Vamos a repetir la escritura de Parquet con Snappy (sistema de compresión), que prioriza velocidad. Ejecuta este ejemplo después de crear_parquet.py.
import pyarrow.parquet as pqfrom pathlib import Pathfrom datos_ventas import resumir
# Leer y comprimir un lote cada vezwith pq.ParquetFile('ventas.parquet') as entrada: with pq.ParquetWriter('ventas-snappy.parquet', entrada.schema_arrow, compression='snappy') as salida: for lote in entrada.iter_batches(batch_size=50000): salida.write_batch(lote)
# Leer archivo por lotes para comprobar el resultadodef leer_archivo(ruta): with pq.ParquetFile(ruta) as archivo: for lote in archivo.iter_batches(batch_size=50000): for venta in lote.to_pylist(): yield venta
assert resumir(leer_archivo('ventas-snappy.parquet')) == resumir(leer_archivo('ventas.parquet'))print('Sin compresión:', Path('ventas.parquet').stat().st_size)print('Con Snappy:', Path('ventas-snappy.parquet').stat().st_size)python comprimir_parquet.pyentrada.schema_arrow recupera el esquema del archivo. write_batch(lote) permite escribir directamente el lote Arrow recibido, sin convertirlo a una lista de registros. La comprobación vuelve a recorrer ambos archivos por lotes.
Compresión dentro del formato y compresión del archivo completo
Section titled “Compresión dentro del formato y compresión del archivo completo”Un códec es el procedimiento que comprime los datos y permite descomprimirlos al leer. En el ejemplo anterior, Parquet organiza sus columnas y aplica Snappy a sus datos. El lector sabe por los metadatos qué códec necesita para recuperarlos.
Cada formato admite determinados códecs. Por ejemplo, podemos indicar codec='deflate' al escribir Avro o compression='zlib' al escribir ORC. Estas opciones comprimen partes internas del formato. El lector debe incluir soporte para el códec elegido.
Otra posibilidad es comprimir externamente un archivo completo, como hicimos con purchases.txt.gz. Para recuperar su texto utilizamos Gzip. Un archivo Avro escrito con el códec deflate sigue siendo un contenedor Avro y se abre con un lector Avro; ese códec no añade el contenedor y las cabeceras de un archivo .gz.
La diferencia importa al repartir trabajo. Parquet y ORC conservan información sobre grupos o stripes y columnas para que un lector compatible localice las partes que necesita. Si envolvemos externamente todo el archivo en un único flujo Gzip, un lector habitual puede necesitar descomprimirlo secuencialmente antes de acceder a esas partes. Por eso, para analizar en paralelo, debemos comprobar tanto el formato interno como la compresión aplicada y las capacidades del lector.
Particionar para leer solo el período necesario
Section titled “Particionar para leer solo el período necesario”Supongamos que guardamos ventas de muchos días y por lo general, las consulta suelen ser de un determinado día. Existe la posibilidad de agrupar los archivos por fecha en vez de colocar todo el histórico en una única carpeta.
Esta organización puede utilizarse con CSV, JSONL, Avro, Parquet u ORC. El particionado organiza un conjunto de archivos en carpetas según un criterio, independientemente del formato de cada archivo. En el dibujo usamos Parquet, pero las carpetas también podrían contener archivos .csv, .jsonl, .avro o .orc.
historico/├── fecha=2026-04-19/│ └── part-000.parquet├── fecha=2026-04-20/│ └── part-000.parquet└── fecha=2026-04-21/ └── part-000.parquetCada carpeta contiene las ventas de su fecha. Esta organización se llama particionado. Un motor configurado para reconocer esas particiones puede resolver el filtro de fecha leyendo la carpeta fecha=2026-04-20 y omitiendo las otras dos. En un programa sencillo, podemos conseguir esa selección indicando directamente la carpeta que debe leer. Crear las carpetas no hace que cualquier lector las seleccione automáticamente.
La selección de carpetas y la selección de columnas actúan en niveles diferentes. Con CSV también podemos evitar leer los archivos de otros días, pero debemos recorrer los campos de las líneas de los archivos elegidos. Con Parquet u ORC, además de elegir la partición, el lector puede solicitar solo las columnas necesarias dentro de sus archivos.
La columna de partición debe existir o derivarse de un dato conocido. Las tres ventas sintéticas no tienen fecha; el archivo purchases.txt del bloque 04 sí la tiene en la primera columna. Para particionar esos datos habría que conservar y utilizar ese campo.
Elegir una partición por día puede agrupar muchas ventas en cada archivo. Crear una carpeta distinta por identificador de venta puede producir millones de carpetas y archivos diminutos. Gestionarlos obliga a listar y abrir muchos objetos para recuperar pocos datos de cada uno. La partición se elige según los filtros habituales y el volumen que reúna cada grupo. La práctica distribuida de estas decisiones corresponde a Spark en M03.
Crear las particiones con Python
Section titled “Crear las particiones con Python”Para observar el reparto por fecha necesitamos datos que incluyan ese campo. En el mismo directorio del contenedor, crea esta segunda fuente de prueba. Los archivos anteriores siguen utilizando ventas_entrada.csv.
ventas_fechas.csv
fecha,producto,importe2026-04-19,auriculares,119.802026-04-20,altavoz,45.002026-04-21,teclado,75.00PyArrow puede repartir automáticamente los registros en carpetas mediante write_dataset. Le entregaremos un generador de lotes Arrow. Así recibe los datos de forma progresiva y puede escribir las particiones.
import csvfrom itertools import islicefrom pathlib import Pathimport pyarrow as paimport pyarrow.dataset as dsfrom datos_ventas import resumir
esquema = pa.schema([ ('fecha', pa.string()), ('producto', pa.string()), ('importe', pa.float64()),])
# Leer la fuente registro a registrodef leer_fechas(): with open('ventas_fechas.csv', encoding='utf-8', newline='') as entrada: for venta in csv.DictReader(entrada): venta['importe'] = float(venta['importe']) yield venta
# Preparar un lote Arrow cada vezdef leer_lotes(): entrada = leer_fechas() while True: bloque = list(islice(entrada, 50000)) if not bloque: break yield pa.RecordBatch.from_pylist(bloque, schema=esquema)
# Crear los archivos separados por fechaparticiones = ds.partitioning( pa.schema([('fecha', pa.string())]), flavor='hive',)destino = Path('historico-ejemplo')if destino.exists(): raise SystemExit('historico-ejemplo ya existe. Elige otro destino.')ds.write_dataset( leer_lotes(), base_dir=str(destino), schema=esquema, format='parquet', partitioning=particiones, use_threads=False, max_open_files=4, max_rows_per_group=50000,)
# Leer por lotes únicamente la fecha solicitadafecha_elegida = '2026-04-20'historico = ds.dataset(destino, format='parquet', partitioning='hive')
def leer_seleccion(): consulta = historico.scanner( filter=ds.field('fecha') == fecha_elegida, columns=['fecha', 'producto', 'importe'], batch_size=50000, use_threads=False, ) for lote in consulta.to_batches(): for venta in lote.to_pylist(): assert venta['fecha'] == fecha_elegida yield venta
# Comprobar la selección contra la misma fecha del originaldef leer_referencia(): for venta in leer_fechas(): if venta['fecha'] == fecha_elegida: yield venta
resultado = resumir(leer_seleccion())assert resultado == resumir(leer_referencia())print('Fecha:', fecha_elegida, 'cantidad y total en céntimos:', resultado)python particionar_ventas.pypa.RecordBatch.from_pylist construye un lote Arrow con el bloque actual de registros. write_dataset solicita esos lotes y distribuye sus registros según la columna declarada en partitioning. El esquema completo se indica mediante schema para que el escritor conozca los tipos antes de empezar.
flavor='hive' elige carpetas con la forma campo=valor, como fecha=2026-04-20. Esta convención también se usa fuera de Hive; aquí no necesitamos arrancar ese servicio. El campo de partición queda representado en la ruta y el lector lo recupera al interpretar las carpetas.
Limitamos a cuatro los archivos abiertos simultáneamente y a 50.000 las filas por grupo, y desactivamos los hilos paralelos en este ejemplo para reducir búferes simultáneos. Estos límites ayudan a controlar recursos, pero el consumo también depende del tamaño de las filas y de los búferes internos del lector y del escritor.
Para leer, historico.scanner prepara la consulta con su filtro y columnas. ds.field('fecha') representa la columna del filtro. consulta.to_batches() ejecuta la lectura entregando un lote cada vez. La consulta puede omitir los archivos de otras fechas y nosotros convertimos a diccionarios únicamente el lote actual.
Con la muestra, la selección recupera una venta por 45,00 y muestra (1, 4500). La comprobación compara la cantidad y el total con los registros de esa fecha en el original. El orden de los registros puede cambiar al repartirlos entre archivos. Escritura de conjuntos particionados con PyArrow
Cómo medir tiempo y tamaño con Python
Section titled “Cómo medir tiempo y tamaño con Python”En los ejemplos anteriores hemos usado Path.stat().st_size para consultar el tamaño en bytes. Para medir el tiempo de una operación, anotamos el instante anterior y el posterior con perf_counter y calculamos la diferencia. Este contador sirve para medir duraciones.
Crea y ejecuta este ejemplo después de generar ventas.parquet.
from pathlib import Pathfrom time import perf_counterimport pyarrow.parquet as pqfrom datos_ventas import resumir
# Leer archivo por lotes y entregar sus registrosdef leer_archivo(): with pq.ParquetFile('ventas.parquet') as archivo: for lote in archivo.iter_batches(batch_size=50000): for venta in lote.to_pylist(): yield venta
inicio = perf_counter()resultado = resumir(leer_archivo())segundos = perf_counter() - inicioprint('Cantidad y total en céntimos:', resultado)print('Tiempo de lectura:', segundos, 'segundos')print('Tamaño:', Path('ventas.parquet').stat().st_size, 'bytes')python medir_lectura.pyLa medida incluye lectura por lotes, conversión a registros Python y comprobación de cantidad e importe, porque esas operaciones están entre las dos llamadas al contador. Si midiéramos escritura, colocaríamos esas llamadas antes y después de la operación que genera el archivo. Esta es la misma idea que aplicaremos en la comparación grande, con un programa preparado para leer por bloques.
Repetir una medida y calcular su mediana
Section titled “Repetir una medida y calcular su mediana”Los tiempos cambian entre ejecuciones por la actividad del equipo y las cachés. Vamos a repetir tres veces la lectura del archivo pequeño y obtener la mediana, el valor central después de ordenar las tres duraciones.
from statistics import medianfrom time import perf_counterimport pyarrow.parquet as pqfrom datos_ventas import resumir
# Leer archivo por lotesdef leer_archivo(): with pq.ParquetFile('ventas.parquet') as archivo: for lote in archivo.iter_batches(batch_size=50000): for venta in lote.to_pylist(): yield venta
tiempos = []for repeticion in range(3): inicio = perf_counter() resultado = resumir(leer_archivo()) tiempos.append(perf_counter() - inicio)
print('Cantidad y total en céntimos:', resultado)print('Tiempos:', tiempos)print('Mediana:', median(tiempos), 'segundos')python repetir_medida.pyLa caché puede conservar datos recién escritos o leídos. Por tanto, repetir una lectura no garantiza medir siempre acceso al disco. Anota que estas pruebas se realizan sin vaciar las cachés.
Elegir según el trabajo que necesitamos hacer
Section titled “Elegir según el trabajo que necesitamos hacer”Para intercambiar registros completos interesa conocer el esquema y sus cambios. Para sumar unas pocas columnas de un histórico interesa la lectura selectiva. La decisión debe considerar herramientas lectoras, tipos y nulos, evolución del esquema, tamaño, compresión y posibilidades de lectura paralela.
| Necesidad | Decisión razonada |
|---|---|
| Muestra tabular para revisión manual | CSV, acordando delimitador, codificación y tipos |
| Eventos representados como objetos por línea | JSONL, con validación de su estructura |
| Registros con cambios compatibles de esquema | Avro con esquemas versionados |
| Agregaciones sobre pocas columnas | Parquet u ORC según los lectores previstos |
| Tabla con versiones coherentes y varios escritores | Añadir una capa de tabla, como Iceberg, sobre los archivos |
M01-FMT-01 — Crear, leer y comparar formatos Aules
Section titled “M01-FMT-01 — Crear, leer y comparar formatos Aules”Queremos comprobar qué cambia cuando pasamos de tres registros a millones de ventas. Vamos a medir cuánto ocupa cada formato, cuánto tarda en generarse y cuánto tarda en leerse. También compararemos leer todos los campos con leer únicamente la tienda y el importe.
Usaremos el archivo original purchases.txt del ejercicio de MapReduce del bloque 04. Cada línea contiene seis campos separados por tabuladores, sin cabecera.
Archivo original de ventas comprimido
Los ejemplos pequeños usan tres ventas sintéticas y cinco campos. El archivo original que vamos a utilizar tiene otros datos y seis campos. Para estudiar el efecto de aumentar el volumen compararemos sobre todo las ejecuciones de 100.000 ventas y del archivo completo, que comparten fuente y esquema.
Este archivo tiene 4.138.476 registros y unos 211 MB descomprimidos en el ensayo docente. Primero utilizaremos sus primeras 100.000 ventas para comprobar el procedimiento. Después repetiremos la comparación con el archivo completo. Cada ejecución guardará sus resultados en una carpeta diferente.
Preparar el script y la entrada
Section titled “Preparar el script y la entrada”Programa tú la comparación reutilizando lo aprendido en los ejemplos. Crea tus scripts Python en BigDataAplicadoLab_2627/modulo01/trabajo/formatos/, en el anfitrión, o edítalos dentro del contenedor en /lab/trabajo/formatos. Puedes repartir el trabajo entre varios scripts o utilizar uno con funciones. Guarda también tu informe y las tablas de resultados en esa carpeta.
Si ya tienes una copia descomprimida de purchases.txt en el anfitrión, cópiala a esa misma carpeta. Si la conservas únicamente en master, puedes recuperarla. Sal de la shell de formatos con exit y ejecuta desde modulo01/ en el anfitrión esta orden, si el archivo está en la ruta del ejercicio del bloque 04.
docker compose -f hadoop/compose.yaml cp master:/home/hadoop/ventas-por-tienda/purchases.txt trabajo/formatos/purchases.txtdocker compose -f formatos/compose.yaml run --rm --build formatos bashVuelve a /lab/trabajo/formatos dentro del contenedor de formatos.
cd /lab/trabajo/formatospwdSi todavía no tienes el archivo, descárgalo directamente dentro del contenedor de formatos, desde /lab/trabajo/formatos. Ejecuta una de estas dos opciones.
Con curl.
curl -fL -o purchases.txt.gz https://github.com/josepgarcia/datos/raw/refs/heads/main/purchases.txt.gzO con wget.
wget -O purchases.txt.gz https://github.com/josepgarcia/datos/raw/refs/heads/main/purchases.txt.gzAmbas órdenes guardan la descarga como purchases.txt.gz. En curl, -f detecta errores HTTP, -L sigue las redirecciones y -o indica el archivo de destino. En wget, -O indica ese nombre. Si ya conservas una descarga completa, puedes utilizarla.
Después de una descarga correcta, descomprime el archivo y comprueba su contenido.
gzip -dk purchases.txt.gzls -lh purchases.txthead purchases.txtwc -l purchases.txtgzip -d descomprime y -k conserva el comprimido. Si purchases.txt ya existe, comprueba si puedes reutilizarlo antes de repetir la descompresión. El archivo completo utilizado en el ensayo contiene 4.138.476 líneas.
Si estás en una sesión abierta con una imagen anterior, sal con exit y vuelve a ejecutar make formatos-shell desde modulo01/ en el anfitrión. Esta orden reconstruye la imagen y abre una nueva sesión con las herramientas instaladas. Los datos bajo /lab se conservan en el montaje del anfitrión.
1. Preparar una primera prueba con 100.000 ventas
Section titled “1. Preparar una primera prueba con 100.000 ventas”Trabaja dentro del contenedor de formatos, desde /lab/trabajo/formatos. Crea un archivo con las primeras 100.000 líneas del original y una carpeta nueva para sus resultados.
head -n 100000 purchases.txt > purchases-100000.txtwc -l purchases-100000.txtmkdir comparacion-100000El recuento debe ser 100.000. Si la carpeta ya existe por una prueba anterior, utiliza otro nombre para conservar esos resultados. Mantén purchases.txt como original y utiliza purchases-100000.txt durante esta primera prueba. Los archivos pequeños de los ejemplos anteriores conservan sus nombres y su ubicación.
Vas a crear tus propios scripts Python. Puedes utilizar un programa con funciones o repartir el trabajo entre varios archivos. Guarda el código en /lab/trabajo/formatos y explica en el informe cómo ejecutarlo.
2. Adaptar el lector a los seis campos del original
Section titled “2. Adaptar el lector a los seis campos del original”Crea un lector nuevo para este ejercicio, tomando como referencia leer_ventas() de los ejemplos anteriores. Guarda el nuevo lector en tus scripts de la práctica. Debe recorrer el archivo separado por tabuladores y sin cabecera, utilizando los nombres de campo en este orden.
| Posición | Campo | Tipo que debes conservar |
|---|---|---|
| 1 | fecha | texto |
| 2 | hora | texto |
| 3 | tienda | texto |
| 4 | categoria | texto |
| 5 | importe | número de doble precisión |
| 6 | pago | texto |
Cinco campos son textos. Solo importe se convierte a número. Utiliza float en Python, double en el esquema Avro y pa.float64() en los esquemas Arrow de Parquet y ORC. En CSV tendrás que recuperar ese tipo al leer; en JSONL guarda el importe como número JSON, no como texto.
El lector debe entregar una venta cada vez, como el generador de los ejemplos. Puedes usar csv.reader con delimiter='\t', como en MapReduce, o DictReader indicando los seis nombres mediante fieldnames y el mismo delimitador. Comprueba con unas pocas líneas que conserva los seis campos antes de continuar.
3. Guardar las mismas ventas en los cinco formatos
Section titled “3. Guardar las mismas ventas en los cinco formatos”Usa tu lector para crear estos archivos dentro de comparacion-100000/.
comparacion-100000/├── ventas.csv├── ventas.jsonl├── ventas.avro├── ventas.parquet└── ventas.orcCada archivo debe contener las mismas 100.000 ventas y los mismos seis campos. El CSV tendrá cabecera. No añadas columnas ni calcules otro importe.
Escribe los cinco archivos sin compresión. En Avro utiliza codec='null'; en Parquet, compression='NONE'; en ORC, compression='uncompressed'. CSV y JSONL se guardan directamente, sin comprimirlos como .gz. Así compararás las formas de organizar los datos sin añadir la compresión como otra diferencia.
Procesa los datos con memoria acotada. En CSV, JSONL y Avro escribe registros a medida que los lees. Para Parquet y ORC utiliza los escritores ya practicados y convierte a tablas Arrow bloques de hasta 50.000 ventas. Procesa cada bloque antes de pedir el siguiente. Evita reunir el archivo completo en una lista o tabla.
Mide la generación de cada archivo mientras la realizas. Inicia el cronómetro antes de empezar a leer el TSV y detenlo después de cerrar la salida. Este tiempo se llamará «conversión» e incluirá leer el original, interpretar sus campos y escribir el nuevo formato. Reabre el mismo TSV para cada conversión. Guarda una duración por formato y el tamaño final de su archivo en bytes y MiB.
4. Comprobar que las ventas se han conservado
Section titled “4. Comprobar que las ventas se han conservado”Antes de medir las lecturas, recorre una vez purchases-100000.txt y obtén dos referencias, el número de ventas y la suma del importe de cada tienda. Utiliza un contador y un diccionario cuyas claves sean los nombres de tienda. Al encontrar una tienda por primera vez, empieza su total en cero; en las siguientes apariciones suma sobre el acumulado. totales.get(tienda, 0) permite recuperar el total guardado o utilizar cero si todavía no existe esa clave.
Después lee cada uno de los cinco archivos generados y calcula las mismas referencias. Conserva la referencia del TSV para reutilizarla; ese recorrido queda fuera de los tiempos de conversión y lectura. Todos los archivos deben contener 100.000 ventas y devolver los mismos totales por tienda que el TSV. Si un resultado cambia, localiza y corrige la diferencia antes de comparar tiempos.
Para sumar los importes de forma consistente, convierte cada importe a céntimos mediante round(importe * 100) y suma esos enteros. Repite esta operación tanto al recorrer el original como al recorrer los formatos generados.
Estas comprobaciones verifican el recuento y los totales por tienda. No demuestran por sí solas que todos los textos se hayan conservado. Documenta qué has comprobado y ese límite.
5. Medir dos maneras de leer cada archivo
Section titled “5. Medir dos maneras de leer cada archivo”Para cada formato vas a realizar dos pruebas de lectura.
| Prueba | Qué debe hacer tu programa |
|---|---|
| Todos los campos | Leer los seis campos de cada venta y comprobar el número de ventas y los totales por tienda |
| Solo tienda e importe | Solicitar esas dos columnas cuando el lector lo permita y realizar las mismas comprobaciones |
En Parquet y ORC solicita expresamente tienda e importe para la segunda prueba. CSV y JSONL necesitan recorrer el texto; Avro recorre registros. Aunque el cálculo use dos campos, eso no convierte esos lectores en lectores por columnas.
Recorre los archivos por registros, lotes o stripes, como en los ejemplos anteriores. Inicia el cronómetro antes de abrir el archivo. Recorre sus registros, cuenta las ventas y suma los importes por tienda. Detén el cronómetro después de terminar el recorrido y cerrar el lector. Compara entonces los acumuladores con la referencia. El tiempo incluye la lectura, la conversión a registros Python y el cálculo de los acumuladores.
Repite tres veces cada prueba de lectura de cada formato. Conserva las tres duraciones y calcula su mediana con statistics.median. En cada repetición vuelve a abrir el archivo y empieza los contadores y sumas desde cero. Un generador que ya ha llegado al final no permite repetir la lectura. Tendrás una mediana para leer todos los campos y otra para tienda e importe. Para cada formato tendrás una duración de conversión, tres duraciones de lectura completa y tres duraciones de lectura de tienda e importe.
Los datos recién escritos o leídos pueden seguir en la caché del sistema operativo y recuperarse desde memoria. Anota que no has vaciado las cachés. Utiliza el mismo equipo y las mismas bibliotecas para los cinco formatos.
Guarda comparacion-100000/resultados.csv con una fila por formato y estos resultados.
| Formato | Tamaño MiB | Conversión s | Lectura completa, mediana s | Tienda e importe, mediana s | Filas y totales verificados |
|---|---|---|---|---|---|
| CSV | |||||
| JSONL | |||||
| Avro | |||||
| Parquet | |||||
| ORC |
Guarda también comparacion-100000/metadatos.json con las versiones, los nombres y tipos de los campos, la compresión utilizada, las duraciones individuales y los resultados de las comprobaciones. Puedes utilizar csv.DictWriter y json.dumps, ya estudiados.
6. Repetir con el archivo completo
Section titled “6. Repetir con el archivo completo”Cuando la prueba de 100.000 ventas funcione, utiliza el mismo código con purchases.txt y guarda las salidas en una carpeta nueva comparacion-completa/. Repite las cinco conversiones, sus comprobaciones y las dos pruebas de lectura. Conserva las dos carpetas para compararlas después.
La copia completa del ensayo docente tiene 4.138.476 ventas y 103 tiendas. Comprueba esos recuentos y la coincidencia de los totales por tienda con el original. Guarda resultados.csv y metadatos.json dentro de comparacion-completa/ para esta ejecución.
La programación, la primera prueba y la interpretación se trabajan durante las horas del bloque. Si la ejecución completa no termina en la sesión, conserva lo realizado e informa al docente, que concretará cómo completar esa evidencia. Identifica cualquier resultado docente que utilices como referencia.
Entrega en Aules
Section titled “Entrega en Aules”Entrega un archivo ZIP con tus scripts Python, un informe en Markdown y las tablas CSV y archivos JSON de las dos ejecuciones. El informe debe explicar cómo ejecutar tus scripts, indicar las versiones del entorno y describir tus comprobaciones. Conserva los archivos de datos generados en tu equipo; el ZIP de entrega incluye el código y los resultados resumidos.
Incluye en el informe las dos tablas, una para las primeras 100.000 ventas y otra para el archivo completo.
Interpreta tus resultados respondiendo estas preguntas.
- ¿Qué formato ocupa menos con 100.000 ventas y con el archivo completo? ¿Coincide con lo observado con tres registros?
- ¿Qué cambia en el tiempo de lectura al solicitar únicamente tienda e importe? Relaciónalo con la organización por filas o columnas.
- ¿Qué comprobaciones permiten detectar pérdidas de ventas o diferencias en los importes? ¿Qué campos no comprueban esas verificaciones?
- ¿Qué incluyen los tiempos de conversión y lectura? ¿Cómo pueden influir la caché, las bibliotecas y el equipo?
- ¿Qué formato elegirías para intercambio manual, eventos e histórico analítico? Apoya las decisiones en las propiedades y mediciones trabajadas.
Qué se revisará
Section titled “Qué se revisará”Se revisarán la autoría y explicación del código, el uso de los mismos datos y tipos en los cinco formatos, el procesamiento con memoria acotada, las comprobaciones de filas e importes, la definición de las mediciones y la justificación de las conclusiones. El plazo y las condiciones de la entrega se comunicarán en Aules.
Conservar el Parquet para MinIO
Section titled “Conservar el Parquet para MinIO”ventas.parquet queda en modulo01/trabajo/formatos/ del anfitrión. Para continuar con el laboratorio local de MinIO, copia ese archivo a trabajo-m01/formatos/ en la raíz de los apuntes. Son dos carpetas de proyectos diferentes. MinIO monta la segunda en /trabajo y espera encontrar /trabajo/formatos/ventas.parquet.
Del sistema de ficheros al almacenamiento de objetos
Section titled “Del sistema de ficheros al almacenamiento de objetos”HDFS y S3/MinIO resuelven necesidades de almacenamiento masivo, pero siguen arquitecturas diferentes. HDFS distribuye bloques entre DataNodes y mantiene el espacio de nombres mediante un NameNode. S3 y MinIO almacenan objetos identificados por una clave dentro de un bucket y se acceden mediante una API compatible con S3 sobre HTTP.
| Aspecto | HDFS | S3 / MinIO |
|---|---|---|
| Unidad de almacenamiento | Bloques de un fichero | Objetos de tamaño variable |
| Organización | Rutas y directorios del espacio HDFS | Buckets y claves; los prefijos simulan carpetas |
| Metadatos | NameNode administrado por el clúster | Administrados internamente por el servicio de objetos |
| Protección | Replicación de bloques | Según despliegue y configuración; MinIO distribuido puede usar erasure coding |
| Acceso | Cliente y protocolos Hadoop | API S3 sobre HTTP |
| Arquitectura típica | Almacenamiento y cómputo próximos | Almacenamiento y cómputo desacoplados |
Por ejemplo, en el bucket historico-ventas se puede almacenar un objeto cuya clave completa sea ventas/2026/04/20.parquet. Ni ventas, ni 2026, ni 04 son directorios independientes que MinIO deba crear: son fragmentos de texto dentro de la clave. La consola web y mc usan / como delimitador para representar una navegación cómoda por carpetas virtuales.
HDFS sigue siendo útil para comprender bloques, localidad de datos, replicación y el ecosistema histórico Hadoop. S3/MinIO facilita que diferentes motores de cómputo compartan un mismo almacenamiento de objetos sin depender del mismo clúster de DataNodes.
M01-OBJ-01 — Objetos y metadatos con MinIO y mc
Section titled “M01-OBJ-01 — Objetos y metadatos con MinIO y mc”La práctica no migra un sistema productivo ni prueba fallos de discos. Su objetivo es reconocer el modelo de objetos y comparar sus operaciones con HDFS. El despliegue reproducible está en laboratorio-minio.
- Inicia MinIO con Docker Compose.
- Accede opcionalmente a la consola web y configura un alias del cliente
mc. - Crea un bucket, carga un fichero Parquet, lista el contenido y consulta sus metadatos.
docker compose -f M01/laboratorio-minio/docker-compose.yml up -d --wait miniodocker compose -f M01/laboratorio-minio/docker-compose.yml pscurl -fsS http://localhost:9002/minio/health/liveTodas estas órdenes se ejecutan en el anfitrión. En PowerShell utiliza curl.exe -fsS http://localhost:9002/minio/health/live para invocar el ejecutable de curl y evitar la posible función del mismo nombre. Una respuesta de salud correcta tiene código de salida cero; que el contenedor figure en ps no garantiza todavía que la API esté disponible.
Si la comprobación de salud falla, revisa antes los registros del servicio:
docker compose -f M01/laboratorio-minio/docker-compose.yml logs minio --tail=50El servicio S3 queda disponible en http://localhost:9002 y la consola en http://localhost:9001. El puerto anfitrión 9002 evita colisionar con el RPC HDFS publicado en 9000. Las credenciales minioadmin / minioadmin son exclusivamente para el laboratorio local; antes de un despliegue compartido se sustituyen por secretos propios y se gestionan fuera del repositorio.
Preparar el cliente
Section titled “Preparar el cliente”mc es MinIO Client, distinto de MinIO Server y del gestor de archivos Midnight Commander que puede compartir su nombre. La imagen MinIO ya utilizada en el laboratorio incluye /usr/bin/mc: reutilizamos esa misma imagen como servicio auxiliar mc, abriendo su shell en lugar del servidor. Así se conserva el entorno Docker en macOS, Linux y Windows y se evita depender de una instalación global del cliente. Su presencia y versión se comprobaron expresamente, sin presuponer que cualquier imagen de servidor lo incluya.
Desde el anfitrión, situado en la raíz del proyecto, abre una sesión del cliente:
docker compose -f M01/laboratorio-minio/docker-compose.yml run --rm mcLa imagen está fijada a minio/minio:RELEASE.2025-07-18T21-56-31Z; contiene mc RELEASE.2025-07-16T15-35-03Z. El servicio tiene un perfil de herramientas y solo se inicia al solicitarlo expresamente; --rm retira el contenedor auxiliar al salir. Su carpeta /trabajo es el montaje de trabajo-m01 del anfitrión: el Parquet generado anteriormente debe verse como /trabajo/formatos/ventas.parquet.
La shell que acabas de abrir está dentro del contenedor cliente. Comprueba programa, ubicación y archivo antes de configurar la conexión:
mc --versionpwdls -lh /trabajo/formatos/ventas.parquetAnota la versión del cliente. Si el archivo no aparece, sal con exit y comprueba que copiaste el Parquet desde el laboratorio de formatos a trabajo-m01/formatos/ en la raíz de los apuntes.
Subir, inspeccionar y recuperar el mismo objeto
Section titled “Subir, inspeccionar y recuperar el mismo objeto”Las siguientes órdenes se ejecutan dentro del cliente mc. alias set guarda un nombre local para la conexión; mb crea el bucket; cp copia; ls lista y stat muestra metadatos. En esta red de Compose el servidor se encuentra por el nombre minio y su puerto interno 9000; localhost:9002 corresponde al acceso desde el anfitrión y no identifica otro contenedor desde esta shell.
mc alias set m01local http://minio:9000 minioadmin minioadminmc mb --ignore-existing m01local/datos-pruebamc cp /trabajo/formatos/ventas.parquet m01local/datos-prueba/ventas/ventas.parquetmc ls --recursive m01local/datos-prueba/mc stat m01local/datos-prueba/ventas/ventas.parquetmc cp m01local/datos-prueba/ventas/ventas.parquet /trabajo/formatos/ventas-recuperadas.parquetexitEl archivo recuperado queda en el anfitrión gracias al montaje. La configuración del alias se retira con el cliente efímero; los objetos permanecen en el volumen del servidor MinIO. Si abres otra sesión de cliente, vuelve a definir el alias.
Ya de nuevo en el anfitrión, compara los archivos con Python reutilizando la imagen del contenedor de formatos. Ejecuta esta orden desde la raíz de los apuntes, en Bash o en la terminal Ubuntu de WSL. El montaje de solo lectura permite comprobar los archivos conservados en trabajo-m01. Esta comprobación compara los bytes completos y no depende de interpretar el ETag del servidor como si siempre fuera un MD5:
docker run --rm -v "$PWD/trabajo-m01:/datos:ro" bda-m01-formatos:3.12-r3 python -c "from pathlib import Path; a=Path('/datos/formatos/ventas.parquet'); b=Path('/datos/formatos/ventas-recuperadas.parquet'); print('Mismos bytes:', a.read_bytes() == b.read_bytes())"Antes de ejecutar mc cp, escribe en tu cuaderno qué bucket y qué clave esperas obtener. Después de mc ls, contrasta tu respuesta con el nombre mostrado por el cliente. Esta comprobación breve evita confundir una ruta local con la clave completa de un objeto.
La evidencia es la salida de mc stat, la comprobación Mismos bytes: True y una explicación de qué representa el bucket (datos-prueba), qué representa la clave (ventas/ventas.parquet) y en qué se diferencia esta operación de hdfs dfs -put. No elimines el original ni el descargado hasta haber contrastado sus tamaños y contenido.
Al terminar, conserva el volumen si quieres recuperar los objetos en la siguiente sesión. Usa down -v solo si deseas borrar de forma intencionada todo el almacenamiento local de MinIO:
docker compose -f M01/laboratorio-minio/docker-compose.yml downPara reinicializar deliberadamente el almacenamiento, y solo después de conservar los objetos necesarios, la variante destructiva es docker compose -f M01/laboratorio-minio/docker-compose.yml down -v. Esta variante se explica como referencia; no se ejecuta como continuación automática de la parada normal.
Data lake y lakehouse
Section titled “Data lake y lakehouse”Un data lake es un repositorio central de datos crudos y procesados, normalmente sustentado en almacenamiento de objetos y formatos abiertos como Parquet. Permite conservar datos a escala y que distintos motores los lean, pero gestionar solo carpetas y archivos puede complicar la consistencia, la evolución de esquema y el control de qué versión representa una tabla.
Un lakehouse añade una capa de tabla y metadatos sobre el data lake. Mantiene los archivos abiertos en el almacenamiento, pero aporta versiones coherentes, cambios atómicos, evolución de esquema y un catálogo que permite a los motores localizar la versión actual de cada tabla.
Data lake: objetos S3/MinIO + archivos ParquetLakehouse: objetos S3/MinIO + Parquet + formato de tabla + catálogo + metadatosNo se trata de sustituir todos los sistemas por un lakehouse. PostgreSQL sigue siendo adecuado para transacciones y datos operacionales; el lakehouse resuelve el histórico analítico compartido entre motores.
Apache Iceberg: formato de tabla para un lakehouse
Section titled “Apache Iceberg: formato de tabla para un lakehouse”Apache Iceberg no es un sistema de ficheros ni un formato de serialización como Parquet. Es un formato de tabla que organiza archivos de datos y sus metadatos para que un conjunto de Parquet pueda comportarse como una tabla analítica consistente.
| Concepto | Función |
|---|---|
| Archivos de datos | Contienen las filas, habitualmente en Parquet. |
| Metadatos y manifiestos | Describen qué archivos pertenecen a cada versión y sus estadísticas. |
| Snapshot | Estado consistente de una tabla tras un commit. |
| Catálogo | Mantiene la referencia a la versión actual de la tabla. |
| Particionado oculto | La tabla aplica su especificación de partición sin exigir que las consultas conozcan los directorios físicos. |
Cada cambio crea una nueva versión de metadatos y actualiza de forma atómica la referencia del catálogo. Así, una consulta ve un estado consistente de la tabla, en lugar de una mezcla de archivos antes y después de una escritura.
Iceberg permite añadir, renombrar o reordenar campos mediante cambios de metadatos, sin reescribir necesariamente todos los archivos de datos. Sus snapshots permiten consultar versiones anteriores (time travel) y facilitan auditoría o recuperación lógica.
Sigue la figura desde arriba: el catálogo permite localizar metadata JSON; ese archivo describe los snapshots y cuál es el actual. El snapshot actual referencia una lista de manifiestos; cada manifiesto describe archivos de datos almacenados como objetos Parquet. Una consulta usa ese recorrido para decidir qué archivos forman su versión de la tabla. Los snapshots anteriores conservan referencias a otras versiones: no son necesariamente copias completas de todos los Parquet. El dibujo muestra tres manifiestos como ejemplo, no como cantidad obligatoria de una tabla Iceberg.
Alcance de M01 y continuidad en M03
Section titled “Alcance de M01 y continuidad en M03”En M01 se comprende el modelo: archivo Parquet frente a tabla Iceberg, función del catálogo, snapshots, evolución de esquema y particionado oculto. En M03, con Spark como herramienta práctica principal, se trabajarán la lectura y escritura de tablas Iceberg, time travel, evolución de esquema y mantenimiento de archivos pequeños.
No configuraremos en M01 un conector S3A para Hive ni una tabla Iceberg funcional con Hive. Son integraciones técnicas que requieren configuración, dependencias y pruebas específicas; no son necesarias para entender la evolución arquitectónica.
M01-FMT-02 — Decidir formato, almacenamiento y tabla
Section titled “M01-FMT-02 — Decidir formato, almacenamiento y tabla”Completa la tabla con una propuesta y dos frases de justificación por fila. Se admiten decisiones equivalentes si responden al requisito y distinguen archivo, almacenamiento y tabla.
| Caso y requisito | Formato propuesto | Capa de almacenamiento | ¿Necesita formato de tabla Iceberg? | Justificación |
|---|---|---|---|---|
| Exportación de 200 ventas para revisión manual e intercambio entre dos aplicaciones | ||||
| Eventos cuyo esquema añade campos opcionales y que deben seguir leyendo consumidores anteriores | ||||
| Histórico anual consultado por categoría y fecha, con lecturas de pocas columnas y cargas nocturnas completas | ||||
| Histórico con escritores concurrentes, evolución de esquema y consultas sobre versiones anteriores |
No basta con escribir «Big Data» o nombrar una herramienta. La comprobación consiste en relacionar cada necesidad con una propiedad técnica explicada en el bloque.
Fuentes y vigencia del laboratorio
Section titled “Fuentes y vigencia del laboratorio”La conversión sigue las interfaces de pandas 2.2: DataFrame.to_parquet y Apache Arrow 18.1: lectura y escritura Parquet. El cliente se documenta en el repositorio oficial minio/mc, que figura archivado desde el 14/07/2026 en la consulta del 21/10/2026. Se conserva el laboratorio local y su servidor MinIO fijado por decisión docente; el registro de versiones y las pruebas de funcionamiento delimitan su uso educativo y no constituyen una recomendación de despliegue productivo.