Ингест и экспорт: from_df, to_df, write_parquet
Аналитический пайплайн на Python постоянно перемещает данные через границу между Python-объектами и DuckDB: что-то приходит из DataFrame в SQL-запрос, результат уходит обратно в DataFrame или в файл. В прошлых уроках мы разобрали механику этого перемещения — replacement scan и zero-copy. Этот урок практический: какими конкретно методами загружать данные в DuckDB (ингест) и забирать результат (экспорт), чем эти методы отличаются и когда что применять.
Две формы материализации результата
Запрос в DuckDB возвращает Relation — ленивое описание (см. прошлый урок). Чтобы получить из него данные, есть два принципиально разных направления: забрать результат в память Python как объект или записать его в файл на диске.
Экспорт в память Python: df, arrow, pl, fetch
У результата запроса есть набор методов «забрать в Python», и выбор между ними — это выбор формата объекта-получателя.
.df() возвращает Pandas DataFrame. Историческая основа Python-аналитики; нужен, если дальше работаете с Pandas или с библиотекой, ожидающей Pandas.
.pl() возвращает Polars DataFrame. Современная быстрая колоночная библиотека; данные передаются zero-copy, потому что Polars нативно на Arrow.
.arrow() возвращает PyArrow Table — результат как чистая Arrow-структура. Самый прямой формат: ноль конвертаций, удобен как мост к другим Arrow-совместимым системам.
.fetchall() возвращает список кортежей Python — обычные питоновские строки. Подходит для небольших результатов и итерации в чистом Python без DataFrame-библиотек. .fetchone() берёт одну строку, .fetchnumpy() — словарь NumPy-массивов по колонкам.
import duckdb
con = duckdb.connect()
con.sql("CREATE TABLE sales AS SELECT * FROM 'sales.parquet'")
q = con.sql("SELECT region, SUM(amount) AS total FROM sales GROUP BY region")
df_pandas = q.df() # Pandas DataFrame
df_polars = q.pl() # Polars DataFrame
tbl_arrow = q.arrow() # PyArrow Table
rows = q.fetchall() # список кортежей: [('EU', 12400), ('US', 9800), ...]
| Метод | Возвращает | Когда применять |
|---|---|---|
.df() | Pandas DataFrame | Дальнейшая работа в Pandas |
.pl() | Polars DataFrame | Дальнейшая работа в Polars, zero-copy |
.arrow() | PyArrow Table | Мост к Arrow-системам, минимум конвертаций |
.fetchall() | Список кортежей | Малые результаты, чистый Python |
.fetchnumpy() | Словарь NumPy-массивов | Численные расчёты, ML-препроцессинг |
Все эти методы материализуют ВЕСЬ результат в память Python целиком. Если запрос возвращает датасет, который сам по себе больше доступной RAM, .df() или .arrow() приведут к нехватке памяти — даже если DuckDB сам обработал бы такой объём через спилл на диск. Для огромных результатов не забирайте их в Python целиком: пишите сразу в файл через write_parquet или итерируйте порциями. Размер результата — не размер обработанных данных.
Экспорт в файл: write_parquet, write_csv, COPY
Второе направление — сохранить результат на диск. У Relation есть методы записи:
.write_parquet("file.parquet") — записать результат в Parquet. Это предпочтительный формат для данных: сжатый, колоночный, с footer-статистикой, которую потом используют projection и filter pushdown.
.write_csv("file.csv") — записать в CSV. Нужен для совместимости с инструментами, которые читают только CSV; для данных как таковых хуже Parquet.
Через файл проходят результаты, которые слишком велики для памяти, и результаты, которые должны жить дольше сессии — стать входом следующего шага пайплайна.
import duckdb
con = duckdb.connect()
con.sql("CREATE TABLE sales AS SELECT * FROM 'sales.parquet'")
# Записать результат запроса прямо в Parquet, не материализуя в Python
(con.sql("SELECT region, SUM(amount) AS total FROM sales GROUP BY region")
.write_parquet("region_totals.parquet"))
Из SQL то же самое делает команда COPY — она универсальнее: партиционированная запись, выбор кодека сжатия, формат JSON. COPY детально разбирается в модуле про запись данных; в Python-контексте достаточно знать, что .write_parquet() — это её удобная обёртка для частого случая.
# COPY из Python даёт полный контроль над форматом
con.sql("""
COPY (SELECT * FROM sales)
TO 'export' (FORMAT parquet, PARTITION_BY (region))
""")
Ингест: загрузка данных в DuckDB
Теперь обратное направление — данные из Python в DuckDB. Здесь надо различать два сценария, и разница принципиальная.
Сценарий 1: просто запросить DataFrame. Если нужно выполнить SQL над DataFrame и больше ничего — никакой явной загрузки не требуется. Работает replacement scan: переменная видна по имени (урок 1), данные читаются zero-copy (урок 2). Это не «загрузка» в полном смысле — DuckDB читает DataFrame на месте.
import duckdb
import pandas as pd
orders = pd.DataFrame({"id": [1, 2, 3], "amount": [100, 200, 150]})
# Никакой загрузки — replacement scan читает orders прямо из памяти
duckdb.sql("SELECT SUM(amount) FROM orders")
Сценарий 2: скопировать данные в нативную таблицу DuckDB. Иногда DataFrame нужно именно скопировать внутрь DuckDB — в её собственную таблицу с её колоночным хранением, сжатием и zonemap. Это оправдано, когда: одни и те же данные запрашиваются многократно (нативная таблица быстрее повторного чтения DataFrame); данные должны быть персистентными (сохраниться в файл базы); по таблице нужны индексы или ограничения.
Делается это через CREATE TABLE ... AS SELECT поверх DataFrame:
import duckdb
import pandas as pd
con = duckdb.connect("warehouse.duckdb")
orders = pd.DataFrame({"id": [1, 2, 3], "amount": [100, 200, 150]})
# Скопировать DataFrame в постоянную нативную таблицу DuckDB
con.sql("CREATE TABLE orders_native AS SELECT * FROM orders")
Теперь orders_native — настоящая таблица: она в файле базы, она колоночно сжата, она переживёт перезапуск. Существуют и вспомогательные конструкторы вроде con.from_df(dataframe) — они оборачивают DataFrame в Relation; за from_ стоит та же механика чтения, что у replacement scan.
Не копируйте DataFrame в нативную таблицу по привычке. Если данные используются один раз — replacement scan быстрее: вы экономите и время на копирование, и память на дубликат. CREATE TABLE AS SELECT оправдан именно при повторном использовании или потребности в персистентности. Лишнее копирование — частая необязательная трата в Python-пайплайнах с DuckDB.
Полный цикл: туда и обратно
Соберём картину в один пайплайн — типичный паттерн работы с DuckDB в Python:
import duckdb
import pandas as pd
# 1. Данные приходят из Python (например, собраны из API)
raw = pd.DataFrame({
"city": ["Berlin", "Lyon", "Berlin", "Lyon"],
"revenue": [1200, 800, 1500, 950],
})
# 2. Тяжёлая агрегация — в DuckDB через SQL (replacement scan, zero-copy вход)
agg = duckdb.sql("""
SELECT city, SUM(revenue) AS total, AVG(revenue) AS avg_rev
FROM raw GROUP BY city
""")
# 3. Результат — обратно в Pandas для дальнейшей работы
result_df = agg.df()
# 4. И параллельно — на диск в Parquet как артефакт пайплайна
agg.write_parquet("city_revenue.parquet")
Данные свободно перетекают: Python -> DuckDB (вход zero-copy) -> Python (выход) и -> файл. Каждый шаг использует лучший инструмент: сбор данных — Python, тяжёлый агрегат — SQL-движок DuckDB, дальнейшая работа — снова Python, долговременное хранение — Parquet. Именно эта лёгкость переходов делает DuckDB естественным звеном Python data-стека.
Попробуй сам
Для задания: pip install duckdb pandas polars pyarrow.
- Выполните один запрос и заберите результат всеми четырьмя способами:
.df(),.pl(),.arrow(),.fetchall(). Сравните типы получившихся объектов и то, как они печатаются. - Запишите результат запроса в Parquet через
.write_parquet(). Затем прочитайте этот файл обратно в DuckDB и убедитесь, что данные совпали. - Возьмите DataFrame. Сценарий А: просто запросите его через replacement scan. Сценарий Б: скопируйте в нативную таблицу через
CREATE TABLE AS SELECTи запросите её. Прогоните один и тот же запрос 100 раз в каждом сценарии и сравните суммарное время. - Сформулируйте для себя: когда оправдано копировать DataFrame в нативную таблицу DuckDB, а когда это лишняя трата времени и памяти.