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.
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
| Feature | pg_audit | Custom Triggers |
|---|---|---|
| Setup complexity | Low | High |
| Log format | Standardized | Custom |
| Performance | Optimized | Variable |
| Flexibility | Limited to configured statements | Full control |
| Cross-table operations | Partial | Full |
| DDL auditing | Yes | Limited |
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
| Dimension | Database Triggers | CDC (Debezium) | pg_audit | Application-Level |
|---|---|---|---|---|
| Row-level old/new values | Yes | Yes | No | Yes |
| DDL auditing | No | No | Yes | Limited |
| Performance impact | Moderate to high | Low | Low | Low |
| Setup complexity | Medium | High | Low | Medium |
| Real-time streaming | No | Yes | No | Yes |
| Schema evolution handling | Manual trigger update | Automatic | Automatic | Manual |
| Infrastructure needed | None extra | Kafka cluster | Extension only | Logging framework |
| Best for | Per-table compliance | High-volume audit | Standard DDL/Session | Business-level audit |
Production Failure Scenarios
| Failure | Impact | Mitigation |
|---|---|---|
| Trigger causing deadlocks on high-write table | Write throughput collapses | Move auditing to application layer, benchmark before deploying |
| CDC consumer lag growing unbounded | Audit events delayed, compliance gap | Monitor lag metric, alert at threshold, scale consumers |
| Audit log table filling disk | Database stops writing | Partition audit table by time, enforce retention automatically |
| pg_audit flooding log storage | Storage costs spike | Set appropriate log levels, rotate aggressively |
| Selective column audit missing updates | Incomplete audit trail | Test 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
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.
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.
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?).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
- Constraint Enforcement — Data integrity fundamentals
- Encryption at Rest — Protecting stored audit data
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.
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.
GDPR Compliance: Technical Implementation for Database Systems
Understand GDPR requirements: deletion, portability, consent, agreements, breach notification. Database implementation strategies.