Parquet: read_parquet, globs и pushdown
Parquet — основной формат хранения данных в современной аналитике, и для DuckDB это первоклассный гражданин.
Parquet: Row Groups — структура горизонтальной нарезкиParquet: Encodings — словарное и целочисленное кодирование Но просто «уметь читать Parquet» мало. Сила DuckDB в том, что он читает Parquet выборочно: не весь файл, а только те колонки и только те фрагменты, которые реально нужны запросу. Эта избирательность называется pushdown, и именно она превращает запрос к 50-гигабайтному файлу на S3 в чтение нескольких сотен мегабайт. Чтобы pushdown работал и чтобы вы умели проверять, что он сработал, нужно понимать внутреннее устройство Parquet-файла.
Анатомия Parquet-файла
Parquet — колоночный формат с трёхуровневой структурой. Файл делится на row groups — горизонтальные срезы по строкам (типично от десятков до сотен тысяч строк в каждом). Внутри row group данные каждой колонки лежат отдельным непрерывным куском — column chunk. Column chunk, в свою очередь, состоит из pages — минимальных единиц сжатия и кодирования.
В самом конце файла лежит footer — метаданные. Footer содержит схему, список row groups и, что критически важно, для каждого column chunk — статистику: минимальное и максимальное значение, число NULL, число строк. Именно footer DuckDB читает первым, ещё до данных.
Эта структура — не деталь формата, а основа двух оптимизаций. Колонки лежат раздельно — значит можно прочитать одну колонку и не трогать остальные. У каждого row group есть min/max — значит можно по фильтру понять, что в этом row group нужных строк нет, и не читать его данные вообще.
Projection pushdown: читаем только нужные колонки
Projection pushdown — это «проталкивание» списка нужных колонок из SELECT вниз, в сканер. Если запрос обращается к двум колонкам из тридцати, DuckDB прочитает с диска только эти два column chunk на каждый row group. Остальные 28 не будут даже подняты в память.
-- В файле 30 колонок, но запрос трогает только две
SELECT carrier, dep_delay
FROM read_parquet('flights.parquet')
WHERE dep_delay > 60;
DuckDB определяет нужные колонки на этапе оптимизации, до сканирования, и сканер запрашивает у файла только их байтовые диапазоны. Для широких таблиц это разница на порядок. Главный практический вывод прост и почти банален, но его постоянно нарушают: не пишите SELECT *, если вам нужны не все колонки. SELECT * заставляет читать весь файл и убивает projection pushdown.
Filter pushdown: пропускаем целые row groups
Filter pushdown идёт дальше. DuckDB берёт предикаты из WHERE и сопоставляет их со статистикой row groups из footer. Если для фильтра WHERE dep_delay > 60 у какого-то row group в footer записано max(dep_delay) = 35, то ни одна строка этого row group не пройдёт фильтр — и DuckDB пропускает его целиком, не читая данные.
Filter pushdown даёт выигрыш только тогда, когда данные в файле физически упорядочены или кластеризованы по колонке фильтра. Если файл отсортирован по dep_delay, то у каждого row group узкий диапазон min/max, и предикат отсекает почти все. Если же dep_delay разбросан случайно, у каждого row group будет min близко к глобальному минимуму и max близко к максимуму — диапазоны перекрываются, и пропустить ничего не получится. Поэтому при записи Parquet, который потом будут много фильтровать, имеет смысл сортировать данные по колонке фильтра.
Внутри row group у каждой page тоже может быть своя статистика min/max в так называемом page index (column index). Если он есть, DuckDB отсекает не только целые row groups, но и отдельные pages внутри них. Это вторая, более тонкая ступень того же filter pushdown.
Как проверить, что pushdown сработал
Не верьте на слово — измеряйте. EXPLAIN ANALYZE показывает, сколько строк реально прочитал сканер. Сравним один и тот же файл с фильтром и без:
EXPLAIN ANALYZE
SELECT carrier FROM read_parquet('flights.parquet')
WHERE year = 2026;
В выводе у оператора PARQUET_SCAN будет строка Rows scanned. Если файл кластеризован по year, она окажется намного меньше общего числа строк в файле — это и есть доказательство, что filter pushdown отсёк row groups. Запрос без WHERE покажет полное число строк. Разница между ними — мера эффективности pushdown.
Косвенно эффект projection pushdown виден так же: запрос SELECT carrier отрабатывает заметно быстрее SELECT * на широком файле, потому что поднимает с диска один column chunk вместо тридцати.
Glob-шаблоны: много файлов одним запросом
Реальные датасеты редко лежат одним файлом. read_parquet принимает glob-шаблон и читает все совпавшие файлы как одну таблицу:
-- Все Parquet в папке
SELECT COUNT(*) FROM read_parquet('data/*.parquet');
-- Рекурсивно по всем подпапкам: ** означает любую глубину вложенности
SELECT COUNT(*) FROM read_parquet('data/**/*.parquet');
-- Список конкретных путей
SELECT COUNT(*) FROM read_parquet(['q1.parquet', 'q2.parquet']);
Поддерживаются * (любые символы в пределах одного уровня), ** (любая глубина вложенности), ? (один символ), [abc] (один из перечисленных). Часто полезна виртуальная колонка filename — она добавляет к каждой строке путь файла, из которого строка прочитана:
SELECT filename, COUNT(*) AS rows
FROM read_parquet('data/*.parquet', filename = true)
GROUP BY filename;
┌────────────────────┬─────────┐
│ filename │ rows │
│ varchar │ int64 │
├────────────────────┼─────────┤
│ data/q1.parquet │ 712038 │
│ data/q2.parquet │ 698201 │
│ data/q3.parquet │ 731559 │
└────────────────────┴─────────┘
Начиная с DuckDB 1.3.0 колонка filename доступна автоматически, даже без явного filename = true, — её просто нужно перечислить в SELECT. Опция остаётся для совместимости и для случаев, когда колонку хочется получить в SELECT *.
Metadata-функции: заглянуть в файл, не читая данные
Иногда нужно изучить сам файл: какие колонки, сколько row groups, какое сжатие, какая статистика. Для этого есть отдельные table functions, которые читают только footer:
-- Схема: имена колонок и их типы
SELECT name, type FROM parquet_schema('flights.parquet');
-- Статистика по каждому column chunk каждого row group
SELECT row_group_id, column_id, num_values, stats_min, stats_max, compression
FROM parquet_metadata('flights.parquet')
LIMIT 4;
┌──────────────┬───────────┬────────────┬───────────┬───────────┬─────────────┐
│ row_group_id │ column_id │ num_values │ stats_min │ stats_max │ compression │
│ int64 │ int64 │ int64 │ varchar │ varchar │ varchar │
├──────────────┼───────────┼────────────┼───────────┼───────────┼─────────────┤
│ 0 │ 0 │ 122880 │ 2026-01-01│ 2026-03-31│ SNAPPY │
│ 0 │ 1 │ 122880 │ AA │ WN │ SNAPPY │
│ 1 │ 0 │ 122880 │ 2026-04-01│ 2026-06-30│ SNAPPY │
│ 1 │ 1 │ 122880 │ AA │ WN │ SNAPPY │
└──────────────┴───────────┴────────────┴───────────┴───────────┴─────────────┘
Главные функции этого семейства: parquet_schema() — типы колонок, parquet_metadata() — построчная статистика column chunk-ов, parquet_file_metadata() — общие сведения о файле (число строк, версия формата, кодек), parquet_kv_metadata() — произвольные пользовательские пары ключ-значение из footer. Все они читают только footer, поэтому работают почти мгновенно даже на огромных файлах. parquet_metadata() — главный инструмент диагностики: по нему сразу видно, узкие ли диапазоны stats_min/stats_max (значит filter pushdown будет работать) или они перекрываются от row group к row group (значит pushdown бессилен и файл стоит пересортировать).
Статистика min/max в footer заполняется тем инструментом, который писал Parquet. Большинство современных писателей (DuckDB, Arrow, Spark) её пишут, но встречаются файлы со старых пайплайнов без статистики. Для таких файлов filter pushdown по row groups невозможен — отсекать просто не по чему. Проверить наличие статистики можно через parquet_metadata(): пустые stats_min/stats_max означают, что статистики нет.
Попробуй сам
Возьмите Parquet-файл с числовой колонкой (например dep_delay в датасете рейсов).
- Выполните
SELECT * FROM parquet_file_metadata('file.parquet')и посмотрите общее число строк и кодек сжатия. ЗатемSELECT * FROM parquet_metadata('file.parquet')— сколько в файле row groups и какие у них диапазоныstats_min/stats_max. - Сравните
EXPLAIN ANALYZE SELECT one_column FROM read_parquet('file.parquet')иEXPLAIN ANALYZE SELECT * FROM read_parquet('file.parquet'). Найдите разницу во времени — это эффект projection pushdown. - Запишите две версии файла:
COPY (SELECT * FROM 'file.parquet' ORDER BY dep_delay) TO 'sorted.parquet'иCOPY (SELECT * FROM 'file.parquet') TO 'unsorted.parquet'. Для обеих выполнитеEXPLAIN ANALYZE ... WHERE dep_delay > 100и сравнитеRows scanned. Объясните, почему у отсортированного файла прочитано меньше строк. - Положите несколько Parquet-файлов в папку и прочитайте их одним glob-шаблоном с колонкой
filenameвSELECT. Убедитесь, что строки из разных файлов помечены своим путём.
Parquet row groups и column chunks: физическая раскладка файла