Apache Parquet: columnar формат для аналитики
Avro оптимизирован под транзакционную работу: одна запись — одно сообщение, append-only поток. Когда нужно делать SQL-аналитику над миллионами строк (SELECT avg(price) FROM events WHERE date = '2026-05-14'), row-формат проигрывает: чтобы вычислить среднее по одной колонке, движок читает все остальные.
Apache Parquet — columnar бинарный формат, оптимизированный под аналитику. Спроектирован Twitter и Cloudera в 2013 году, основан на работе Google Dremel. В 2026 — стандарт для data lake-ов (S3, ADLS, GCS), нативно поддерживается Spark, ClickHouse, Snowflake, Iceberg, Delta Lake, DuckDB, DataFusion.
Бинарные форматы: Avro, Parquet, ORC в PythonЗачем колоночное хранение
В row-формате на диске последовательно лежат полные записи:
[ id=1 | name='Alice' | age=30 | city='Moscow' | salary=100000 ]
[ id=2 | name='Bob' | age=25 | city='Kazan' | salary=80000 ]
[ id=3 | name='Carol' | age=35 | city='Moscow' | salary=120000 ]
Запрос SELECT avg(salary) FROM users читает все 5 колонок, чтобы добраться до salary. На таблице из 50 колонок — 98% I/O впустую.
В columnar-формате значения одной колонки лежат рядом:
column id: [1, 2, 3]
column name: ['Alice', 'Bob', 'Carol']
column age: [30, 25, 35]
column city: ['Moscow', 'Kazan', 'Moscow']
column salary: [100000, 80000, 120000]
Запрос на средний salary читает только последнюю колонку — 2% от объёма данных. При типичной аналитике 10-100x ускорение.
Бонус: значения одной колонки часто похожи. Все salary — целые числа в узком диапазоне, города часто повторяются. Это делает
Структура файла
Parquet-файл — не поток записей, а строгая трёхуровневая иерархия: row groups -> column chunks -> pages, плюс metadata в footer-е.
Row group (~128 MB по умолчанию) — это горизонтальный «слой» строк, единица параллельного чтения. На кластере из 10 нод можно поделить файл из 100 row groups между нодами по 10 каждой.
Column chunk — все значения одной колонки в одном row group. Это первое, что читает движок при projection: «нужны только salary и city из 50 колонок» -> читаем 2 column chunks из 50, остальные пропускаем.
Page (~1 MB) — минимальная единица сжатия и кодирования. Внутри page значения кодируются и сжимаются.
Footer хранит всю метаинформацию: schema, расположение каждого row group, statistics (min/max/null_count) для каждого column chunk. Movie читает footer первым делом.
Encoding: умные представления значений
Parquet применяет несколько слоёв оптимизации до сжатия:
- Plain — значения как есть, в нативных байтах. Baseline.
- Dictionary — для колонок с low cardinality (статус, страна). Уникальные значения кладутся в dictionary, в основной части — индексы.
['ACTIVE', 'INACTIVE', 'ACTIVE', ...]-> dictionary['ACTIVE', 'INACTIVE']+ indices[0, 1, 0, ...]. - RLE (Run-Length Encoding) — повторы одного значения кодируются как пары
(count, value). - Delta encoding — для отсортированных или почти отсортированных чисел/timestamps записывается разница с предыдущим значением. Если интервал данных — каждая секунда, дельты = 1, и они отлично сжимаются.
- Bit packing — для маленьких чисел (например, индексов dictionary размером 256) используется ровно столько бит, сколько нужно.
Эти кодировки применяются автоматически. Pyarrow выбирает оптимальную стратегию в зависимости от данных в page.
Compression: ещё один слой
После encoding к страницам применяется обычный compression-кодек:
- snappy — стандарт по умолчанию. Быстро, средний compression ratio.
- gzip — медленнее, лучше сжимает.
- zstd — современный, лучшее соотношение скорость/размер. В 2026 рекомендуется по умолчанию для новых pipeline-ов.
- lz4 — самое быстрое сжатие/распаковка, ratio хуже.
- brotli — высокое сжатие, сравнимо с gzip, чуть быстрее.
Двухступенчатое (encoding + compression) даёт реальную выгоду: на типичных аналитических данных Parquet занимает 5-15% от исходного CSV.
Pyarrow в Python
Каноническая Python-библиотека — pyarrow (binding Apache Arrow). Простейший пример записи DataFrame:
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
df = pd.DataFrame({
"id": range(1_000_000),
"city": ["Moscow", "Kazan", "Sochi"] * 333_334,
"ts": pd.date_range("2026-01-01", periods=1_000_000, freq="1s"),
"value": np.random.uniform(0, 100, 1_000_000),
})
# Через Pandas
df.to_parquet("data.parquet", engine="pyarrow", compression="zstd")
# Через pyarrow напрямую -- больше контроля
table = pa.Table.from_pandas(df)
pq.write_table(
table,
"data.parquet",
compression="zstd",
use_dictionary=True,
row_group_size=500_000, # сколько строк в одном row group
)
# Чтение всего файла
table = pq.read_table("data.parquet")
df = table.to_pandas()
# Чтение только нужных колонок (column pruning)
table = pq.read_table("data.parquet", columns=["city", "value"])
# Чтение с фильтрами (predicate pushdown)
table = pq.read_table(
"data.parquet",
columns=["city", "value"],
filters=[("city", "=", "Moscow")],
)
Параметр filters — критически важный. Без него движок читает все row groups; с ним использует statistics в footer-е, чтобы пропустить row groups, где Moscow точно отсутствует.
Predicate pushdown
В footer для каждого column chunk Parquet хранит статистики:
column 'city' in row group 0:
min = 'Anapa'
max = 'Kazan'
null_count = 0
column 'city' in row group 1:
min = 'Krasnodar'
max = 'Novosibirsk'
null_count = 12
Запрос WHERE city = 'Moscow' обрабатывается так:
- Прочитать footer.
- Для каждого row group проверить statistics column ‘city’: попадает ли ‘Moscow’ в [min, max]?
- Row group 0:
max='Kazan', что меньше'Moscow'-> SKIP. - Row group 1:
'Krasnodar' <= 'Moscow' <= 'Novosibirsk'-> READ.
Это и есть predicate pushdown — фильтр опускается до уровня I/O, мы вообще не читаем нерелевантные row groups. На отсортированных данных эффект максимальный.
Сортируйте данные по часто фильтруемой колонке перед записью в Parquet. Это даёт min/max statistics высокой селективности и многократно ускоряет аналитические запросы. ClickHouse, Iceberg и Delta Lake специально поддерживают сортированную запись.
Дополнительные механизмы pruning-а:
- Column index (Parquet 2.0+) — page-level statistics. Можно пропускать целые pages внутри column chunk.
- Bloom filter — для high-cardinality колонок (user_id, transaction_id). Позволяет с большой вероятностью сказать «этого значения точно нет в row group».
- Dictionary filtering — если запрос на одно значение, и оно отсутствует в dictionary этого chunk-а, можно пропустить.
Partitioning по Hive-style
В data lake-ах файлы обычно разбиты по директориям, имитирующим колонки:
events/
year=2026/
month=04/
day=01/
part-00000.parquet
day=02/
part-00000.parquet
month=05/
day=14/
part-00000.parquet
part-00001.parquet
Это
WHERE year = 2026 AND month = 5 обрабатывается через partition pruning: движок не читает футеры файлов из других месяцев — он даже не открывает эти файлы.
# Запись с партиционированием
pq.write_to_dataset(
table,
root_path="s3://lake/events",
partition_cols=["year", "month", "day"],
)
# Чтение с фильтром по партиции
import pyarrow.dataset as ds
dataset = ds.dataset("s3://lake/events", format="parquet", partitioning="hive")
filtered = dataset.to_table(
columns=["user_id", "value"],
filter=(ds.field("year") == 2026) & (ds.field("month") == 5),
)
Хорошее партиционирование = ключи запросов. Плохое — слишком мелкие партиции (один файл на каждый user_id) или партиция по непопулярному фильтру.
Parquet vs Avro vs CSV
| Сценарий | CSV | Avro | Parquet |
|---|---|---|---|
| Объём | Базовая (1x) | 0.3x | 0.05-0.15x |
| SELECT * FROM huge_table | Медленно | Средне | Быстро (с column pruning) |
| SELECT col1 FROM huge_table | Очень медленно | Очень медленно | Очень быстро |
| INSERT 1 запись | Тривиально | Тривиально | Не подходит (нужен row group) |
| Streaming Kafka message | Подходит | Идеально | Не подходит |
| Schema evolution | Никакой | Хорошая | Ограниченная |
| Human-readable | Да | Нет | Нет |
| Параллельное чтение | По размеру | По sync marker | По row groups (идеально) |
Parquet почти всегда выбирают для аналитического хранения, Avro — для streaming и transport, CSV — только для interop с не-DE инструментами (Excel, ad-hoc).
Где встретится у DE
- S3 Data Lake — стандартный формат. Iceberg, Delta Lake, Hudi — все построены на Parquet под капотом.
- Snowflake — внешние таблицы через Parquet, COPY INTO Parquet.
- ClickHouse — функция
s3('https://.../*.parquet')для прямого SQL. - DuckDB —
SELECT * FROM 'data.parquet'без импорта. - Spark — нативный формат:
spark.read.parquet(path). - dbt — материализации в Parquet через адаптеры (Spark, Trino).
- Pandas / Polars — стандартный формат сериализации в data engineering pipeline-ах.
Junior DE в первый месяц напишет десятки df.to_parquet() и pd.read_parquet() — это базовый рабочий инструмент.