Регистрация Python-объектов и UDF
Replacement scan делает Python-переменные видимыми в SQL автоматически. Но иногда автоматики мало: объект создан без именованной переменной, имя нужно другое, или — самое интересное — в SQL-запросе нужно вызвать свою функцию на Python. Этот завершающий урок модуля про два инструмента ручного управления: register для явной привязки объектов и Python UDF — пользовательские функции, которые приносят Python-код прямо внутрь SQL-запроса DuckDB.
register: явная привязка объекта
DataFusion Python: UDF, UDAF и UDWF ClickHouse: SQL UDF и Executable UDFМетод con.register(name, object) создаёт в каталоге соединения именованную ссылку на Python-объект. После него объект доступен в SQL под заданным именем — независимо от того, как называется (и называется ли вообще) Python-переменная.
import duckdb
import pandas as pd
con = duckdb.connect()
df = pd.DataFrame({"product": ["A", "B"], "price": [100, 250]})
# Явно привязываем объект к имени catalog в SQL
con.register("catalog", df)
con.sql("SELECT * FROM catalog")
Зачем это, если есть автоматический replacement scan? В трёх случаях:
- Имя в SQL не должно зависеть от имени переменной. Replacement scan требует совпадения.
registerразрывает связь: переменная может называться как угодно, в SQL — фиксированное имя. Это важно для кода, который генерирует запросы. - У объекта нет именованной переменной. DataFrame создан выражением, лежит в списке, пришёл аргументом функции — replacement scan не за что зацепиться.
registerдаёт ему имя явно. - Контроль над временем жизни привязки.
registerсоздаёт привязку до явногоunregisterили закрытия соединения; replacement scan ищет переменную заново при каждом запросе.
# DataFrame без именованной переменной — replacement scan бессилен
con.register("first_batch", build_dataframe()[0])
con.sql("SELECT COUNT(*) FROM first_batch")
# Снять привязку, когда объект больше не нужен
con.unregister("first_batch")
register привязывает данные. Вторая, более мощная возможность — привязать код.
Python UDF: своя функция внутри SQL
SQL DuckDB богат встроенными функциями, но Python-экосистема богаче на порядки: библиотеки для текста, дат, геокодирования, ML-инференса, доменной логики. UDF (user-defined function, пользовательская функция) позволяет вызвать Python-функцию прямо из SQL-запроса. Вы пишете обычную функцию на Python, регистрируете её — и она становится SQL-функцией, как upper или abs.
Регистрация — метод con.create_function(name, func, argument_types, return_type):
import duckdb
con = duckdb.connect()
# Обычная Python-функция
def tax(amount: float) -> float:
return round(amount * 0.2, 2)
# Регистрируем её как SQL-функцию
con.create_function("tax", tax, ["DOUBLE"], "DOUBLE")
# Теперь tax() доступна в SQL наравне со встроенными
con.sql("SELECT product, price, tax(price) AS vat FROM (VALUES ('A', 100.0), ('B', 250.0)) t(product, price)")
┌─────────┬────────┬────────┐
│ product │ price │ vat │
│ varchar │ double │ double │
├─────────┼────────┼────────┤
│ A │ 100.0 │ 20.0 │
│ B │ 250.0 │ 50.0 │
└─────────┴────────┴────────┘
При регистрации указываются: имя функции в SQL, сама Python-функция, список типов аргументов и тип возвращаемого значения — DuckDB должен знать сигнатуру, чтобы встроить функцию в типизированный план запроса. Часто типы можно вывести из аннотаций Python, но явное указание надёжнее.
UDF особенно ценны там, где встроенного SQL не хватает: применить Python-библиотеку нормализации текста, дернуть ML-модель для скоринга каждой строки, посчитать доменную метрику со сложной логикой. Функция пишется на привычном Python, а вызывается декларативно в SQL.
# UDF, использующая Python-библиотеку, которой нет в SQL
import hashlib
def sha256_hex(s: str) -> str:
return hashlib.sha256(s.encode()).hexdigest()
con.create_function("sha256_hex", sha256_hex, ["VARCHAR"], "VARCHAR")
con.sql("SELECT sha256_hex(email) AS email_hash FROM users")
Цена UDF: производительность
Здесь — главное предостережение урока. Python UDF удобны, но они медленнее встроенных функций DuckDB, и понимать почему — обязательно.
Встроенная функция DuckDB векторизована: она написана на C++ и обрабатывает весь DataChunk — батч на ~2048 значений — за один вызов, без накладных расходов на каждое значение. Python UDF в простейшей форме вызывается построчно: для каждого значения колонки DuckDB передаёт управление в интерпретатор Python, вызывает функцию, забирает результат. На миллионах строк это миллионы переходов между движком и интерпретатором плюс оверхед самого интерпретатора Python. Векторизованный конвейер при этом фактически разрывается на каждом значении.
Есть способ смягчить эту цену. DuckDB поддерживает векторизованные Python UDF: функция-UDF получает не одно значение, а сразу вектор значений (как PyArrow-массив) и должна вернуть вектор. Накладные расходы на переход амортизируются по всему батчу, а внутри функции можно работать с массивом целиком средствами PyArrow или NumPy — векторно, без Python-цикла. Это резко ближе по скорости к встроенным функциям. Тип UDF задаётся при регистрации (через параметр, выбирающий построчный или Arrow-векторизованный режим).
| Аспект | Встроенная функция | Построчная Python UDF | Векторизованная Python UDF |
|---|---|---|---|
| Язык | C++ | Python | Python (+ PyArrow/NumPy) |
| Гранулярность вызова | Весь батч | Одно значение | Весь батч (вектор) |
| Скорость | Максимальная | Низкая | Близка к встроенной |
| Когда применять | Всегда, если есть | Простая логика, малые данные | Python-логика на больших данных |
Не оборачивайте в Python UDF то, что DuckDB уже умеет сам. Арифметика, конкатенация строк, работа с датами, регулярные выражения, JSON — для всего этого есть быстрые встроенные функции. UDF на чистом SQL-выражении — это потеря производительности без всякой выгоды. Сначала проверьте, нет ли встроенной функции или возможности выразить логику обычным SQL; UDF берите только когда нужна именно Python-логика или Python-библиотека.
Если логику можно выразить SQL-выражением, но хочется переиспользуемое именованное выражение — это не повод для UDF. Используйте CREATE MACRO: макрос подставляется в запрос как SQL-выражение и исполняется векторизованным движком на полной скорости, в отличие от Python UDF. Python UDF нужны именно тогда, когда требуется выполнить Python-код, а не переиспользовать SQL.
Как это собирается с остальным модулем
Подведём черту под Python-экосистемой DuckDB. Replacement scan и zero-copy (уроки 1-2) дают автоматический бесшовный мост данных. Relational API (урок 3) — построение запросов методами Python. Ингест и экспорт (урок 4) — явные методы переноса данных. Инструменты (урок 5) — DuckDB как встроенный движок. И этот урок — ручное управление: register для явной привязки данных, UDF для внесения Python-кода в SQL.
Вместе это делает границу между SQL-миром и Python-миром почти прозрачной в обе стороны: данные ходят туда-сюда без копий, запросы строятся хоть строкой, хоть методами, а недостающую функциональность SQL можно дописать на Python. DuckDB не «ещё одна библиотека рядом с Pandas» — это SQL-движок, вплавленный в Python data-стек.
Попробуй сам
Для задания: pip install duckdb pandas pyarrow.
- Создайте DataFrame как элемент списка (без отдельной именованной переменной). Убедитесь, что replacement scan его не видит. Затем привяжите через
con.register("data", список[0])и запросите. Снимите привязку черезunregister. - Напишите простую Python-функцию (например классификацию числа на «low/mid/high»), зарегистрируйте через
create_functionи вызовите в SQL-запросе над таблицей. - Сгенерируйте таблицу на несколько миллионов строк. Примените к колонке построчную Python UDF и измерьте время. Затем сделайте то же самое встроенным SQL-выражением (если логика выразима) и сравните. Прочувствуйте разницу в скорости.
- Сформулируйте для себя правило выбора: когда нужна Python UDF, когда хватит встроенной функции, а когда лучше CREATE MACRO. Опирайтесь на то, нужен ли именно Python-код и каков объём данных.
DataFusion: UDF на Rust — векторизованные функции без оверхеда