Soft Deletes and Audit Logs: Keeping History Without Making a Mess
Deleting rows is irreversible; hiding them has costs too. When to use soft deletes, how to implement them without leaking 'deleted' data (partial indexes, unique constraints, views, RLS), the privacy tension with erasure requests, and how to build an audit log with triggers or application events.
A user deletes a project by mistake and emails support. An admin changes a customer's plan and nobody knows who. An AI agent "cleans up old records" more broadly than intended. Each of these is easier to survive if your app keeps history. The two main tools are soft deletes (hide instead of remove) and audit logs (record who changed what). Both are easy to add badly.
Soft deletes
Instead of DELETE, mark the row:
ALTER TABLE projects ADD COLUMN deleted_at timestamptz;
UPDATE projects SET deleted_at = now() WHERE id = $1; -- "delete"
UPDATE projects SET deleted_at = NULL WHERE id = $1; -- restore
The costs people underestimate
- Every query must filter. Miss
WHERE deleted_at IS NULLin one list endpoint, report, search index, or export, and deleted data reappears. This is the most common soft-delete bug. - Unique constraints break. A user deletes the project
acme, then can't create a newacmebecause the hidden row still holds the name. - Foreign keys don't cascade.
ON DELETE CASCADEnever fires on anUPDATE; child rows of a soft-deleted parent stay "live." - Tables grow forever unless you purge.
- Privacy law may require actually deleting personal data (below).
Implementing it well
Partial unique indexes — uniqueness only among live rows:
CREATE UNIQUE INDEX projects_slug_live_uniq
ON projects (workspace_id, slug)
WHERE deleted_at IS NULL;
Partial indexes for hot queries — so the dead rows don't bloat the index you query most:
CREATE INDEX projects_workspace_live_idx
ON projects (workspace_id, created_at DESC)
WHERE deleted_at IS NULL;
Make the filter the default, not a habit:
- A view of live rows that the app queries (
CREATE VIEW live_projects AS SELECT * FROM projects WHERE deleted_at IS NULL). - Row-level security policies that exclude deleted rows for the app role. (Multi-Tenant Postgres Patterns.)
- ORM-level global filters or extensions (Prisma client extensions, Django custom managers, Rails
default_scope-style gems) — convenient, but raw SQL bypasses them.
Decide cascade behaviour explicitly — soft-delete children in the same transaction, or treat them as hidden through the parent.
Purge on a schedule — hard-delete rows soft-deleted more than N days ago (30 is common). That gives an "undo" window without keeping data forever. (Your App Needs Background Jobs.)
Alternatives
- Archive table — move the row to
projects_archive(in a transaction) on delete. Live tables stay clean; restores are explicit. - Status column — when "deleted" is really a business state (
archived,cancelled), model the state properly instead. - Just delete, and rely on backups plus an audit log for rare recoveries. Often right for low-value data. (Backups for Beginners.)
The privacy tension
A soft-deleted row is not deleted for data-protection purposes. If a user exercises a right to erasure, you generally need to remove or irreversibly anonymise their personal data — in live tables, soft-deleted rows, search indexes, caches, and (on a reasonable schedule) backups and logs. Design the purge path at the same time as the soft delete. (GDPR Basics for App Builders.)
Audit logs
An audit log answers: who did what, to which record, when, and what changed?
CREATE TABLE audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
occurred_at timestamptz NOT NULL DEFAULT now(),
actor_id bigint, -- user, admin, or service account
actor_type text NOT NULL, -- 'user' | 'admin' | 'system' | 'agent'
action text NOT NULL, -- 'project.deleted', 'plan.changed'
entity_type text NOT NULL,
entity_id text NOT NULL,
changes jsonb, -- {"plan": ["pro", "team"]}
request_id text,
ip inet
);
CREATE INDEX audit_entity_idx ON audit_log (entity_type, entity_id, occurred_at DESC);
Two ways to populate it
Application-level events — write an audit row in the same transaction as the change:
await db.transaction(async (tx) => {
await tx.update(projects).set({ deletedAt: new Date() }).where(eq(projects.id, id))
await tx.insert(auditLog).values({
actorId: user.id, actorType: "user", action: "project.deleted",
entityType: "project", entityId: String(id), requestId,
})
})
Rich context (who, why, which request) and meaningful action names. Risk: code paths that forget to log.
Database triggers — capture every INSERT/UPDATE/DELETE on audited tables, whatever code ran it:
CREATE FUNCTION audit_trigger() RETURNS trigger AS $$
BEGIN
INSERT INTO audit_log (actor_id, actor_type, action, entity_type, entity_id, changes)
VALUES (
nullif(current_setting('app.user_id', true), '')::bigint,
coalesce(nullif(current_setting('app.actor_type', true), ''), 'system'),
lower(TG_OP), TG_TABLE_NAME,
coalesce(to_jsonb(NEW), to_jsonb(OLD))->>'id',
jsonb_build_object('old', to_jsonb(OLD), 'new', to_jsonb(NEW))
);
RETURN NULL; -- AFTER trigger: return value is ignored
END $$ LANGUAGE plpgsql;
CREATE TRIGGER projects_audit
AFTER INSERT OR UPDATE OR DELETE ON projects
FOR EACH ROW EXECUTE FUNCTION audit_trigger();
Complete coverage, including manual SQL and scripts. The app sets app.user_id per transaction (SET LOCAL app.user_id = '42') to attribute changes — with a transaction-mode connection pooler, SET LOCAL inside the transaction is the safe form. Storing full OLD/NEW rows is heavy; store diffs or selected columns for large tables.
Many teams use both: triggers as the safety net, application events for business-meaningful actions.
Audit log hygiene
- Append-only: the app role gets
INSERTbut notUPDATE/DELETEonaudit_log. - Don't log secrets or unnecessary personal data in
changes; redact sensitive columns. (Structured Logging.) - Retention: partition by month and drop old partitions per your policy.
- Log AI agent actions distinctly (
actor_type = 'agent') — when an agent changes data, you want to know it was the agent.
EasySpawn gives each project a managed Postgres with daily backups, and on-demand backups on the Team plan, so soft deletes and audit logs sit on top of a real recovery path — not instead of one. See pricing or join the waitlist.
Related: Database Transactions Explained · What Is CRUD? · How to Stop an AI Agent From Deleting Your Production Database
Keep reading
Validating Input With Zod: One Schema for Forms, APIs, and Types
Every trust boundary — request bodies, query strings, webhooks, environment variables, AI output — needs runtime validation TypeScript can't provide. Using Zod schemas at each boundary, sharing them between client and server, stripping unknown keys, and useful errors.
Designing a REST API That Won't Embarrass You Later
APIs are hard to change once clients depend on them. The conventions that keep a REST API predictable — resource naming, methods, status codes, errors, pagination, validation, idempotency, and versioning — with the specific mistakes AI-generated APIs tend to make.