Learning Platform
Глоссарий Troubleshooting
Урок 10.01 · 20 мин
Средний
external-datareplacement-scanparquettable-functions

Запрос файла напрямую и 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-параллелизм — всё работает одинаково.

Файл как источник DataChunk-ов
Файл на дискеflights.parquet — колоночный файл с footer-метаданными, лежит в файловой системе, DuckDB его не открывал заранее
read_parquet()
Table functionФункция-сканер: открывает файл, читает footer, отдаёт движку поток колоночных батчей по ~2048 строк
DataChunk
Операторы запросаFilter, projection, join, aggregate — те же векторизованные операторы, что и для нативных таблиц

Полная явная форма запроса выглядит так:

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'). Дальше по конвейеру идёт уже обычный план. Замена происходит один раз, на этапе планирования; на скорость исполнения она не влияет вообще.

Что делает binder с FROM 'flights.parquet'
Имя в позиции FROMСтроковый литерал 'flights.parquet' стоит там, где обычно имя таблицы
поиск в каталоге
В каталоге не найденоНет ни таблицы, ни view с таким именем — обычная СУБД выдала бы ошибку здесь
опрос replacement scans
Callback по расширениюВстроенный callback видит расширение .parquet и подставляет вызов table function
подстановка
FROM read_parquet('flights.parquet')Дальше планировщик видит обычный вызов table function, как если бы вы написали его руками

Тот же самый механизм replacement scan в Python-клиенте подменяет имя переменной Pandas/Polars DataFrame на сканирование этого объекта в памяти — об этом подробно в модуле про Python-экосистему. Сейчас важно зафиксировать: replacement scan — это единый универсальный приём «имя, которого нет в каталоге, превращается в источник данных», и файлы на диске — лишь один из его случаев.

NOTE

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 — в следующем уроке.

WARNING

Когда несколько файлов читаются как одна таблица, по умолчанию предполагается, что у них одинаковая схема — те же колонки в том же порядке. Если схемы расходятся (где-то лишняя колонка, где-то другой порядок), нужен параметр union_by_name => true, который сопоставляет колонки по именам, а не по позициям. Без него запрос либо упадёт, либо тихо смешает не те колонки.

Попробуй сам

Возьмите любой Parquet-файл (или скачайте публичный, например NYC taxi trips) и положите рядом с ним CSV-копию — её легко получить через COPY (SELECT * FROM 'trips.parquet') TO 'trips.csv'.

  1. Выполните SELECT COUNT(*) FROM 'trips.parquet' и SELECT COUNT(*) FROM 'trips.csv'. Убедитесь, что короткая форма распознаёт оба расширения и числа совпадают.
  2. Переименуйте trips.parquet в trips.data и повторите SELECT * FROM 'trips.data' LIMIT 1. Получите ошибку. Затем выполните SELECT * FROM read_parquet('trips.data') LIMIT 1 — запрос пройдёт. Это доказывает, что replacement scan смотрит на расширение, а table function — на содержимое.
  3. Выполните EXPLAIN SELECT * FROM 'trips.parquet' и найдите в плане оператор PARQUET_SCAN (или READ_PARQUET). Это и есть результат работы replacement scan — короткая форма развернулась в вызов сканера.
  4. Создайте CREATE TABLE "trips.parquet" AS SELECT 1 AS x; и снова выполните SELECT * FROM 'trips.parquet'. Объясните себе, почему теперь вернулась одна строка с колонкой x, а не данные файла.

DataFusion: TableProvider — аналог replacement scan в API
Проверка знанийKnowledge check
Запрос SELECT * FROM 'sales.parquet' работает без предварительного CREATE TABLE. Какой механизм это обеспечивает и на каком этапе обработки запроса он срабатывает?
ОтветAnswer
Это работает за счёт механизма replacement scan, который встроен в binder — фазу обработки запроса, идущую сразу после parser и связывающую имена в запросе с объектами каталога. Когда binder встречает имя в позиции FROM и не находит в каталоге ни таблицы, ни view с таким именем, он не выдаёт ошибку сразу, а опрашивает зарегистрированные replacement scan callbacks. Один из встроенных callbacks распознаёт строки, похожие на путь к файлу, по расширению (.parquet, .csv, .json) и подменяет ссылку на несуществующую таблицу вызовом подходящей table function: 'sales.parquet' превращается в read_parquet('sales.parquet'). Замена происходит один раз, на этапе планирования, и на скорость исполнения не влияет — дальше по конвейеру идёт обычный план со сканером Parquet. Каталог при этом всегда имеет приоритет: replacement scan включается только как fallback на ненайденное имя. Ограничение короткой формы — она опирается на расширение и не позволяет передавать опции; для нестандартных файлов и опций нужна явная форма read_parquet(...).

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. На каком этапе обработки запроса срабатывает replacement scan, превращающий 'sales.parquet' в источник данных?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6