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

DELETE в SQL: как удалить строки и не потерять лишнее

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

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

Тестировщики неделю проверяли продукт под обычными аккаунтами, и их клики попали в события. В воронке лишние создания отчётов, в 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.

Общий вид команды в 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.

Предпросмотр и удаление с одинаковым WHERE
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, здесь дубли источника безвредны.

Удаление событий тестовых аккаунтов: DELETE 62 (PostgreSQL)
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.

Одна задача, два условия; в subscriptions есть строка с user_id = NULL
Условие 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 он возвращает пустой результат: там дублей нет. Как искать и считать дубли без удаления, разобрано в статье про дедупликацию событий.

Два способа удалить копии: оба дают DELETE 94 (PostgreSQL)
-- полностью одинаковые строки: оставить одну по 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 открытия приложения» и вернуть строки, если список тестировщиков оказался неверным.

Удалить и заархивировать одной командой: INSERT 0 62 (PostgreSQL)
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 в PostgreSQL
DELETETRUNCATEDROP TABLE
Что удаляетвыбранные строкивсе строкитаблицу целиком
WHEREданетнет
Скорость на больших таблицахпропорциональна числу строкпочти не зависит от размерапочти не зависит от размера
Откат в транзакциидадада
Триггеры ON DELETEсрабатываютнет, только ON TRUNCATEнет
Счётчик identityне сбрасываетсясбрасывается с RESTART IDENTITYудаляется вместе с таблицей
Если на таблицу ссылается внешний ключошибка только для строк со ссылкамиошибка без CASCADEошибка без CASCADE

Что происходит с внешними ключами

Если на строку ссылаются из другой таблицы, PostgreSQL не даст её удалить. Две маленькие таблицы: workspaces и reports, у отчёта есть внешний ключ на пространство. Попытка удалить пространство 1, в котором два отчёта, заканчивается ошибкой, а TRUNCATE родительской таблицы — другой.

С on delete cascade в определении ключа удаление проходит и тянет за собой зависимые строки. Ответ при этом DELETE 1: в числе учтена только строка из workspaces, а два отчёта исчезли молча. В reports остался один отчёт из пространства 2. Каскад удобен для служебных связей, но в аналитических таблицах из-за него легко потерять больше, чем вы посчитали в предпросмотре.

sqlОтвет PostgreSQL 14 на DELETE и TRUNCATE родительской таблицы
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 выполняйте на своей копии базы.

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