Audit Logging: Tracking Data Changes for Compliance

Implement audit logging for compliance. Learn row-level change capture with triggers and CDC, log aggregation strategies, and retention policies.

published: reading time: 27 min read author: GeekWorkBench

Audit Logging: Tracking Data Changes for Compliance

Every data change tells a story. Who modified a customer record at 3am? Which user accessed that sensitive configuration? Why did that pricing calculation change last month? Without audit logging, these questions become unanswerable—and regulators do not accept “we don’t know” as an answer.

Financial services, healthcare, payment processors—these industries require tracking who changed what, when, and often why. This guide covers the technical implementation of audit logging without the vendor fluff.

flowchart LR
    subgraph Capture["Audit Capture"]
        T[("Trigger<br/>Row Change")]
        CDC[("CDC<br/>Log Stream")]
        PGA[("pg_audit<br/>Statement")]
    end

    subgraph Pipeline["Processing Pipeline"]
        Queue[("Kafka /<br/>Log Stream")]
        Consumer[("Consumer<br/>Processor")]
        Agg[("Log<br/>Aggregator")]
    end

    subgraph Storage["Audit Storage"]
        AL[("Audit<br/>Log Table")]
        Partition[("Time<br/>Partition")]
        Archive[("S3 Glacier<br/>Archive")]
    end

    subgraph Analysis["SIEM / Analysis"]
        SIEM[("SIEM<br/>Platform")]
        Alert[("Real-time<br/>Alert")]
        Query[("Ad-hoc<br/>Query")]
    end

    T --> Queue
    CDC --> Queue
    PGA --> Queue
    Queue --> Consumer
    Consumer --> Agg
    Agg --> AL
    AL --> Partition
    Partition --> Archive
    Agg --> SIEM
    SIEM --> Alert
    SIEM --> Query

Introduction

Audit logging captures every data change so you can answer “who changed what, when, and why.” This is not just a compliance checkbox — it is how you diagnose production incidents, prove regulatory compliance, and recover from accidental data deletion. Without audit logging, data changes are invisible unless you have row-level history or external logging.

This guide covers what to log (the four Ws: who, what, when, and where), capture mechanisms (triggers, change data capture, pg_audit), log storage and retention, and how to implement audit logging that does not become a performance bottleneck on your write path.

What to Log: The Four Ws

Effective audit logging captures the complete picture of data changes.

Who (Identity)

Log the user identity associated with each operation:

-- Include application user ID, not database session
-- Database sessions may be pooled (PostgreSQL pgBouncer, MySQL Proxy)
audit_log: {
    user_id: 12345,
    username: "john.smith@company.com",
    session_id: "sess_abc123",
    ip_address: "192.168.1.100",
    user_agent: "Mozilla/5.0..."
}

What (Operation)

Record the specific operation performed:

audit_log: {
    table_name: "customer_records",
    operation: "UPDATE",
    record_id: 789,
    old_values: { "credit_limit": 5000 },
    new_values: { "credit_limit": 10000 },
    query: "UPDATE customer_records SET credit_limit = 10000 WHERE id = 789"
}

When (Timestamp)

Use UTC timestamps with sufficient precision:

-- Microsecond precision for PostgreSQL
audit_log: {
    timestamp: "2026-03-26T14:30:00.123456Z",
    transaction_id: 1847293,
    sequence_in_transaction: 3
}

Additional Context

For a complete picture, consider logging:

  • Application name: Which service initiated the change
  • Request ID: Correlation ID for distributed tracing
  • Reason code: Business justification when required
  • Approval chain: Who approved privileged operations

Database Triggers for Audit Logging

Triggers provide the most direct way to capture row-level changes.

PostgreSQL Trigger Implementation

-- Create audit log table
CREATE TABLE audit_log (
    id BIGSERIAL PRIMARY KEY,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    table_name TEXT NOT NULL,
    operation TEXT NOT NULL,
    record_id TEXT,
    old_data JSONB,
    new_data JSONB,
    user_id INTEGER,
    username TEXT,
    application_name TEXT,
    transaction_id BIGINT,
    client_addr INET
);

-- Create the trigger function
CREATE OR REPLACE FUNCTION audit_trigger_function()
RETURNS TRIGGER AS $$
DECLARE
    audit_row audit_log;
    current_user_id INTEGER;
    current_username TEXT;
    current_app TEXT;
BEGIN
    -- Capture current user context
    SELECT usesysid::INTEGER, usename, COALESCE(current_setting('application_name', true), '')
    INTO current_user_id, current_username, current_app
    FROM pg_user WHERE usesysid = current_user;

    audit_row := ROW(
        nextval('audit_log_id_seq'),
        CURRENT_TIMESTAMP,
        TG_TABLE_NAME,
        TG_OP,
        CASE WHEN TG_OP = 'UPDATE' OR TG_OP = 'DELETE' THEN OLD.id::TEXT ELSE NEW.id::TEXT END,
        CASE WHEN TG_OP = 'UPDATE' OR TG_OP = 'DELETE' THEN to_jsonb(OLD) END,
        CASE WHEN TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN to_jsonb(NEW) END,
        current_user_id,
        current_username,
        current_app,
        txid_current(),
        inet_client_addr()
    );

    INSERT INTO audit_log VALUES (audit_row.*);
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

-- Attach trigger to a table
CREATE TRIGGER customers_audit
AFTER INSERT OR UPDATE OR DELETE ON customers
FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();

Selective Column Auditing

Only audit sensitive columns to reduce storage and noise:

CREATE OR REPLACE FUNCTION audit_trigger_function()
RETURNS TRIGGER AS $$
DECLARE
    audit_row audit_log;
    sensitive_columns TEXT[] = ARRAY['ssn', 'credit_card', 'salary'];
    old_sensitive JSONB;
    new_sensitive JSONB;
BEGIN
    -- Only capture changes to sensitive columns
    IF TG_OP = 'UPDATE' THEN
        SELECT jsonb_object_agg(key, value) INTO old_sensitive
        FROM jsonb_each_text(to_jsonb(OLD))
        WHERE key = ANY(sensitive_columns);

        SELECT jsonb_object_agg(key, value) INTO new_sensitive
        FROM jsonb_each_text(to_jsonb(NEW))
        WHERE key = ANY(sensitive_columns);

        -- Skip if no sensitive columns changed
        IF old_sensitive IS DISTINCT FROM new_sensitive THEN
            INSERT INTO audit_log VALUES (
                nextval('audit_log_id_seq'),
                CURRENT_TIMESTAMP,
                TG_TABLE_NAME,
                TG_OP,
                OLD.id::TEXT,
                old_sensitive,
                new_sensitive,
                current_user,
                txid_current()
            );
        END IF;

        RETURN OLD;
    END IF;

    -- Full audit for INSERT and DELETE
    INSERT INTO audit_log VALUES (
        nextval('audit_log_id_seq'),
        CURRENT_TIMESTAMP,
        TG_TABLE_NAME,
        TG_OP,
        COALESCE(NEW.id::TEXT, OLD.id::TEXT),
        to_jsonb(OLD),
        to_jsonb(NEW),
        current_user,
        txid_current()
    );

    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

MySQL Trigger Implementation

-- Create audit log table
CREATE TABLE audit_log (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    table_name VARCHAR(255) NOT NULL,
    operation ENUM('INSERT', 'UPDATE', 'DELETE') NOT NULL,
    record_id VARCHAR(255),
    old_data JSON,
    new_data JSON,
    user_id INT,
    username VARCHAR(255),
    client_ip VARCHAR(45)
);

-- Create the trigger
DELIMITER //

CREATE TRIGGER customers_audit_insert
AFTER INSERT ON customers
FOR EACH ROW
BEGIN
    INSERT INTO audit_log (table_name, operation, record_id, new_data, user_id, username, client_ip)
    VALUES (
        'customers',
        'INSERT',
        NEW.id,
        JSON_OBJECT('name', NEW.name, 'email', NEW.email),
        @current_user_id,
        @current_username,
        @current_client_ip
    );
END//

CREATE TRIGGER customers_audit_update
AFTER UPDATE ON customers
FOR EACH ROW
BEGIN
    INSERT INTO audit_log (table_name, operation, record_id, old_data, new_data, user_id, username, client_ip)
    VALUES (
        'customers',
        'UPDATE',
        OLD.id,
        JSON_OBJECT('name', OLD.name, 'email', OLD.email),
        JSON_OBJECT('name', NEW.name, 'email', NEW.email),
        @current_user_id,
        @current_username,
        @current_client_ip
    );
END//

CREATE TRIGGER customers_audit_delete
AFTER DELETE ON customers
FOR EACH ROW
BEGIN
    INSERT INTO audit_log (table_name, operation, record_id, old_data, user_id, username, client_ip)
    VALUES (
        'customers',
        'DELETE',
        OLD.id,
        JSON_OBJECT('name', OLD.name, 'email', OLD.email),
        @current_user_id,
        @current_username,
        @current_client_ip
    );
END//

DELIMITER ;

Change Data Capture (CDC)

For high-volume systems, triggers can become a bottleneck. CDC offers an alternative approach using the database’s transaction log.

PostgreSQL CDC with Debezium

# Docker Compose for Debezium PostgreSQL connector
version: "3"
services:
  postgres:
    image: postgres:15
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: mydb
    command:
      - "postgres"
      - "-c"
      - "wal_level=logical"
      - "-c"
      - "max_wal_senders=10"
      - "-c"
      - "max_replication_slots=10"

  zookeeper:
    image: confluentinc/cp-zookeeper:7.4.0
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181

  kafka:
    image: confluentinc/cp-kafka:7.4.0
    depends_on: [zookeeper]
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092

  debezium:
    image: debezium/connect:2.3
    depends_on: [kafka, postgres]
    environment:
      BOOTSTRAP_SERVERS: kafka:9092
      GROUP_ID: 1
      CONFIG_STORAGE_TOPIC: debezium_configs
      OFFSET_STORAGE_TOPIC: debezium_offsets
      STATUS_STORAGE_TOPIC: debezium_statuses
// Debezium connector configuration
{
  "name": "postgres-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "postgres",
    "database.port": "5432",
    "database.user": "postgres",
    "database.password": "postgres",
    "database.dbname": "mydb",
    "topic.prefix": "dbserver1",
    "table.include.list": "public.customers,public.orders",
    "plugin.name": "pgoutput",
    "key.converter": "org.apache.kafka.connect.json.JsonConverter",
    "value.converter": "org.apache.kafka.connect.json.JsonConverter"
  }
}

CDC Advantages over Triggers

  • Lower overhead: No per-table trigger execution
  • Near real-time: Changes stream as they happen, not after commit
  • Multi-system propagation: Feed to multiple sinks (data warehouse, search index, cache)
  • Schema evolution: CDC handles schema changes more gracefully

CDC Limitations

  • Setup complexity: Requires Kafka, connector configuration, consumer infrastructure
  • Latency during failures: Kafka consumer lag during outages
  • Initial snapshot: Full table copy required to bootstrap

PostgreSQL pg_audit

PostgreSQL’s pg_audit extension provides standardized, comprehensive audit logging:

-- Enable pg_audit (requires PostgreSQL installation with extension)
CREATE EXTENSION pgaudit;

-- Configure pg_audit in postgresql.conf
-- pgaudit.log = 'read, write, function, role, ddl'
-- pgaudit.log_catalog = on
-- pgaudit.log_relation = on
-- pgaudit.log_statement_once = off
-- pgaudit.role = audit_user

-- Create dedicated audit role
CREATE ROLE audit_user;
GRANT EXECUTE ON FUNCTION pg_logonal_session TO audit_user;

-- Example audit log output (in PostgreSQL log)
-- 2026-03-26 14:30:00.123 UTC [12345] user@db LOG:  AUDIT: SESSION,1,1,READ,SELECT,,,SELECT * FROM customers WHERE id = 1; <pid=12345>
-- 2026-03-26 14:30:01.456 UTC [12345] user@db LOG:  AUDIT: SESSION,1,2,WRITE,INSERT,customer_records,"INSERT INTO customer_records ...",<not logged>

pg_audit vs. Custom Triggers

Featurepg_auditCustom Triggers
Setup complexityLowHigh
Log formatStandardizedCustom
PerformanceOptimizedVariable
FlexibilityLimited to configured statementsFull control
Cross-table operationsPartialFull
DDL auditingYesLimited

When to Use / When Not to Use Each Audit Approach

Use Triggers when:

  • You need row-level capture of old/new values
  • Per-table auditing is acceptable overhead
  • You need deterministic capture within transaction context

Do not use Triggers when:

  • Your system writes millions of rows per second
  • You need cross-table correlation in a single audit event
  • You want minimal performance impact on write throughput

Use CDC when:

  • You need audit data feeding multiple downstream systems
  • Write throughput cannot tolerate trigger overhead
  • You need near-real-time audit event streaming

Do not use CDC when:

  • Your infrastructure budget doesn’t cover Kafka/connector
  • You need synchronous audit within the transaction that made the change

Use pg_audit when:

  • You need standardized DDL and session auditing on PostgreSQL
  • Compliance requires evidence of all statement executions

Do not use pg_audit alone when:

  • You need row-level old/new value capture (pg_audit doesn’t provide this)

Audit Approach Trade-offs

DimensionDatabase TriggersCDC (Debezium)pg_auditApplication-Level
Row-level old/new valuesYesYesNoYes
DDL auditingNoNoYesLimited
Performance impactModerate to highLowLowLow
Setup complexityMediumHighLowMedium
Real-time streamingNoYesNoYes
Schema evolution handlingManual trigger updateAutomaticAutomaticManual
Infrastructure neededNone extraKafka clusterExtension onlyLogging framework
Best forPer-table complianceHigh-volume auditStandard DDL/SessionBusiness-level audit

Production Failure Scenarios

FailureImpactMitigation
Trigger causing deadlocks on high-write tableWrite throughput collapsesMove auditing to application layer, benchmark before deploying
CDC consumer lag growing unboundedAudit events delayed, compliance gapMonitor lag metric, alert at threshold, scale consumers
Audit log table filling diskDatabase stops writingPartition audit table by time, enforce retention automatically
pg_audit flooding log storageStorage costs spikeSet appropriate log levels, rotate aggressively
Selective column audit missing updatesIncomplete audit trailTest with updates that skip audited columns

Capacity Estimation: Log Volume Per Transaction and Storage Planning

Audit logging generates substantial data volume. Planning storage requires understanding your transaction rate and the size of each logged change.

Log volume formula:

audit_log_bytes_per_transaction = avg_changed_columns × avg_column_size × (old_value_json_overhead + new_value_json_overhead)
daily_audit_log_volume = transactions_per_day × changes_per_transaction × audit_log_bytes_per_change

For an OLTP system with 50,000 daily transactions, each modifying 5 columns averaging 50 bytes each (as JSON):

  • Old + new value as JSON: ~2× overhead for JSON wrapper and field names
  • Bytes per change: 5 × 50 × 2.5 = 625 bytes per modified row
  • Audit log per transaction: 3 modified rows × 625 = ~1.9KB
  • Daily log volume: 50,000 × 1.9KB = ~95MB/day
  • Annual: 95MB × 365 = ~35GB/year

This assumes the audit log stores both old and new values as JSON. Storing old values only (for INSERT/DELETE) or new values only (for UPDATE) halves the volume. Storing diffs instead of full values reduces it further.

CDC-based audit log volume (for Kafka-based CDC):

kafka_message_bytes = avg_row_width × number_of_columns × 2 (old+new) × json_overhead × schema_compression
daily_kafka_volume = cdc_events_per_day × avg_event_size

For the same 50K transactions/day with CDC capturing all columns (50 columns, 500 bytes each): 50K × 50 × 500 × 2.5 = ~3.1GB/day — significantly larger than trigger-based audit logging because CDC captures entire rows, not just changed columns.

Observability Hooks: CDC Lag Metrics and Audit Log Completeness Checks

Key audit logging metrics: CDC lag, log completeness, and write latency.

-- PostgreSQL: check if any audit log entries are missing (gap detection)
SELECT
    last_log_id,
    last_log_time,
    EXTRACT(EPOCH FROM (now() - last_log_time)) AS gap_seconds
FROM audit_log_sequence
WHERE last_log_time < now() - INTERVAL '5 minutes';

-- Verify audit log completeness for a critical table
SELECT
    audit_table_name,
    COUNT(*) AS audit_entries,
    COUNT(DISTINCT DATE(occurred_at)) AS days_covered,
    MIN(occurred_at) AS oldest_entry,
    MAX(occurred_at) AS newest_entry
FROM audit_log
WHERE audit_table_name = 'financial_transactions'
GROUP BY audit_table_name;
# Critical: CDC lag exceeds threshold (data freshness at risk)
- alert: CdcLagExceedsThreshold
  expr: cdc_consumer_lag_messages > 10000
  for: 10m
  labels:
    severity: critical
  annotations:
    summary: "CDC consumer lag {{ $value }} messages on {{ $labels.topic }}"

# Critical: Audit log write latency spike (could indicate log writer bottleneck)
- alert: AuditLogWriteLatencyHigh
  expr: histogram_quantile(0.95, audit_log_write_duration_seconds) > 1.0
  for: 5m
  labels:
    severity: critical
  annotations:
    summary: "Audit log P95 write latency {{ $value }}s exceeds 1 second"

# Warning: Audit log entry count anomaly (could indicate missed entries)
- alert: AuditLogVolumeAnomaly
  expr: rate(audit_log_entries_total[1h]) < 0.5 * avg_over_time(rate(audit_log_entries_total[1h])[24h:1h])
  for: 1h
  labels:
    severity: warning
  annotations:
    summary: "Audit log volume {{ $value }} is 50% below average"

# Critical: Audit log storage approaching capacity
- alert: AuditLogStorageCritical
  expr: (audit_log_storage_used_bytes / audit_log_storage_quota_bytes) > 0.85
  for: 15m
  labels:
    severity: critical
  annotations:
    summary: "Audit log storage at {{ $value | humanizePercentage }} capacity"

Quick Recap Checklist

Use this checklist when designing or reviewing audit logging for a database system:

  • Audit log captures who, what, when, and context for every data change
  • Application user ID (not just database session) is captured in audit entries
  • Row-level old/new values are captured for INSERT/UPDATE/DELETE operations
  • Audit table is partitioned by time for efficient retention management
  • Retention policy satisfies compliance requirements (PCI-DSS, HIPAA, GDPR)
  • Completeness monitoring is in place with gap detection alerts
  • CDC or trigger overhead has been benchmarked under realistic write load
  • Selective column auditing configured for high-volume tables
  • Audit log queries use appropriate indexes for performance
  • Archival strategy defined for moving old partitions to cold storage

Log Aggregation and Analysis

Raw audit logs aren’t audit logs until they’re analyzed. Collecting logs without querying them is just storage expense.

Centralized Log Collection

import logging
import json
from datetime import datetime
import psycopg2
from kafka import KafkaProducer

class AuditLogger:
    def __init__(self, kafka_servers: list, audit_topic: str):
        self.producer = KafkaProducer(
            bootstrap_servers=kafka_servers,
            value_serializer=lambda v: json.dumps(v).encode('utf-8')
        )
        self.audit_topic = audit_topic

    def log_change(
        self,
        table: str,
        operation: str,
        record_id: str,
        old_values: dict,
        new_values: dict,
        context: dict
    ):
        event = {
            'timestamp': datetime.utcnow().isoformat() + 'Z',
            'table': table,
            'operation': operation,
            'record_id': record_id,
            'old_values': old_values,
            'new_values': new_values,
            **context
        }

        self.producer.send(self.audit_topic, event)

    def close(self):
        self.producer.flush()
        self.producer.close()

Query Patterns for Audit Analysis

-- Who modified this record and when?
SELECT
    created_at,
    username,
    operation,
    old_data,
    new_data
FROM audit_log
WHERE table_name = 'customers'
  AND record_id = '123'
  AND (old_data->>'email') IS DISTINCT FROM (new_data->>'email')
ORDER BY created_at DESC;

-- What changed in the last 24 hours?
SELECT
    table_name,
    operation,
    COUNT(*) as change_count,
    COUNT(DISTINCT username) as users_involved
FROM audit_log
WHERE created_at > CURRENT_TIMESTAMP - INTERVAL '24 hours'
GROUP BY table_name, operation
ORDER BY change_count DESC;

-- Flag unusual access patterns
SELECT
    username,
    COUNT(*) as access_count,
    COUNT(DISTINCT table_name) as tables_accessed,
    MAX(created_at) as last_access
FROM audit_log
WHERE created_at > CURRENT_TIMESTAMP - INTERVAL '1 hour'
GROUP BY username
HAVING COUNT(*) > 1000;  -- Flag unusual volume

Retention Policies

Audit logs have regulatory lifecycles:

  • PCI-DSS: 1 year minimum, 3 years recommended
  • HIPAA: 6 years from creation or last effective date
  • GDPR: As long as necessary for processing purposes
  • SOC 2: 1 year minimum

Archival Strategy

-- Partition audit log by month for efficient retention management
CREATE TABLE audit_log (
    id BIGSERIAL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL,
    table_name TEXT NOT NULL,
    operation TEXT NOT NULL,
    record_id TEXT,
    old_data JSONB,
    new_data JSONB,
    user_id INTEGER,
    username TEXT,
    PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

-- Create monthly partitions
CREATE TABLE audit_log_2026_03 PARTITION OF audit_log
    FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');

CREATE TABLE audit_log_2026_04 PARTITION OF audit_log
    FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');

-- Retention: drop partitions older than 2 years
-- This is much faster than DELETE
DROP TABLE audit_log_2024_03;
import boto3
from datetime import datetime, timedelta

def archive_old_partitions(db_config: dict, retention_days: int):
    """Archive audit log partitions to S3 before dropping"""
    s3 = boto3.client('s3')

    cutoff_date = datetime.now() - timedelta(days=retention_days)

    # Export partitions older than retention
    # In production, use proper export mechanism
    for partition in get_partitions_before(db_config, cutoff_date):
        # Export to Parquet for efficient storage
        export_to_parquet(partition)

        # Upload to S3 Glacier for long-term storage
        s3.upload_file(
            f'/tmp/{partition}.parquet',
            'audit-log-archive',
            f'archive/{partition}.parquet'
        )

        # Drop the partition from database
        drop_partition(partition)

Interview Questions

1. Your CDC-based audit system is generating 10× more data than you budgeted for. What happened and how do you fix it?

CDC captures change events at the row level. If your source table has 100 columns but only 5 change on average, you should capture only those 5 changed columns. Full-row CDC captures all 100 columns regardless of what changed, multiplying data volume by 20×. Most CDC tools (Debezium, AWS DMS) support selective column CDC — enable it to capture only changed columns. Also check whether CDC is capturing before/after images for all operations or just after images — before+after doubles the data. Fix: configure CDC to capture only changed columns and only new values (not old values) for UPDATE operations.

2. How do you ensure audit log completeness — that every change to your database appears in the audit log?

Completeness guarantees depend on your audit mechanism. Triggers are synchronous and provide strong completeness — if the trigger fires, the audit entry is written as part of the same transaction. If the transaction rolls back, the audit entry rolls back too. CDC provides at-least-once delivery — if the CDC process fails and restarts, it may re-send the same events. To verify completeness: use a sequence or monotonically increasing ID on the audit log table and check for gaps. Store the last processed sequence ID in a separate table. On startup, the consumer should check whether the last processed ID is contiguous with the next ID to detect missed events.

3. Your audit log query performance is degrading as the table grows. How do you maintain query performance?

Partition the audit log by time (monthly or weekly partitions). This allows SELECT WHERE occurred_at BETWEEN '2026-01-01' AND '2026-02-01' to scan only the relevant partition. Use BRIN indexes on the occurred_at column within each partition — BRIN indexes are small and fast for sequentially ordered data. For queries filtering by table_name and record_id, add a composite index on (table_name, record_id, occurred_at DESC). If queries still slow, consider Materialized Views that pre-aggregate audit data for common query patterns (who touched this record most frequently? what tables are changing most?).

4. When should you choose CDC over trigger-based audit logging?

CDC is better for high-volume systems where trigger overhead would impact write performance (trigger fires synchronously on every row modification), for multi-database scenarios where you need to audit changes across heterogeneous sources, and when you want audit data in a stream format for real-time processing. Triggers are better for synchronous completeness guarantees (audit entry and data change in same transaction), for simpler deployments without Kafka infrastructure, and for environments where CDC tools are not supported (older databases, specific configurations). For compliance-grade audit, trigger-based is often preferred because the completeness guarantee is stronger.

5. How would you design an audit logging system for a multi-tenant SaaS application where each tenant must only see their own audit logs?

Multi-tenant audit isolation requires careful design. Option 1: separate audit log tables per tenant — simple but operational overhead. Option 2: single audit log table with tenant_id as the first dimension in a composite index — query performance depends on proper partitioning by tenant_id. Option 3: separate audit database per tenant — strong isolation but management complexity. Regardless of approach: enforce tenant isolation at the database layer via RLS policies so even DBAs cannot cross-query tenant data, encrypt audit logs with tenant-specific keys so cross-tenant access is cryptographically prevented, and ensure audit log queries always filter by tenant_id by default in application code.

6. Your team wants to implement real-time alerting on suspicious database access patterns using the audit log. What patterns would you alert on?

Key patterns: unusual volume of queries from a single user (potential credential compromise or insider threat), access to tables outside normal working hours (especially admin or sensitive tables like payment or user data), queries selecting large fractions of tables (data exfiltration attempt), modifications to access control or permission structures, and bulk deletes or drops. Implementation: stream audit logs to a SIEM, define baseline access patterns per role, alert on deviation from baseline with severity based on data sensitivity. Threshold examples: >1000 queries/hour from a service account, access to customer PII tables by non-support roles after business hours.

7. What is the difference between pg_audit and PostgreSQL's standard logging for compliance purposes?

PostgreSQL standard logging (log_statement) logs all SQL statements but with limited standardization and no guarantee of capturing all required compliance detail. pg_audit provides structured, standardized audit logging specifically designed for compliance: it logs session information (user, session ID, application name), operation type (read, write, function, role, ddl), and includes per-object granularity with the ability to exclude certain statements from logging. Standard logging cannot reliably capture row-level old/new values or satisfy compliance requirements for showing what specific data was accessed. pg_audit is specifically designed to meet PCI-DSS, HIPAA, and similar compliance frameworks' requirements.

8. How do you handle audit logging for a database that uses read replicas — ensuring audit entries capture the correct user identity even when writes go through replicas?

Read replicas typically do not handle writes (or writes are routed differently), so audit triggers on the primary handle all write capture. The challenge is ensuring the audit trigger captures the correct user identity — if connections go through a load balancer or connection pool that routes to the primary, the database session may show a generic application user rather than the actual application end user. Solution: set user identity at the session level after connecting via SET app.user_id = '...' and SET app.session_id = '...', and have the trigger capture these via current_setting('app.user_id'). Alternatively, pass user identity through the connection string's application_name parameter — postgresql://host?application_name=user123 — which the trigger can access via current_setting('application_name'). The key is ensuring the identity-setting logic executes before any write operations so the trigger always has the correct context.

9. How do you design an audit log table schema that scales to billions of events without degrading query performance?

Partition by time (monthly or weekly partitions) as the primary dimension. Use BRIN indexes on the timestamp column within each partition — BRIN indexes are small and efficient for sequentially ordered data. Add composite indexes for common query patterns: (table_name, occurred_at), (record_id, occurred_at), (user_id, occurred_at). Partition pruning ensures queries touching a single time range skip all other partitions. For high-volume environments, consider columnar storage (PostgreSQL columnar extension or ClickHouse) which compresses audit data 10× and accelerates aggregation queries. Archive old partitions to S3 as Parquet files for compliance retrieval.

10. Your audit log shows a spike in UPDATE operations on a sensitive table at 2am, but application logs show no unusual activity. What do you investigate?

Investigate: check database session logs for the timestamp — were there direct SQL connections (pg_stat_activity shows client_addr and application_name), check PostgreSQL's pg_stat_statements for top queries at that time, verify pg_hba.conf for any unusual connection sources, check for scheduled jobs or ETL pipelines that might run at 2am. If application logs show no activity but audit logs show updates, either the application logging is incomplete, the updates bypassed the application (direct SQL), or the audit trigger captured changes from a source you did not account for. Cross-reference the audit timestamp with database connection logs and cron job schedules.

11. How do you implement audit log tamper detection to prove that existing logs have not been modified or deleted?

Write-once storage: audit logs written to append-only storage (WORM — write once, read many) that cannot be overwritten or deleted without special privileges. Cryptographic chaining: each log entry includes a hash of the previous entry (blockchain-style), creating a tamper-evident chain. If any entry is modified, the hash chain breaks from that point forward. Implementation: store hash of previous entry in each audit row, verify chain integrity on query. Additional: write audit logs to multiple independent systems (e.g., database + SIEM + immutable storage), and implement alert if logs in any system diverge from others.

12. Your SIEM platform receives audit events from multiple databases in different regions. How do you correlate events across regions for a single user action?

Correlation requires a consistent identifier present in all events. Use application-level correlation ID: generate a UUID at the request start, propagate it through all database calls via session variable (SET app.correlation_id = '...'), and include it in audit log entries. Alternatively, correlate by (username, timestamp, operation) — imprecise but works when events are sparse. SIEM platforms provide correlation rules that match events across sources using shared field values. The key is consistent field naming across all audit sources and a correlation ID injected at the application layer.

13. How do you handle audit logging for bulk operations that modify millions of rows — logging every row would overwhelm the system?

For bulk operations, log at the statement level (one entry per bulk operation) rather than the row level. Include metadata: operation type, table, number of rows affected, user, timestamp, and a WHERE clause summary. For sensitive data that requires row-level logging, use batched logging: sample rows (every 100th or every 1000th), or log only changed columns and only when significant columns change (not every UPDATE to a frequently-updated timestamp). If row-level logging is mandatory for compliance, consider CDC-based audit which handles bulk operations more efficiently than trigger-based logging.

14. Your audit trigger is causing deadlocks on a high-write table. How do you redesign to eliminate the deadlocks without losing audit coverage?

Move audit log writes out of the transaction path: the trigger queues audit events to an asynchronous queue ( PostgreSQL LISTEN/NOTIFY, or a message queue like Kafka), and a separate consumer process writes to the audit log table. This decouples the audit write from the data write — the transaction commits immediately, and audit writes happen asynchronously. Trade-off: audit entries are no longer in the same transaction as the data change — if the transaction rolls back, the audit entry still exists. For compliance requiring transactional consistency, use CDC instead of triggers to avoid deadlocks while maintaining near-real-time audit.

15. A compliance auditor asks to see "who had access to the production database in Q1 2026." How do you generate this report?

PostgreSQL's pg_audit (if enabled) logs all connections and session activity. Query the PostgreSQL log for session events: SELECT * FROM pg_logical/logicaldecode/ or use pg_stat_activity historical data. If pg_audit is not available, query pg_stat_activity snapshots (if you archive them), or rely on application-layer audit which captures the application user ID. The limitation: database session logs show database user (e.g., "app_user"), not the application end user — you need application-level audit for user-level attribution. Proactively archive pg_stat_activity snapshots for compliance use.

16. How do you implement row-level audit for soft-delete operations where deleted_at is set but the row remains?

An UPDATE setting deleted_at is an UPDATE operation, not a DELETE. Your trigger fires on UPDATE, captures old_values and new_values, and logs the soft-delete as an audit entry with operation type 'SOFT_DELETE' or similar. To distinguish soft-delete from regular updates, include a marker column or check if deleted_at transitioned from NULL to non-NULL: CASE WHEN OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN 'soft_delete' ELSE 'update' END. This matters for compliance queries: "show me all deletions" should include soft-deletes as deletions.

17. Your team uses connection pooling (pgBouncer). How does this affect audit logging user attribution?

pgBouncer multiplexes many application sessions onto few database sessions. The database session shows the pgBouncer user, not the application user. Fix: set the application user identity at the session level via SET app.user_id = '...' after connecting through pgBouncer, and capture current_setting('app.user_id') in your audit trigger. Alternatively, use PostgreSQL's application_name parameter to pass user identity through the connection pool. Never rely on the database session user for application-level audit — it will show the pool user, not the actual user.

18. A data analyst needs read access to the production database for ad-hoc queries, but you must maintain audit logs of what they access. How do you implement this?

Enable pg_audit with pgaudit.log = 'read' to log all SELECT queries. Grant the analyst a read-only role, configure pg_audit to log SELECT statements for that role specifically, and route their queries through an audit-aware connection pool. Combine with row-level security: RLS policies can filter results based on sensitivity classification, and the audit trigger logs which rows were returned. This gives you both statement-level and data-level audit for read operations — critical for compliance in regulated environments.

19. What is the performance difference between pg_audit and custom triggers for PostgreSQL audit logging?

pg_audit logs at the statement level with minimal overhead — it's designed for low-impact audit capture and typically adds 2-5% overhead for configured statement types. Custom triggers fire per-row, per-operation — for UPDATE affecting 10,000 rows, the trigger fires 10,000 times. This can add 20-100% overhead depending on what the trigger does. pg_audit cannot capture row-level old/new values; triggers can. The choice: pg_audit for statement-level compliance (DDL, session activity), triggers for row-level change capture (old/new values). Use both in combination for complete audit coverage.

20. How do you ensure your audit log remains compliant when migrating from one database to another (e.g., PostgreSQL to Aurora)?

Audit logs must be migrated as a separate stream, not merged into the new database's audit logs. Approach: during migration, maintain the source database's audit log stream independently (archived, queryable), and configure the target database to produce its own audit log stream. The compliance record spans both sources. If regulatory requirements mandate a single audit log, use CDC to stream source audit entries to the target's audit system during the migration window. Never stop auditing during migration — the period of migration itself is often the highest-risk period for compliance.


Further Reading


Conclusion

Audit logging is infrastructure, not afterthought. Capture the complete picture—who, what, when, and context—at the database level where it cannot be bypassed. Use triggers for straightforward per-table tracking, consider CDC for high-volume systems, and leverage pg_audit for standardized PostgreSQL logging.

Aggregate logs centrally for analysis, and implement retention policies that satisfy your regulatory requirements without accumulating unnecessary data.

For related reading, explore our constraint enforcement guide for maintaining data integrity, or dive into logging best practices for broader operational logging patterns.

Category

Related Posts

Data Masking Strategies for Non-Production Environments

Learn static and dynamic data masking: nulling, shuffling, hashing, and range techniques. Understand GDPR and PII considerations for PostgreSQL and Oracle.

#database #data-masking #security

Encryption at Rest: TDE, Key Management, and Performance

Learn Transparent Data Encryption (TDE), application-level encryption, and key management using AWS KMS and HashiCorp Vault. Performance overhead explained.

#database #encryption #security

GDPR Compliance: Technical Implementation for Database Systems

Understand GDPR requirements: deletion, portability, consent, agreements, breach notification. Database implementation strategies.

#database #gdpr #compliance