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 .