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

INSERT INTO в SQL: вставка строк, INSERT SELECT и RETURNING

INSERT INTO в SQL и PostgreSQL: вставка нескольких строк, DEFAULT и identity, INSERT … SELECT для витрины, RETURNING и частые ошибки.

КейсПрактика25 сентября 2026 г.9 мин

Дашборд активности каждое утро пересчитывает 35 тысяч событий, хотя ему нужно по одной строке на день. Логичный шаг — завести маленькую таблицу-витрину и складывать в неё готовые итоги. Для этого понадобится INSERT INTO: сначала чтобы заполнить витрину за прошлые месяцы, потом чтобы дописывать новый день. Разберём команду на этой задаче и на справочнике промокодов.

Что делает INSERT INTO

INSERT INTO — команда SQL, которая добавляет в таблицу новые строки. Значения берутся из списка VALUES или из результата SELECT, а колонки, которые вы не заполнили, получают значение по умолчанию. База проверяет каждую строку на ограничения таблицы и сообщает, сколько строк вставлено: в PostgreSQL ответ выглядит как INSERT 0 N.

Примеры выполнены в PostgreSQL 14 на копии учебной базы «Маяк». Песочница SQL-курса принимает только SELECT и WITH, поэтому INSERT запускайте на своём PostgreSQL: локальном или в Docker. SELECT, который собирает строки для вставки, можно сначала проверить в песочнице.

Синтаксис INSERT INTO

Две основные формы: со списком значений и с запросом. В обеих после имени таблицы стоит список колонок в скобках, и значения сопоставляются с ним по порядку.

Две формы команды
insert into table_name (column1, column2, column3)
values (value1, value2, value3);

insert into table_name (column1, column2)
select expression1, expression2
from source_table
where condition;

Таблица для примеров: identity и значения по умолчанию

Справочник промокодов. Идентификатор генерирует сама база через generated always as identity, у скидки и флага активности есть значения по умолчанию, код обязан быть уникальным, а тариф — заполненным. Как создавать такие таблицы, разобрано в статье про CREATE TABLE.

Справочник промокодов (PostgreSQL)
create table promo_codes (
  promo_id   int generated always as identity primary key,
  code       text not null unique,
  plan       text not null,
  discount   numeric(4,2) not null default 0.10,
  is_active  boolean not null default true
);

Как вставить одну и несколько строк

Одна строка: insert into promo_codes (code, plan, discount) values ('SUMMER26', 'pro', 0.20) — INSERT 0 1. Первое число в ответе — наследие старых версий PostgreSQL, для обычных таблиц оно всегда 0. Второе — число вставленных строк.

Несколько строк вставляются одной командой: наборы значений в скобках перечисляются через запятую. Это быстрее, чем отдельный INSERT на каждую строку, и атомарно: если одна строка нарушит ограничение, не вставится ни одна. Ключевое слово default на месте значения просит взять значение по умолчанию, и у WELCOME скидка стала 0.10.

Содержимое promo_codes после двух команд
promo_idcodeplandiscountis_active
1SUMMER26pro0.20t
2TEAM3team0.15t
3BASICFREEbasic1.00t
4WELCOMEbasic0.10t
Три строки одной командой: INSERT 0 3
insert into promo_codes (code, plan, discount)
values
  ('TEAM3',     'team',  0.15),
  ('BASICFREE', 'basic', 1.00),
  ('WELCOME',   'basic', default);

Зачем всегда перечислять колонки

Без списка колонок значения сопоставляются с колонками таблицы в порядке их создания. Запрос insert into promo_codes values ('AUTUMN', 'pro', 0.25, true) пытается положить строку AUTUMN в promo_id и падает с ERROR: invalid input syntax for type integer: "AUTUMN". Здесь повезло: типы не совпали. Если бы две соседние колонки были одного типа, значения молча легли бы не туда.

Список колонок делает запрос независимым от порядка колонок в таблице и сразу показывает читателю, что куда пишется. Колонки, которых нет в списке, получают значения по умолчанию — поэтому promo_id, is_active, а иногда и discount в примерах не упоминаются.

Колонку generated always as identity заполнить вручную нельзя: insert into promo_codes (promo_id, code, plan) values (100, 'VIP', 'team') отвечает ERROR: cannot insert a non-DEFAULT value into column "promo_id" с подсказкой про OVERRIDING SYSTEM VALUE. Это защита, а не неудобство. У старого serial её нет: в таблице notes (id serial primary key, note text) ручная вставка id = 1 проходит, а следующая обычная вставка берёт из счётчика ту же единицу и падает с ERROR: duplicate key value violates unique constraint "notes_pkey".

RETURNING: получить id вставленной строки

Часто после вставки нужен сгенерированный идентификатор, например чтобы записать его в связанную таблицу. RETURNING возвращает вставленные строки так же, как SELECT, и видит значения, которые подставила база: identity и значения по умолчанию.

Идентификаторы не обязаны идти подряд. После двух неудачных вставок, о которых ниже, следующий успешный промокод получил promo_id 8, а не 6: номера 6 и 7 израсходовали упавшие команды. Счётчик identity не откатывается вместе с транзакцией, и дыры в нумерации — нормальное состояние, а не потеря данных.

Вставка с возвратом id: promo_id 5
insert into promo_codes (code, plan)
values ('PARTNER5', 'pro')
returning promo_id, code, discount, is_active;
-- promo_id | code     | discount | is_active
--        5 | PARTNER5 |     0.10 | t

Какие ошибки выдаёт INSERT при нарушении ограничений

Ограничения таблицы проверяются на каждой строке, и сообщения PostgreSQL прямо называют колонку и значение. Промокод без тарифа нарушает NOT NULL, повторный SUMMER26 — уникальность кода. В DETAIL видна строка целиком, включая значения по умолчанию и номер 6, который уже взят из счётчика.

sqlОтвет PostgreSQL 14 на две неудачные вставки
insert into promo_codes (code, discount) values ('NOPLAN', 0.10);
-- ERROR:  null value in column "plan" of relation "promo_codes" violates not-null constraint
-- DETAIL:  Failing row contains (6, NOPLAN, null, 0.10, t).

insert into promo_codes (code, plan) values ('SUMMER26', 'basic');
-- ERROR:  duplicate key value violates unique constraint "promo_codes_code_key"
-- DETAIL:  Key (code)=(SUMMER26) already exists.

INSERT INTO … SELECT: заполнить витрину из событий

Вернёмся к дашборду. Витрина daily_activity хранит по строке на день: активные пользователи, все события и созданные отчёты. День — первичный ключ, а loaded_at заполняется по умолчанию временем загрузки. Вместо VALUES после списка колонок стоит SELECT с группировкой.

Июнь загружается командой ниже: INSERT 0 30, в витрине 30 дней, 6018 событий и 454 созданных отчёта. Тот же SELECT без первой строки можно запустить в песочнице курса и сверить числа до вставки. Июль дописывается тем же запросом с другими границами: INSERT 0 31 и 12 752 события.

Первые пять строк витрины
dayactive_userseventsreports_created
2026-06-01327015
2026-06-02538310
2026-06-037611713
2026-06-04851248
2026-06-0510414613
Загрузка июня в витрину: INSERT 0 30
create table daily_activity (
  day              date primary key,
  active_users     int not null,
  events           int not null,
  reports_created  int not null,
  loaded_at        timestamp not null default now()
);

insert into daily_activity (day, active_users, events, reports_created)
select
  event_time::date                                      as day,
  count(distinct user_id)                               as active_users,
  count(*)                                              as events,
  count(*) filter (where event_name = 'report_created') as reports_created
from events
where event_time >= '2026-06-01'
  and event_time <  '2026-07-01'
group by event_time::date;

Частые ошибки INSERT SELECT

Колонки сопоставляются по позиции, а не по именам. Псевдонимы в SELECT ни на что не влияют. Если в запросе за 1 августа поменять местами count(*) as events и count(distinct user_id) as active_users, команда выполнится без ошибок и запишет 466 активных пользователей при 428 событиях. Такое соотношение невозможно, и на нём ошибку легко поймать, но между двумя похожими суммами подмену никто не заметит. Держите порядок выражений в SELECT таким же, как в списке колонок.

Повторный запуск за тот же период падает: ERROR: duplicate key value violates unique constraint "daily_activity_pkey" с DETAIL: Key (day)=(2026-06-01) already exists. Первичный ключ сработал как предохранитель и не дал удвоить витрину. Без ключа тот же повтор тихо вставил бы вторую копию июня.

Если перезапуск — штатная ситуация, например поздние события приходят через день, используйте insert … on conflict (day) do update. Команда обновит существующие дни и вставит новые; пример с этой же витриной разобран в статье про UPSERT.

Как дописать только новые дни

Когда витрину пополняет ежедневная задача, удобно не вычислять границы периода, а вставлять только те дни, которых в ней ещё нет. Условие NOT EXISTS отсекает события уже загруженных дней до группировки.

В витрине лежат июнь и июль, 61 день. Команда ниже отвечает INSERT 0 30: добавился август по 30-е число включительно. Теперь в витрине 91 строка, а сумма событий — 35 341, ровно столько, сколько в исходной таблице. Повторный запуск отвечает INSERT 0 0: вставлять нечего, и ошибки ключа тоже нет.

У подхода есть ограничение: день, загруженный частично, так и останется неполным, потому что NOT EXISTS увидит его в витрине и пропустит. Если события приходят с опозданием, последний день лучше перезаписывать через ON CONFLICT.

Вставить дни, которых нет в витрине: INSERT 0 30, повтор — INSERT 0 0
insert into daily_activity (day, active_users, events, reports_created)
select
  event_time::date,
  count(distinct user_id),
  count(*),
  count(*) filter (where event_name = 'report_created')
from events as e
where not exists (
  select 1 from daily_activity as d
  where d.day = e.event_time::date
)
group by event_time::date;

Чем CREATE TABLE AS отличается от INSERT SELECT

create table daily_activity_ctas as select … создаёт таблицу и заполняет её одной командой: ответ SELECT 91, по строке на каждый день с 1 июня по 30 августа. Удобно для разовой выгрузки, но типы колонок база выводит сама: count(*) стал bigint, первичного ключа, NOT NULL и значений по умолчанию нет.

Для витрины, которую будут пополнять, надёжнее создать таблицу явно и заполнять её INSERT SELECT: ключ защитит от дублей, а типы и ограничения останутся такими, как вы их задумали. CREATE TABLE AS хорош для временных расчётов и снимков.

Что вы получаете в каждом случае
CREATE TABLE ASCREATE TABLE + INSERT SELECT
Типы колоноквыводятся из запросазадаёте вы
Первичный ключ, NOT NULL, DEFAULTнетесть, если объявлены
Повторный запускошибка: таблица уже существуетошибка ключа или upsert
Дописать новый периоднельзя, только пересоздатьещё один INSERT SELECT

Как загрузить много строк: COPY и \copy

Для загрузки файла на тысячи и миллионы строк INSERT не лучший инструмент. В PostgreSQL для этого есть COPY table (columns) FROM 'file.csv' WITH (FORMAT csv, HEADER): он читает файл на сервере базы и работает заметно быстрее построчных вставок. Если файл лежит на вашем компьютере, используйте команду psql \copy с тем же синтаксисом: она читает файл локально и передаёт строки на сервер. Ограничения таблицы при этом проверяются так же, как у INSERT.

Чеклист перед INSERT

Что проверить, прежде чем дописывать строки в общую таблицу.

  • Список колонок указан явно, порядок выражений в VALUES или SELECT совпадает с ним.
  • SELECT для вставки запущен отдельно, число строк и итоги сходятся с ожиданием.
  • У таблицы есть ключ, который не даст вставить тот же период дважды.
  • Идентификаторы генерирует база, а нужные значения возвращает RETURNING.
  • Если перезапуск ожидаем, вместо INSERT используется ON CONFLICT.

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

Соберите в песочнице SELECT для недельной витрины: неделя, активные пользователи, события, отчёты. Когда числа сойдутся, создайте таблицу на своём PostgreSQL и загрузите её через INSERT SELECT.

Продолжить чтение
Вся библиотека