How 2 Soft Delete.

Padrões e melhores práticas para retenção de dados.

Mateus Henrique Bosquetti
por Mateus Henrique Bosquetti
7 minutos de leitura

Em sistemas modernos, a restauração de dados é uma demanda real, seja por erro do usuário, auditoria ou suporte. O objetivo deste artigo é apresentar o conceito do Soft Delete, assim como suas principais estratégias de forma interativa, para que você possa testar e escolher a melhor abordagem para o seu próximo projeto.

O Problema

Imagine um cenário comum onde um usuário exclui um registro importante por engano. Pode ser um admin que clicou no botão errado, um cliente removendo dados que depois viram críticos, ou alguém limpando o que achou que era temporário. Quando não existe uma estratégia de retenção ou recuperação desses dados, esse erro simples vira uma crise real, onde você precisa parar tudo, restaurar backup em ambiente isolado, caçar registros manualmente e reinserir o que for possível, com risco de perder tudo que foi criado depois do último snapshot do banco.

Esse cenário é um exemplo perfeito de Hard Delete em tabelas críticas. Hard Delete é a exclusão física do registro, normalmente feita com um comando DELETE direto na tabela. Resumindo o dado é realmente removido do banco e do disco. Não há volta imediata, não há trilha de auditoria por padrão e, sem backup externo, não há como recuperar.

Aqui esta um exemplo de Hard Delete:

Hard Delete

Exemplo de hard delete em uma tabela de tarefas

Etapa

Selecione alguma linha

tasks

id
name
urgency
11Send financial reportHIGH
22Review API documentationMEDIUM
33Update project dependenciesLOW

SQL Console

readonly
>
SELECT * FROM tasks
>
3 rows retrieved in 1301 ms (execution: 11 ms, fetching: 1290 ms)

Essa abordagem é perigosa para tabelas críticas, mas é a ideal (e nativa) para dados secundários, registros temporários ou cadastros rápidos que não impactam o negócio.

A Solução

O Soft Delete trata a exclusão como um estado lógico, não físico. Para o usuário, o dado sumiu, mas para o banco ele apenas foi marcado como oculto. Existem duas principais formas de se implementar isso, que são o que chamamos de Soft Delete Patterns.

Logical Delete

O primeiro Soft Delete Pattern é o Logical Delete, sendo o que tem a abordagem mais simples e comum. Consiste em adicionar uma coluna na própria tabela para sinalizar o estado do registro.

Siga as etapas abaixo para entender o fluxo de um Logical Delete

Logical Delete Pattern

Exemplo de Logical Delete em uma tabela de tarefas

Etapa

Selecione alguma linha

tasks

id
name
urgency
archived_at
11Send financial reportHIGHnull
22Review API documentationMEDIUMnull
33Update project dependenciesLOWnull

SQL Console

readonly
>
SELECT * FROM tasks
>
3 rows retrieved in 1301 ms (execution: 11 ms, fetching: 1290 ms)

Como podemos observar, em vez de remover fisicamente o registro, apenas atualizamos a coluna 'archived_at'. Dessa forma, conseguimos distinguir quem está ativo e quem já foi arquivado ou deletado.

Para implementar o Logical Delete, basta adicionar uma nova coluna em sua tabela:

SQL SCRIPT

readonly
ALTER TABLE tasks
ADD COLUMN archived_at TIMESTAMPTZ DEFAULT NULL;

Esse campo pode ser um booleano ou um timestamp. O importante é que ele indique claramente se o registro está ativo ou não.

O problema surge em larga escala. Milhões de registros desativados continuam ocupando espaço e sujando seus índices na tabela principal.

Aqui esta um desafio comum do Logical Delete:

The Zombie Table

Exemplo do problema de tabela poluída com logical delete

Etapa

Execute um select para exibir todas as tarefas ativas

tasks

id
name
urgency
archived_at
11Send financial reportHIGH2026-02-19T10:22:32.123Z
22Review API documentationMEDIUMnull
33Update project dependenciesLOW2026-02-05T17:51:12.332Z
1-3 of 4173289

SQL Console

readonly
>
SELECT * FROM tasks
>
4173289 rows retrieved in 4580 ms (execution: 780 ms, fetching: 3800 ms)

Em 99% do tempo você só quer os dados ativos, mas é obrigado a carregar o peso de milhões de registros mortos e lembrar de filtrá-los em toda query.

Shadow Table

O segundo Soft Delete Pattern é o Shadow Table, que consiste numa solução mais complexa para resolver o inchaço da tabela principal. Aqui, em vez de marcar o registro, nós o movemos para uma tabela secundária de arquivo.

Siga as etapas abaixo para entender o fluxo do Shadow Table

Shadow Table Pattern

Exemplo do padrão shadow table para recuperação de dados

Etapa

Selecione alguma linha

tasks

id
name
urgency
11Send financial reportHIGH
22Review API documentationMEDIUM
33Update project dependenciesLOW

archives

id
table_name
record_id
data
archived_at
caused_by_table
caused_by_id
No rows selected

SQL Console

readonly
>
SELECT * FROM tasks
>
3 rows retrieved in 1301 ms (execution: 11 ms, fetching: 1290 ms)

Como podemos observar, ao deletar um registro da tabela 'tasks', ele é automaticamente movido para a tabela 'archives' através de um trigger no banco de dados.

Os exemplos abaixo utilizam a sintaxe e recursos do PostgreSQL (como Triggers e JSONB). Algumas modificações podem ser necessárias para outros bancos de dados.

Para implementar o Shadow Table, a complexidade aumenta, primeiro devemos criar a tabela 'archives' em nosso banco de dados:

SQL SCRIPT

readonly
CREATE TABLE archives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
table_name TEXT NOT NULL,
record_id JSONB NOT NULL,
data JSONB NOT NULL,
caused_by_table TEXT NOT NULL,
caused_by_id JSONB NOT NULL,
archived_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);

Agora devemos criar a função que lidará com a lógica de arquivamento:

How to implement Shadow Table

readonly
CREATE OR REPLACE FUNCTION fn_shadow_archive()
RETURNS TRIGGER AS $$
DECLARE
v_cause_table TEXT;
v_cause_id_text TEXT;
BEGIN
v_cause_table := current_setting('app.current_deleter_table', true);
v_cause_id_text := current_setting('app.current_deleter_id', true);

IF (v_cause_table IS NULL OR v_cause_table = '') THEN
v_cause_table := TG_TABLE_NAME;
v_cause_id_text := OLD.id::text;

PERFORM set_config('app.current_deleter_table', v_cause_table, true);
PERFORM set_config('app.current_deleter_id', v_cause_id_text, true);
END IF;

INSERT INTO archives (
table_name,
record_id,
data,
caused_by_table,
caused_by_id
)
VALUES (
TG_TABLE_NAME,
jsonb_build_object('id', OLD.id),
to_jsonb(OLD),
v_cause_table,
jsonb_build_object('id', v_cause_id_text)
);

RETURN OLD;
END;
$$ LANGUAGE plpgsql;

Essa função é responsável por pegar o registro deletado e inserir ele na tabela 'archives'.

Agora basta criarmos os triggers para as tabelas críticas. Assim, elas ficam protegidas contra o Hard Delete. No exemplo abaixo, o padrão de Shadow Table será aplicado em três tabelas de exemplo: 'users', 'establishments' e 'appointments'.

How to create Shadow Table Triggers

readonly
CREATE TRIGGER trg_archive_users BEFORE DELETE ON users FOR EACH ROW EXECUTE FUNCTION fn_shadow_archive();
CREATE TRIGGER trg_archive_establishments BEFORE DELETE ON establishments FOR EACH ROW EXECUTE FUNCTION fn_shadow_archive();
CREATE TRIGGER trg_archive_appointments BEFORE DELETE ON appointments FOR EACH ROW EXECUTE FUNCTION fn_shadow_archive();

Nada é de graça. Enquanto o Logical Delete exige apenas um UPDATE, restaurar dados em uma Shadow Table exige queries de INSERT INTO e SELECT mais complexas, especialmente se houver relacionamentos de chaves estrangeiras envolvidos.

No exemplo abaixo, temos 3 exemplos de situações onde você deve recuperar dados deletados.

Restoring Data

Exemplo de restauração de dados arquivados

archives

id
table_name
record_id
data
archived_at
caused_by_table
caused_by_id
12a8f5d9c-5f74-4d0f-8f61-1f45e6d0a101establishments{"id":"est-500"}{"id":"est-500","name":"Downtown Barbershop","owner_id":"user-owner-1","created_at":"2026-01-10T10:00:00.000Z"}2026-02-23T10:00:00.000Zestablishments{"id":"est-500"}
29d0f7b6a-2f43-4b23-a6df-3a4db44cf102appointments{"user_id":"user-200","establishment_id":"est-500","scheduled_at":"2026-03-01T09:00:00.000Z"}{"user_id":"user-200","establishment_id":"est-500","scheduled_at":"2026-03-01T09:00:00.000Z","service":"Haircut","status":"CONFIRMED"}2026-02-23T10:00:00.050Zestablishments{"id":"est-500"}
37bb2a10e-61b4-46e9-9f83-58d39f2aa103appointments{"user_id":"user-201","establishment_id":"est-500","scheduled_at":"2026-03-01T10:00:00.000Z"}{"user_id":"user-201","establishment_id":"est-500","scheduled_at":"2026-03-01T10:00:00.000Z","service":"Beard Trim","status":"PENDING"}2026-02-23T10:00:00.080Zestablishments{"id":"est-500"}
445d98f8a-8cf9-4f35-8f69-c9ec3f5b4104users{"id":"user-300"}{"id":"user-300","name":"Fernanda Lima","email":"fernanda@email.com","role":"CUSTOMER","created_at":"2026-01-15T12:00:00.000Z"}2026-02-23T11:00:00.000Zusers{"id":"user-300"}
5c0f57e2b-2d9f-4d8c-8f2e-98f703a6b105appointments{"user_id":"user-300","establishment_id":"est-800","scheduled_at":"2026-03-05T14:00:00.000Z"}{"user_id":"user-300","establishment_id":"est-800","scheduled_at":"2026-03-05T14:00:00.000Z","service":"Manicure","status":"CONFIRMED"}2026-02-23T11:00:00.040Zusers{"id":"user-300"}
6ad3bcae9-7c31-4ceb-a20d-e4f952107106appointments{"user_id":"user-300","establishment_id":"est-801","scheduled_at":"2026-03-06T16:00:00.000Z"}{"user_id":"user-300","establishment_id":"est-801","scheduled_at":"2026-03-06T16:00:00.000Z","service":"Pedicure","status":"PENDING"}2026-02-23T11:00:00.070Zusers{"id":"user-300"}
7f6a1db8e-34a9-4d3d-b7f3-27a8c1de7107appointments{"user_id":"user-555","establishment_id":"est-500","scheduled_at":"2026-03-10T18:00:00.000Z"}{"user_id":"user-555","establishment_id":"est-500","scheduled_at":"2026-03-10T18:00:00.000Z","service":"Massage","status":"CANCELLED_BY_USER"}2026-02-23T12:00:00.000Zappointments{"user_id":"user-555","establishment_id":"est-500","scheduled_at":"2026-03-10T18:00:00.000Z"}
1-7 of 142

Mateus (mateus@email.com) acionou o suporte informando que apagou por engano o estabelecimento 'Downtown Barbershop' e precisa recuperar o registro.

A cliente Fernanda Lima solicitou a recuperação da conta removida acidentalmente durante uma limpeza manual de dados.

O suporte recebeu pedido para restaurar um agendamento cancelado por engano, mantendo o histórico para auditoria.

SQL Console

readonly
>
SELECT * FROM archives LIMIT 7;
>
7 rows retrieved in 1432 ms (execution: 12 ms, fetching: 1420 ms)

O trade-off é direto, recuperar dado aqui não é um simples UPDATE, normalmente envolve buscar contexto, remontar dependências e executar uma sequência de queries com mais cuidado.

Os 3 SELECTs deixam claro por que vale modelar record_id e caused_by_id como JSONB. Isso abre espaço para registros com chave composta sem forçar o schema da 'archives' a cada novo caso. As colunas caused_by_table e caused_by_id também melhoram muito a rastreabilidade do cascade delete, porque tornam explícita a origem do evento.

Se o seu sistema exige recuperações frequentes, considere utilizar shadow tables dedicadas para cada entidade. Isso preserva a tipagem original e evita o uso de JSONB, garantindo maior integridade e velocidade no acesso aos dados.

Conclusão: Qual escolher?

Na prática, a melhor estratégia de exclusão depende muito do momento do produto e do nível de maturidade da arquitetura. Não existe resposta única, existe escolha consciente por contexto.

Para POCs e fases iniciais, o Logical Delete costuma fazer mais sentido. Ele entrega segurança para recuperar dados com implementação rápida, baixo atrito e sem elevar cedo demais a complexidade do projeto.

Para MVPs robustos, pilotos e produto final em produção, o Shadow Table pode ser bem mais vantajoso. Apesar do custo maior de implementação, ele ajuda a manter a tabela principal mais limpa, melhora previsibilidade de performance e organiza melhor a estratégia de arquivamento.

Existem outros patterns de soft delete além dos citados aqui. Neste artigo, eu trouxe os mais conhecidos que já usei na prática, justamente para compartilhar decisões que testei em cenários reais.

Menção honrosa a Alex Buchanan, criador do blog atlas9.dev, que me inspirou a montar esse artigo.