INSERT INTO в SQL: вставка строк, INSERT SELECT и RETURNING
INSERT INTO в SQL и PostgreSQL: вставка нескольких строк, DEFAULT и identity, INSERT … SELECT для витрины, RETURNING и частые ошибки.
Содержание статьи
Дашборд активности каждое утро пересчитывает 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.
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_id | code | plan | discount | is_active |
|---|---|---|---|---|
| 1 | SUMMER26 | pro | 0.20 | t |
| 2 | TEAM3 | team | 0.15 | t |
| 3 | BASICFREE | basic | 1.00 | t |
| 4 | WELCOME | basic | 0.10 | t |
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 не откатывается вместе с транзакцией, и дыры в нумерации — нормальное состояние, а не потеря данных.
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, который уже взят из счётчика.
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 события.
| day | active_users | events | reports_created |
|---|---|---|---|
| 2026-06-01 | 32 | 70 | 15 |
| 2026-06-02 | 53 | 83 | 10 |
| 2026-06-03 | 76 | 117 | 13 |
| 2026-06-04 | 85 | 124 | 8 |
| 2026-06-05 | 104 | 146 | 13 |
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 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 AS | CREATE 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.
Материалы по теме

UPDATE в SQL: синтаксис, примеры и обновление по другой таблице
UPDATE в SQL и PostgreSQL: обновление одного и нескольких полей, CASE, UPDATE … FROM по другой таблице, RETURNING и безопасный порядок через транзакцию.

DELETE в SQL: как удалить строки и не потерять лишнее
DELETE в SQL и PostgreSQL: удаление по условию, DELETE … USING, удаление дублей, RETURNING, разница DELETE, TRUNCATE и DROP, внешние ключи.

CREATE TABLE в SQL: как создать таблицу, типы и ограничения
CREATE TABLE в SQL: как создать таблицу в PostgreSQL, выбрать типы, задать PRIMARY KEY, NOT NULL, CHECK и внешний ключ, CREATE TABLE AS.