auditrum

A PostgreSQL audit system for Python applications. Every insert, update and delete is logged by the database itself, together with who made it, when, why and from where.

PyPI v0.6.0 Python 3.11+ MIT
Get auditrum pip install auditrum · PostgreSQL

Tracking

Triggers, not application code

The audit trail is written by PostgreSQL triggers, so a row changed by a management command, a migration, a background worker or somebody in psql lands in the log the same way a row changed through the ORM does. There is no code path that can quietly skip it, because the code path isn’t what does the writing.

Every event carries the whole row on both sides, as old_data and new_data JSONB. That costs more storage than a diff and buys back everything else on this page: a full snapshot at every point in time is what makes reconstructing a row later a lookup rather than a replay.

The log is one time-partitioned table, split by month. Partitions are created ahead of time, and old ones can be dropped instead of deleted — a DROP that costs nothing rather than a DELETE that floods the WAL.

Context

Who, when, why and from where

A timestamp and a diff tell you a value changed. They don’t tell you that it changed because support processed a refund request, in a task that came from a particular HTTP request. auditrum carries that context from the application into the trigger, so it is stored on the event rather than reconstructed from logs afterwards.

A reason can be attached with a decorator or a context manager. In Django, request id and actor come from middleware; management commands can declare their own source, so migrations and shell sessions are attributed rather than anonymous.

from auditrum.context import with_change_reason


@with_change_reason("User requested password reset")
def reset_password(user_id):
    ...


from auditrum.context import audit_context

with audit_context.use_change_reason("Bulk update for compliance"):
    ...

Django

Register a model, run migrate

Add the integration to INSTALLED_APPS, add two middlewares, and put the models you want audited in an audit.py next to your admin.py — it is discovered and executed the same way. migrate then creates the audit table and installs the triggers.

What gets tracked is per model: only certain fields, everything except certain fields, or only when a SQL condition holds.

# yourapp/audit.py
from auditrum.integrations.django.audit import register

from .models import Product, Subscription, User

register(User, track_only=["name", "email"])
register(Product, exclude_fields=["created_at", "updated_at"])
register(Subscription, log_conditions="NEW.is_active = TRUE")

Keeping partitions ahead

One cron entry a month is the whole maintenance story: manage.py audit_add_partitions --months 3 keeps three months of partitions in front of the writes, and is idempotent, so running it twice costs nothing.

Without Django

The schema and trigger SQL are generated by plain functions — generate_auditlog_table_sql and generate_trigger_sql — that you can execute over any psycopg connection. There is a SQLAlchemy integration as well, and a CLI that needs neither.

Reading it back

Time travel and blame

Because every event stores the full row, the latest event at or before a timestamp is the state at that timestamp. Two PL/pgSQL helpers do the lookup server-side — for one row, or for every surviving row of a table — so reconstructing last Tuesday’s invoice does not mean shipping the whole history to Python and replaying it.

The same thing from the command line is blame: the history of a single row, or a single column of it, in the shape git taught everyone to read. --format json for when it feeds something else.

# everything that ever happened to this row
auditrum blame users 42

# just one column
auditrum blame users 42 email

# the row as it stood at a past moment
auditrum as-of users 42 --at '2026-01-15T10:00:00Z'
CommandWhat it does
init-schemaemit the audit table, its indexes and the default partition
create-partitionscreate monthly partitions N months forward — idempotent
generate-triggeremit the trigger function and trigger for one table
statusreport what is actually installed: triggers, partitions and the rest
blamegit-style history for one row or one column
as-ofreconstruct a row, or a whole table, at a past timestamp
revertgenerate the UPDATE that would roll one event back
hardenrevoke mutating privileges on the audit log
enable-hash-chainturn on the SHA-256 tamper-evident chain
verify-chainrecompute the chain end to end and report disagreements
purgedelete or drop events older than a given interval

revert prints the SQL rather than running it. Undoing a change in a system whose whole point is that changes are recorded should be something a person reads before it happens.

Hardening

Append-only, and tamper-evident

An audit log the application can rewrite is a log that proves nothing in the situation it exists for. Three building blocks address that, and each can be turned on by itself — none of them is on by default, because each has a cost worth choosing deliberately.

  1. Append-only. auditrum harden revokes UPDATE, DELETE and TRUNCATE on the log from the application role and grants them to a separate admin role. After it, the role your app runs as can only append; maintenance runs as somebody else.
  2. Retention. auditrum purge --older-than '2 years' deletes old events, or drops whole month partitions with --drop-partitions — fast, and gentle on the WAL.
  3. Hash chain. auditrum enable-hash-chain adds a BEFORE INSERT trigger that hashes the event together with its predecessor’s hash, via pgcrypto. auditrum verify-chain recomputes the whole chain server-side and names any row that disagrees.

The chain serializes writes through pg_advisory_xact_lock, so peak insert throughput drops. That is the trade being made — measure it against your write volume before turning it on in a hot path.

Requirements

What it asks of your stack

  1. PostgreSQL. The design is built on triggers, JSONB and declarative partitioning, so it is not portable to another database and does not pretend to be.
  2. Python 3.11 or newer. Typed throughout, and the package ships its type information.
  3. Django 4.2+ or SQLAlchemy 2.0+ if you want the integration for one of them. Both are optional extras; the core and the CLI need neither.
  4. Nothing for observability unless you ask — OpenTelemetry, Prometheus and Sentry are behind their own extra.

Get it

Install

On PyPI, with the framework integrations as extras so you install only the one you use.

auditrum on PyPI

Source and documentation on github.com/tauvin/auditrum · questions and bug reports

pip install auditrum
pip install auditrum[django]

# or with uv
uv add auditrum
uv add auditrum[django]

Where it actually stands

Published and in use, but pre-1.0 and marked beta on PyPI: the API is still allowed to move between minor versions. The changelog and the roadmap to 1.0 are both in the repository, and the docs cover every subcommand and every option.

License

MIT.