Ситуация обычная: пришёл запрос на удаление персональных данных, разработчик выполнил DELETE FROM clients WHERE id = ..., отчитался. Формально всё правильно — строки в таблице нет, приложение её не видит, выгрузка не содержит.

А в файле базы она есть. Целиком, вместе с именем и номером карты, и достаётся обычным grep.

Показываю на SQLite

Создаём базу, кладём двести записей, удаляем часть.

import sqlite3

c = sqlite3.connect('base.db')
c.execute('pragma secure_delete = 0')
c.execute('create table clients(id integer, name text, card text)')
for i in range(1, 201):
    c.execute('insert into clients values (?,?,?)',
              (i, f'Клиент №{i} Иванов Пётр', f'4276 1600 0000 {i:04d}'))
c.commit()
c.execute('delete from clients where id between 5 and 50')
c.commit()

Проверяем, что удалилось:

строк в таблице: 154

Теперь ищем удалённое прямо в файле:

grep -a -c 'Клиент №7 Иванов Пётр' base.db
grep -a -c '4276 1600 0000 0007' base.db
1
1

Строки нет в таблице, а данные лежат в файле. DELETE помечает страницу как свободную для повторного использования, но не затирает её содержимое. Пока эту страницу не займут новые данные, там остаётся всё: имя, карта, что угодно.

От чего зависит результат: режим secure_delete

Строка pragma secure_delete = 0 стоит в примере не для красоты — именно она определяет, что произойдёт со страницей при удалении.

У SQLite есть два режима работы с освобождаемым местом. При secure_delete = 0 страница помечается свободной, содержимое остаётся нетронутым до момента, когда её займут новые данные. При secure_delete = 1 освобождаемая область заполняется нулями сразу.

Разница видна на том же примере. С secure_delete = 0 поиск по файлу находит удалённую запись:

'Клиент №7 Иванов Пётр'      вхождений в файле: 1
'4276 1600 0000 0007'        вхождений в файле: 1

С secure_delete = 1 тот же поиск не находит ничего.

Значение по умолчанию определяется тем, как собрана библиотека: у SQLite есть флаг компиляции SQLITE_SECURE_DELETE, и дистрибутивы выставляют его по-разному. Поэтому одно и то же приложение на сервере, в мобильной сборке и в десктопном клиенте может вести себя по-разному.

Узнать текущее значение для конкретной базы:

print(sqlite3.connect('base.db').execute('pragma secure_delete').fetchone())

0 означает, что удалённые данные остаются в файле до переиспользования страницы. 1 — что область затирается при освобождении. Проверять это нужно на той сборке и в той среде, где база работает в бою: значение из документации или из другой системы к вашему случаю отношения не имеет.

Отдельно стоит помнить, что secure_delete = 1 не отменяет ни журнала — WAL или откатного, куда прежние версии страниц попадают в ходе транзакций, — ни уже существующих резервных копий.

С другими базами то же самое, только сложнее

PostgreSQL при DELETE помечает версию строки мёртвой, физически она живёт до VACUUM. При обычном VACUUM место переиспользуется внутри файла, при VACUUM FULL файл перестраивается. Плюс есть журнал предзаписи, куда старые значения тоже попадают.

MySQL с InnoDB — аналогично: страницы освобождаются, но не затираются, плюс журналы отката и бинлог, который хранит операции с данными столько, сколько настроено.

Иначе говоря, «удалить строку» и «удалить данные» — не одно и то же ни в одной популярной СУБД.

Где это по-настоящему стреляет

Запросы на удаление персональных данных. Компания отвечает «данные удалены», имея в виду DELETE. При изъятии или утечке файла базы удалённое достаётся вместе с остальным.

Резервные копии. Даже если в живой базе всё затёрто, вчерашняя копия содержит данные полностью. Про них при обработке запроса на удаление не вспоминают почти никогда — а это самая частая точка, где «удалённое» продолжает жить месяцами.

Реплики и аналитика. Строку удалили в основной базе, а в хранилище отчётности она приехала утренней выгрузкой и осталась.

Файл, отданный вовне. Дамп SQLite-базы мобильного приложения, отправленный в поддержку для разбора, содержит всё, что пользователь когда-либо удалял.

Что с этим делать

Для SQLite — включить затирание там, где хранится чувствительное:

PRAGMA secure_delete = ON;

Плюс VACUUM после массовых удалений: он перестраивает файл и выбрасывает свободные страницы.

Для серверных СУБД — не полагаться на физическое затирание вообще, а решать задачу на уровне архитектуры:

Шифровать чувствительные поля отдельным ключом на запись или на клиенте. Тогда «удаление» — это уничтожение ключа, и остатки в файле превращаются в мусор. Это единственный подход, который честно работает с резервными копиями: старая копия зашифрована ключом, которого больше нет.

Хранить меньше. Данные, которых нет, не нужно ни удалять, ни объяснять регулятору.

Держать срок хранения в самой схеме. Поле «удалить после» и регулярная задача очистки надёжнее, чем обещание удалить по запросу.

И самое простое: в процедуру обработки запроса на удаление внесите пункт про резервные копии, реплики и аналитические выгрузки. Формально ответ «удалено» без этого пункта неверен, а проверить это может кто угодно, у кого окажется файл базы.