Cheatsheet for soft-delete + audit. Common requirements; common pitfalls.
Timestamps mixin
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=datetime.utcnow,
)
class User(TimestampMixin, Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
Soft-delete mixin
class SoftDeleteMixin:
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), default=None, index=True,
)
@property
def is_deleted(self) -> bool:
return self.deleted_at is not None
class User(SoftDeleteMixin, Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
Soft-delete operation
async def soft_delete(session, user: User):
user.deleted_at = datetime.utcnow()
await session.commit()
Don’t use session.delete() — that’s a hard delete.
Auto-filter deleted rows
from sqlalchemy.orm import with_loader_criteria
from sqlalchemy import event, Session
@event.listens_for(Session, "do_orm_execute")
def soft_delete_filter(execute_state):
if not execute_state.is_select:
return
if execute_state.execution_options.get("include_deleted"):
return
execute_state.statement = execute_state.statement.options(
with_loader_criteria(
SoftDeleteMixin,
lambda cls: cls.deleted_at == None,
include_aliases=True,
)
)
Every SELECT auto-filters deleted unless opt-in.
Include deleted (opt-in)
result = await session.execute(
select(User).execution_options(include_deleted=True)
)
Composite mixin
class TrackedMixin(TimestampMixin, SoftDeleteMixin):
pass
class User(TrackedMixin, Base):
__tablename__ = "users"
Audit log table
class AuditLog(Base):
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(primary_key=True)
actor_id: Mapped[int | None]
action: Mapped[str] # "user.create", "user.update", ...
target_type: Mapped[str]
target_id: Mapped[str]
before: Mapped[dict | None] = mapped_column(JSONB if PG else JSON)
after: Mapped[dict | None] = mapped_column(JSONB if PG else JSON)
occurred_at: Mapped[datetime] = mapped_column(server_default=func.now())
Audit emit via events
@event.listens_for(Session, "before_flush")
def collect_audits(session, flush_context, instances):
audits = []
for obj in session.new:
audits.append(AuditLog(
actor_id=session.info.get("actor_id"),
action=f"{type(obj).__name__.lower()}.create",
target_type=type(obj).__name__,
target_id=str(getattr(obj, "id", "")),
after=_serialize(obj),
))
for obj in session.dirty:
attrs = inspect(obj).attrs
before = {k: a.history.deleted[0] for k, a in attrs.items() if a.history.has_changes()}
after = {k: getattr(obj, k) for k in before}
audits.append(AuditLog(
actor_id=session.info.get("actor_id"),
action=f"{type(obj).__name__.lower()}.update",
target_type=type(obj).__name__,
target_id=str(obj.id),
before=before, after=after,
))
for obj in session.deleted:
audits.append(AuditLog(...))
for a in audits:
session.add(a)
Attaching actor to session
async def get_db_with_actor(
user: User = Depends(current_user),
) -> AsyncSession:
async with AsyncSessionLocal() as session:
session.info["actor_id"] = user.id
yield session
Snapshot serialization
def _serialize(obj):
# Drop sensitive fields; only primitive-cast values
SENSITIVE = {"hashed_password", "password"}
out = {}
for col in inspect(obj.__class__).columns:
if col.name in SENSITIVE: continue
v = getattr(obj, col.name)
out[col.name] = v.isoformat() if isinstance(v, datetime) else v
return out
Versioning (full history)
For a versioned history of a record:
class UserHistory(Base):
__tablename__ = "user_history"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int]
version: Mapped[int]
snapshot: Mapped[dict] = mapped_column(JSONB)
occurred_at: Mapped[datetime] = mapped_column(server_default=func.now())
Append a history row on every change. SQLAlchemy-continuum can automate this.
DB triggers (alternative)
For DB-side audit immune to app bugs:
CREATE OR REPLACE FUNCTION audit_users() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_log (action, target_type, target_id, before, after)
VALUES (TG_OP, 'User', NEW.id::text, row_to_json(OLD), row_to_json(NEW));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER user_audit_trigger
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION audit_users();
App can’t accidentally skip; harder to extend.
Hard delete on schedule
For soft-deleted rows older than N days:
DELETE FROM users WHERE deleted_at < now() - interval '30 days';
Run via cron.
Common mistakes
- Soft delete + unique constraint on email — re-registration fails. Use partial index:
UNIQUE WHERE deleted_at IS NULL. - Forgetting to filter in joins — soft-deleted children show up.
- Sensitive fields in audit
before/after— leak. - Auditing inside the same transaction as the change — pathological coupling. Often fine; sometimes prefer outbox.
Read this next
If you want my soft-delete + audit mixins, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .