
Every production database eventually faces the same questions: Who changed this customer's payout address? When did this order status switch to cancelled? Can we restore yesterday's version of this row?
You do not need to install Kafka, Debezium, or complex event-streaming workers to answer those questions. A single append-only audit table driven by database triggers captures every INSERT, UPDATE, and DELETE at the engine level.
Here is how to set up triggers in PostgreSQL, MySQL 8.4, and SQLite that log the logged-in user and capture JSON diffs. We also walk through the exact storage math and write penalties so you know what it costs at scale.
The unified audit table
Instead of creating a shadow history table for every single entity in your schema, use one central append-only log table:
Why this design works well:
- Uniform schema: One table handles audit logs for customers, orders, inventory, and permissions.
- Targeted composite index: Indexing
(table_name, record_id, changed_at desc)turns row-history lookups into fast index range scans. - True statement clock: In PostgreSQL, use
clock_timestamp()rather thannow()so multiple writes within a long transaction receive distinct, strictly ascending timestamps.
Capturing who made the change
The most common flaw in database triggers is logging <code>current_user</code>. If your backend connects through a connection pool as <code>app_user</code>, every change gets recorded as <code>app_user</code>, hiding whether Alice, Bob, or a background worker made the change.
Fix this by injecting the application user ID into the database session before executing queries:
- PostgreSQL: Use
set_config('app.current_user_id', :user_id, true). The third parameteris_local=truebinds the setting strictly to the current transaction. When the transaction commits or rolls back, the variable disappears. It never leaks to another web request reusing the pooled connection. - MySQL 8.4: Set a user variable before your query:
SET @app_user_id = 'usr_849201';. Clear it or overwrite it at the start of each request. - SQLite: SQLite is in-process and lacks session variables. Create a temporary table in connection memory:
create temp table if not exists _app_session (user_id text);and update row 1 before running your update.
PostgreSQL: Log only changed columns
If a table has 30 columns and an UPDATE only modifies <code>status</code>, logging the entire row wastes disk and makes diffs hard to read. This PL/pgSQL trigger compares <code>to_jsonb(NEW)</code> with <code>to_jsonb(OLD)</code> and records only the modified keys:
Attach it to any table with a single DDL statement:
MySQL 8.4: Explicit column diffs
MySQL lacks dynamic row reflection in triggers, so you assemble the JSON diff by checking updated fields explicitly with <code>JSON_OBJECT</code> and <code>JSON_SET</code>:
Using the null-safe equality operator <code><=></code> ensures that transitioning from <code>NULL</code> to a string or vice versa registers as a real change without triggering null comparison errors.
SQLite: In-process triggers with json_object
In SQLite, write an <code>AFTER UPDATE</code> trigger guarded by a <code>WHEN</code> clause so it only fires when tracked columns change:
What it costs: Storage growth per 1,000,000 updates
Every trigger write carries a physical cost. Below is the worked storage calculation for 1,000,000 updates on a typical 20-column table (e.g. 500 bytes per full row), comparing full-row logging against changed-columns diffs in PostgreSQL:
| Metric | Full-Row Snapshot | Changed-Columns Only | Difference / Savings |
|---|---|---|---|
| Average JSON payload | 520 bytes | 48 bytes | 90.7% smaller payload |
| Row header + tuple alignment | 32 bytes | 32 bytes | Identical (engine fixed) |
| Table heap data (1M updates) | 552 MB | 80 MB | 472 MB saved (85.5% less heap) |
| Primary key index (bigint id) | 74 MB | 74 MB | Unchanged |
| Composite record index | 92 MB | 92 MB | Unchanged |
| Total disk footprint on table | 718 MB | 246 MB | 472 MB saved (65.7% total storage reduction) |
| WAL generation per update | ~760 bytes | ~210 bytes | 72.4% less transaction log volume |
Key takeaways on write overhead:
- Write amplification: A single row update becomes two writes (one to the target row, one to
audit_log), plus two index insertions on the audit table. - Index cost dominates diffs: At 1,000,000 rows, index pages (166 MB) exceed the table heap (80 MB) when logging compact diffs. Maintain only the indexes you actually query.
- Throughput penalty: For high-concurrency single-row transactions, trigger auditing adds between 18% and 35% latency per write due to procedural overhead and secondary index updates.
- Bulk load risk: If you run a data migration updating 500,000 rows in one statement, triggers will choke your write throughput. Disable triggers during bulk ETL:
alter table customers disable trigger trg_customers_audit;and log one summary audit event instead.
Rebuild a row as of any point in time
Because each audit entry stores the cumulative diffs, you can reconstruct what a record looked like at any historical second without maintaining snapshot tables. In PostgreSQL, aggregate the historical JSON changes in chronological order using the jsonb concatenation operator <code>||</code>:
How this query reconstructs the past:
- Filters future changes:
changed_at <= target_timestampexcludes any edits made after your audit target time. - Replays updates in sequence: Later updates naturally overwrite earlier values for the same key.
- Handles deletions: If the last entry before the cutoff is a DELETE, the query returns null or empty, confirming the record did not exist at that moment.
Inspect audit tables, diff schema migrations, and browse JSON columns with a clean, lightweight GUI. Pilotbase connects to PostgreSQL, MySQL, SQLite, and MariaDB.
Download Pilotbase freeDeploying audit triggers to staging or production? Compare your database schemas before running migrations to verify trigger definitions and index structures.
Compare database schemasRelated: Customer address history in SQL: valid-from rows and the as-of join and Soft deletes in SQL: deleted_at, unique keys that still work, and the queries that forget the filter.
Sources verified 10 November 2026: PostgreSQL 18 PL/pgSQL triggers, JSON functions and operators, and System Administration Functions (set_config); MySQL 8.4 Trigger Syntax and Examples and JSON Function Reference; SQLite CREATE TRIGGER documentation and JSON1 extension.


