DELETE в SQL: как удалить строки и не потерять лишнее
DELETE в SQL и PostgreSQL: удаление по условию, DELETE … USING, удаление дублей, RETURNING, разница DELETE, TRUNCATE и DROP, внешние ключи.
Содержание статьи
Тестировщики неделю проверяли продукт под обычными аккаунтами, и их клики попали в события. В воронке лишние создания отчётов, в DAU — пятеро людей, которые не клиенты. Их события надо убрать из аналитической копии базы. DELETE сделает это одной строкой, и той же одной строкой, но без WHERE, он удалит все 35 341 событие.
Что делает DELETE в SQL
DELETE — команда SQL, которая удаляет из таблицы строки, подходящие под условие WHERE. Структура таблицы, колонки, индексы и права остаются на месте, пропадают только выбранные строки. Команда возвращает число удалённых строк, а в PostgreSQL может ещё и вернуть сами удалённые строки через RETURNING.
Примеры выполнены на копии учебной базы «Маяк» в PostgreSQL 14. Песочница SQL-курса принимает только SELECT и WITH, поэтому DELETE запускайте на своём PostgreSQL, локальном или в Docker. Для каждого удаления ниже есть SELECT, который считает затронутые строки, — его можно выполнить в песочнице.
Синтаксис DELETE FROM
Обязательны только delete from и имя таблицы. Всё остальное — фильтр и дополнительные возможности PostgreSQL.
delete from table_name [as alias]
[using other_table] -- расширение PostgreSQL
[where condition]
[returning columns];Как удалить строки по условию
Первая неделя июня была закрытой бетой, и её события не должны попадать в продуктовые отчёты. Сначала считаем: select count(*) from events where event_time < '2026-06-08' возвращает 748. Затем тот же WHERE уходит в DELETE, и PostgreSQL отвечает DELETE 748.
Без WHERE команда удаляет всё: delete from events отвечает DELETE 35341. Внутри транзакции это исправляется одним ROLLBACK, вне её — только восстановлением из резервной копии. Поэтому любое удаление на рабочих данных начинайте с begin.
select count(*) from events
where event_time < '2026-06-08'; -- 748
begin;
delete from events
where event_time < '2026-06-08'; -- DELETE 748
commit; -- или rollback;DELETE … USING: удаление по списку из другой таблицы
Вернёмся к тестировщикам. Их идентификаторы собраны в таблицу test_users из пяти строк. В PostgreSQL вторую таблицу можно указать в USING, а условие соединения записать в WHERE — это аналог delete … join из MySQL.
До удаления проверьте масштаб. В песочнице это делается запросом по тем же идентификаторам: select user_id, count(*) from events where user_id in (7, 42, 105, 1500, 3001) group by user_id показывает 14, 15, 14, 9 и 10 событий, всего 62. DELETE отвечает DELETE 62.
Если в test_users окажется дубль идентификатора, лишних удалений не будет: строка удаляется один раз, сколько бы совпадений ни нашлось в USING. В отличие от UPDATE … FROM, здесь дубли источника безвредны.
create table test_users (user_id int);
insert into test_users values (7), (42), (105), (1500), (3001);
delete from events as e
using test_users as t
where e.user_id = t.user_id;DELETE с подзапросом: IN и EXISTS
Тот же список можно передать подзапросом, и такой вариант работает в любой базе. Участие тестировщиков в экспериментах удаляется так: delete from experiment_exposures where user_id in (select user_id from test_users) — DELETE 4. Эквивалентная запись через where exists (select 1 from test_users t where t.user_id = experiment_exposures.user_id) удаляет те же строки.
Опасен NOT IN. Допустим, в демо-копии базы нужно оставить только события пользователей с подпиской. select count(*) from events where user_id not in (select user_id from subscriptions) показывает 27 250 строк к удалению. Но если в subscriptions появится хотя бы одна строка с пустым user_id, DELETE с тем же NOT IN ответит DELETE 0: сравнение с NULL внутри списка делает условие неизвестным для каждой строки. NOT EXISTS в той же ситуации удаляет все 27 250.
Для удаления «всего, чего нет в другой таблице» пишите NOT EXISTS. Подробности с примерами — в статье про EXISTS и IN.
| Условие DELETE | Ответ PostgreSQL |
|---|---|
| user_id not in (select user_id from subscriptions) | DELETE 0 |
| not exists (select 1 from subscriptions s where s.user_id = e.user_id) | DELETE 27250 |
Как удалить дубликаты и оставить одну строку
Загрузчик дважды выгрузил 7 июня. Чтобы показать это на учебных данных, мы собрали таблицу events_raw из событий 1–7 июня и второй копии событий 7 июня. В ней 842 строки, но уникальных event_id только 748: 94 события лежат в двух копиях. Строки полностью одинаковы, поэтому отличить их можно только по физическому адресу строки — системной колонке ctid в PostgreSQL. Самосоединение оставляет копию с меньшим ctid и удаляет остальные: DELETE 94, после чего строк снова 748.
Бывает, что при повторной загрузке события получили новые идентификаторы. Тогда event_id у копий разный, и дубль определяется по смыслу: тот же пользователь, то же время, то же событие. Нумеруем копии через row_number() и удаляем все, кроме первой. Результат тот же: DELETE 94, осталось 748 строк с исходными идентификаторами.
Перед удалением посмотрите на сами дубли: select user_id, event_time, event_name, count(*) from events_raw group by 1, 2, 3 having count(*) > 1. Это обычный SELECT, и в песочнице его можно запустить на таблице events. На учебной таблице events он возвращает пустой результат: там дублей нет. Как искать и считать дубли без удаления, разобрано в статье про дедупликацию событий.
-- полностью одинаковые строки: оставить одну по ctid
delete from events_raw as a
using events_raw as b
where a.event_id = b.event_id
and a.ctid > b.ctid;
-- копии с разными id: оставить строку с меньшим event_id
delete from events_raw
where event_id in (
select event_id
from (
select event_id,
row_number() over (
partition by user_id, event_time, event_name
order by event_id
) as rn
from events_raw
) as ranked
where rn > 1
);RETURNING: сохранить удалённые строки для аудита
RETURNING возвращает удалённые строки так же, как SELECT. В связке с CTE их можно сразу записать в таблицу-архив, и удаление с копированием пройдут одной командой: либо обе части, либо ни одна.
Для тестовых аккаунтов команда отвечает INSERT 0 62: все удалённые события лежат в events_deleted, в events осталось 35 279 строк. Архив помогает ответить на вопрос «куда из отчёта делись 52 открытия приложения» и вернуть строки, если список тестировщиков оказался неверным.
create table events_deleted (like events);
alter table events_deleted add column deleted_at timestamp default now();
with removed as (
delete from events as e
using test_users as t
where e.user_id = t.user_id
returning e.*
)
insert into events_deleted (event_id, user_id, event_time, event_name)
select event_id, user_id, event_time, event_name
from removed;Чем DELETE отличается от TRUNCATE и DROP TABLE
Все три команды что-то убирают, но на разных уровнях. DELETE удаляет строки по условию. TRUNCATE очищает таблицу целиком, не просматривая строки. DROP TABLE удаляет саму таблицу вместе со структурой, индексами и правами.
Для скорости есть ориентир с ноутбука: на копии events из 35 341 строки DELETE без условия занял 7,6–10,2 мс, TRUNCATE — 1,0–2,3 мс в трёх прогонах. Числа иллюстративные; разница в том, что DELETE помечает каждую строку удалённой и оставляет работу для VACUUM, а TRUNCATE просто заменяет файлы таблицы.
В PostgreSQL TRUNCATE транзакционный: внутри begin таблица events_raw после TRUNCATE показывала 0 строк, а после rollback снова 748. В MySQL и ряде других баз TRUNCATE фиксируется сразу, так что эту деталь проверяйте по документации своей СУБД.
| DELETE | TRUNCATE | DROP TABLE | |
|---|---|---|---|
| Что удаляет | выбранные строки | все строки | таблицу целиком |
| WHERE | да | нет | нет |
| Скорость на больших таблицах | пропорциональна числу строк | почти не зависит от размера | почти не зависит от размера |
| Откат в транзакции | да | да | да |
| Триггеры ON DELETE | срабатывают | нет, только ON TRUNCATE | нет |
| Счётчик identity | не сбрасывается | сбрасывается с RESTART IDENTITY | удаляется вместе с таблицей |
| Если на таблицу ссылается внешний ключ | ошибка только для строк со ссылками | ошибка без CASCADE | ошибка без CASCADE |
Что происходит с внешними ключами
Если на строку ссылаются из другой таблицы, PostgreSQL не даст её удалить. Две маленькие таблицы: workspaces и reports, у отчёта есть внешний ключ на пространство. Попытка удалить пространство 1, в котором два отчёта, заканчивается ошибкой, а TRUNCATE родительской таблицы — другой.
С on delete cascade в определении ключа удаление проходит и тянет за собой зависимые строки. Ответ при этом DELETE 1: в числе учтена только строка из workspaces, а два отчёта исчезли молча. В reports остался один отчёт из пространства 2. Каскад удобен для служебных связей, но в аналитических таблицах из-за него легко потерять больше, чем вы посчитали в предпросмотре.
delete from workspaces where workspace_id = 1;
-- ERROR: update or delete on table "workspaces" violates foreign key constraint "reports_workspace_id_fkey" on table "reports"
-- DETAIL: Key (workspace_id)=(1) is still referenced from table "reports".
truncate workspaces;
-- ERROR: cannot truncate a table referenced in a foreign key constraint
-- DETAIL: Table "reports" references "workspaces".
-- HINT: Truncate table "reports" at the same time, or use TRUNCATE ... CASCADE.Мягкое удаление вместо DELETE
Аналитику часто нужнее не удалить строку, а перестать её учитывать. Для этого в таблицу добавляют колонку deleted_at timestamp, и «удаление» превращается в UPDATE: update users set deleted_at = now() where user_id in (7, 42, 105, 1500, 3001) — UPDATE 5. В таблице по-прежнему 4613 строк, но с условием where deleted_at is null их 4608.
Плюсы понятны: историю можно восстановить, а в отчёте за прошлый месяц видно, кто и когда был исключён. Минус — каждый запрос обязан помнить про фильтр. Обычно поверх таблицы делают представление с where deleted_at is null, и отчёты читают уже его.
Чеклист перед DELETE
Короткая проверка перед удалением строк из таблицы, которую читает кто-то ещё.
- SELECT с тем же WHERE вернул ожидаемое число строк.
- Команда запускается внутри
begin, а ответDELETE Nсовпадает с предпросмотром. - Условие «нет в другой таблице» написано через NOT EXISTS, а не NOT IN.
- Проверено, не ссылаются ли на строки внешние ключи и нет ли каскадного удаления.
- Удалённые строки сохранены через RETURNING или хватит мягкого удаления.
Что почитать дальше
Прогоните в песочнице SELECT-версии удалений из статьи: события тестовых аккаунтов, NOT IN против NOT EXISTS и поиск дублей. Сами DELETE выполняйте на своей копии базы.
Материалы по теме

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

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

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