Learning Platform
Глоссарий Troubleshooting
Урок 03.02 · 24 мин
Средний
pythonapidataframearrow

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 делает границы видимыми: вы точно знаете, какое соединение держит какие данные, когда оно создано и когда закрыто.

Соединение как точка входа в DuckDB
duckdb.connect()Создаёт объект соединения: in-memory без аргумента, persistent с именем файла. Держит состояние сессии
даёт
Объект conСоединение: через него исполняются запросы, оно хранит открытую базу, временные таблицы и настройки
исполняет
SQL-запросыcon.sql() и con.execute() отправляют SQL движку и возвращают результат

Важная деталь про потоки. Объект соединения хранит состояние сессии, и если в коде есть многопоточность, безопаснее, чтобы каждый поток работал со своим соединением, а не делил один объект 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 — это классическая уязвимость.

WARNING

Не формируйте 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 TableZero-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.

Формат результата и внутреннее представление DuckDB
Внутри DuckDB: колонкиДвижок хранит и обрабатывает данные поколоночно — векторами значений
fetchall: переворот в строки
Список кортежейКолоночные данные превращаются в построчные кортежи Python-объектов: для большого результата медленно
Внутри DuckDB: колонкиТо же колоночное внутреннее представление движка
arrow/pl: колонки в колонки
Arrow / PolarsArrow тоже колоночный, представление близко к внутреннему — передача дешёвая, в идеале zero-copy

Практический вывод. Для маленького результата (несколько строк) выбор формата не важен. Для большого результата избегайте .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 над большими данными.

TIP

Если в коде на pandas есть медленный шаг — тяжёлая агрегация, соединение больших таблиц, сложная группировка — попробуйте вынести именно этот шаг в duckdb.sql(), запросив DataFrame напрямую. Часто такой точечный перенос одной операции в DuckDB ускоряет узкое место в разы, не требуя переписывать весь пайплайн. Замеры DuckDB против pandas курс делает в капстоуне.


Попробуй сам

Сравните форматы результата на ощутимом объёме данных.

  1. Создайте соединение и сгенерируйте таблицу побольше: con.execute("CREATE TABLE big AS SELECT i AS id, i * 1.5 AS val FROM range(2_000_000) AS r(i)").
  2. Замерьте время материализации одного и того же запроса SELECT * FROM big в разные форматы. Оберните каждый вызов в замер времени (time.perf_counter() до и после): отдельно .fetchall(), отдельно .df(), отдельно .arrow().
  3. Сравните три числа. Обратите внимание, насколько .fetchall() отстаёт от .arrow() на двух миллионах строк — и свяжите это с тем, что .fetchall() создаёт миллионы Python-объектов, а .arrow() остаётся в колоночном представлении.
  4. Отдельно потренируйте параметры: вставьте в таблицу несколько строк и сделайте SELECT с условием через параметр ?. Убедитесь, что значение подставляется правильно и без f-строк.

Этот замер закрепит главное правило урока: на больших результатах формат материализации — это вопрос производительности, а не вкуса.

PyArrow: что такое Arrow и зачем он нужен в Python DataFusion Python API: аналогичный embedded-паттерн
Проверка знанийKnowledge check
Почему материализация большого результата через .arrow() обычно быстрее, чем через .fetchall(), и как это связано с внутренним устройством DuckDB?
ОтветAnswer
DuckDB внутри хранит и обрабатывает данные поколоночно (columnar) — векторами значений одного типа. Скорость материализации результата зависит от того, насколько целевой формат близок к этому внутреннему представлению. Метод .fetchall() строит список кортежей Python: колоночные данные приходится «перевернуть» в построчные кортежи, и каждое значение обернуть в отдельный Python-объект. Для большого результата это миллионы созданных объектов и переворот раскладки — операция дорогая по времени и памяти. Метод .arrow() отдаёт результат в формате Apache Arrow, который тоже колоночный и устроен близко к внутреннему представлению DuckDB. Поэтому передача в Arrow требует минимума преобразований, в идеале вообще без копирования данных (zero-copy): колонки движка отображаются в колонки Arrow почти напрямую. То же касается .pl(), потому что Polars построен на Arrow. Практический вывод: для маленького результата формат не важен, но для большого нужно избегать построчного .fetchall() и выбирать колоночные форматы — .arrow(), .pl(), .df() — а если данные дальше идут в Arrow-совместимый инструмент, .arrow() будет самым дешёвым мостом.

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

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Как правильно подставить пользовательское значение в SQL-запрос через Python-клиент DuckDB?

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

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

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

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