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.
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'
| Command | What it does |
|---|---|
init-schema | emit the audit table, its indexes and the default partition |
create-partitions | create monthly partitions N months forward — idempotent |
generate-trigger | emit the trigger function and trigger for one table |
status | report what is actually installed: triggers, partitions and the rest |
blame | git-style history for one row or one column |
as-of | reconstruct a row, or a whole table, at a past timestamp |
revert | generate the UPDATE that would roll one event back |
harden | revoke mutating privileges on the audit log |
enable-hash-chain | turn on the SHA-256 tamper-evident chain |
verify-chain | recompute the chain end to end and report disagreements |
purge | delete 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.
- Append-only.
auditrum hardenrevokesUPDATE,DELETEandTRUNCATEon 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. - Retention.
auditrum purge --older-than '2 years'deletes old events, or drops whole month partitions with--drop-partitions— fast, and gentle on the WAL. - Hash chain.
auditrum enable-hash-chainadds aBEFORE INSERTtrigger that hashes the event together with its predecessor’s hash, viapgcrypto.auditrum verify-chainrecomputes 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
- 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.
- Python 3.11 or newer. Typed throughout, and the package ships its type information.
- 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.
- 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 PyPISource 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.