sqlite-diff-log
Zero-dependency, trigger-based audit logging for SQLite databases. Automatically tracks INSERT, UPDATE, and DELETE operations as structured JSON diffs — no ORM, no schema changes, no external services.
Why this exists
Existing ecosystem tools like sqlite-utils require running CLI commands out-of-band or manually computing diffs inside application code. sqlite-diff-log takes a different approach: it delegates diff generation to SQLite's own C core using native AFTER triggers and json_object().
- Zero runtime dependencies — built strictly with the Python standard library (
sqlite3,json). - Language- and process-agnostic — because the logic lives entirely in SQLite triggers, not in Python code, any process that writes to the database gets audited automatically, whether it's another Python script, a Node.js service, a Go binary, or the
sqlite3CLI. You attach the trigger once; every writer downstream is covered. - SQL-injection protected — all identifiers (table names, column names, trigger names) are quoted and validated against
sqlite_masterbefore use; all literals are escaped. - Litestream / replication friendly — audit logs live inside a standard SQLite table, so they replicate transparently with tools like Litestream or Turso.
Installation
pip install sqlite-diff-log
Quick Start
import sqlite3
from sqlite_diff_log import SQLiteDiffLog
conn = sqlite3.connect("app.db")
audit = SQLiteDiffLog(conn)
# Attach triggers to a table (pk_col defaults to "id")
audit.attach_to_table("users", pk_col="id")
# Any INSERT / UPDATE / DELETE on "users" — from this process
# or any other process writing to the same database file —
# is captured automatically from this point on.
with conn:
conn.execute("UPDATE users SET role = 'admin' WHERE id = 42")
# Query structured diffs
diffs = audit.get_logs("users")
print(diffs[0]["action"]) # "UPDATE"
print(diffs[0]["old_data"]) # {'id': 42, 'name': 'Alice', 'role': 'user'}
print(diffs[0]["new_data"]) # {'id': 42, 'name': 'Alice', 'role': 'admin'}
How it works
+------------------+ SQL Operations +--------------------+
| Any Writer | ---------------------------> | Target Table |
| (Python, Node, | (INSERT / UPDATE / DELETE) +--------------------+
| Go, sqlite3 CLI…)| |
+------------------+ Native SQLite Triggers
|
v
+--------------------+ JSON Diffs
| _audit_log Table | <----------------
+--------------------+
^
|
+--------------------+
| Audit Reader API |
+--------------------+
attach_to_table() installs three AFTER triggers (INSERT, UPDATE, DELETE) directly in SQLite. Each trigger fires inside the same transaction as the write it audits and inserts a JSON snapshot of the old and/or new row into _audit_log. Because the triggers are part of the database schema itself — not application code — they apply to every writer, not just the process that called attach_to_table().
Audit log schema
| Column | Type | Description |
|---|---|---|
id |
INTEGER | Autoincrementing primary key |
table_name |
TEXT | Name of the audited table |
action |
TEXT | INSERT, UPDATE, or DELETE |
row_id |
ANY | Primary key value of the affected row |
old_data |
TEXT/JSON | Row state before the change (NULL on INSERT) |
new_data |
TEXT/JSON | Row state after the change (NULL on DELETE) |
created_at |
TIMESTAMP | When the change was recorded |
API
SQLiteDiffLog(conn: sqlite3.Connection)
Wraps an existing connection and creates the _audit_log table if it doesn't exist.
attach_to_table(table_name: str, pk_col: str = "id") -> None
Installs INSERT / UPDATE / DELETE triggers on table_name. Idempotent — safe to call multiple times (CREATE TRIGGER IF NOT EXISTS). Raises ValueError if the table doesn't exist or pk_col isn't a valid column.
get_logs(table_name: Optional[str] = None) -> List[Dict[str, Any]]
Returns audit entries as a list of dicts, with old_data / new_data already parsed from JSON. Pass a table name to filter, or omit to get every audited table.
Limitations
- Single-column primary keys only. Tables must use a single PK column (e.g.
id). Composite primary keys are not currently supported. - No caller identity. Triggers only see row data (
OLD.*/NEW.*), not who made the change or from which process — SQLite triggers have no concept of a connected user or session. If you need "who changed this," you'll need to pass that context into the row data yourself (e.g. anupdated_bycolumn) or handle it at the application layer. - Schema changes require re-attaching. If you add or drop columns on an audited table, drop and recreate the triggers (
attach_to_tableagain) so the generatedjson_object()calls reflect the current schema.
Running Tests
python -m unittest test_sqlite_diff_log.py
License
MIT — see LICENSE.
Author
MigMarGil
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sqlite_diff_log-0.1.1.tar.gz.
File metadata
- Download URL: sqlite_diff_log-0.1.1.tar.gz
- Upload date:
- Size: 6.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b367c731e52909b76b835a6cecea9fddd6f2d80ed9cd38ffa3cb574510aec357
|
|
| MD5 |
861f259960a4efeaf14624849e9fb6f2
|
|
| BLAKE2b-256 |
dbbdb30acfd3addebab8428ce28536b106eb4f8dca1a1cb5a861a19565f1bbe6
|
File details
Details for the file sqlite_diff_log-0.1.1-py3-none-any.whl.
File metadata
- Download URL: sqlite_diff_log-0.1.1-py3-none-any.whl
- Upload date:
- Size: 6.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3022ca29ba0ada14ee1b7542d34edb29ee4ef67540ea46eb28dec5948e9f63a5
|
|
| MD5 |
50031e71e23093d298b270085e80e925
|
|
| BLAKE2b-256 |
e2e9488835919c4ef89d3a3e82873972bfe347d555a77328427881f3d51c6743
|