100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
PostgreSQL Mastery
50 minintermediate

Advanced Features Practice: Audit System

What You'll Build

You will build a complete, application-agnostic audit system using the module's features together: a generic trigger function that captures every INSERT, UPDATE, and DELETE as a JSONB before/after snapshot into a central audit table, attached to whichever tables you want tracked, with a scheduled pg_cron job that enforces a retention policy on the audit history. It is a realistic, self-contained system that no application can bypass.

The design uses one reusable PL/pgSQL trigger function (driven by TG_OP and TG_TABLE_NAME), JSONB to store arbitrary row shapes, triggers to make capture automatic, and pg_cron to purge old audit rows. By the end you will have a tamper-resistant change history that records who changed what, when, and how — the kind of audit trail compliance and debugging both demand.

Analogy🏏Cricket
🏏 Think of it like cricket: Just as setting up a new league means first writing the registration rules and eligibility checks, then entering the teams and players, then running the fixtures, you will first define the constrained schema, then load data, then query it. The insight is that the rules come before the play — get the eligibility and scoring regulations right first, and the matches that follow stay orderly because the framework already enforces fair play. Run the sequence backwards to see why the order is non-negotiable: admit teams first and write the eligibility rules afterwards, and you discover mid-season that three registered players were never eligible — now every result they touched is in dispute and unwinding it means re-examining a season of records. That is retrofitting constraints onto a table already full of violating rows: the ALTER fails until you hand-clean the data it was supposed to prevent. Founding the league in the right order also changes how boldly you can operate later: fixtures can be scheduled aggressively because the framework guarantees every listed player is legal, just as queries and application code can stay simple when the schema has already promised the data is sound.

Prerequisites

  • Completion of lessons 21–24, or equivalent familiarity with JSONB, PL/pgSQL, triggers, and pg_cron.
  • PostgreSQL 15+ with superuser access to create the pg_cron extension (or an external scheduler as a fallback).
  • Permission to create tables, functions, and triggers.
  • One or more business tables to audit (the example uses accounts and players).
  • Comfort reading JSONB and writing a basic PL/pgSQL function.

Setup & Project Structure

Create a single central audit_log table that can hold changes from any table: columns for the source table name, the operation, the timestamp, the user, and two JSONB columns for the old and new row states. Using JSONB means one audit table serves every audited table regardless of their differing columns.

Analogy🏏Cricket
🏏 Think of it like cricket: before a tournament you set up a dedicated ground and a clean scoring system rather than borrowing a crowded club pitch. Just as you create a fresh database so your exercise work is isolated, a groundsman prepares a separate net facility so practice never disturbs the main square. Just as you will build four related tables — team, player, match, and appearance — a league secretary maintains four linked registers: the clubs, the registered players, the fixtures, and the record of who actually took the field in each fixture. Just as an appearance ties a specific player to a specific match, that turnout register is the join that connects players to the games they played. And just as keeping everything in one schema keeps the practice tidy, running one competition under one governing body keeps all records consistent. The payoff: a clean, isolated setup lets you apply type and constraint choices deliberately and see exactly how they behave.

Index the audit table for the queries you will run against it — by table and time, and optionally a GIN index on the JSONB if you will search within changes. Keep it in its own schema if you want to lock down access separately from the business tables.

bash
CREATE TABLE audit_log (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    table_name  text        NOT NULL,
    operation   text        NOT NULL,           -- INSERT / UPDATE / DELETE
    changed_at  timestamptz NOT NULL DEFAULT now(),
    changed_by  text        NOT NULL DEFAULT current_user,
    old_row     jsonb,
    new_row     jsonb
);

-- Query patterns: by table over time, and optional containment search
CREATE INDEX idx_audit_table_time ON audit_log (table_name, changed_at DESC);
CREATE INDEX idx_audit_new_gin   ON audit_log USING gin (new_row jsonb_path_ops);

Step 1 — Write the Generic Audit Trigger Function

Write one PL/pgSQL function, RETURNS trigger, that works for any table. Use TG_OP to know the operation and TG_TABLE_NAME for the source, and serialise OLD and NEW with to_jsonb, recording old_row for UPDATE/DELETE and new_row for INSERT/UPDATE. Because it is an AFTER trigger, its return value is ignored, so RETURN NULL.

The genius of to_jsonb(OLD) and to_jsonb(NEW) is that the function needs no knowledge of any table's columns — it captures whatever shape the row has. This single function will audit every table you attach it to, which is exactly what makes the system reusable.

Analogy🏏Cricket
🏏 Think of it like cricket: Just as a single standard scoring notation works for any match on any ground — you do not invent a new notation per venue — one generic trigger function works for any table. The insight is that a universal recording method, capturing whatever happened in a flexible standard form, scales effortlessly: the scorers use the same notation everywhere, and your function uses the same JSONB capture for every table. The notation's universality comes from recording form, not vocabulary: the scorer writes 'what happened' in a standard shape whatever the venue, and the generic function does the same by capturing whole rows as JSONB — to_jsonb(OLD) and to_jsonb(NEW) — so it never needs to know a table's column names; TG_TABLE_NAME and TG_OP fill in where and what kind. Every table's history lands in one uniform log: operation, table, before-image, after-image, actor, timestamp. The universality has honest costs, mirrored in the notation: a generic record cannot enforce table-specific judgement (that is for dedicated validation triggers, a different duty), and reading it back requires interpreting JSONB rather than typed columns — the price of one method that covers every match ever scheduled.
sql
CREATE OR REPLACE FUNCTION audit_changes()
RETURNS trigger AS $$
BEGIN
    INSERT INTO audit_log(table_name, operation, old_row, new_row)
    VALUES (
        TG_TABLE_NAME,
        TG_OP,
        CASE WHEN TG_OP IN ('UPDATE','DELETE') THEN to_jsonb(OLD) END,
        CASE WHEN TG_OP IN ('INSERT','UPDATE') THEN to_jsonb(NEW) END
    );
    RETURN NULL;                       -- AFTER trigger: return value ignored
END;
$$ LANGUAGE plpgsql;

Step 2 — Attach Triggers to the Audited Tables

Attach the function to each table you want audited with an AFTER INSERT OR UPDATE OR DELETE ... FOR EACH ROW trigger. The same function serves them all; you simply create one trigger per table. Choose AFTER so you record durable changes, and FOR EACH ROW so each changed row is captured individually with its values.

If you audit tables that receive large bulk updates, consider a statement-level variant with transition tables for efficiency, but per-row is the simplest correct default for typical write volumes. Verify that inserting, updating, and deleting a row each produces the expected audit_log entry.

Analogy🏏Cricket
🏏 Think of it like cricket: Just as the same pair of official scorers can be assigned to cover any match in the tournament — the assignment changes, the scoring method does not — you attach the same audit function to any table via a per-table trigger. The insight is that one proven recording capability deployed across many venues beats bespoke arrangements at each: assign the standard scorers everywhere, and every match is logged the same dependable way. The one-line assignment is the whole deployment: CREATE TRIGGER ... EXECUTE FUNCTION audit_changes() per table — the scorers already know their method; the assignment sheet just names the venue. That thinness is what makes the system administrable: auditing a new table is one statement, not new code to review, and the coverage list is queryable — pg_trigger tells you exactly which venues have scorers assigned, so 'are we auditing table X?' has an authoritative answer instead of a guess. Contrast the bespoke alternative: a hand-written audit trigger per table means every new column is a maintenance task and every table's log drifts toward its own dialect. One caution keeps the assignment honest: attach for all three operations — INSERT, UPDATE, DELETE — because an audit that misses deletions misses the disputes that matter most.
sql
CREATE TRIGGER trg_audit_accounts
AFTER INSERT OR UPDATE OR DELETE ON accounts
FOR EACH ROW EXECUTE FUNCTION audit_changes();

CREATE TRIGGER trg_audit_players
AFTER INSERT OR UPDATE OR DELETE ON players
FOR EACH ROW EXECUTE FUNCTION audit_changes();

-- Smoke test: each of these should create one audit_log row
INSERT INTO accounts(id, balance) VALUES (1, 100);
UPDATE accounts SET balance = 150 WHERE id = 1;
DELETE FROM accounts WHERE id = 1;

Step 3 — Schedule Retention with pg_cron

An audit log grows forever, so add a retention policy. Enable pg_cron and schedule a daily job that deletes audit rows older than your retention window — say one year. The DELETE is idempotent and bounded by the date predicate, making it safe to run repeatedly on the schedule.

For very large audit tables, consider partitioning audit_log by month (from Lesson 14) so retention becomes an instant DROP of an old partition instead of a large DELETE; the pg_cron job would then drop expired partitions and create upcoming ones. Either way, the schedule keeps the audit history bounded without manual intervention.

Analogy🏏Cricket
🏏 Think of it like cricket: Just as a club archives or discards match records older than a set number of seasons on a fixed schedule, rather than letting the archive grow without limit, your pg_cron job prunes old audit rows automatically. The insight is that any permanent record needs a retention rule applied routinely — the scorers' archive is kept useful by regularly retiring the oldest volumes, exactly as the audit log is kept manageable by a scheduled purge. The retention rule is policy made mechanical, and the design choices are the policy: how many seasons to keep is a business and compliance decision — fraud investigations reach back years, debugging reaches back weeks — encoded once in the DELETE's WHERE clause and applied on schedule ever after. The mechanics deserve care at scale: purging by striking out millions of rows nightly churns the very log it maintains, so mature archives partition the audit table by month and retire whole volumes with DROP PARTITION — removal as a shelf operation, not an erasure. And the pruning job itself is part of the audited system's trust: monitor its runs in cron.job_run_details, because the failure mode of a silent purge job is a disk quietly filling with history until the ground has no room left for the match.
sql
CREATE EXTENSION IF NOT EXISTS pg_cron;

-- Daily at 02:30, purge audit rows older than one year (idempotent, bounded)
SELECT cron.schedule('audit-retention', '30 2 * * *',
    $$DELETE FROM audit_log WHERE changed_at < now() - interval '1 year'$$);

-- Confirm the job is scheduled and watch its outcomes
SELECT jobname, schedule, command FROM cron.job WHERE jobname = 'audit-retention';
SELECT jobname, status, start_time FROM cron.job_run_details
WHERE jobname = 'audit-retention' ORDER BY start_time DESC LIMIT 5;

Step 4 — Testing & Verification

Exercise the whole system: perform inserts, updates, and deletes on the audited tables and confirm each produces a correctly typed audit_log row with the right old_row/new_row JSONB. Then query the audit trail to reconstruct a row's history, and confirm the retention job is scheduled and (by lowering the interval briefly) actually removes old rows.

Analogy🏏Cricket
🏏 Think of it like cricket: before a match counts, you check the ground, the kit, and the Laws all behave as intended. Just as you inspect the table definitions with \d, the ground authority reviews the official team sheets and pitch report to confirm everything is set up as designed. Just as you verify that constraint-violating inserts fail, an umpire confirms that an illegal action — a bowler exceeding their over limit, an unregistered player trying to bat — is correctly rejected rather than slipping through. Just as you check that queries return correct, deterministically ordered results, the scorers confirm the batting order and totals come out the same every time, not shuffled at random. And just as you run the constraint and referential checks to prove the database enforces what you intended, the officials run through the rulebook to prove the game will hold to its Laws. The payoff: verifying the enforcement, not just the happy path, is what proves the schema is genuinely doing its job.
sql
-- Reconstruct the full change history of one account from the audit log
SELECT operation, changed_at, changed_by,
       old_row ->> 'balance' AS old_balance,
       new_row ->> 'balance' AS new_balance
FROM audit_log
WHERE table_name = 'accounts' AND (old_row ->> 'id' = '1' OR new_row ->> 'id' = '1')
ORDER BY changed_at;

-- Verify a containment search works via the GIN index
SELECT count(*) FROM audit_log WHERE new_row @> '{"balance": 150}';

-- Confirm retention removes old rows (test with a short window first)
DELETE FROM audit_log WHERE changed_at < now() - interval '1 year';

Warning: An audit trigger runs on every write to the audited table, so it adds overhead to all inserts, updates, and deletes; on extremely high-write tables a per-row trigger can become a bottleneck, and the audit_log itself will grow rapidly. Use a statement-level trigger with transition tables for bulk-heavy tables, partition the audit table for easy retention, and never audit tables where the write cost outweighs the value.

Extension Challenge: Capture the real end-user behind each change, not just current_user. Have the application set a session variable (SELECT set_config('app.user_id', '...', true)) at the start of each request, then read it in the trigger with current_setting('app.user_id', true) and store it in audit_log. This records the authenticated application user even when all connections share one database role via a connection pool.

  • One generic PL/pgSQL trigger function (using TG_OP, TG_TABLE_NAME, to_jsonb) can audit any table.
  • to_jsonb(OLD)/to_jsonb(NEW) capture arbitrary row shapes, so a single JSONB audit table serves every table.
  • Use AFTER … FOR EACH ROW triggers for durable per-row capture; the function returns NULL.
  • Attach the same function to each audited table with one trigger per table.
  • Schedule retention with a pg_cron DELETE (or partition + DROP) to keep the audit log bounded.
  • Audit triggers add write overhead — use statement-level/transition tables for bulk tables and audit selectively.
Lesson 25 of 35
0% complete