En los últimos años, Parquet se ha convertido en un formato estándar para el almacenamiento de datos en Big data Ecosistemas. Su formato orientado a columnas ofrece varias ventajas:
- Ejecución de consulta más rápida cuando solo se procesa un subconjunto de columnas
- Cálculo rápido de estadísticas en todos los datos
- Volumen de almacenamiento reducido gracias a la compresión eficiente
Cuando se combina con marcos de almacenamiento como Delta Lake o Apache Iceberg, se integra sin problemas con los motores de consulta (por ejemplo, Trino) y los grupos de cómputo de almacén de datos (por ejemplo, Snowflake, BigQuery). En este artículo, el contenido de un archivo de parquet se disecciona utilizando principalmente herramientas de Python estándar para comprender mejor su estructura y cómo contribuye a tales actuaciones.
Escribir archivos parquet
Para producir archivos de parquet, usamos Pyarrow, un enlace de Python para Apache Arrow que almacena marcos de datos en memoria en formato columnar. Pyarrow permite un ajuste de parámetros de grano fino al escribir el archivo. Esto hace que Pyarrow sea ideal para la manipulación de parquet (también se puede usar simplemente Pandas).
# generator.py
import pyarrow as pa
import pyarrow.parquet as pq
from faker import Faker
fake = Faker()
Faker.seed(12345)
num_records = 100
# Generate fake data
names = [fake.name() for _ in range(num_records)]
addresses = [fake.address().replace("\n", ", ") for _ in range(num_records)]
birth_dates = [
fake.date_of_birth(minimum_age=67, maximum_age=75) for _ in range(num_records)
]
cities = [addr.split(", ")[1] for addr in addresses]
birth_years = [date.year for date in birth_dates]
# Cast the data to the Arrow format
name_array = pa.array(names, type=pa.string())
address_array = pa.array(addresses, type=pa.string())
birth_date_array = pa.array(birth_dates, type=pa.date32())
city_array = pa.array(cities, type=pa.string())
birth_year_array = pa.array(birth_years, type=pa.int32())
# Create schema with non-nullable fields
schema = pa.schema(
[
pa.field("name", pa.string(), nullable=False),
pa.field("address", pa.string(), nullable=False),
pa.field("date_of_birth", pa.date32(), nullable=False),
pa.field("city", pa.string(), nullable=False),
pa.field("birth_year", pa.int32(), nullable=False),
]
)
table = pa.Table.from_arrays(
[name_array, address_array, birth_date_array, city_array, birth_year_array],
schema=schema,
)
print(table)
pyarrow.Table
name: string not null
address: string not null
date_of_birth: date32[day] not null
city: string not null
birth_year: int32 not null
----
name: [["Adam Bryan","Jacob Lee","Candice Martinez","Justin Thompson","Heather Rubio"]]
address: [["822 Jennifer Field Suite 507, Anthonyhaven, UT 98088","292 Garcia Mall, Lake Belindafurt, IN 69129","31738 Jonathan Mews Apt. 024, East Tammiestad, ND 45323","00716 Kristina Trail Suite 381, Howelltown, SC 64961","351 Christopher Expressway Suite 332, West Edward, CO 68607"]]
date_of_birth: [[1955-06-03,1950-06-24,1955-01-29,1957-02-18,1956-09-04]]
city: [["Anthonyhaven","Lake Belindafurt","East Tammiestad","Howelltown","West Edward"]]
birth_year: [[1955,1950,1955,1957,1956]]
La salida refleja claramente un almacenamiento orientado a las columnas, a diferencia de los pandas, que generalmente muestra una tabla tradicional «en cuanto a la fila».
¿Cómo se almacena un archivo parquet?
Los archivos de parquet generalmente se almacenan en bases de datos de almacenamiento de objetos baratas como S3 (AWS) o GCS (GCP) para que sean fácilmente accesibles mediante tuberías de procesamiento de datos. Estos archivos generalmente se organizan con una estrategia de partición aprovechando las estructuras de directorio:
# generator.py
num_records = 100
# ...
# Writing the parquet files to disk
pq.write_to_dataset(
table,
root_path='dataset',
partition_cols=['birth_year', 'city']
)
Si birth_year
y city columns
se definen como claves de partición, Pyarrow crea tal estructura de árbol en el conjunto de datos del directorio:
dataset/
├─ birth_year=1949/
├─ birth_year=1950/
│ ├─ city=Aaronbury/
│ │ ├─ 828d313a915a43559f3111ee8d8e6c1a-0.parquet
│ │ ├─ 828d313a915a43559f3111ee8d8e6c1a-0.parquet
│ │ ├─ …
│ ├─ city=Alicialand/
│ ├─ …
├─ birth_year=1951 ├─ ...
La estrategia permite la poda de partición: cuando una consulta se filtra en estas columnas, el motor puede usar los nombres de las carpetas para leer solo los archivos necesarios. Esta es la razón por la cual la estrategia de partición es crucial para limitar el retraso, la E/S y calcular los recursos al manejar grandes volúmenes de datos (como ha sido el caso durante décadas con bases de datos relacionales tradicionales).
El efecto de la poda se puede verificar fácilmente contando los archivos abridos por un script de Python que filtra el año de nacimiento:
# query.py
import duckdb
duckdb.sql(
"""
SELECT *
FROM read_parquet('dataset/*/*/*.parquet', hive_partitioning = true)
where birth_year = 1949
"""
).show()
> strace -e trace=open,openat,read -f python query.py 2>&1 | grep "dataset/.*\.parquet"
[pid 37] openat(AT_FDCWD, "dataset/birth_year=1949/city=Box%201306/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 3
[pid 37] openat(AT_FDCWD, "dataset/birth_year=1949/city=Box%201306/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 3
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=Box%201306/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 4
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=Box%203487/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 5
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=Box%203487/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 3
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=Clarkemouth/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 4
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=Clarkemouth/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 5
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=DPO%20AP%2020198/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 3
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=DPO%20AP%2020198/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 4
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=East%20Morgan/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 5
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=East%20Morgan/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 3
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=FPO%20AA%2006122/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 4
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=FPO%20AA%2006122/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 5
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=New%20Michelleport/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 3
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=New%20Michelleport/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 4
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=North%20Danielchester/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 5
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=North%20Danielchester/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 3
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=Port%20Chase/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 4
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=Port%20Chase/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 5
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=Richardmouth/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 3
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=Richardmouth/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 4
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=Robbinsshire/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 5
[pid 39] openat(AT_FDCWD, "dataset/birth_year=1949/city=Robbinsshire/e1ad1666a2144fbc94892d4ac1234c64-0.parquet", O_RDONLY) = 3
Solo se leen 23 archivos de 100.
Leer un archivo de Parquet Raw
Decodemos un archivo de parquet sin procesar sin bibliotecas especializadas. Para simplificar, el conjunto de datos se descarga en un solo archivo sin compresión o codificación.
# generator.py
# ...
pq.write_table(
table,
"dataset.parquet",
use_dictionary=False,
compression="NONE",
write_statistics=True,
column_encoding=None,
)
Lo primero que debe saber es que el archivo binario está enmarcado por 4 bytes cuya representación ASCII es «PAR1». El archivo está dañado si este no es el caso.
# reader.py
with open("dataset.parquet", "rb") as file:
parquet_data = file.read()
assert parquet_data[:4] == b"PAR1", "Not a valid parquet file"
assert parquet_data[-4:] == b"PAR1", "File footer is corrupted"
Como se indica en el documentaciónel archivo se divide en dos partes: los «grupos de filas» que contienen datos reales y el pie de página que contiene metadatos (esquema a continuación).
El pie de página
El tamaño del pie de página se indica en los 4 bytes que preceden al marcador final como un entero sin firmar escrito en formato «Little Endian» (señalado «unpack función).
# reader.py
import struct
# ...
footer_length = struct.unpack("<I", parquet_data[-8:-4])[0]
print(f"Footer size in bytes: {footer_length}")
footer_start = len(parquet_data) - footer_length - 8
footer_data = parquet_data[footer_start:-8]
Footer size in bytes: 1088
La información del pie de página está codificada en un formato de serialización de lenguaje cruzado llamado Apache ahorro. Usar un formato legible pero verboso como JSON y luego traducirlo al binario sería menos eficiente en términos de uso de la memoria. Con el ahorro de ahorro, uno puede declarar estructuras de datos de la siguiente manera:
struct Customer {
1: required string name,
2: optional i16 birthYear,
3: optional list<string> interests
}
Sobre la base de esta declaración, la segunda mano puede generar el código de Python para decodificar las cadenas de byte con dicha estructura de datos (también genera código para realizar la parte de codificación). El archivo de segunda mano que contiene todas las estructuras de datos implementadas en un archivo de parquet se puede descargar aquí. Después de haber instalado el binario de segunda mano, ejecutemos:
thrift -r --gen py parquet.thrift
El código Python generado se coloca en la carpeta «Gen-Py». La estructura de datos del pie de página está representada por la clase FilEmetadata, una clase de Python generada automáticamente a partir del esquema de segunda mano. Utilizando las utilidades de Python de Thrift, los datos binarios se analizan y se poblan en una instancia de esta clase FileMetadata.
# reader.py
import sys
# ...
# Add the generated classes to the python path
sys.path.append("gen-py")
from parquet.ttypes import FileMetaData, PageHeader
from thrift.transport import TTransport
from thrift.protocol import TCompactProtocol
def read_thrift(data, thrift_instance):
"""
Read a Thrift object from a binary buffer.
Returns the Thrift object and the number of bytes read.
"""
transport = TTransport.TMemoryBuffer(data)
protocol = TCompactProtocol.TCompactProtocol(transport)
thrift_instance.read(protocol)
return thrift_instance, transport._buffer.tell()
# The number of bytes read is not used for now
file_metadata_thrift, _ = read_thrift(footer_data, FileMetaData())
print(f"Number of rows in the whole file: {file_metadata_thrift.num_rows}")
print(f"Number of row groups: {len(file_metadata_thrift.row_groups)}")
Number of rows in the whole file: 100
Number of row groups: 1
El pie de página contiene información extensa sobre la estructura y el contenido del archivo. Por ejemplo, rastrea con precisión el número de filas en el marcado de datos generados. Todas estas filas están contenidas dentro de un solo «grupo de filas». Pero, ¿qué es un «grupo de filas»?
Grupos de hileras
A diferencia de los formatos puramente orientados a la columna, Parquet emplea un enfoque híbrido. Antes de escribir bloques de columna, el marco de datos se divide por primera vez verticalmente en grupos de fila (el archivo parquet que generamos es demasiado pequeño para dividirse en múltiples grupos de filas).

Esta estructura híbrida ofrece varias ventajas:
Parquet calcula estadísticas (como valores MIN/Max) para cada columna dentro de cada grupo de filas. Estas estadísticas son cruciales para la optimización de consultas, lo que permite que los motores de consulta omitan grupos de filas completos que no coinciden con los criterios de filtrado. Por ejemplo, si una consulta se filtra para birth_year > 1955
Y el año máximo de nacimiento de un grupo de filas es 1954, el motor puede omitir eficientemente toda esa sección de datos. Esta optimización se llama «Pushdown de predicado». Parquet también almacena otras estadísticas útiles como los recuentos de valor distintos y los recuentos nulos.
# reader.py
# ...
first_row_group = file_metadata_thrift.row_groups[0]
birth_year_column = first_row_group.columns[4]
min_stat_bytes = birth_year_column.meta_data.statistics.min
max_stat_bytes = birth_year_column.meta_data.statistics.max
min_year = struct.unpack("<I", min_stat_bytes)[0]
max_year = struct.unpack("<I", max_stat_bytes)[0]
print(f"The birth year range is between {min_year} and {max_year}")
The birth year range is between 1949 and 1958
- Los grupos de filas permiten el procesamiento paralelo de datos (particularmente valioso para marcos como Apache Spark). El tamaño de estos grupos de fila se puede configurar en función de los recursos informáticos disponibles (utilizando el
row_group_size
propiedad en funciónwrite_table
Cuando se usa pyarrow).
# generator.py
# ...
pq.write_table(
table,
"dataset.parquet",
row_group_size=100,
)
# /!\ Keep the default value of "row_group_size" for the next parts
- Incluso si este no es el objetivo principal de un formato de columna, la estructura híbrida de Parquet mantiene un rendimiento razonable al reconstruir filas completas. Sin grupos de fila, la reconstrucción de una fila completa puede requerir escanear la totalidad de cada columna que sería extremadamente ineficiente para archivos grandes.
Páginas de datos
La subestructura más pequeña de un archivo parquet es la página. Contiene una secuencia de valores de la misma columna y, por lo tanto, del mismo tipo. La elección del tamaño de la página es el resultado de una compensación:
- Las páginas más grandes significan menos metadatos para almacenar y leer, lo cual es óptimo para consultas con filtrado mínimo.
- Las páginas más pequeñas reducen la cantidad de datos de datos innecesarios, lo cual es mejor cuando las consultas se dirigen a los rangos de datos pequeños y dispersos.

Ahora decodificemos el contenido de la primera página de la columna dedicada a direcciones cuya ubicación se puede encontrar en el pie de página (dada por el data_page_offset
atributo de la derecha ColumnMetaData
). Cada página está precedida por un ahorro PageHeader
objeto que contiene algunos metadatos. El desplazamiento en realidad apunta a una representación binaria de segunda mano de los metadatos de la página que precede a la página en sí. La clase de segunda mano se llama PageHeader
y también se puede encontrar en el gen-py
directorio.
💡 Entre la página de página y los valores reales contenidos en la página, puede haber algunos bytes dedicados a implementar el Dremel formato, que permite la codificación Estructuras de datos anidadas. Dado que nuestros datos tienen un formato tabular regular y los valores no son anulables, estos bytes se omiten al escribir el archivo (https://parquet.apache.org/docs/file-format/data-pages/).
# reader.py
# ...
address_column = first_row_group.columns[1]
column_start = address_column.meta_data.data_page_offset
column_end = column_start + address_column.meta_data.total_compressed_size
column_content = parquet_data[column_start:column_end]
page_thrift, page_header_size = read_thrift(column_content, PageHeader())
page_content = column_content[
page_header_size : (page_header_size + page_thrift.compressed_page_size)
]
print(column_content[:100])
b'6\x00\x00\x00481 Mata Squares Suite 260, Lake Rachelville, KY 874642\x00\x00\x00671 Barker Crossing Suite 390, Mooreto'
Los valores generados finalmente aparecen, en texto plano y no codificados (como se especifica al escribir el archivo parquet). Sin embargo, para optimizar el formato columnar, se recomienda utilizar uno de los siguientes algoritmos de codificación: codificación del diccionario, codificación de longitud de ejecución (RLE) o codificación delta (los últimos tipos de int32 e int64), seguido de compresión utilizando GZIP o SNAPPY (los códecs disponibles están listados aquí). Dado que las páginas codificadas contienen valores similares (todas las direcciones, todos los números decimales, etc.), las relaciones de compresión pueden ser particularmente ventajosas.
Como se documenta en el especificaciónCuando las cadenas de caracteres (byte_array) no están codificadas, cada valor está precedido por su tamaño representado como un entero de 4 bytes. Esto se puede observar en la salida anterior:

Para leer todos los valores (por ejemplo, los primeros 10), el bucle es bastante simple:
idx = 0
for _ in range(10):
str_size = struct.unpack("<I", page_content[idx : (idx + 4)])[0]
print(page_content[(idx + 4) : (idx + 4 + str_size)].decode())
idx += 4 + str_size
481 Mata Squares Suite 260, Lake Rachelville, KY 87464
671 Barker Crossing Suite 390, Mooretown, MI 21488
62459 Jordan Knoll Apt. 970, Emilyfort, DC 80068
948 Victor Square Apt. 753, Braybury, RI 67113
365 Edward Place Apt. 162, Calebborough, AL 13037
894 Reed Lock, New Davidmouth, NV 84612
24082 Allison Squares Suite 345, North Sharonberg, WY 97642
00266 Johnson Drives, South Lori, MI 98513
15255 Kelly Plains, Richardmouth, GA 33438
260 Thomas Glens, Port Gabriela, OH 96758
¡Y ahí lo tenemos! Hemos recreado con éxito, de una manera muy simple, cómo una biblioteca especializada leería un archivo de parquet. Al comprender sus bloques de construcción, incluidos encabezados, pies de página, grupos de filas y páginas de datos, podemos apreciar mejor cómo características como el empuje de predicado y la poda de partición ofrecen beneficios de rendimiento tan impresionantes en entornos intensivos en datos. Estoy convencido de saber cómo funciona Parquet bajo el capó ayuda a tomar mejores decisiones sobre las estrategias de almacenamiento, las opciones de compresión y la optimización del rendimiento.
Todo el código utilizado en este artículo está disponible en mi repositorio de GitHub en https://github.com/kili-mandjaro/anatomy-parquetdonde puede explorar más ejemplos y experimentar con diferentes configuraciones de archivos de parquet.
Ya sea que esté construyendo tuberías de datos, optimizando el rendimiento de la consulta o simplemente curioso sobre los formatos de almacenamiento de datos, espero que esta inmersión profunda en las estructuras interiores de Parquet haya proporcionado información valiosa para su Ingeniería de datos viaje.
Todas las imágenes son del autor.