Learning Platform
Глоссарий Troubleshooting
Урок 12.03 · 21 мин
Средний
pythonrelational-apilazy-evaluationduckdb

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 это естественные операции над объектом.

Цепочка методов строит Relation, шаг за шагом
con.table('flights')Стартовый Relation — описание чтения таблицы; данных ещё нет
.filter()
+ WHEREНовый Relation: к рецепту добавлен шаг фильтрации; по-прежнему только описание
.aggregate()
+ GROUP BYЕщё новый 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(). Ленивость даёт оптимизатору полную картину.

Ленивость: построение и исполнение разделены
Цепочка методовВызовы .filter, .aggregate, .order только наращивают описание запроса — вычислений ноль
.df() / .fetchall() / .write_parquet()
МатериализацияЗапрос целиком уходит в оптимизатор, затем исполняется векторизованным движком — один раз
WARNING

У ленивости есть практическое следствие. 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-файл.

  1. Загрузите файл в таблицу. Получите Relation через con.table(...). Постройте цепочку .filter().aggregate().order().limit() и материализуйте её через .df(). Сравните с эквивалентным duckdb.sql("...") — результат должен совпасть.
  2. Проверьте ленивость: постройте Relation с фильтром, но НЕ материализуйте. Убедитесь, что пока ничего не вычислено (никакого результата). Затем вызовите .fetchall() — вот теперь запрос исполнился.
  3. Постройте Relation, затем измените исходную таблицу (INSERT несколько строк), затем материализуйте Relation. Объясните себе, почему результат отражает новые строки.
  4. Постройте часть запроса методами Relational API, а часть — допишите SQL-строкой поверх Relation. Убедитесь, что два API смешиваются. Сформулируйте, почему это доказывает, что под ними один и тот же движок.
Ibis: ленивый Python API поверх любого SQL-движка
Проверка знанийKnowledge check
Что такое Relational API в DuckDB, что означает его ленивость, и почему он не является отдельным от SQL движком?
ОтветAnswer
Relational API — это способ строить запросы к DuckDB из Python не строкой SQL, а цепочкой вызовов методов: .filter(), .project(), .aggregate(), .order(), .join() и других. В его основе объект Relation, который представляет собой не данные и не результат, а описание запроса — план, рецепт получения набора данных. Получить Relation можно из таблицы, файла, DataFrame или SQL-строки, а каждый метод-трансформация возвращает новый Relation с добавленным шагом. Ленивость (lazy evaluation) означает, что построение цепочки методов само по себе ничего не вычисляет: вызовы .filter, .aggregate, .order лишь наращивают описание запроса, данные при этом не читаются и движок не запускается. Реальное исполнение происходит только в момент материализации — когда от Relation запрашивают конкретный результат: методами .fetchall(), .df(), .arrow(), .pl(), .to_table(), .write_parquet() или при печати. Ленивость полезна тем, что к моменту материализации DuckDB видит весь запрос целиком и передаёт его своему оптимизатору, который может протолкнуть фильтр к источнику, отбросить ненужные колонки, выбрать порядок джойнов; если бы каждый метод выполнялся сразу, оптимизировать было бы нечего. Важное следствие ленивости: Relation — это описание, а не снимок данных, поэтому если изменить исходную таблицу между построением Relation и его материализацией, результат отразит новые данные. Relational API не является отдельным от SQL движком — это просто другой фасад для построения того же самого плана запроса. Вызов .filter() строит ровно тот же узел логического плана, что и WHERE в SQL-строке; при материализации Relation проходит через тот же binder, тот же оптимизатор и тот же векторизованный исполнитель, поэтому производительность Relational API и эквивалентного SQL идентична. Доказательство — то, что два API свободно смешиваются: можно построить часть запроса методами, а часть SQL-строкой поверх того же Relation. Выбор между ними чисто стилистический: SQL-строки нагляднее для статичных запросов, Relational API удобнее, когда запрос строится программно — шаги зависят от условий или добавляются в цикле.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Что представляет собой объект Relation в Relational API DuckDB?

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

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

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

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