Все материалы
Продуктовая аналитикагайдсредний

DuckDB для аналитика: SQL по CSV и Parquet без сервера

Что такое DuckDB и как выполнять SQL по CSV, Parquet и pandas DataFrame без сервера: примеры в Python, задача от выгрузки до ответа, отличия от PostgreSQL и ограничения.

КейсПрактика2 октября 2026 г.14 мин

DuckDB — встраиваемая аналитическая СУБД: она работает внутри вашей программы, хранит базу в одном файле или в памяти и не требует сервера. Аналитику она нужна, когда данные лежат файлами или в таблице pandas, а считать удобнее на SQL: запрос SELECT … FROM 'users.csv' выполняется сразу, без загрузки в базу. Ниже — запросы к CSV, Parquet и DataFrame, одна задача от выгрузки до ответа, отличия синтаксиса от PostgreSQL и случаи, когда DuckDB не подходит. Примеры выполнены на учебной базе симулятора SQL-аналитика: Python-блоки — на DuckDB 1.4.1 и pandas 2.3, SQL перепроверен в DuckDB 1.5.4 (движок песочницы курса) и PostgreSQL 14.

Что такое DuckDB и когда он нужен аналитику

Привычная СУБД — отдельная программа-сервер: её ставят, настраивают, к ней подключаются по сети. DuckDB устроен как библиотека. Страница «Why DuckDB» официального сайта перечисляет его свойства: работает целиком внутри процесса-хозяина, не имеет внешних зависимостей, обрабатывает данные по столбцам и пакетами значений, распространяется по лицензии MIT. В Python он ставится командой pip install duckdb.

Отсюда три рабочие ситуации. Вам прислали выгрузку на миллионы строк, и её нужно быстро посчитать. Данные уже лежат в DataFrame, а соединение трёх таблиц с оконной функцией проще написать на SQL. Или нужен разовый расчёт на ноутбуке, ради которого не хочется поднимать PostgreSQL.

Минимальный пример — ниже. В статье файлы получены из учебной базы командой COPY … TO: она пишет таблицу users в CSV, а следующий запрос читает файл. Самой базы у вас на диске нет, а песочница курса файлов не пишет, поэтому блоки с COPY показывают, откуда взялся файл; чтобы повторить приём, подставьте в FROM свою выгрузку. Файлы в примерах лежат в /tmp; на Windows подставьте свой путь.

Схема: слева файлы CSV и Parquet и таблица DataFrame, в центре DuckDB выполняет SQL-запрос, справа результат — таблица, DataFrame или новый файл
DuckDB читает файлы и таблицы в памяти той же программы, где вы пишете запрос. Отдельного сервера нет.
DuckDB: выгрузить таблицу в CSV и сразу спросить файл
COPY (SELECT * FROM users ORDER BY user_id)
  TO '/tmp/mayak_users.csv' (HEADER);

SELECT channel, count(*) AS users
FROM '/tmp/mayak_users.csv'
GROUP BY channel
ORDER BY users DESC;

-- organic     | 1747
-- referral    | 1037
-- paid_search | 1015
-- partner     |  814

Как выполнить SQL-запрос к CSV без загрузки в базу

Путь к файлу в FROM — сокращение для функции read_csv. Она сама определяет разделитель, строку заголовков и типы столбцов. По документации (раздел «CSV Auto Detection») типы подбираются по выборке, по умолчанию это 20 480 строк, от строгих к общим: логический, дата и время, BIGINT, DOUBLE, VARCHAR.

Что получилось, показывает DESCRIBE. Дата оплаты распознана как DATE, но два типа изменились: целые INTEGER стали BIGINT, а сумма DECIMAL(10,2) превратилась в DOUBLE. CSV типов не хранит, их каждый раз угадывают заново. Для денег это существенно: DOUBLE хранит дроби приближённо, 0.1 + 0.2 в нём равно 0.30000000000000004.

Тип можно задать явно: read_csv('/tmp/mayak_payments.csv', types = {'amount': 'DECIMAL(10,2)'}). Остальные столбцы по-прежнему определятся сами.

Без Python то же делает командная строка — отдельная программа duckdb. По документации («Command Line Client») запуск без аргументов открывает базу в памяти, duckdb mayak.duckdb — файл базы, duckdb -c "SELECT …" выполняет одну команду и завершается, а флаг -csv переключает вывод в CSV. Запрос к файлу одной строкой терминала: duckdb -csv -c "SELECT channel, count(*) FROM '/tmp/mayak_users.csv' GROUP BY channel".

Типы столбцов payments: в базе, после CSV и после Parquet
СтолбецВ базеИз CSVИз Parquet
payment_idINTEGERBIGINTINTEGER
paid_atDATEDATEDATE
amountDECIMAL(10,2)DOUBLEDECIMAL(10,2)
planVARCHARVARCHARVARCHAR
DuckDB: какие типы распознаны в CSV
COPY (SELECT * FROM payments ORDER BY payment_id)
  TO '/tmp/mayak_payments.csv' (HEADER);

SELECT column_name, column_type
FROM (DESCRIBE SELECT * FROM read_csv('/tmp/mayak_payments.csv'));

Чем Parquet лучше CSV для аналитического запроса

Parquet — двоичный формат, в котором данные лежат по столбцам, сжаты и подписаны типами. Таблица событий учебной базы, 35 341 строка, занимает 1,4 МБ в CSV и около 0,4 МБ в Parquet, а типы после чтения совпадают с исходными.

Второе отличие — сколько приходится читать. По документации DuckDB («Reading and Writing Parquet Files») из файла берутся только столбцы, нужные запросу, а условие фильтра передаётся в чтение и позволяет пропускать части файла. В CSV ради одного столбца нужно разобрать каждую строку.

Чтобы увидеть разницу, мы сгенерировали 10 млн строк через range(), записали их в оба формата и посчитали число строк и сумму по четырём каналам. Замер сделан на ноутбуке с десятью ядрами и 16 ГБ памяти. DuckDB читал файл всеми ядрами, взят лучший из трёх запусков; pandas 2.3 без pyarrow читает CSV в один поток, запуск один. Строки таблицы сравнивают форматы и способы чтения на одной машине. Это порядок величин: коэффициент «DuckDB против pandas» из них выводить нельзя, на другом железе и других данных соотношение будет иным.

10 млн строк, один GROUP BY по каналу: что измерено
Чем считалиФайлВремя
DuckDBCSV, 407 МБоколо 0,25 с
DuckDBParquet, 107 МБоколо 0,01 с
pandas: read_csv двух столбцов и groupbyCSV, 407 МБоколо 2 с
DuckDB: записать события в Parquet и посчитать по файлу
COPY (SELECT * FROM events ORDER BY event_id)
  TO '/tmp/mayak_events.parquet' (FORMAT parquet);

SELECT event_name, count(*) AS events
FROM read_parquet('/tmp/mayak_events.parquet')
GROUP BY event_name
ORDER BY events DESC;

-- app_open          | 28693
-- workspace_created |  2750
-- report_created    |  1775
-- invite_sent       |  1135
-- export_completed  |   988

DuckDB в Python: SQL-запрос к pandas DataFrame

В Python DuckDB видит DataFrame как таблицу: достаточно назвать в FROM имя переменной. Копировать данные в базу не нужно, документация называет этот механизм replacement scan. Метод .df() возвращает результат обратно в pandas, поэтому SQL встраивается в середину обычного ноутбука.

Так решается вопрос «как писать SQL в pandas». Соединения, оконные функции и условные агрегаты многим проще читать в SQL; сводные таблицы, графики и запись в Excel остаются за pandas. Переключаться можно на каждом шаге.

duckdb.sql(…) работает с базой в памяти: закрыли ноутбук — таблицы исчезли. Чтобы они пережили сессию, откройте файл: con = duckdb.connect('mayak.duckdb'), дальше con.sql(…).

С pandas 3 нужен DuckDB не старше 1.4.4. Более ранние версии не знают нового строкового типа pandas: на DataFrame с текстовым столбцом DuckDB 1.4.1 отвечает Data type 'str' not recognized. Помогает обновление DuckDB или приведение столбца: payments.astype({'plan': object}).

pythonPython: запрос к DataFrame по имени переменной и результат обратно в pandas
import duckdb
import pandas as pd

payments = pd.DataFrame({
    'user_id': [1, 1, 2, 3, 3, 3, 4],
    'plan':    ['basic', 'basic', 'pro', 'team', 'team', 'team', 'basic'],
    'amount':  [19, 19, 29, 39, 39, 39, 19],
})

# DataFrame виден запросу по имени переменной
by_plan = duckdb.sql("""
    SELECT plan,
           count(DISTINCT user_id)     AS payers,
           CAST(sum(amount) AS BIGINT) AS revenue  -- без CAST сумма придёт в pandas дробной: 117.0
    FROM payments
    GROUP BY plan
    ORDER BY revenue DESC
""").df()   # .df() возвращает обычный pandas DataFrame

print(by_plan)
#     plan  payers  revenue
# 0   team       1      117
# 1  basic       2       57
# 2    pro       1       29
print(type(by_plan).__name__, int(by_plan.revenue.sum()), int(payments.amount.sum()))
# DataFrame 203 203

Задача целиком: конверсия в оплату по каналам из двух выгрузок

Теперь рабочая задача. Есть две выгрузки, users и payments; нужно узнать, какая доля зарегистрированных из каждого канала заплатила хотя бы раз. Запрос соединяет два файла так же, как соединял бы две таблицы.

Окно регистраций выбрано по данным. Платный поиск в этой базе запущен 1 июля, остальные каналы работают с 1 июня, а первая оплата приходит через 3–20 дней после регистрации. Поэтому берём регистрации с 1 июля по 9 августа: период у каналов общий, и у каждого пользователя до конца выгрузки прошло не меньше 20 дней.

Результат: referral — 31,4% (141 из 449), organic — 25,5%, partner — 21,3%, paid_search — 10,8% (67 из 621). Между крайними каналами 20,6 п. п., 95%-й интервал разницы ±4,9 п. п. Без условия на даты тот же запрос даёт 26,8%, 23,5%, 17,0% и 8,5%: в знаменатель попадают регистрации последних трёх недель, для которых срок первой оплаты ещё не вышел, а у трёх каналов — ещё и июнь, которого у платного поиска нет.

В pandas та же задача — два read_csv, фильтр и groupby; числа совпадают. Чтобы блок выполнялся без учебной базы, он сам пишет две мини-выгрузки с её итогами по каналам: те же 4 613 пользователей и 912 плательщиков в окне и вне его, а даты регистрации и суммы оплат в них условные. Обратите внимание на parse_dates: DuckDB распознал дату сам, pandas без этого параметра оставит её текстом. Последние строки блока показывают обратный ход — SQL по уже отфильтрованному DataFrame.

Конверсия в оплату по каналам: все регистрации и окно 1 июля – 9 августа, %

Учебная база симулятора SQL-аналитика. Без фильтра дат конверсия ниже у каждого канала: в расчёт входят регистрации, для которых срок первой оплаты ещё не истёк.

Все регистрации1 июля – 9 августа
DuckDB: конверсия в оплату по каналам из двух CSV-файлов
COPY (SELECT * FROM users ORDER BY user_id)
  TO '/tmp/mayak_users.csv' (HEADER);
COPY (SELECT * FROM payments ORDER BY payment_id)
  TO '/tmp/mayak_payments.csv' (HEADER);

SELECT
  u.channel,
  count(*)                                       AS users,
  count(p.user_id)                               AS payers,
  round(100.0 * count(p.user_id) / count(*), 1)  AS conversion_pct
FROM read_csv('/tmp/mayak_users.csv') AS u
LEFT JOIN (
  SELECT DISTINCT user_id FROM read_csv('/tmp/mayak_payments.csv')
) AS p ON p.user_id = u.user_id
WHERE u.signup_date >= DATE '2026-07-01'
  AND u.signup_date <  DATE '2026-08-10'
GROUP BY u.channel
ORDER BY conversion_pct DESC;

-- referral    | 449 | 141 | 31.4
-- organic     | 780 | 199 | 25.5
-- partner     | 315 |  67 | 21.3
-- paid_search | 621 |  67 | 10.8
pythonpandas: та же конверсия; блок сам создаёт мини-выгрузки с итогами учебной базы
import duckdb
import pandas as pd

# Мини-выгрузки с итогами учебной базы: регистрации и плательщики
# в окне 1 июля – 9 августа (in_*) и вне его (out_*)
duckdb.sql("""
    CREATE OR REPLACE TEMP TABLE totals AS
    SELECT * FROM (VALUES
        ('organic',     780, 199, 967, 211),
        ('paid_search', 621,  67, 394,  19),
        ('partner',     315,  67, 499,  71),
        ('referral',    449, 141, 588, 137)
    ) AS t(channel, in_users, in_payers, out_users, out_payers);

    CREATE OR REPLACE TEMP TABLE gen AS
    SELECT row_number() OVER (ORDER BY channel, signup_date, n) AS user_id, *
    FROM (
        SELECT channel, n, DATE '2026-07-01' + CAST(n % 40 AS INTEGER) AS signup_date,
               n < in_payers AS pays
        FROM totals, range(in_users) AS r(n)
        UNION ALL
        SELECT channel, n, DATE '2026-08-10' + CAST(n % 20 AS INTEGER), n < out_payers
        FROM totals, range(out_users) AS r(n)
    );

    COPY (SELECT user_id, channel, signup_date FROM gen ORDER BY user_id)
      TO '/tmp/mayak_mini_users.csv' (HEADER);
    COPY (SELECT user_id, 19 AS amount FROM gen WHERE pays ORDER BY user_id)
      TO '/tmp/mayak_mini_payments.csv' (HEADER);
""")

users = pd.read_csv('/tmp/mayak_mini_users.csv', parse_dates=['signup_date'])
payments = pd.read_csv('/tmp/mayak_mini_payments.csv')

mature = users[(users.signup_date >= '2026-07-01') & (users.signup_date < '2026-08-10')]
paid = mature.user_id.isin(payments.user_id)
out = paid.groupby(mature.channel).agg(users='size', payers='sum')
out['conversion_pct'] = (100 * out.payers / out.users).round(1)
print(out.sort_values('conversion_pct', ascending=False))
#              users  payers  conversion_pct
# channel
# referral       449     141            31.4
# organic        780     199            25.5
# partner        315      67            21.3
# paid_search    621      67            10.8

# тот же DataFrame можно спросить на SQL — число то же
print(duckdb.sql("""
    SELECT round(100.0 * count(*) FILTER (WHERE user_id IN (SELECT user_id FROM payments))
                 / count(*), 1)
    FROM mature
    WHERE channel = 'referral'
""").fetchone()[0])
# 31.4

Что в DuckDB короче, чем в PostgreSQL: GROUP BY ALL, median, arg_max

DuckDB понимает большую часть синтаксиса PostgreSQL и добавляет сокращения (часть из них собрана в разделе «Friendly SQL» документации). GROUP BY ALL группирует по всем столбцам SELECT, не обёрнутым в агрегат. median(x) считает медиану одной функцией. arg_max(a, b) возвращает a из строки, где b максимально. FILTER (WHERE …) считает агрегат по части строк; он есть и в PostgreSQL.

Запрос ниже сворачивает оплаты в дни и по каждому каналу считает дни с оплатами, медиану выручки в такие дни, лучший день и число дней с выручкой от $200. Это демонстрация синтаксиса, а не сравнение каналов: платный поиск стартовал на месяц позже, дней у него меньше.

В PostgreSQL каждое сокращение раскрывается: группировка — перечислением столбцов, медиана — percentile_cont(0.5) WITHIN GROUP, arg_max — первым элементом array_agg с сортировкой. NULLS LAST в ней нужен из-за пустых значений: arg_max их пропускает, а PostgreSQL при сортировке по убыванию ставит первыми. Второй блок выполнен в обоих движках и вернул одинаковые строки.

Ловушка — равные значения. У referral два дня с выручкой $231: 7 и 19 августа. arg_max(paid_at, revenue) без уточнения возвращает то один, то другой: в документации функция помечена как зависящая от порядка строк. Порядок задаётся внутри агрегата — ORDER BY paid_at закрепляет более ранний день. В PostgreSQL ту же роль играет второй ключ сортировки в array_agg: без него результат не гарантирован.

Результат: дневная выручка по каналам, $
channelpay_daysmedian_daybest_revenuebest_daydays_over_200
organic8316349222 августа26
paid_search464817528 августа0
partner745816329 июля0
referral811062317 августа5
DuckDB: дневная выручка по каналам с GROUP BY ALL, median, arg_max и FILTER
WITH daily AS (
  SELECT u.channel, p.paid_at, sum(p.amount) AS revenue
  FROM payments p
  JOIN users u USING (user_id)
  GROUP BY ALL
)
SELECT
  channel,
  count(*)                                   AS pay_days,
  median(revenue)                            AS median_day,
  max(revenue)                               AS best_revenue,
  arg_max(paid_at, revenue ORDER BY paid_at) AS best_day,
  count(*) FILTER (WHERE revenue >= 200)     AS days_over_200
FROM daily
GROUP BY ALL
ORDER BY channel;
sqlТо же без сокращений: выполняется в PostgreSQL и в DuckDB
WITH daily AS (
  SELECT u.channel, p.paid_at, SUM(p.amount) AS revenue
  FROM payments p
  JOIN users u ON u.user_id = p.user_id
  GROUP BY u.channel, p.paid_at
)
SELECT
  channel,
  COUNT(*)                                             AS pay_days,
  percentile_cont(0.5) WITHIN GROUP (ORDER BY revenue) AS median_day,
  MAX(revenue)                                         AS best_revenue,
  (array_agg(paid_at ORDER BY revenue DESC NULLS LAST, paid_at))[1] AS best_day,
  COUNT(*) FILTER (WHERE revenue >= 200)               AS days_over_200
FROM daily
GROUP BY channel
ORDER BY channel;

QUALIFY и SELECT * EXCLUDE: как убрать лишний подзапрос

QUALIFY фильтрует по результату оконной функции — так же, как HAVING фильтрует по агрегату. Типовая задача «первая оплата каждого пользователя» в стандартном SQL требует подзапроса: внутри посчитать ROW_NUMBER(), снаружи оставить строки с единицей. В PostgreSQL есть ещё DISTINCT ON, но это его собственное расширение, в стандарт оно не входит. В DuckDB условие пишется в том же запросе.

SELECT * EXCLUDE (…) возвращает все столбцы, кроме перечисленных. Это удобно, когда в таблице сорок столбцов, а лишних два. В PostgreSQL нужные столбцы придётся назвать по именам.

Оба блока возвращают одни и те же три строки: пользователи 6, 39 и 214, первая оплата $39 по тарифу team. Таблица собирает соответствия. Средний столбец — стандартная запись, которая работает в обоих движках; правый — что отвечает PostgreSQL 14 на сокращение.

Сокращения DuckDB и их запись для PostgreSQL
В DuckDBПереносимая записьPostgreSQL 14 на сокращение
GROUP BY ALLперечислить столбцы в GROUP BYсинтаксическая ошибка
median(x)percentile_cont(0.5) WITHIN GROUP (ORDER BY x)ошибка: функции нет
arg_max(a, b)(array_agg(a ORDER BY b DESC NULLS LAST, a))[1]ошибка: функции нет
QUALIFY условиеподзапрос и WHERE снаружисинтаксическая ошибка
SELECT * EXCLUDE (a)список нужных столбцовсинтаксическая ошибка
FILTER (WHERE …)та же записьработает
DuckDB: первая оплата каждого пользователя через QUALIFY и EXCLUDE
SELECT * EXCLUDE (payment_id, payment_type)
FROM payments
QUALIFY row_number() OVER (
  PARTITION BY user_id ORDER BY paid_at, payment_id
) = 1
ORDER BY amount DESC, user_id
LIMIT 3;
sqlТо же с подзапросом: выполняется в PostgreSQL и в DuckDB
SELECT user_id, paid_at, amount, plan
FROM (
  SELECT
    p.*,
    ROW_NUMBER() OVER (
      PARTITION BY user_id ORDER BY paid_at, payment_id
    ) AS rn
  FROM payments p
) AS numbered
WHERE rn = 1
ORDER BY amount DESC, user_id
LIMIT 3;

--   6 | 2026-06-17 | 39.00 | team
--  39 | 2026-06-08 | 39.00 | team
-- 214 | 2026-06-27 | 39.00 | team

На чём спотыкается перенос запроса из DuckDB в PostgreSQL

Запрос, отлаженный в DuckDB, на рабочем PostgreSQL может упасть или, что хуже, молча вернуть другое число. Таблица ниже — выражения, выполненные в обоих движках.

Опаснее всего деление: ошибки нет, а ответ другой. В учебной базе 1 251 платёж от 912 плательщиков, и «платежей на плательщика» получается 1,37 в DuckDB и ровно 1 в PostgreSQL. Вторая тихая разница — деление на ноль: 1 / 0 в DuckDB возвращает бесконечность, а PostgreSQL останавливает запрос ошибкой. Третья — numeric без точности: в DuckDB это DECIMAL(18,3), и 2 / 3.0 превращается в 0,667.

Остальные расхождения заметны сразу, потому что запрос падает. date_diff в PostgreSQL нет. Литерал '5' + 3 PostgreSQL молча считает восьмёркой, DuckDB просит явное приведение; с текстовым столбцом ошибку дают оба движка. Алиас из SELECT в WHERE DuckDB понимает — так он находит 68 первых оплат на третий день после регистрации, — а PostgreSQL отвечает, что столбца нет. Тип результата date_trunc от даты зависит даже от версии: DuckDB 1.4 возвращал DATE, DuckDB 1.5 возвращает TIMESTAMP, PostgreSQL — timestamp with time zone.

Одно выражение — два ответа: DuckDB 1.5 и PostgreSQL 14
ВыражениеDuckDBPostgreSQL
7 / 23.53
7 // 23ошибка: оператора нет
1 / 0infошибка: division by zero
count(*) / count(DISTINCT user_id) по payments1.371
CAST(2 / 3.0 AS numeric)0.6670.66666666666666666667
date_diff('day', d1, d2)89ошибка: функции нет
date_trunc('month', дата)TIMESTAMP (в 1.4 — DATE)timestamp with time zone
'5' + 3 (литерал)ошибка: нужен явный CAST8
алиас из SELECT в WHEREработаетошибка: column does not exist
DuckDB: пять выражений, которые в PostgreSQL ведут себя иначе
SELECT
  7 / 2                                                  AS division,        -- 3.5
  7 // 2                                                 AS int_division,    -- 3
  CAST(2 / 3.0 AS numeric)                               AS numeric_default, -- 0.667
  date_diff('day', DATE '2026-06-01', DATE '2026-08-29') AS days,            -- 89
  typeof(date_trunc('month', DATE '2026-06-15'))         AS month_type;      -- TIMESTAMP (в DuckDB 1.4 — DATE)
sqlPostgreSQL only: те же вопросы на рабочей базе
SELECT
  7 / 2                                              AS division,        -- 3
  7 / 2.0                                            AS exact_division,  -- 3.5000000000000000
  CAST(2 / 3.0 AS numeric)                           AS numeric_default, -- 0.66666666666666666667
  DATE '2026-08-29' - DATE '2026-06-01'              AS days,            -- 89
  pg_typeof(date_trunc('month', DATE '2026-06-15'))  AS month_type,      -- timestamp with time zone
  '5' + 3                                            AS text_plus_int;   -- 8
  • Делите через 1.0 * a / b: дробный результат получится в обоих движках.
  • У numeric указывайте точность: CAST(x AS numeric(18, 6)).
  • Дни между датами считайте вычитанием d2 - d1, а к date_trunc добавляйте CAST(… AS DATE).
  • Условие по вычисленному столбцу выносите в подзапрос или CTE и не опирайтесь на алиас в WHERE.

На каком движке работает учебная база курса

Задания симулятора SQL-аналитика и тренажёров КейсПрактики проверяет DuckDB: запрос ученика выполняется на сервере, проверка принимает один SELECT или WITH. При этом курсы учат SQL, совместимому с PostgreSQL, — тому, что встретится на рабочей базе. Поэтому в главах симулятора и в тренажёрах под вердиктом появляется замечание, если запрос держится на вольностях DuckDB: алиасе в WHERE, QUALIFY, GROUP BY ALL, median, arg_max или делении счётчика на счётчик.

Привычка отсюда простая. Считаете для себя в ноутбуке — пользуйтесь сокращениями. Пишете запрос, который уйдёт в отчёт на PostgreSQL или коллеге, — держитесь переносимой записи из двух таблиц выше.

Когда DuckDB не подходит

DuckDB рассчитан на аналитические запросы: агрегации и соединения по большой части таблицы («Why DuckDB»). Границы применимости документация называет сама.

  • Много пишущих процессов. Раздел «Concurrency»: либо один процесс читает и пишет, либо несколько процессов открывают базу только на чтение. Запись из нескольких процессов требует сервера: либо протокол Quack, который превращает DuckDB в клиент-серверную базу и в документации версии 1.5.2 назван бета-версией, либо формат DuckLake с каталогом в PostgreSQL.
  • Транзакционная нагрузка. Там же: когда два потока меняют одну строку, второй получает ошибку конфликта. Приложению с потоком коротких вставок и обновлений от многих клиентов нужна серверная СУБД.
  • Данные больше одной машины. DuckDB считает на одном компьютере. Запрос, которому не хватило оперативной памяти, сбрасывается на диск (руководство «Tuning Workloads»), но с оговорками: list(), string_agg() и PIVOT на диск не выгружаются. Данные, которые не помещаются на диск одной машины, — задача для распределённого хранилища.

Частые вопросы

DuckDB заменяет PostgreSQL? Нет, у них разная работа. PostgreSQL — сервер для приложения и общей базы команды, DuckDB — личный вычислитель для файлов и таблиц в памяти. Аналитик часто пользуется обоими: забирает выгрузку из рабочей базы и досчитывает локально.

Что учить новичку — DuckDB или PostgreSQL? Учите переносимый SQL: SELECT, соединения, агрегаты, окна одинаковы в обоих. Сокращения DuckDB — надстройка над ним, а привычку к 1.0 * при делении лучше выработать сразу.

Где DuckDB хранит данные? В памяти процесса либо в одном файле, который вы указали при подключении. Файлы CSV и Parquet он читает на месте и в базу не копирует.

Как выполнить SQL-запрос в Python без сервера базы данных? Установить duckdb и вызвать duckdb.sql(…). Запрос читает DataFrame по имени переменной или файл по пути, а .df() возвращает результат в pandas.

Как сохранить результат запроса? COPY (SELECT …) TO 'result.parquet' (FORMAT parquet) запишет файл, .df() в Python вернёт таблицу pandas.

Что читать дальше

DuckDB убирает расстояние между файлом и запросом: выгрузка, которую раньше приходилось загружать в базу или читать в pandas целиком, становится таблицей в FROM. Платить за это приходится вниманием к диалекту — делению, типам и сокращениям, которых нет в PostgreSQL.

Запросы статьи, кроме блоков с COPY и блока для PostgreSQL, выполняются в песочнице симулятора на той же учебной базе.

Продолжить чтение
Вся библиотека
Продуктовая аналитика2 октября 2026 г.16 мин
Две линии разного цвета сходятся в одной точке и дальше идут вместе.

SQL или Python: что учить аналитику первым и где граница между ними

SQL и Python в работе аналитика данных: что учить первым, какие задачи решать запросом, а какие в pandas. Одна задача двумя способами на учебной базе и порядок изучения.

Читать материал
Продуктовая аналитика8 августа 2026 г.13 мин
Каждая строка столбца раскрывается в собственную группу ячеек.

LATERAL JOIN в SQL: последняя запись, top-N на пользователя и функции в FROM

LATERAL JOIN на реальной учебной базе: последний платёж пользователя со всеми полями, разница между LEFT и CROSS, почему подзапрос с агрегатом никогда не отбрасывает строки, три последних события на человека, индекс, без которого LATERAL читает таблицу на каждой строке, и когда хватит GROUP BY, DISTINCT ON или окна.

Читать материал