INSERT, INSERT OR REPLACE/IGNORE и производительность вставки
Большая часть курса до этого была про чтение: запросы, оптимизатор, out-of-core исполнение. Но данные нужно ещё и записывать — наполнять таблицы, обновлять их, экспортировать. Этот модуль про запись, и начинается он с самой базовой операции — INSERT.
INSERT кажется тривиальным, но в колоночной аналитической СУБД у него есть нюансы, которые на больших объёмах решают всё. Этот урок разбирает формы INSERT, варианты разрешения конфликтов (INSERT OR REPLACE / INSERT OR IGNORE) и — главное — почему способ вставки влияет на скорость в сотни раз.
Базовый INSERT
Простейшая форма — вставка явных значений:
CREATE TABLE products (id INTEGER, name VARCHAR, price DECIMAL(10,2));
INSERT INTO products VALUES (1, 'Keyboard', 49.99);
-- несколько строк за один INSERT
INSERT INTO products VALUES
(2, 'Mouse', 24.50),
(3, 'Monitor', 199.00),
(4, 'Webcam', 79.90);
Можно указать колонки явно — тогда порядок значений привязан к указанному списку, а пропущенные колонки получат NULL или значение по умолчанию:
INSERT INTO products (id, name) VALUES (5, 'Headset');
-- price будет NULL
DuckDB поддерживает удобное расширение INSERT INTO ... BY NAME — сопоставление по именам колонок, а не по позиции. Это полезно, когда источник даёт колонки в другом порядке или не все:
INSERT INTO products BY NAME
SELECT 'Cable' AS name, 6 AS id;
-- сопоставление по имени: name -> name, id -> id; price -> NULL
INSERT из запроса: основной способ загрузки
Вставка явных VALUES — для мелочи. Реальная загрузка данных в DuckDB — это INSERT INTO ... SELECT: результат запроса целиком отправляется в таблицу. Источником SELECT может быть другая таблица, файл, DataFrame — что угодно.
-- из другой таблицы
INSERT INTO products_archive SELECT * FROM products WHERE price < 50;
-- прямо из Parquet-файла, без промежуточных шагов
INSERT INTO sales SELECT * FROM 'new_sales_2026.parquet';
-- из CSV
INSERT INTO events SELECT * FROM read_csv('events.csv');
Это и есть штатный способ массовой загрузки: одна команда переносит миллионы строк. Внутри она обрабатывается векторизованно — данные идут чанками по ~2048 значений, сжимаются и пишутся колоночными сегментами.
Родственная конструкция — CREATE TABLE AS SELECT (CTAS): создаёт таблицу и сразу наполняет её результатом запроса.
CREATE TABLE top_products AS
SELECT * FROM products ORDER BY price DESC LIMIT 100;
Производительность вставки: почему пакетность критична
Здесь — главный практический урок. Способ вставки определяет скорость с разницей в сотни раз. Причина — в колоночной архитектуре DuckDB.
DuckDB хранит данные колоночными сегментами, сжатыми, с zonemap-статистикой. Запись эффективна, когда данные приходят большими порциями: можно набрать полный чанк, выбрать схему сжатия, записать сегмент. Каждая отдельная транзакция вставки несёт фиксированные накладные расходы — оформление транзакции, обновление метаданных, потенциальная запись в WAL.
Сравним два подхода загрузить миллион строк.
Антипаттерн — построчно, миллион транзакций:
# ПЛОХО: миллион отдельных INSERT, каждый в своей транзакции
for row in million_rows:
con.execute("INSERT INTO t VALUES (?, ?)", row)
# фиксированные накладные расходы умножаются на 1 000 000
Правильно — одной пакетной операцией:
# ХОРОШО: одна команда, весь объём разом
con.execute("INSERT INTO t SELECT * FROM source_data")
# или вставка DataFrame целиком через replacement scan:
con.execute("INSERT INTO t SELECT * FROM my_dataframe")
Разница не «немного»: построчная загрузка миллиона строк может занять минуты, пакетная — доли секунды. На колоночном движке построчная вставка борется с самой архитектурой.
Построчная вставка в цикле — самая частая причина «DuckDB медленно пишет». Это не DuckDB медленная — это построчный паттерн противоречит колоночному хранилищу. Всегда стремитесь вставлять данные пакетами: INSERT … SELECT, загрузка файла целиком, вставка DataFrame одной командой. Если данные поступают потоком — накапливайте их в буфер и вставляйте крупными порциями.
Если построчность неизбежна (данные приходят по одной записи), есть промежуточные решения: оборачивать много INSERT в одну транзакцию (BEGIN ... много INSERT ... COMMIT) — это убирает накладные расходы на транзакцию с каждой строки; или, в Python, использовать механизм Appender для эффективной потоковой дозаписи. Но базовый принцип неизменен: чем крупнее порция, тем быстрее.
| Подход | Скорость загрузки 1 млн строк | Когда применять |
|---|---|---|
| Построчный INSERT, транзакция на строку | минуты | практически никогда |
| Много INSERT в одной транзакции | заметно лучше | если построчность неизбежна |
| INSERT … SELECT / загрузка файла / DataFrame | доли секунды | всегда, когда возможно |
Разрешение конфликтов: INSERT OR REPLACE и INSERT OR IGNORE
Обычный INSERT при нарушении ограничения (например, дубликат по PRIMARY KEY или UNIQUE) завершается ошибкой и откатывается. Часто это не то, что нужно: при загрузке данных хочется либо пропустить дубликаты, либо перезаписать их. DuckDB даёт два модификатора.
INSERT OR IGNORE — при конфликте строка молча пропускается, остальные вставляются. Полезно для идемпотентной дозагрузки: «вставь то, чего ещё нет».
CREATE TABLE users (id INTEGER PRIMARY KEY, email VARCHAR);
INSERT INTO users VALUES (1, '[email protected]'), (2, '[email protected]');
-- id=2 уже есть, id=3 новый
INSERT OR IGNORE INTO users VALUES (2, '[email protected]'), (3, '[email protected]');
-- строка с id=2 ПРОПУЩЕНА (email остался [email protected]... нет, [email protected]),
-- строка с id=3 ВСТАВЛЕНА
SELECT * FROM users ORDER BY id;
-- 1 [email protected]
-- 2 [email protected] <- не изменилась, конфликт проигнорирован
-- 3 [email protected] <- вставлена
INSERT OR REPLACE — при конфликте существующая строка заменяется новой. Полезно, когда новые данные должны побеждать: «вставь или обнови».
INSERT OR REPLACE INTO users VALUES (2, '[email protected]'), (4, '[email protected]');
-- строка с id=2 ЗАМЕНЕНА на новую,
-- строка с id=4 ВСТАВЛЕНА
SELECT * FROM users ORDER BY id;
-- 1 [email protected]
-- 2 [email protected] <- заменена
-- 3 [email protected]
-- 4 [email protected] <- вставлена
Эти модификаторы — короткая форма более общего механизма INSERT ... ON CONFLICT, который позволяет точно задать, что делать при конфликте (вплоть до условного обновления отдельных колонок). INSERT OR IGNORE эквивалентен ON CONFLICT DO NOTHING, INSERT OR REPLACE — ON CONFLICT DO UPDATE со всеми колонками. Полная форма даёт больше контроля: можно обновлять не все колонки, а только некоторые, и ссылаться на конфликтующую строку:
-- обновить только email и счётчик, остальные колонки не трогать
INSERT INTO users (id, email, visits)
VALUES (2, '[email protected]', 1)
ON CONFLICT (id) DO UPDATE
SET email = excluded.email,
visits = users.visits + 1;
-- excluded — это строка, которую пытались вставить;
-- users — существующая строка
Для сложной логики слияния (произвольное условие, не только по ключу) в DuckDB 1.4 появился MERGE INTO — ему посвящён отдельный урок этого модуля.
Идемпотентность загрузки
INSERT OR IGNORE важен в практическом смысле — он делает загрузку идемпотентной. Идемпотентная операция даёт один и тот же результат, сколько бы раз её ни повторили. Для пайплайнов данных это ценное свойство: если загрузка упала на середине и её перезапустили, не должно появиться дублей.
Сравним. Обычный INSERT идемпотентным не является: повторный запуск либо упадёт на дубликатах (если есть PRIMARY KEY), либо насоздаёт дубли (если ключа нет). INSERT OR IGNORE при повторе просто пропустит уже существующие строки и доставит только недостающие — повторный запуск безопасен.
-- эту команду можно запускать сколько угодно раз подряд:
-- результат одинаков, дублей не будет
INSERT OR IGNORE INTO loaded_events
SELECT * FROM read_parquet('daily_batch.parquet');
Это конкретная причина, почему INSERT OR IGNORE (или MERGE INTO для более сложных случаев) предпочитают обычному INSERT в повторяемых пайплайнах: повтор после сбоя не ломает данные.
INSERT OR IGNORE и INSERT OR REPLACE требуют, чтобы у таблицы было ограничение (PRIMARY KEY или UNIQUE) — именно по нему определяется конфликт. Без такого ограничения «конфликту» неоткуда взяться, и модификатор не на что опереться. MERGE INTO, напротив, работает по произвольному условию слияния и наличия ключа не требует — это его принципиальное отличие.
Попробуй сам
- Создай таблицу
inventory(sku VARCHAR PRIMARY KEY, qty INTEGER)и вставь несколько строк через одну командуINSERT ... VALUES. - Сделай
INSERT OR IGNOREнабора, где частьskuуже существует, а часть новые. ПроверьSELECT *: пропустились ли существующие, добавились ли новые? - Сделай
INSERT OR REPLACEпохожего набора. Сравни с предыдущим шагом: чем поведение при конфликте отличается? - Замерь производительность. Создай таблицу
t(id INTEGER, v DOUBLE). Вставь 200 тысяч строк двумя способами: (а) циклом по одной строке, (б) одной командойINSERT INTO t SELECT range, random() FROM range(200000). Засеки время каждого. Во сколько раз отличается? - Попробуй обернуть построчный цикл из шага 4 в одну транзакцию (
BEGINперед циклом,COMMITпосле). Стало ли быстрее построчной вставки без транзакции? Объясни, какие накладные расходы убрала единая транзакция.