«Удалить все строки из таблицы» — две команды, один результат на экране и две совершенно разные истории внутри PostgreSQL. Одна из них закончится спокойным вечером, другая — ночным созвоном с инцидентом. Разберём обе на примере одной пятницы и поиграем с симулятором, чтобы почувствовать разницу руками.
Пятница, 17:40
Лена — бэкенд-разработчик в небольшой финтех-команде. Таблица events с логами действий пользователей выросла до 180 млн строк и 90 ГБ. Диск на проде заполнен на 88%, а продакт говорит: «Нам нужны только последние 30 дней, остальное можно выбросить».
Лена открывает консоль и пишет первое, что приходит в голову:
DELETE FROM events WHERE created_at < now() - interval '30 days';Запрос работает 40 минут. Всё это время растёт отставание реплики, а когда он наконец завершается… диск заполнен ещё сильнее. Таблица «похудела» на 150 млн строк, но файл не уменьшился ни на мегабайт. Лена в замешательстве, и тогда в чат пишет коллега: «А ты не пробовала TRUNCATE?»
Лена пробует на staging — и таблица очищается за долю секунды, а диск освобождается мгновенно. Воодушевлённая, она готовит ту же команду для прода… но тут вспоминает, что в 30 днях нужно что-то сохранить. И что на таблицу смотрит отчётный сервис. И что у неё есть дочерние таблицы.
DELETE: скальпель
DELETE — обычная DML-команда. Она находит строки, подходящие под условие WHERE, и удаляет именно их. Это точный инструмент: вы управляете тем, что исчезнет.
Что это и зачем
Используйте DELETE, когда нужно убрать часть данных: конкретного пользователя, устаревшие записи, дубликаты, отменённые заказы. Он работает по правилам MVCC, поэтому параллельные запросы продолжают работать, пока вы удаляете.
-- удалить конкретные строки
DELETE FROM orders WHERE status = 'cancelled' AND created_at < now() - interval '1 year';
-- вернуть удалённое (удобно для аудита и логов)
DELETE FROM sessions
WHERE expires_at < now()
RETURNING id, user_id;
-- удаление по данным из другой таблицы
DELETE FROM order_items oi
USING orders o
WHERE oi.order_id = o.id AND o.status = 'cancelled';Что умеет только DELETE
WHERE— выборочное удаление;RETURNING— получить удалённые строки в ответе;USING— удаление с присоединением других таблиц;- срабатывание триггеров
ON DELETEи проверки внешних ключей (ON DELETE CASCADE,SET NULLи т.д.); - конкурентная работа: берёт только блокировки на уровне строк и
ROW EXCLUSIVEна таблице, так что обычныеSELECTне мешают и не мешаются.
DELETE работает построчно. Для каждой строки пишется WAL, обновляются индексы, проверяются ограничения и вызываются триггеры. Чем больше строк, тем дольше — и тем больше «следов» остаётся в таблице.TRUNCATE: бульдозер
TRUNCATE не ищет строки. Он берёт таблицу целиком и заменяет её пустым файлом. Никакого WHERE, никаких построчных проверок.
Что это и зачем
Это быстрый способ обнулить таблицу полностью: очистить staging перед загрузкой, сбросить тестовые данные между прогонами, избавиться от временных агрегатов. В документации PostgreSQL это «быстрое удаление всех строк» — и это не маркетинг: время почти не зависит от количества строк.
-- очистить одну таблицу
TRUNCATE TABLE staging_events;
-- сразу несколько таблиц одной командой
TRUNCATE TABLE staging_events, staging_users;
-- сбросить и счётчики (sequence), которыми владеют колонки таблицы
TRUNCATE TABLE staging_events RESTART IDENTITY;
-- очистить таблицу и все, кто на неё ссылается по внешним ключам
TRUNCATE TABLE users CASCADE;Ключевые свойства
- берёт
ACCESS EXCLUSIVE— самую сильную блокировку: на время работы таблица недоступна даже дляSELECT; - не запускает триггеры
ON DELETE(есть отдельныеON TRUNCATE); - транзакционный: в PostgreSQL его можно откатить (в отличие от, например, MySQL, где
TRUNCATE— DDL с неявным коммитом); - не MVCC-безопасен: транзакции со старым снимком (например,
REPEATABLE READ) после коммита могут увидеть таблицу пустой; - отказывается работать, если на таблицу ссылаются внешние ключи из других таблиц — пока не добавить их в ту же команду или не указать
CASCADE; - требует отдельной привилегии
TRUNCATE, а неDELETE.
Что происходит под капотом
Чтобы понять, почему у них настолько разное поведение, нужно заглянуть в то, как PostgreSQL хранит данные.
Мёртвые строки и VACUUM
В PostgreSQL таблица — это файл, разбитый на страницы по 8 КБ. В странице лежат версии строк (tuples). Когда вы делаете DELETE, строка не стирается: в ней проставляется идентификатор удаляющей транзакции (xmax). Старые транзакции, которые ещё должны её «видеть», продолжают читать прежние данные. Это и есть MVCC.
Мёртвые строки физически убирает VACUUM (обычно это делает autovacuum). Но он лишь помечает место внутри файла как пригодное для повторного использования. Размер файла на диске при этом, как правило, остаётся прежним. Вернуть место операционной системе может только VACUUM FULL (он переписывает всю таблицу и берёт ACCESS EXCLUSIVE) либо — в частном случае — обычный VACUUM, если пустые страницы оказались в самом конце файла.
TRUNCATE устроен иначе: он создаёт для таблицы новый пустой файл (новый relfilenode) и удаляет старый. Строки не обходятся и не помечаются — поэтому и мёртвых строк, и работы для VACUUM нет.
-- сколько живых и мёртвых строк, когда был autovacuum
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'events';
-- сколько реально занимает таблица вместе с индексами и TOAST
SELECT pg_size_pretty(pg_total_relation_size('events'));
-- имя файла таблицы: после TRUNCATE оно изменится
SELECT pg_relation_filepath('events');pg_relation_filepath до и после TRUNCATE на тестовой таблице — вы увидите другой файл. Это самое наглядное доказательство, что строки не удалялись, а таблицу «подменили».Симулятор: попробуй сам
Ниже — упрощённая модель одной таблицы events. Каждый квадрат — версия строки, блок из шести квадратов — страница. Нажимайте кнопки и следите за счётчиками: файл, мёртвые строки, WAL, sequence. Включите галочки с триггером и внешним ключом, чтобы увидеть ограничения. Попробуйте открыть транзакцию, выполнить TRUNCATE и откатить её.
Что стоит попробовать
- Нажмите DELETE (без WHERE). Живых строк нет, а страниц — столько же. Размер не изменился, мёртвые строки висят.
- Нажмите VACUUM. Мёртвые строки превратились в свободное место, но файл не уменьшился (только пустой «хвост» может отрезаться).
- Нажмите INSERT: новые строки займут освободившееся место, файл не растёт. Вот ради чего нужен VACUUM.
- Сбросьте симулятор и нажмите TRUNCATE. Файл сразу стал пустым, мёртвых строк нет. Посмотрите на WAL — почти ноль.
- Включите триггер и повторите оба варианта. DELETE пишет строки в аудит, TRUNCATE — нет.
- Включите внешний ключ и попробуйте TRUNCATE без CASCADE. Затем с CASCADE — и посмотрите, что случилось с дочерней таблицей.
- Нажмите BEGIN → TRUNCATE RESTART IDENTITY → ROLLBACK. Данные и счётчик вернулись. Откат TRUNCATE в Postgres работает.
Блокировки: цена скорости
У TRUNCATE есть расплата за скорость — блокировка ACCESS EXCLUSIVE. Она конфликтует вообще со всеми другими режимами, включая безобидный ACCESS SHARE, который берёт обычный SELECT. Пока TRUNCATE не закончился (а в транзакции — пока она не закоммичена), другие сессии не могут даже прочитать таблицу.
DELETE берёт ROW EXCLUSIVE: он не конфликтует с SELECT и другими вставками/удалениями. Конфликтуют только запросы, пытающиеся изменить те же строки.
Очередь блокировок: самая частая ловушка
Самое неприятное — не сам TRUNCATE, а то, что происходит, пока он ждёт своей блокировки. Если кто-то держит долгую транзакцию с SELECT по этой таблице, TRUNCATE встанет в очередь. И все новые запросы встанут в очередь за ним, потому что обгонять запрос, ждущий эксклюзивной блокировки, нельзя. Приложение «замирает» при том, что никто ничего «страшного» не делает.
Пройдите сценарий по шагам и переключайтесь между TRUNCATE и DELETE:
| Сессия | Команда | Состояние |
|---|
-- защита: не ждать блокировку дольше нескольких секунд
BEGIN;
SET LOCAL lock_timeout = '3s';
TRUNCATE TABLE staging_events;
COMMIT;
-- кто кого блокирует прямо сейчас
SELECT pid,
pg_blocking_pids(pid) AS blocked_by,
wait_event_type,
state,
left(query, 80) AS query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;Как это влияет на производительность
Разница в работе — это не «чуть быстрее», а другой порядок величины. DELETE стоит примерно O(N) от числа удаляемых строк, TRUNCATE — почти O(1) от числа строк (зависит скорее от числа файлов: таблица, индексы, TOAST).
Что именно делает DELETE дорогим
- WAL. Для каждой строки пишется запись в журнал. Миллионы удалений — гигабайты WAL, нагрузка на диск и отставание физических и логических реплик.
- Индексы. Записи в индексах не исчезают сразу, а тоже становятся мёртвыми и ждут очистки. Чем больше индексов, тем дороже.
- Триггеры и внешние ключи. На каждую строку может сработать триггер или проверка ссылочной целостности.
- Bloat и VACUUM. После массового удаления таблица раздута мёртвыми строками, а autovacuum тратит ресурсы на очистку. Раздутые таблицы и индексы замедляют последовательные чтения.
- Долгая транзакция. Пока она открыта, VACUUM не может убрать то, что нужно её снимку, и мёртвые строки копятся по всей базе.
Прикинем масштаб
Двигайте ползунок. Модель очень грубая: она показывает порядок величин, а не результат вашего железа.
А если нужно удалять много, но не всё?
Для массового удаления «по условию» есть два проверенных приёма.
1. Удаление пачками. Короткие транзакции дают autovacuum шанс работать параллельно, не раздувают WAL одним куском и не держат блокировки долго:
-- повторяйте, пока команда не вернёт DELETE 0
DELETE FROM events
WHERE id IN (
SELECT id
FROM events
WHERE created_at < now() - interval '30 days'
LIMIT 10000
FOR UPDATE SKIP LOCKED
);
-- в psql можно повторять автоматически каждые 2 секунды
\watch 22. Партиционирование. Если данные естественно делятся по времени, то «удалить старое» превращается в отсоединение и удаление целой партиции. Это такая же мгновенная операция, как TRUNCATE, но только для одного куска таблицы:
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY,
user_id bigint NOT NULL,
payload jsonb,
created_at timestamptz NOT NULL
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2026_08 PARTITION OF events
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
-- через месяц: старую партицию убираем без нагрузки на основную таблицу
ALTER TABLE events DETACH PARTITION events_2026_08 CONCURRENTLY;
DROP TABLE events_2026_08;Примеры из практики
Staging-таблица для ETL
Каждую ночь импорт заливает в staging_products свежий прайс-лист, а затем переносит в основную таблицу. Перед загрузкой staging нужно очистить целиком. Читателей нет, триггеров нет, строк — миллионы.
BEGIN;
TRUNCATE TABLE staging_products;
\copy staging_products FROM 'prices.csv' CSV HEADER
COMMIT;Почему так: транзакция гарантирует, что при ошибке загрузки вы не останетесь с пустой таблицей.
Очистка БД между интеграционными тестами
Тестам нужна чистая база и предсказуемые id. Удалять по одной таблице в правильном порядке FK долго и хрупко.
TRUNCATE TABLE users, orders, order_items
RESTART IDENTITY CASCADE;Почему так: быстро, сбрасывает счётчики, не важен порядок таблиц. На тестовой базе опасность CASCADE не страшна.
Право на забвение (GDPR)
Пользователь просит удалить его данные. Нужно удалить только его строки, зафиксировать, что именно удалено, и не мешать остальным.
WITH removed AS (
DELETE FROM user_events
WHERE user_id = 4217
RETURNING id
)
INSERT INTO erasure_audit (user_id, table_name, rows_removed)
SELECT 4217, 'user_events', count(*) FROM removed;Почему так: WHERE и RETURNING — это то, чего нет у TRUNCATE.
Просроченные сессии на живой таблице
Раз в минуту cron удаляет истёкшие сессии. Таблица постоянно читается и пишется приложением, остановить её нельзя.
DELETE FROM sessions WHERE expires_at < now();Почему так: TRUNCATE здесь немыслим — он заблокировал бы авторизацию во всём сервисе.
Где это опасно
Лена решила не рисковать продом: сохранить последние 30 дней в соседнюю таблицу, подменить и затем сделать TRUNCATE старой. Но коллега из соседней команды предупредил: «У нас отчётный сервис держит транзакцию часами. Если TRUNCATE встанет в очередь — повиснет весь сайт». Хорошо, что она спросила до запуска.
Опасности DELETE
- Забытый WHERE.
DELETE FROM users;выполнится без вопросов. Привычка: сначалаBEGIN;, затем команда, проверка числа строк (DELETE 12) и только потомCOMMIT. Или тот же запрос какSELECT count(*)с тем же условием. - Один гигантский запрос. Долгая транзакция, всплеск WAL, отставание реплик, блокировки строк для других.
- Иллюзия освобождённого места. Диск не освободится. Кроме того, раздутые таблицы и индексы замедляют запросы.
- Каскады по внешним ключам.
ON DELETE CASCADEтихо удалит дочерние данные — проверьте схему заранее.
Опасности TRUNCATE
- ACCESS EXCLUSIVE и очередь блокировок. Может «подвесить» работающий сервис, даже если сам выполняется мгновенно. Всегда ставьте
lock_timeout. - CASCADE выходит за рамки. Очищает все таблицы, которые ссылаются на вашу, и их собственные зависимости. Посмотрите связи (
\d+ table) до запуска. - Нет триггеров ON DELETE. Аудит, «мягкие» каскады, уведомления — ничего не сработает. Нужны триггеры — вешайте
ON TRUNCATE. - Не MVCC-безопасен. Транзакция
REPEATABLE READ/SERIALIZABLE, начатая доTRUNCATE, после его коммита увидит пустую таблицу. Для отчётных и аналитических сессий это сюрприз. - Репликация. В логической репликации
TRUNCATEреплицируется, только если публикация его включает (по умолчанию да). Проверьте подписчиков, чтобы не очистить их неожиданно. - Отдельное право. Привилегия
TRUNCATEвыдаётся отдельно отDELETE. Не раздавайте её лишний раз. - Нет «откатить на проде» как стратегии. Откат возможен только в рамках незавершённой транзакции. После
COMMITданные вернёт лишь бэкап.
ACCESS EXCLUSIVE всё то время, пока думаете.Что выбрать: помощник
Отметьте условия своей ситуации — подсказка обновится сразу.
Сводная таблица: последствия и побочные эффекты
| Критерий | DELETE | TRUNCATE |
|---|---|---|
| Выбор строк | Любое условие WHERE, USING | Только вся таблица (или несколько таблиц целиком) |
| Блокировка таблицы | ROW EXCLUSIVE + блокировки строк. SELECT не мешает | ACCESS EXCLUSIVE: блокирует даже чтение |
| Скорость на больших таблицах | Растёт линейно с числом строк | Почти не зависит от числа строк |
| Объём WAL | Запись на каждую строку (и индексы) | Минимальный, несколько записей |
| Место на диске | Не освобождается сразу; нужен VACUUM, полностью — VACUUM FULL | Освобождается сразу: старые файлы удаляются |
| Мёртвые строки, bloat | Остаются, нагрузка на autovacuum | Нет: таблица и индексы новые |
| Триггеры | Срабатывают ON DELETE для каждой строки | Только ON TRUNCATE; ON DELETE не срабатывают |
| Внешние ключи | Проверяются, работают ON DELETE CASCADE/SET NULL | Запрещён без включения ссылающихся таблиц; CASCADE очищает их целиком |
RETURNING | Есть | Нет |
| Sequence / identity | Не сбрасываются | По умолчанию не сбрасываются, RESTART IDENTITY сбрасывает |
| Откат в транзакции | Да | Да (но блокировка держится до конца транзакции) |
| MVCC-безопасность | Да | Нет: старые снимки видят пустую таблицу после коммита |
| Репликация | Много строковых изменений — лаг реплик | Одна операция; логическая репликация передаёт её, если публикация включает TRUNCATE |
| Привилегия | Право DELETE (+ SELECT для WHERE) | Отдельное право TRUNCATE |
| Типичное применение | Точечные и регулярные удаления на живой таблице, GDPR, чистка по сроку | Staging, тесты, полный сброс изолированной таблицы |
Плюсы и минусы коротко
DELETE
- Точность: ровно те строки, что нужны
- Не мешает чтению и другим записям
RETURNING, триггеры, FK-каскады- Безопасен для MVCC
- Медленный на больших объёмах
- Раздувает таблицу и WAL
- Не возвращает место на диск
- Требует VACUUM и мониторинга
TRUNCATE
- Мгновенный на любом объёме
- Мало WAL, нет bloat
- Сразу освобождает диск
RESTART IDENTITYсбрасывает счётчики
ACCESS EXCLUSIVE: блокирует даже SELECT- Нет
WHEREиRETURNING - Не вызывает
ON DELETE - Опасный
CASCADE, не MVCC-безопасен
Проверь себя
Четыре вопроса по статье. Ответ можно менять до тех пор, пока не нажали на правильный.
В итоге Лена пошла другим путём: перевела events на секционирование по месяцам, а «хвост» старых данных вычистила пачками по 10 тысяч строк в ночное окно. С тех пор старые данные уходят командой DETACH PARTITION + DROP TABLE за секунды, а TRUNCATE она применяет там, где он на месте: в staging и тестах, с lock_timeout и без сюрпризов.
Шпаргалка
- Нужна часть данных или нужен
RETURNING—DELETE. - Вся таблица, изолированная, без читателей —
TRUNCATE. - Много строк по условию — пачками или через партиции.
- Перед
TRUNCATEна проде:lock_timeout, проверка FK и репликации. - После массового
DELETE: смотритеn_dead_tupи состояние autovacuum.
Полезные материалы
Все ссылки ведут в документацию PostgreSQL ветки current — на последнюю стабильную версию (на момент публикации — 18).
- DELETE — синтаксис,
RETURNING,USING - TRUNCATE —
RESTART IDENTITY,CASCADE, замечания о блокировках и MVCC - Concurrency Control: Introduction — как работает MVCC
- Explicit Locking — режимы блокировок таблиц и строк, таблица конфликтов
- Routine Vacuuming — зачем нужен VACUUM, autovacuum, bloat
- VACUUM — команда,
FULL, когда файл уменьшается - Client Connection Defaults — параметры
lock_timeoutиstatement_timeout - Table Partitioning — партиционирование,
DETACH PARTITION - ALTER TABLE —
DETACH PARTITION … CONCURRENTLY - Foreign Keys —
ON DELETE CASCADE,SET NULL - CREATE TRIGGER — триггеры
ON DELETEиON TRUNCATE - The Cumulative Statistics System —
pg_stat_user_tables,n_dead_tup - Write-Ahead Logging — почему WAL влияет на производительность и репликацию
- Logical Replication: Publication — какие операции публикуются, включая
TRUNCATE - GRANT — привилегии
DELETEиTRUNCATE - Database File Layout — файлы таблиц,
relfilenode - Database Object Management Functions —
pg_relation_filepath,pg_total_relation_size