Mastering pgAudit in PostgreSQL: Production Audit Logging and Security Monitoring Guide

Database tutorial - IT technology blog
Database tutorial - IT technology blog

When Default Logs Fail: The Hidden Cost of Untracked Queries

A rogue UPDATE query hit our production cluster at 2:15 AM, flipping account statuses across 14,000 active customer records. When security asked for the origin IP, the database user, and the bound parameters, our standard PostgreSQL log files had nothing to offer.

Our instance captured deadlocks, errors, and queries running over 250ms. But routine data modifications and read queries slipped through completely unnoticed. For teams subject to SOC 2 Type II, HIPAA, or PCI-DSS v4.0, missing an immutable audit trail for sensitive data access can fail an audit immediately.

Why Built-In PostgreSQL Logging Falls Short

PostgreSQL ships with native logging flags like log_statement = 'all' and log_min_duration_statement. While simple to toggle, these settings cause serious operational headaches in busy production environments:

  • No object-level targeting: Setting log_statement = 'all' records every query across the cluster. On a system running 15,000 QPS, this generates over 60 GB of raw log data per hour, burying critical security events in routine noise.
  • Blind spots on read operations: Standard settings cannot selectively track SELECT queries against specific tables. You either log every read in the entire database or nothing at all.
  • Unstructured event categories: Native logs do not classify operations by risk category (such as schema updates, role modifications, or writes). This makes automated parsing and SIEM alerting unnecessarily difficult.

Comparing PostgreSQL Audit Strategies

Database engineers typically evaluate three options when designing an audit architecture:

1. Native Statement Logging (log_statement)

Setting log_statement = 'ddl' or 'mod' inside postgresql.conf provides basic visibility into structural changes. Even so, it cannot target individual tables, often drops parameterized values, and drives heavy disk I/O under sustained write loads.

2. Custom Table Triggers

Writing AFTER INSERT OR UPDATE OR DELETE triggers dumps row-level deltas directly into an internal audit table. Triggers work well for tracking historical revisions. However, they cannot audit SELECT queries, add 10% to 25% write latency, increase database bloat, and superusers can disable them at will.

3. The pgAudit Extension

The pgAudit extension hooks directly into the PostgreSQL query executor and parser. It logs events based on structured categories (DDL, Role, Read, Write) or specific table permissions. In production benchmarks, pgAudit typically adds less than 2% to 3% CPU overhead while keeping security audit logs clean and isolated.

Step-by-Step pgAudit Production Setup

Step 1: Install the pgAudit Package

On Debian or Ubuntu running PostgreSQL 16, install the package matching your database engine:

sudo apt-get update
sudo apt-get install -y postgresql-16-pgaudit

On RHEL 9 or Rocky Linux environments:

sudo dnf install -y pgaudit16_16

Step 2: Update Server Parameters and Preload Libraries

Because pgAudit relies on shared memory hooks, you must register it in postgresql.conf and restart PostgreSQL:

# Edit postgresql.conf
shared_preload_libraries = 'pgaudit'

# Audit logging parameters
pgaudit.log = 'role, ddl, write'
pgaudit.log_catalog = off
pgaudit.log_parameter = on
pgaudit.log_relation = on
pgaudit.log_statement_once = off

Restart the systemd service to load the shared library:

sudo systemctl restart postgresql

Log in to your database cluster with psql and enable the extension:

CREATE EXTENSION pgaudit;

Step 3: Set Up Object-Level Auditing

Auditing every query across an entire database wastes disk and I/O. The cleanest pattern is object-level auditing, which isolates monitoring to sensitive business schemas and compliance-critical tables.

First, create an unprivileged audit role without login rights:

-- Create dedicated auditor role
CREATE ROLE auditor NOLOGIN;

-- Direct pgAudit to track permissions tied to this role
ALTER SYSTEM SET pgaudit.role = 'auditor';
SELECT pg_reload_conf();

Next, grant specific permissions to the audit role only on tables requiring compliance tracking:

-- Track full DML and read activity on critical tables
GRANT SELECT, INSERT, UPDATE, DELETE ON customer_payments TO auditor;
GRANT SELECT ON user_credentials TO auditor;

Now, any statement reading from or writing to customer_payments generates a detailed audit record. Routine operations on background job queues and cache tables stay unlogged.

When seeding our staging environment, we needed mock billing records from raw spreadsheets. For quick test fixtures, I use toolcraft.app/en/tools/data/csv-to-json because it processes files locally in the browser. That keeps sensitive fixture data on your workstation without routing anything through external web APIs.

Step 4: Parse and Inspect pgAudit Output

Once active, pgAudit writes structured lines to standard PostgreSQL log streams or syslog:

LOG:  AUDIT: OBJECT,1,1,READ,SELECT,TABLE,public.customer_payments,SELECT * FROM customer_payments WHERE id = $1;,<42>

The comma-separated fields provide predictable metadata for log shippers:

  • AUDIT TYPE: SESSION (for session-wide rules) or OBJECT (triggered by role grants).
  • STATEMENT ID / SUBSTATEMENT ID: Tracks multi-step statements within larger transactions.
  • CLASS: The query classification (READ, WRITE, DDL, ROLE, or MISC).
  • COMMAND: The executed SQL command (e.g., SELECT, UPDATE, ALTER TABLE).
  • OBJECT TYPE & NAME: Target identifier (e.g., TABLE, public.customer_payments).
  • STATEMENT: The raw SQL query string sent by the client.
  • PARAMETERS: The bound arguments when pgaudit.log_parameter = on is active.

Step 5: Production Best Practices and Log Aggregation

Keep your audit pipeline reliable under heavy load with these essential practices:

  • Ship structured formats: Set log_destination = 'csvlog,jsonlog' in PostgreSQL 15+. Log agents like Vector, Fluent Bit, or Promtail can forward parsed events to OpenSearch, Datadog, or Grafana Loki without custom regex rules.
  • Turn off catalog logging: Keep pgaudit.log_catalog = off to ignore routine internal queries run by ORMs against pg_catalog and information_schema.
  • Enforce strict disk rotation: Prevent disk saturation by setting log_rotation_age = 1d and log_rotation_size = 1GB on any local PostgreSQL host.

Pairing pgAudit with targeted object roles gives your team strict compliance readiness without overwhelming your logging storage.

Share: