Кратко: DuckDB v2.0 выйдет этой осенью. В материале собраны его главные особенности: DuckDB как сервер, триггеры, тип VARIANT, асинхронный I/O, новый SQL-парсер, новый формат хранения и многое другое.

DuckDB v2.0 получит имя «Cyanoptera» в честь корицевого чирка (Anas cyanoptera) — утки с ярко выраженным красновато-коричневым оперением, обитающей в западной части Америки.

Повышение мажорной версии — решение не спонтанное и не церемониальное: v2.0 несёт новый SQL-парсер, новый формат хранения по умолчанию, переработанный C API и небольшое число тщательно отобранных обратно несовместимых изменений. Но прежде всего это релиз с новыми функциями, собранный из более чем 10 000 коммитов с момента выхода v1.5 в марте. Если прошлый год был годом lakehouse, этот релиз открывает год DuckDB как сервера. Многие из этих функций уже были показаны в докладе «State of the Duck» на DuckCon #7 — для тех, кто предпочитает видео тексту.

DuckDB развивается достаточно быстро, поэтому здесь охвачена лишь малая часть изменений. Сжать все новые функции в короткий список — всегда борьба за то, что попадёт в финальный вариант, и да, по сути ниже технически список из разряда «Десять вещей, которые появятся в DuckDB v2.0, номер восемь вас шокирует». Формат не самый изящный, но рабочий, так что вот он — начиная с функций уровня SQL и постепенно спускаясь к движку.

1. DuckDB как сервер: Quack и CONNECT

DuckDB с самого начала работал как встроенная (in-process) база данных. Но пользователи настойчиво просили клиент-серверный режим, и разработчики наконец пошли навстречу. Расширение quack реализует нативный протокол DuckDB для общения с другими экземплярами DuckDB. Оно было выпущено как превью незадолго до DuckCon #7, в v2.0 переходит в статус стабильного и становится важной частью будущего DuckDB: любой процесс DuckDB может обслуживать свои базы данных по сети, а любой другой DuckDB может подключиться к нему и направлять туда запросы с помощью новой команды CONNECT. Например:

DuckDB server:

CALL quack_serve(
    token = 'my_token'
);

DuckDB client:

ATTACH 'quack:server.example.com'
    AS qk (TOKEN 'my_token');

CONNECT qk;
SELECT count(*) FROM events;
-- executes on the server,
-- results stream back
DISCONNECT;

CONNECT заменяет собой временное решение remote.query($$...$$), показанное при первом анонсе Quack — стало ясно, что таким синтаксис оставаться не должен. CONNECT не ограничивается Quack: команда направляет сессию на любую удалённую базу данных, которая её поддерживает, а новый оптимизатор удалённого пушдауна (#22914) отправляет SQL напрямую в PostgreSQL и MySQL, вместо того чтобы перекачивать таблицы по сети:

CONNECT 'postgres://localhost/mydb';
SELECT count(*) FROM orders; -- runs on the PostgreSQL server
DISCONNECT;

Тем, кто работал с аналитическими системами, может показаться, что DuckDB не подходит для транзакционных нагрузок. На деле DuckDB с самого начала строился как транзакционная база данных с поддержкой множества соединений, полным MVCC и изоляцией транзакций — просто в однопользовательском сценарии эта возможность обычно не требовалась. Оказывается, DuckDB неплохо справляется с транзакциями: по скорости он способен конкурировать с базами общего назначения вроде PostgreSQL на многих нагрузках, а клиент-серверная модель наконец позволяет этой машинерии проявить себя в мультиарендных, долгоживущих развёртываниях.

Долгая работа DuckDB в качестве сервиса приносит и новые вызовы — поэтому в v2.0 много внимания уделено метрикам, логам и наблюдаемости (см., например, переработку слоя метрик в #22799), позволяющим увидеть, чем именно занят экземпляр DuckDB. Сообщество уже за несколько недель после превью успело написать самостоятельные клиенты для протокола Quack. Изначально DuckDB планировался как средство общения между экземплярами DuckDB друг с другом — но пользователи решили иначе и написали собственные клиенты.

2. VARIANT становится полноценным типом

Тип VARIANT появился в DuckDB v1.5, и проще всего представлять его как JSON на стероидах — как если бы JSON был быстрым. Как и JSON, столбец VARIANT может хранить данные разной формы в каждой строке. В отличие от JSON, это не текстовый формат: DuckDB автоматически распознаёт общую структуру, скрытую в полуструктурированных данных, и «расщепляет» её (shredding), благодаря чему данные хорошо сжимаются при хранении и быстро обрабатываются в запросах — без необходимости объявлять схему. Это делает VARIANT естественным выбором для приёма логов в реальном времени, где потоки записей в духе JSON имеют общую структуру, но со временем меняются.

В v2.0 весь конвейер работает целиком: расщеплённое выполнение прямо из хранилища (#20912), пушдаун извлечения в сканирование (#22478), расщеплённое чтение и запись VARIANT для Parquet, а также набор функций variant_*:

CREATE TABLE events (payload VARIANT);
INSERT INTO events
VALUES ('{"user": {"id": 42, "tags": ["a", "b"]}}'::JSON::VARIANT);

SELECT variant_type(payload), variant_keys(payload)
FROM events;

SELECT *
FROM events
WHERE variant_contains(payload, {'user': {'id': 42}}::VARIANT);

В дальнейшем, вероятно вскоре после v2.0 (но без твёрдых гарантий), обычный тип JSON планируется реализовать поверх VARIANT, чтобы существующие рабочие нагрузки на JSON получили все эти преимущества без изменения хотя бы одного запроса.

3. Триггеры

Триггеры долгое время оставались одним из самых востребованных пожеланий, и DuckDB v2.0 реализует их в полном объёме: триггеры BEFORE и AFTER, режимы FOR EACH ROW и FOR EACH STATEMENT, переходные таблицы через REFERENCING OLD/NEW TABLE, несколько триггеров на одно событие, RETURNING для таблиц с триггерами и DROP TRIGGER.

Классический сценарий использования — таблицы аудита: что-то происходит в системе, и триггер записывает изменение. Например:

CREATE TABLE target (id INTEGER, val INTEGER);
CREATE TABLE audit (id INTEGER, old_val INTEGER, new_val INTEGER);

CREATE TRIGGER trg_audit AFTER UPDATE ON target
REFERENCING OLD TABLE AS o NEW TABLE AS n
FOR EACH STATEMENT
    INSERT INTO audit
    SELECT n.id, o.val, n.val
    FROM o
    JOIN n ON o.id = n.id;

INSERT INTO target VALUES (1, 10), (2, 20);
UPDATE target SET val = val * 10 WHERE id <= 2;
SELECT * FROM audit;
idold_valnew_val
110100
220200

Триггеры естественно вписываются в сценарии долгоживущих сервисов DuckDB, и команда также планирует использовать их внутри для реализации ряда будущих функций. Они полностью доступны на уровне SQL, так что пользователи могут строить на их основе собственные решения.

4. Дополнения диалекта SQL

Диалект SQL в DuckDB, как обычно, продолжает расширяться. Несколько заметных изменений этого цикла:

С джойнами NEAREST (#24137) поиск top-k по сходству превращается в обычное условие join — удобно для векторных и embedding-нагрузок:

SELECT q.user_id, t.product_id
FROM users q
    INNER JOIN products t APPROX NEAREST 2
    BY SIMILARITY array_cosine_similarity(q.embedding, t.embedding);

DML внутри CTE (#21634, #21997, #24217) позволяет использовать INSERT, UPDATE, DELETE и COPY как шаги конвейера:

WITH moved AS MATERIALIZED (
    DELETE FROM staging RETURNING *
)
INSERT INTO archive SELECT * FROM moved;

Вложенные схемы (#23492, #24222) позволяют создавать схемы внутри схем:

CREATE SCHEMA finance;
CREATE SCHEMA finance.reports;
CREATE TABLE finance.reports.q3 (revenue DECIMAL);

Новый синтаксис переменных (#21194) позволяет писать $x в любом месте, где допустимо выражение — больше не нужна громоздкая конструкция getvariable(...):

SET VARIABLE threshold = 100;
SELECT * FROM orders WHERE amount > $threshold;

Функции изменения JSON json_set, json_insert, json_replace и json_remove (#23786) наконец позволяют изменять JSON-документы на месте:

SELECT json_set('{"a":1}', '$.b', '2');
json_set('{"a":1}', '$.b', '2')
{"a":1,"b":2}

А рекурсивные CTE с агрегацией USING KEY (#19481) позволяют реализовывать итеративные алгоритмы на чистом SQL, опираясь на переписанный движок рекурсивных CTE, описанный ниже:

WITH RECURSIVE tbl(a, b) USING KEY (a, avg(b)) AS (
    SELECT 1, 5
    UNION
    SELECT a, b - 1 FROM tbl WHERE b > 0
)
TABLE tbl;
ab
12.5

Есть и другие изменения: стандартный SQL FETCH FIRST 2 ROWS ONLY (#23533), OVERLAY() (#22456), UNNEST внутри GROUP BY (#23644) и чётко определённая семантика MERGE / UPDATE ... FROM для строк с множественными совпадениями (#24058).

5. Асинхронный I/O

Взаимодействие с объектными хранилищами вроде S3 — центральная часть опыта работы с DuckDB: данные откуда-то поступают, и часто именно из объектного хранилища. DuckDB давно умел читать из таких хранилищ параллельно, но синхронный доступ ограничивал скорость этого процесса. DuckDB v2.0 вводит асинхронный I/O во всём движке. Дизайн подробно описан в отдельном материале.

Благодаря асинхронному доступу слой I/O теперь масштабируется независимо от слоя обработки запросов, что даёт гораздо больший параллелизм при удалённом чтении и значительно ускоряет запросы к сетевым хранилищам. Первой поддержку получила работа с Parquet (#23662), затем — CSV (#23961) и собственный формат файлов DuckDB (#24654), а также асинхронная запись Parquet (#23283) и новые режимы MMAP и DIRECT_IO (#22988). Локальное хранилище тоже немного выигрывает, но основной прирост заметен именно на сетевых хранилищах.

6. Более быстрые запросы по всем направлениям

Как и в каждом релизе, много усилий вложено в ускорение уже существующих запросов без каких-либо действий со стороны пользователя. Из главного: частичные агрегации теперь проталкиваются ниже join'ов (#22572), а избыточные агрегации переиспользуются (#24543); движок рекурсивных CTE переписан (#22211); агрегации теперь сбрасываются на диск, когда перестают помещаться в память (#24499); а CLI под Windows стал примерно в 2.2 раза быстрее материализовывать результаты в многопоточном режиме (#24036).

Насколько быстрее это стало на практике? Вот микробенчмарк, который можно запустить на ноутбуке: поиск достижимости из одной вершины в графе с миллионом рёбер, записанный как обычный рекурсивный CTE.

CREATE TABLE edges AS
    SELECT (range % 100_000)::INTEGER AS src,
           ((range * 13 + 7) % 100_000)::INTEGER AS dst
    FROM range(1_000_000);

WITH RECURSIVE reachable(node) AS (
    SELECT 0
    UNION
    SELECT dst FROM edges, reachable WHERE src = node
)
SELECT count(*) FROM reachable;
ВерсияВремя выполнения
DuckDB v1.5.44.90 с
DuckDB v2.0 (превью)0.12 с

Как видно, DuckDB v2.0 работает примерно в 40 раз быстрее (!) на том же рекурсивном запросе.

Значительно расширены возможности отсечения групп строк (row-group pruning): min-max-индексы (zone maps) и Bloom-фильтры Parquet теперь позволяют пропускать данные для структур, списков, decimal, UUID, фильтров IN и даже предикатов с функциями:

-- these now prune row groups instead of scanning them:
SELECT * FROM logs WHERE contains(message, 'ERROR');
SELECT * FROM t WHERE substr(code, 1, 3) = 'NL-';
SELECT * FROM 'data/*.parquet' WHERE id IN (1, 5, 9);

Планирование запросов также становится партиция-осведомлённым (#22336). Форматы lakehouse (DuckLake, Iceberg и обычный Hive-партиционированный Parquet на S3) всегда партиционированы, и использование этого партиционирования часто определяет разницу между сканированием всего набора данных и пропуском большей его части. В v2.0 планировщик и оптимизатор в полной мере используют существующее партиционирование, а партиционированная запись также была переработана (#22225, #22620).

7. Формат хранения v2.0

DuckDB v2.0 повышает версию формата хранения по умолчанию до v2.0.0 (#22875). Главное изменение — ART-индексы с управлением буфером (#21458, #23605): индексы больше не закрепляются в памяти целиком, благодаря чему большие индексированные таблицы открываются мгновенно, а их индексы подгружаются по мере необходимости.

Метаданные столбцов теперь загружаются лениво (#22333), поэтому широкие таблицы тоже открываются быстрее. Метод сжатия строк DICT_FSST включён по умолчанию (#23733), удаления хранятся компактно (#24336), а слой хранения выполняет намного более строгую проверку на повреждения при чтении. Итог: базы данных с большими индексами и широкими таблицами открываются быстрее и потребляют значительно меньше памяти.

8. Совершенно новый SQL-парсер

DuckDB всегда использовал парсер, унаследованный от PostgreSQL — это хорошо известный факт. В версии 2.0 разработчики решили, что пора это изменить: релиз получает собственный современный расширяемый парсер на основе PEG (#22194) — идею, впервые описанную в материале 2024 года о расширяемых во время выполнения парсерах. Это изменение тесно связано с экосистемой расширений: теперь расширения смогут встраиваться в саму грамматику, поэтому стоит ожидать появления расширений с совершенно новым синтаксисом SQL. Кроме того, новый парсер даёт более понятные сообщения об ошибках с точным указанием места в исходном коде, а также первый режим совместимости диалектов:

SET dialect_compatibility_mode = 'spark';

Смена парсера была спроектирована так, чтобы быть совместимой со старым, поэтому на практике пользователи не должны заметить разницы. Если что-то всё же пойдёт не так — стоит завести issue.

9. Часовые пояса, календари и коллации без ICU

Часовые пояса, календари и коллации в DuckDB всегда работали на основе библиотеки ICU. ICU — неплохая библиотека, но использовалась лишь небольшая её часть, при этом она целиком включалась в каждый дистрибутив DuckDB. В v2.0 библиотека ICU полностью исключена: расширение icu теперь самостоятельно реализует часовые пояса, календари и коллации (#24463, #24403), а данные часовых поясов собираются напрямую из базы IANA и сжимаются примерно до 45 КБ. Всё продолжает работать в точности как раньше:

SELECT '2026-08-14 12:00:00'::TIMESTAMPTZ AT TIME ZONE 'Europe/Paris';
SELECT * FROM names ORDER BY name COLLATE de;

Помимо гораздо меньшего размера и более простого поддержания актуальности, новая реализация ещё и попросту быстрее. Вот небольшой микробенчмарк на MacBook, который переводит 25 миллионов таймстампов в другой часовой пояс и фильтрует 5 миллионов строк с немецкой коллацией:

Запросv1.5.4 (ICU)v2.0 (нативно)Ускорение
ts AT TIME ZONE 'Europe/Paris', 25 млн строк0.24 с0.11 с2.2×
Фильтр с COLLATE de, 5 млн строк0.15 с0.06 с2.6×

10. Расширения: написал один раз — размещай где угодно

Расширения — одна из сильных сторон DuckDB, но сегодня большинство из них, включая официальные, собираются против нестабильного C++ API. Это означает, что авторам расширений приходится пересобирать их под каждый новый релиз DuckDB, а некоторые расширения сообщества могут незаметно исчезать, когда авторы перестают их поддерживать. DuckDB v2.0 расширяет стабильный C API настолько, что расширения теперь можно написать один раз, собрать один раз, опубликовать один раз — и они будут продолжать работать практически бесконечно.

Чтобы обеспечить устойчивость этого подхода в долгосрочной перспективе, C API теперь генерируется из декларативной версионированной спецификации (#24135): каждая функция в duckdb.h, duckdb_extension.h и ABI расширений описана в YAML в директории api_spec/ с полной историей жизненного цикла, а CI сверяет закоммиченные заголовочные файлы со спецификацией, чтобы API и ABI больше не могли разойтись. Релиз также приносит единую систему версионирования символов (#24435), пользовательские обработчики выделения памяти (#23945) и статическую линковку расширений на C API в приложение (#22251).

Как же выглядит написание расширения на стабильном C API? Ниже — полноценное расширение: один файл, регистрирующий векторизованную скалярную функцию, собранный один раз против duckdb_extension.h.

#include "duckdb_extension.h"

DUCKDB_EXTENSION_EXTERN

// a scalar function that adds two BIGINTs, one vector at a time
static void AddNumbers(duckdb_function_info info, duckdb_data_chunk input, duckdb_vector output) {
    idx_t count = duckdb_data_chunk_get_size(input);
    int64_t *a = (int64_t *) duckdb_vector_get_data(duckdb_data_chunk_get_vector(input, 0));
    int64_t *b = (int64_t *) duckdb_vector_get_data(duckdb_data_chunk_get_vector(input, 1));
    int64_t *result = (int64_t *) duckdb_vector_get_data(output);
    for (idx_t row = 0; row < count; row++) {
        result[row] = a[row] + b[row];
    }
}

DUCKDB_EXTENSION_ENTRYPOINT(duckdb_connection con,
                            duckdb_extension_info info,
                            duckdb_extension_access *access) {
    duckdb_scalar_function f = duckdb_create_scalar_function();
    duckdb_scalar_function_set_name(f, "add_numbers");
    duckdb_logical_type bigint = duckdb_create_logical_type(DUCKDB_TYPE_BIGINT);
    duckdb_scalar_function_add_parameter(f, bigint);
    duckdb_scalar_function_add_parameter(f, bigint);
    duckdb_scalar_function_set_return_type(f, bigint);
    duckdb_destroy_logical_type(&bigint);
    duckdb_scalar_function_set_function(f, AddNumbers);
    duckdb_register_scalar_function(con, f);
    duckdb_destroy_scalar_function(&f);
    return true;
}
LOAD add_numbers;
SELECT add_numbers(40, 2);

Ради краткости здесь опущена обработка NULL. Полную версию можно найти в расширении demo_capi.

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

Но как распространять готовое расширение? До сих пор DuckDB мог устанавливать расширения только из встроенных репозиториев (core, core_nightly, community и др.). В v2.0 появится возможность регистрировать собственные доверенные репозитории (#24777, пока в разработке), так что организация сможет размещать и подписывать свои расширения, а устанавливаться и загружаться они будут точно так же, как встроенные:

SET allow_extension_repositories = 'allowed';
CREATE EXTENSION REPOSITORY my_repo FROM 'https://extensions.example.org';
INSTALL my_ext FROM my_repo;
LOAD my_repo/my_ext;

Репозиторий — это имя, URL-префикс и один или несколько открытых RSA-ключей, которым доверяют для проверки подписи расширений, распространяемых из него. Префиксом может служить всё, что способен прочитать DuckDB: локальный путь, https, s3 — что угодно. При создании (CREATE) DuckDB получает открытые ключи репозитория и закрепляет их в его определении, выводя SHA-256-отпечаток каждого ключа для сверки с публично опубликованным значением. Если доверять сети совсем не хочется, ключ можно передать напрямую:

CREATE EXTENSION REPOSITORY my_repo FROM 's3://my-bucket/extensions'
    USING PUBLIC KEY '-----BEGIN PUBLIC KEY----- ...';

Закреплённые репозитории сохраняются между перезапусками, поддерживают ротацию ключей за счёт доверия сразу нескольким ключам, а проверить их в любой момент можно через табличную функцию duckdb_extension_repositories() или удалить командой DROP EXTENSION REPOSITORY. Вместе со стабильным C API история расширений складывается в цельную картину: написать расширение один раз, подписать, разместить где угодно и установить (INSTALL) в любом месте.

Бонус: консультативный совет

Начиная с этой осени в DuckDB Foundation появится консультативный совет заинтересованных сторон. Совет будет давать рекомендации по дорожной карте развития DuckDB, DuckLake и Quack, что позволит ключевым стейкхолдерам влиять на направление развития проектов.

Заключение

Это лишь часть нововведений, а материал — только предварительный обзор. Некоторые детали могут ещё измениться до выхода релиза этой осенью, и многие функции и улучшения здесь не были затронуты. DuckDB v2.0 также принесёт небольшой набор обратно несовместимых изменений, включая новый формат хранения по умолчанию и завершённый переход на новый синтаксис лямбд — подробности будут раскрыты в анонсе релиза.

С момента выхода v1.5 было сделано более 10 000 коммитов силами множества участников. Разработчики благодарят сообщество за подробные отчёты об ошибках, обратную связь и вклад, сформировавшие этот релиз. Попробовать новые возможности уже сейчас можно в превью-сборках, где реализована большая часть описанного функционала, а обо всех найденных проблемах можно сообщить в трекере задач.