Back to Blog

PostgreSQL Audit Logging with Triggers in 2026: Change Data Capture and Temporal Tables

Implement PostgreSQL audit logging with triggers: change data capture, temporal tables with system-time versioning, before/after image recording, and compliance-ready audit trails.

Viprasol Tech Team
January 20, 2027
13 min read

PostgreSQL Audit Logging with Triggers in 2026: Change Data Capture and Temporal Tables

Every SOC 2, HIPAA, and GDPR audit asks the same question: "Who changed what, and when?" Application-level logging misses changes made directly in the database, during batch jobs, or via admin scripts. PostgreSQL triggers solve this at the database levelβ€”every row change captured automatically, regardless of which application path caused it.

This post covers two complementary approaches: a generic audit trigger that captures before/after row images for any table, and PostgreSQL temporal tables (via system-time versioning) that keep full row history queryable as time-travel queries.


Approach 1: Generic Audit Log Table

A single audit_log table captures changes across all tracked tables:

-- migrations/20260101_audit_log.sql

CREATE TABLE audit_log (
  id              BIGSERIAL PRIMARY KEY,
  
  -- What changed
  table_name      TEXT NOT NULL,
  row_id          TEXT NOT NULL,        -- PK of the changed row (cast to text)
  operation       TEXT NOT NULL,        -- INSERT | UPDATE | DELETE
  
  -- Who changed it
  app_user_id     UUID,                 -- From session variable (set by app)
  app_user_email  TEXT,
  db_user         TEXT NOT NULL DEFAULT current_user,
  app_name        TEXT,                 -- application_name from pg_stat_activity
  client_ip       INET,
  
  -- What it looked like
  old_data        JSONB,                -- NULL for INSERTs
  new_data        JSONB,                -- NULL for DELETEs
  changed_fields  TEXT[],              -- Column names that changed (UPDATE only)
  
  -- When
  changed_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  transaction_id  BIGINT NOT NULL DEFAULT txid_current()
);

-- Partition by month for performance (audit tables grow fast)
-- In practice, use pg_partman or manual monthly partitions:
-- CREATE TABLE audit_log_2027_01 PARTITION OF audit_log
--   FOR VALUES FROM ('2027-01-01') TO ('2027-02-01');

CREATE INDEX idx_audit_table_row ON audit_log(table_name, row_id, changed_at DESC);
CREATE INDEX idx_audit_user ON audit_log(app_user_id, changed_at DESC)
  WHERE app_user_id IS NOT NULL;
CREATE INDEX idx_audit_time ON audit_log(changed_at DESC);
CREATE INDEX idx_audit_transaction ON audit_log(transaction_id);

The Generic Audit Trigger Function

-- One trigger function handles all tables
CREATE OR REPLACE FUNCTION audit_trigger_fn()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER  -- Run as owner, not the calling user
AS $$
DECLARE
  v_old_data    JSONB;
  v_new_data    JSONB;
  v_changed     TEXT[];
  v_row_id      TEXT;
  v_user_id     UUID;
  v_user_email  TEXT;
BEGIN
  -- Read app-set session variables (your app sets these on each DB connection)
  BEGIN
    v_user_id    := current_setting('app.current_user_id', TRUE)::UUID;
    v_user_email := current_setting('app.current_user_email', TRUE);
  EXCEPTION WHEN OTHERS THEN
    v_user_id    := NULL;
    v_user_email := NULL;
  END;

  IF TG_OP = 'INSERT' THEN
    v_new_data := to_jsonb(NEW);
    v_old_data := NULL;
    v_row_id   := NEW.id::TEXT;  -- Assumes tables have an `id` column
    v_changed  := NULL;

  ELSIF TG_OP = 'UPDATE' THEN
    v_old_data := to_jsonb(OLD);
    v_new_data := to_jsonb(NEW);
    v_row_id   := NEW.id::TEXT;
    
    -- Compute which fields changed
    SELECT array_agg(key ORDER BY key) INTO v_changed
    FROM (
      SELECT key
      FROM jsonb_each(v_new_data) n
      WHERE n.value IS DISTINCT FROM (v_old_data -> n.key)
    ) changed;

  ELSIF TG_OP = 'DELETE' THEN
    v_old_data := to_jsonb(OLD);
    v_new_data := NULL;
    v_row_id   := OLD.id::TEXT;
    v_changed  := NULL;
  END IF;

  -- Skip if nothing actually changed (UPDATE with identical values)
  IF TG_OP = 'UPDATE' AND (v_changed IS NULL OR array_length(v_changed, 1) = 0) THEN
    RETURN NULL;
  END IF;

  -- Redact sensitive columns before storing
  IF v_old_data IS NOT NULL THEN
    v_old_data := v_old_data
      - 'password_hash'
      - 'stripe_secret_key'
      - 'two_factor_secret';
  END IF;
  IF v_new_data IS NOT NULL THEN
    v_new_data := v_new_data
      - 'password_hash'
      - 'stripe_secret_key'
      - 'two_factor_secret';
  END IF;

  INSERT INTO audit_log (
    table_name, row_id, operation,
    app_user_id, app_user_email, db_user, app_name,
    old_data, new_data, changed_fields
  ) VALUES (
    TG_TABLE_NAME, v_row_id, TG_OP,
    v_user_id, v_user_email, session_user,
    current_setting('application_name', TRUE),
    v_old_data, v_new_data, v_changed
  );

  RETURN NULL; -- AFTER triggers ignore return value
END;
$$;

-- Attach to tables you want to audit
CREATE TRIGGER audit_projects
  AFTER INSERT OR UPDATE OR DELETE ON projects
  FOR EACH ROW EXECUTE FUNCTION audit_trigger_fn();

CREATE TRIGGER audit_tasks
  AFTER INSERT OR UPDATE OR DELETE ON tasks
  FOR EACH ROW EXECUTE FUNCTION audit_trigger_fn();

CREATE TRIGGER audit_team_members
  AFTER INSERT OR UPDATE OR DELETE ON team_members
  FOR EACH ROW EXECUTE FUNCTION audit_trigger_fn();

CREATE TRIGGER audit_subscriptions
  AFTER INSERT OR UPDATE OR DELETE ON subscriptions
  FOR EACH ROW EXECUTE FUNCTION audit_trigger_fn();

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different β€” senior engineers only, direct Slack access, and a 5.0β˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript β€” production-grade stack
  • Fixed-price contracts β€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Setting User Context from Application

// lib/db/audit-context.ts
import { db } from "@/lib/db";

/**
 * Set per-connection session variables so triggers know who made the change.
 * Call this in middleware or at the start of each authenticated request.
 */
export async function withAuditContext<T>(
  userId: string,
  userEmail: string,
  fn: () => Promise<T>
): Promise<T> {
  return db.$transaction(async (tx) => {
    // Set session variables β€” visible to trigger functions
    await tx.$executeRaw`
      SELECT
        set_config('app.current_user_id', ${userId}, TRUE),
        set_config('app.current_user_email', ${userEmail}, TRUE)
    `;
    // TRUE = session-local (scoped to this transaction)

    return fn();
  });
}

// Usage in API route:
export async function updateProject(userId: string, userEmail: string, projectId: string, updates: any) {
  return withAuditContext(userId, userEmail, async () => {
    return db.project.update({
      where: { id: projectId },
      data: updates,
    });
    // Trigger fires automatically β€” audit_log gets the change with user context
  });
}

Querying the Audit Log

// lib/audit/queries.ts
import { db } from "@/lib/db";

export async function getRowHistory(tableName: string, rowId: string) {
  return db.$queryRaw<Array<{
    id: bigint;
    operation: string;
    app_user_email: string | null;
    old_data: Record<string, unknown> | null;
    new_data: Record<string, unknown> | null;
    changed_fields: string[] | null;
    changed_at: Date;
  }>>`
    SELECT
      id,
      operation,
      app_user_email,
      old_data,
      new_data,
      changed_fields,
      changed_at
    FROM audit_log
    WHERE table_name = ${tableName}
      AND row_id = ${rowId}
    ORDER BY changed_at DESC
    LIMIT 100
  `;
}

export async function getUserActivity(userId: string, since?: Date) {
  return db.$queryRaw<Array<{
    table_name: string;
    row_id: string;
    operation: string;
    changed_fields: string[] | null;
    changed_at: Date;
  }>>`
    SELECT table_name, row_id, operation, changed_fields, changed_at
    FROM audit_log
    WHERE app_user_id = ${userId}::uuid
      ${since ? db.$raw`AND changed_at > ${since}` : db.$raw``}
    ORDER BY changed_at DESC
    LIMIT 200
  `;
}

// What changed between two timestamps (compliance query)
export async function getChangesInWindow(start: Date, end: Date, tableName?: string) {
  return db.$queryRaw`
    SELECT
      table_name,
      row_id,
      operation,
      app_user_email,
      changed_fields,
      changed_at
    FROM audit_log
    WHERE changed_at BETWEEN ${start} AND ${end}
      ${tableName ? db.$raw`AND table_name = ${tableName}` : db.$raw``}
    ORDER BY changed_at
  `;
}

πŸš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Approach 2: Temporal Tables (Full Row History)

For tables where you want true time-travel queries ("show me what this row looked like at 3pm yesterday"):

-- Temporal table pattern: main table + history table
-- (PostgreSQL doesn't have built-in temporal tables yet β€” simulate with triggers)

-- Main table
CREATE TABLE project_settings (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  project_id  UUID NOT NULL REFERENCES projects(id),
  settings    JSONB NOT NULL DEFAULT '{}',
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_by  UUID REFERENCES users(id),
  
  -- System-time columns
  valid_from  TIMESTAMPTZ NOT NULL DEFAULT now(),
  valid_to    TIMESTAMPTZ NOT NULL DEFAULT 'infinity'::timestamptz,
  
  -- Only one current row per project (valid_to = infinity)
  CONSTRAINT project_settings_current EXCLUDE USING gist (
    project_id WITH =,
    tstzrange(valid_from, valid_to) WITH &&
  )
);

-- History table β€” same structure, no constraints
CREATE TABLE project_settings_history (LIKE project_settings);
CREATE INDEX idx_psh_project_time ON project_settings_history(project_id, valid_from DESC);

-- Trigger: before update, copy current row to history
CREATE OR REPLACE FUNCTION versioned_update_fn()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
  -- Close the current version
  NEW.valid_from := now();
  NEW.valid_to   := 'infinity'::timestamptz;

  -- Archive the old version
  OLD.valid_to := now();
  INSERT INTO project_settings_history VALUES (OLD.*);

  RETURN NEW;
END;
$$;

CREATE TRIGGER versioned_update
  BEFORE UPDATE ON project_settings
  FOR EACH ROW EXECUTE FUNCTION versioned_update_fn();

-- Trigger: on delete, archive to history
CREATE OR REPLACE FUNCTION versioned_delete_fn()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
  OLD.valid_to := now();
  INSERT INTO project_settings_history VALUES (OLD.*);
  RETURN OLD;
END;
$$;

CREATE TRIGGER versioned_delete
  BEFORE DELETE ON project_settings
  FOR EACH ROW EXECUTE FUNCTION versioned_delete_fn();

Time-travel queries:

-- What did project settings look like at 2027-01-15 14:30?
SELECT *
FROM project_settings
WHERE project_id = 'proj-123'
  AND valid_from <= '2027-01-15 14:30:00+00'
  AND valid_to > '2027-01-15 14:30:00+00'

UNION ALL

SELECT *
FROM project_settings_history
WHERE project_id = 'proj-123'
  AND valid_from <= '2027-01-15 14:30:00+00'
  AND valid_to > '2027-01-15 14:30:00+00';

-- Show all versions of this row
SELECT
  settings,
  updated_by,
  valid_from,
  CASE WHEN valid_to = 'infinity' THEN NULL ELSE valid_to END AS valid_to,
  CASE WHEN valid_to = 'infinity' THEN TRUE ELSE FALSE END AS is_current
FROM (
  SELECT * FROM project_settings WHERE project_id = 'proj-123'
  UNION ALL
  SELECT * FROM project_settings_history WHERE project_id = 'proj-123'
) versions
ORDER BY valid_from DESC;

Performance Considerations

-- Audit log grows fast β€” partition by month
ALTER TABLE audit_log RENAME TO audit_log_default;

CREATE TABLE audit_log (
  LIKE audit_log_default INCLUDING ALL
) PARTITION BY RANGE (changed_at);

CREATE TABLE audit_log_2027_01 PARTITION OF audit_log
  FOR VALUES FROM ('2027-01-01') TO ('2027-02-01');

CREATE TABLE audit_log_2027_02 PARTITION OF audit_log
  FOR VALUES FROM ('2027-02-01') TO ('2027-03-01');

-- Auto-create future partitions with pg_partman extension:
-- SELECT partman.create_parent('public.audit_log', 'changed_at', 'native', 'monthly');

-- For high-write tables: insert audit rows async via LISTEN/NOTIFY
-- (reduces write amplification on hot tables)

Cost and Timeline

ComponentTimelineCost (USD)
Audit trigger function + tables1–2 days$800–$1,600
Session variable context setup0.5 day$400
Temporal table triggers1–2 days$800–$1,600
Audit query API + UI1–2 days$800–$1,600
Partitioning + pg_partman setup0.5–1 day$400–$800
Full audit logging system1–2 weeks$5,000–$9,000

See Also


Working With Viprasol

We implement audit logging and compliance infrastructure for SaaS products β€” from database-level triggers through application audit trails and GDPR-ready data export pipelines. Our team has built audit systems for products requiring SOC 2 and HIPAA compliance.

What we deliver:

  • Generic audit trigger covering all tracked tables
  • Session variable injection for user-attributed changes
  • Temporal table implementation for time-travel queries
  • Audit log partitioning for long-term retention
  • Compliance reporting queries for SOC 2 and GDPR audits

Explore our web development services or contact us to add database-level audit logging to your product.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms β€” we build it all with Next.js and React.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse β‰₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β€” from architecture through deployment.