Relational API: запросы как цепочки методов
До сих пор все наши запросы в Python были строками SQL внутри duckdb.sql("..."). Это удобно, но у строк есть минусы: их трудно собирать по частям, в них нет автодополнения IDE, а сложный запрос превращается в один длинный текстовый блок. У DuckDB есть второй способ писать запросы из Python — Relational API. Это построение запроса не строкой, а цепочкой вызовов методов: .filter(), .aggregate(), .order() и так далее. Этот урок про то, как Relational API устроен, чем он удобен и — главное — почему он не отдельный движок, а тот же самый SQL под другим фасадом.
Объект Relation: что это такое
В основе Relational API — объект Relation. Relation представляет собой запрос, точнее — описание того, как получить некоторый набор данных. Подчеркнём: Relation это не сами данные и не результат. Это план, рецепт, отложенное вычисление.
Получить Relation можно разными способами: из таблицы, из файла, из DataFrame, из SQL-строки. А дальше к нему применяют методы-трансформации, и каждый метод возвращает новый Relation — новый рецепт, к которому добавлен ещё один шаг.
import duckdb
con = duckdb.connect()
con.sql("CREATE TABLE flights AS SELECT * FROM 'flights.parquet'")
# Получаем Relation из таблицы
flights = con.table("flights")
# flights — это Relation: описание запроса, ещё не данные
print(type(flights)) # <class 'duckdb.duckdb.DuckDBPyRelation'>
Цепочка трансформаций
Каждая операция SQL имеет метод-аналог в Relational API. Методы возвращают Relation, поэтому их вызовы выстраиваются в цепочку:
import duckdb
con = duckdb.connect()
con.sql("CREATE TABLE flights AS SELECT * FROM 'flights.parquet'")
result = (
con.table("flights") # Relation из таблицы
.filter("dep_delay > 60") # WHERE dep_delay > 60
.aggregate("carrier, COUNT(*) AS late") # GROUP BY carrier
.order("late DESC") # ORDER BY late DESC
.limit(5) # LIMIT 5
)
print(result)
┌─────────┬───────┐
│ carrier │ late │
│ varchar │ int64 │
├─────────┼───────┤
│ WN │ 88210 │
│ AA │ 71044 │
│ DL │ 63197 │
│ UA │ 59881 │
│ OO │ 41330 │
└─────────┴───────┘
Соответствие методов и SQL прямое:
| Метод Relational API | Конструкция SQL |
|---|---|
.filter("условие") | WHERE |
.project("колонки") | SELECT (выбор колонок) |
.aggregate("...") | GROUP BY с агрегатами |
.order("...") | ORDER BY |
.limit(n) | LIMIT |
.join(other, "условие") | JOIN |
.union(other) | UNION |
.distinct() | SELECT DISTINCT |
Удобство в том, что цепочку легко собирать программно. Запрос строится по частям, в зависимости от условий, в цикле:
import duckdb
con = duckdb.connect()
con.sql("CREATE TABLE flights AS SELECT * FROM 'flights.parquet'")
rel = con.table("flights")
# Условное добавление шагов — со строкой SQL так не получится
only_delayed = True
if only_delayed:
rel = rel.filter("dep_delay > 0")
rel = rel.aggregate("carrier, AVG(dep_delay) AS avg_delay")
С монолитной SQL-строкой такое наращивание потребовало бы склейки текста; с Relation это естественные операции над объектом.
Ленивость: когда запрос реально выполняется
Самое важное свойство Relational API — ленивость (lazy evaluation). Построение цепочки методов ничего не вычисляет. .filter(), .aggregate(), .order() лишь наращивают описание запроса. Данные не читаются, движок не запускается.
Реальное исполнение происходит только в момент, когда от Relation запрашивают конкретный результат — это называется материализацией. Триггеры материализации:
.fetchall(),.fetchone()— забрать строки в Python..df(),.arrow(),.pl()— забрать результат как DataFrame..to_table("name"),.write_parquet("f.parquet")— записать результат.print(relation)— показать (DuckDB материализует превью).
import duckdb
con = duckdb.connect()
con.sql("CREATE TABLE flights AS SELECT * FROM 'flights.parquet'")
# Эти строки ничего не вычисляют — только строят описание
rel = con.table("flights").filter("dep_delay > 60").aggregate("carrier, COUNT(*) AS n")
# А вот ЗДЕСЬ запрос реально исполняется — материализация в DataFrame
df = rel.df()
Почему ленивость — это хорошо? Потому что к моменту материализации DuckDB видит весь запрос целиком и передаёт его своему оптимизатору. Оптимизатор может протолкнуть фильтр ближе к источнику, отбросить ненужные колонки, выбрать порядок джойнов. Если бы каждый метод выполнялся сразу, оптимизировать было бы нечего — .filter() уже отработал бы до того, как стало известно про .aggregate(). Ленивость даёт оптимизатору полную картину.
У ленивости есть практическое следствие. Relation — это описание, а не зафиксированный снимок данных. Если вы построили Relation, потом изменили исходную таблицу, а потом материализовали Relation — вы получите результат поверх НОВЫХ данных. Relation выполняется в момент материализации, а не в момент построения. Если нужен именно снимок на конкретный момент, материализуйте Relation сразу — например через .to_table() в отдельную таблицу.
Relational API — это тот же SQL-движок
Развеем возможное заблуждение. Relational API не альтернативный движок и не отдельная реализация запросов. Это просто другой фасад для построения того же самого плана запроса.
Когда вы вызываете .filter("dep_delay > 60"), DuckDB строит ровно тот же узел логического плана, что и при WHERE dep_delay > 60 в SQL-строке. При материализации Relation проходит через тот же binder, тот же оптимизатор, тот же векторизованный исполнитель. Производительность Relational API и эквивалентного SQL идентична — это один путь исполнения, к которому ведут две разные двери.
Доказательство этому — то, что два API свободно смешиваются. Метод .sql() позволяет применить SQL-фрагмент к Relation, а con.sql() может ссылаться на Relation по имени. Можно построить часть запроса методами, часть — строкой:
import duckdb
con = duckdb.connect()
con.sql("CREATE TABLE flights AS SELECT * FROM 'flights.parquet'")
# Часть запроса — Relational API
base = con.table("flights").filter("year = 2026")
# Часть — SQL-строка поверх того же Relation
result = base.aggregate("carrier, AVG(dep_delay) AS avg_d").order("avg_d DESC")
Раз оба способа сходятся в один план — выбор между ними чисто стилистический.
Когда какой API
Практическое правило:
SQL-строки хороши, когда запрос статичен и известен целиком: его нагляднее прочитать как единый SQL-текст. Аналитики читают SQL свободно; сложный запрос с CTE часто яснее как SQL.
Relational API хорош, когда запрос строится программно: шаги зависят от условий, добавляются в цикле, собираются из переиспользуемых фрагментов. Плюс автодополнение IDE по методам и проверки на этапе написания кода. Для библиотек и фреймворков, которые генерируют запросы к DuckDB, Relational API удобнее строковой склейки.
Оба — полноценны, оба одинаково быстры. Выбирайте по задаче, а не по принципу.
Попробуй сам
Для задания нужен pip install duckdb и любой Parquet-файл.
- Загрузите файл в таблицу. Получите Relation через
con.table(...). Постройте цепочку.filter().aggregate().order().limit()и материализуйте её через.df(). Сравните с эквивалентнымduckdb.sql("...")— результат должен совпасть. - Проверьте ленивость: постройте Relation с фильтром, но НЕ материализуйте. Убедитесь, что пока ничего не вычислено (никакого результата). Затем вызовите
.fetchall()— вот теперь запрос исполнился. - Постройте Relation, затем измените исходную таблицу (
INSERTнесколько строк), затем материализуйте Relation. Объясните себе, почему результат отражает новые строки. - Постройте часть запроса методами Relational API, а часть — допишите SQL-строкой поверх Relation. Убедитесь, что два API смешиваются. Сформулируйте, почему это доказывает, что под ними один и тот же движок.