Запрос файла напрямую и replacement-семантика
В большинстве СУБД, чтобы что-то прочитать, данные сначала нужно загрузить: CREATE TABLE, потом COPY или INSERT, и только затем SELECT. Это шаг ETL: extract -> load -> transform. DuckDB ломает эту последовательность. Запрос
SELECT * FROM 'flights.parquet' LIMIT 5;
работает сразу, без какого-либо предварительного CREATE TABLE. Файл на диске становится таблицей в момент исполнения запроса. Это не синтаксический сахар и не «скрытый импорт» — DuckDB действительно читает файл на месте, во время сканирования, и ничего никуда не копирует. Понять, как именно это происходит, — задача этого урока. Без этого понимания дальнейшие уроки модуля (Parquet pushdown, CSV-sniffer, Hive-партиции) будут выглядеть магией.
Почему это вообще возможно
Почему формат хранения важен для аналитикиКлюч — в том, что DuckDB изначально спроектирован как аналитический движок, отделённый от хранилища. У классической OLTP-СУБД (PostgreSQL, MySQL) данные обязаны лежать в её собственном формате страниц, потому что движок завязан на свой storage. DuckDB же умеет сканировать любой источник, для которого есть table function — функция, которая на вход получает аргументы (путь, опции), а на выход выдаёт поток DataChunk-ов, то есть колоночных батчей по ~2048 строк.
read_parquet('flights.parquet') — это и есть table function. Она открывает файл, читает footer с метаданными, и начинает отдавать движку колоночные батчи. С точки зрения остального конвейера запроса нет разницы, пришёл ли DataChunk из table function поверх Parquet-файла или из сегмента нативной .duckdb-таблицы. Оптимизатор, векторизованные операторы, morsel-параллелизм — всё работает одинаково.
Полная явная форма запроса выглядит так:
SELECT origin, COUNT(*) AS n
FROM read_parquet('flights.parquet')
GROUP BY origin
ORDER BY n DESC
LIMIT 3;
┌─────────┬────────┐
│ origin │ n │
│ varchar │ int64 │
├─────────┼────────┤
│ ATL │ 414513 │
│ ORD │ 350380 │
│ DFW │ 304157 │
└─────────┴────────┘
Для CSV есть read_csv('file.csv'), для JSON — read_json('file.json'). Каждая из них — отдельная table function со своим набором опций.
Replacement scan: откуда берётся короткий синтаксис
Теперь главное. Запрос SELECT * FROM 'flights.parquet' не содержит никакого вызова функции — там просто строковый литерал на месте имени таблицы. Откуда DuckDB знает, что это файл, и какую table function подставить?
Здесь работает механизм replacement scan. Это hook внутри binder — фазы, которая идёт сразу после parser и связывает имена в запросе с реальными объектами каталога. Когда binder встречает имя в позиции FROM и не находит в каталоге ни таблицы, ни view с таким именем, он не сразу выдаёт ошибку. Сначала он по очереди опрашивает зарегистрированные replacement scan callbacks: «никто из вас не знает, что делать с этой строкой?»
Один из встроенных callbacks специально обрабатывает строки, похожие на путь к файлу. Он смотрит на расширение и подменяет (replace — отсюда название) ссылку на несуществующую таблицу вызовом подходящей table function:
- строка заканчивается на
.parquet->read_parquet('...') - на
.csv,.csv.gz,.tsv->read_csv('...') - на
.json,.ndjson->read_json('...')
То есть FROM 'flights.parquet' после работы binder превращается ровно в FROM read_parquet('flights.parquet'). Дальше по конвейеру идёт уже обычный план. Замена происходит один раз, на этапе планирования; на скорость исполнения она не влияет вообще.
Тот же самый механизм replacement scan в Python-клиенте подменяет имя переменной Pandas/Polars DataFrame на сканирование этого объекта в памяти — об этом подробно в модуле про Python-экосистему. Сейчас важно зафиксировать: replacement scan — это единый универсальный приём «имя, которого нет в каталоге, превращается в источник данных», и файлы на диске — лишь один из его случаев.
Replacement scan не срабатывает, если объект с таким именем в каталоге УЖЕ есть. Если вы выполнили CREATE TABLE "flights.parquet" AS ..., то FROM 'flights.parquet' обратится к этой таблице, а не к файлу. Каталог всегда имеет приоритет — replacement scan включается только как fallback на ненайденное имя.
Короткая форма против явной: что выбирать
Короткая форма FROM 'file.parquet' удобна для ad-hoc запросов и быстрой разведки данных. Но у явной формы read_parquet('file.parquet', ...) есть решающее преимущество: только в неё можно передать опции. Sniffer CSV, выбор колонок Hive-партиций, union_by_name, sample_size, явная схема типов — всё это аргументы table function. Через голый строковый литерал передать их некуда.
| Аспект | FROM 'file.parquet' | FROM read_parquet('file.parquet', ...) |
|---|---|---|
| Откуда берётся | Replacement scan в binder | Прямой вызов table function |
| Передача опций | Невозможна | Любые опции аргументами |
| Расширение | Должно быть распознаваемым | Не важно — функция задана явно |
| Скорость исполнения | Идентична | Идентична |
| Когда применять | Ad-hoc, разведка | Продакшен, нестандартные файлы |
Ещё один практический момент: replacement scan опирается на расширение. Файл с данными Parquet, но названный data.bin, короткая форма не распознает — здесь обязателен явный read_parquet('data.bin'), который доверяет не имени, а содержимому файла (Parquet самоописателен через footer).
-- Не сработает: расширение .bin не распознаётся replacement scan
-- SELECT * FROM 'data.bin';
-- Сработает: table function указана явно, расширение не важно
SELECT * FROM read_parquet('data.bin') LIMIT 5;
Несколько файлов как одна таблица
Table function принимает не только один путь. Можно передать glob-шаблон или список путей — и DuckDB прочитает всё это как единую логическую таблицу, конкатенируя строки:
-- Glob: все Parquet-файлы в папке
SELECT COUNT(*) FROM 'data/year=2026/*.parquet';
-- Явный список путей
SELECT COUNT(*) FROM read_parquet(['jan.parquet', 'feb.parquet', 'mar.parquet']);
┌──────────────┐
│ count_star() │
│ int64 │
├──────────────┤
│ 2841902 │
└──────────────┘
Это работает потому, что table function — это поток DataChunk-ов, и ничто не мешает ей читать файлы по очереди и отдавать их батчи один за другим. Каждый файл при этом может читаться своим потоком — основа параллельного сканирования, к которому вернёмся в уроке про Hive-партиции. Детали glob-синтаксиса, projection/filter pushdown и метаданных Parquet — в следующем уроке.
Когда несколько файлов читаются как одна таблица, по умолчанию предполагается, что у них одинаковая схема — те же колонки в том же порядке. Если схемы расходятся (где-то лишняя колонка, где-то другой порядок), нужен параметр union_by_name => true, который сопоставляет колонки по именам, а не по позициям. Без него запрос либо упадёт, либо тихо смешает не те колонки.
Попробуй сам
Возьмите любой Parquet-файл (или скачайте публичный, например NYC taxi trips) и положите рядом с ним CSV-копию — её легко получить через COPY (SELECT * FROM 'trips.parquet') TO 'trips.csv'.
- Выполните
SELECT COUNT(*) FROM 'trips.parquet'иSELECT COUNT(*) FROM 'trips.csv'. Убедитесь, что короткая форма распознаёт оба расширения и числа совпадают. - Переименуйте
trips.parquetвtrips.dataи повторитеSELECT * FROM 'trips.data' LIMIT 1. Получите ошибку. Затем выполнитеSELECT * FROM read_parquet('trips.data') LIMIT 1— запрос пройдёт. Это доказывает, что replacement scan смотрит на расширение, а table function — на содержимое. - Выполните
EXPLAIN SELECT * FROM 'trips.parquet'и найдите в плане операторPARQUET_SCAN(илиREAD_PARQUET). Это и есть результат работы replacement scan — короткая форма развернулась в вызов сканера. - Создайте
CREATE TABLE "trips.parquet" AS SELECT 1 AS x;и снова выполнитеSELECT * FROM 'trips.parquet'. Объясните себе, почему теперь вернулась одна строка с колонкойx, а не данные файла.
DataFusion: TableProvider — аналог replacement scan в API