Python API — главный клиент: connect, execute, fetch в df/arrow/pl
CLI хорош для разведки, но реальная инженерная работа с DuckDB происходит из Python. Пайплайны, ноутбуки, скрипты трансформации, аналитические модули приложений — всё это Python-код, и DuckDB встроен в него как библиотека. Python API — главный клиент DuckDB, и его стоит знать досконально.
В этом уроке мы разберём три кита Python API: как подключаться (connect), как исполнять запросы (sql и execute), и как материализовать результат в нужный формат — pandas DataFrame, Polars, Arrow. Последнее особенно важно: правильный выбор формата результата напрямую влияет на производительность пайплайна. Версия — DuckDB 1.5.2.
Стоит сразу задать правильную рамку. Python API — это не «способ запускать SQL из Python», а полноценный интерфейс встраивания движка в Python-программу. DuckDB в Python ведёт себя как часть процесса: он видит память, обменивается данными с pandas и Polars без копирования, встраивается в поток обработки данных. Поэтому уроку важно не просто перечислить методы, а показать, как Python-код и движок работают как единое целое.
Подключение: connect и неявное соединение
Базовый способ работы — создать объект соединения через duckdb.connect():
import duckdb
# in-memory: данные в RAM, исчезнут с процессом
con = duckdb.connect()
# persistent: данные в файл на диске
con = duckdb.connect("analytics.duckdb")
Объект con — это соединение (connection). Через него вы исполняете запросы, и он держит состояние сессии: открытую базу, временные таблицы, настройки. Соединение стоит закрывать, когда оно больше не нужно — либо явно con.close(), либо через контекстный менеджер:
with duckdb.connect("analytics.duckdb") as con:
con.sql("CREATE TABLE t AS SELECT 1 AS x")
print(con.sql("SELECT * FROM t").fetchall())
# соединение закрыто автоматически на выходе из with
Помимо явного соединения есть неявное: функции duckdb.sql() и duckdb.execute() без объекта con работают с глобальным in-memory соединением модуля. Это удобно для быстрых разовых запросов:
import duckdb
print(duckdb.sql("SELECT 'привет' AS msg").fetchall())
# [('привет',)]
Для разового запроса в ноутбуке — неявное соединение норма. Для пайплайна, где важен контроль над жизненным циклом и состоянием, — создавайте явный con. Разница не косметическая: неявное соединение — это единый глобальный объект модуля, и состояние (временные таблицы, настройки) у него общее на весь процесс. В большом коде это может приводить к неожиданному переплетению состояния между разными частями программы. Явный con делает границы видимыми: вы точно знаете, какое соединение держит какие данные, когда оно создано и когда закрыто.
Важная деталь про потоки. Объект соединения хранит состояние сессии, и если в коде есть многопоточность, безопаснее, чтобы каждый поток работал со своим соединением, а не делил один объект con между потоками. DuckDB позволяет создать дополнительное соединение к той же базе методом con.cursor() — это даёт второй независимый объект соединения, разделяющий с исходным ту же базу данных. Это не противоречит модели «один писатель»: cursor() создаёт соединения внутри одного процесса, а ограничение «один писатель» — про разные процессы. Внутри процесса несколько соединений к одной базе — штатная ситуация.
import duckdb
con = duckdb.connect("analytics.duckdb")
# отдельное соединение к той же базе для другого потока
worker_con = con.cursor()
Это связывает воедино две вещи из модуля: параллелизм у DuckDB работает внутри процесса, и соединения тоже масштабируются внутри процесса — а не через много процессов. Отдельно подчеркнём: один запрос DuckDB и так распараллеливается между потоками сам, без всякого cursor(). Дополнительные соединения через cursor() нужны не для ускорения одного запроса, а для случая, когда ваш собственный многопоточный код хочет вести несколько независимых запросов одновременно.
Исполнение: sql против execute
У соединения два основных метода исполнения запроса — sql() и execute(). Разница тонкая, но важная.
con.sql(query) возвращает объект relation — отложенное представление запроса. Запрос не обязательно выполняется немедленно; relation можно строить дальше, и материализуется он при обращении к результату. sql() удобен для интерактивной работы и построения запросов.
con.execute(query) исполняет запрос и нацелен на императивный стиль: он принимает параметры запроса (для безопасной подстановки значений) и хорошо ложится в код, где запросы выполняются как команды.
Главное, что нужно усвоить про параметры: значения в запрос подставляют не форматированием строк, а через параметры — это защищает от SQL-инъекций и от ошибок экранирования:
con = duckdb.connect()
con.execute("CREATE TABLE users(id INTEGER, name VARCHAR)")
con.execute("INSERT INTO users VALUES (1, 'Анна'), (2, 'Борис'), (3, 'Анна')")
# ПРАВИЛЬНО: значение через параметр (знак ?)
target = "Анна"
rows = con.execute("SELECT id FROM users WHERE name = ?", [target]).fetchall()
print(rows)
# [(1,), (3,)]
Параметр ? заполняется значением из списка [target]. Никогда не вставляйте значения через f-строки в SQL — это классическая уязвимость.
Не формируйте SQL конкатенацией или f-строками с пользовательскими значениями: f"... WHERE name = '{user_input}'" — это уязвимость к SQL-инъекции и источник ошибок с кавычками. Всегда используйте параметры запроса: знак ? в тексте и список значений вторым аргументом execute().
Полезно понимать, чем параметры лучше форматирования строк не только с точки зрения безопасности. Когда значение приходит как параметр, движок получает его отдельно от текста запроса — как типизированное значение, а не как кусок SQL-текста. Это снимает целый класс проблем: не нужно вручную экранировать кавычки в строках, не нужно думать о форматировании дат и чисел, значение NULL передаётся как NULL, а не как строка «None». Текст запроса остаётся неизменным независимо от значений — меняются только подставляемые параметры. Для движка это к тому же возможность переиспользовать разобранный запрос с разными параметрами. Так что параметры — это не «защитная мера на всякий случай», а правильный способ передавать значения в SQL вообще.
Материализация результата: df, arrow, pl и другие
Запрос выполнен — теперь результат нужно получить в Python в каком-то виде. У DuckDB богатый набор методов материализации, и выбор между ними влияет на производительность. Разберём основные.
.fetchall() — список кортежей Python. Прост, но для большого результата неэффективен: каждое значение оборачивается в Python-объект.
.fetchone() — один кортеж (следующая строка). Для построчного перебора.
.df() — результат как pandas DataFrame. Самый частый выбор, когда дальше работа идёт в pandas.
.pl() — результат как Polars DataFrame. Когда дальше работа в Polars.
.arrow() — результат как Apache Arrow Table. Колоночный формат, ключевой для zero-copy обмена между инструментами.
.fetchnumpy() — результат как словарь NumPy-массивов.
import duckdb
con = duckdb.connect()
con.execute("CREATE TABLE sales AS SELECT * FROM range(1, 6) AS r(id)")
rel = con.sql("SELECT id, id * 100 AS amount FROM sales")
# как pandas DataFrame
df = rel.df()
print(type(df).__name__, df.shape)
# DataFrame (5, 2)
# как Arrow Table
at = rel.arrow()
print(type(at).__name__, at.num_rows)
# Table 5
# как список кортежей
print(rel.fetchall())
# [(1, 100), (2, 200), (3, 300), (4, 400), (5, 500)]
| Метод | Возвращает | Когда применять |
|---|---|---|
.fetchall() | Список кортежей | Маленький результат, простой перебор |
.fetchone() | Один кортеж | Построчная обработка |
.df() | pandas DataFrame | Дальше работа в pandas |
.pl() | Polars DataFrame | Дальше работа в Polars |
.arrow() | Arrow Table | Zero-copy обмен между инструментами |
.fetchnumpy() | Словарь NumPy-массивов | Дальше численные вычисления в NumPy |
Отдельно стоит сказать про объект relation, который возвращает con.sql(). Это не «уже посчитанный результат», а отложенное представление запроса. Relation можно строить дальше — добавлять к нему фильтры, проекции, агрегации методами relational API — и реальное исполнение откладывается до момента, когда вы запрашиваете результат конкретным методом (.df(), .arrow(), .fetchall()). Это значит, что вызов con.sql("SELECT ...") сам по себе дёшев: тяжёлая работа происходит при материализации. Практическое следствие: если вы вызвали con.sql() и не обратились к результату, запрос мог и не исполниться полностью — и наоборот, обращение к relation дважды может означать двойную работу, если результат не закэширован. Глубоко relational API курс разбирает в модуле про Python-стек; здесь достаточно понимать, что sql() строит план, а методы материализации его исполняют.
Почему формат результата влияет на скорость
Это ключевая мысль урока. DuckDB внутри хранит и обрабатывает данные поколоночно (columnar). Разные форматы результата по-разному «дружат» с этим внутренним представлением.
.fetchall() строит список кортежей: каждое значение становится отдельным Python-объектом, и колоночные данные «переворачиваются» в построчные кортежи. Для миллиона строк это миллионы созданных объектов — медленно и расходно по памяти.
.arrow() отдаёт результат в формате Arrow — а Arrow тоже колоночный. Внутреннее представление DuckDB и формат Arrow устроены близко, поэтому передача результата в Arrow максимально дешева: минимум преобразований, в идеале zero-copy. То же относится к .pl() — Polars построен на Arrow.
Практический вывод. Для маленького результата (несколько строк) выбор формата не важен. Для большого результата избегайте .fetchall() и тяготейте к колоночным форматам — .arrow(), .pl(), .df(). Если дальше по пайплайну данные всё равно идут в Arrow-совместимый инструмент, .arrow() будет самым быстрым мостом. Глубоко zero-copy interop через Arrow курс разбирает в модуле про Python-стек; здесь важно одно правило: формат результата выбирают под то, куда данные пойдут дальше, и колоночные форматы дешевле построчных.
Поток данных в обе стороны
До сих пор мы говорили о результате — как данные выходят из DuckDB в Python. Но симметрия работает и в другую сторону: данные так же легко входят в DuckDB из Python. И механизм тот же — близость к колоночному представлению.
Python-клиент умеет читать pandas DataFrame, Polars DataFrame и Arrow Table прямо как источник для SQL. Причём для pandas/Polars/Arrow-объекта, доступного в области видимости Python, ничего регистрировать не нужно — DuckDB видит переменную по имени и запрашивает её как таблицу:
import duckdb
import pandas as pd
orders = pd.DataFrame({"id": [1, 2, 3], "amount": [100, 200, 50]})
# DataFrame orders запрашивается прямо в SQL по имени переменной
total = duckdb.sql("SELECT sum(amount) AS total FROM orders").fetchone()
print(total)
# (350,)
Этот механизм называется replacement scans, и подробно курс разбирает его в модуле про Python-стек. Здесь важна одна мысль: вход и выход данных в DuckDB устроены симметрично и оба дёшевы, потому что внутреннее представление движка, формат Arrow и колоночные DataFrame устроены близко. DuckDB в Python — это не «отдельная база, в которую надо грузить данные», а движок, который встраивается в поток данных Python: берёт DataFrame на входе, отдаёт DataFrame на выходе, и колоночность делает оба перехода почти бесплатными.
Из этого вытекает типичный паттерн использования. В пайплайне DuckDB ставят как звено: на входе — данные из файлов или из DataFrame, дальше — тяжёлая SQL-трансформация в движке (где он быстрее pandas за счёт векторизации и оптимизатора), на выходе — DataFrame для дальнейшей работы. DuckDB не вытесняет pandas/Polars из пайплайна — он берёт на себя именно ту часть, где нужен быстрый SQL над большими данными.
Если в коде на pandas есть медленный шаг — тяжёлая агрегация, соединение больших таблиц, сложная группировка — попробуйте вынести именно этот шаг в duckdb.sql(), запросив DataFrame напрямую. Часто такой точечный перенос одной операции в DuckDB ускоряет узкое место в разы, не требуя переписывать весь пайплайн. Замеры DuckDB против pandas курс делает в капстоуне.
Попробуй сам
Сравните форматы результата на ощутимом объёме данных.
- Создайте соединение и сгенерируйте таблицу побольше:
con.execute("CREATE TABLE big AS SELECT i AS id, i * 1.5 AS val FROM range(2_000_000) AS r(i)"). - Замерьте время материализации одного и того же запроса
SELECT * FROM bigв разные форматы. Оберните каждый вызов в замер времени (time.perf_counter()до и после): отдельно.fetchall(), отдельно.df(), отдельно.arrow(). - Сравните три числа. Обратите внимание, насколько
.fetchall()отстаёт от.arrow()на двух миллионах строк — и свяжите это с тем, что.fetchall()создаёт миллионы Python-объектов, а.arrow()остаётся в колоночном представлении. - Отдельно потренируйте параметры: вставьте в таблицу несколько строк и сделайте
SELECTс условием через параметр?. Убедитесь, что значение подставляется правильно и без f-строк.
Этот замер закрепит главное правило урока: на больших результатах формат материализации — это вопрос производительности, а не вкуса.
PyArrow: что такое Arrow и зачем он нужен в Python DataFusion Python API: аналогичный embedded-паттерн