Skip to content

PostgreSQL Monitoring & Observability Best Practices

Objective: Master senior-level PostgreSQL monitoring and observability patterns for production systems. When you need to implement comprehensive monitoring, when you want to track performance metrics, when you need enterprise-grade observability strategies—these best practices become your weapon of choice.

Core Principles

  • Three Pillars: Metrics, Logs, and Traces
  • Proactive Monitoring: Detect issues before they impact users
  • Comprehensive Coverage: Monitor all aspects of PostgreSQL
  • Actionable Alerts: Clear, actionable alerting strategies
  • Historical Analysis: Long-term trend analysis and capacity planning

Metrics Collection

PostgreSQL Statistics

-- Enable essential extensions for monitoring
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
CREATE EXTENSION IF NOT EXISTS pg_stat_statements_reset;

-- View database statistics
SELECT 
    datname,
    numbackends,
    xact_commit,
    xact_rollback,
    blks_read,
    blks_hit,
    tup_returned,
    tup_fetched,
    tup_inserted,
    tup_updated,
    tup_deleted,
    conflicts,
    temp_files,
    temp_bytes,
    deadlocks,
    blk_read_time,
    blk_write_time
FROM pg_stat_database 
WHERE datname = current_database();

-- View table statistics
SELECT 
    schemaname,
    tablename,
    n_tup_ins,
    n_tup_upd,
    n_tup_del,
    n_live_tup,
    n_dead_tup,
    n_mod_since_analyze,
    last_vacuum,
    last_autovacuum,
    last_analyze,
    last_autoanalyze,
    vacuum_count,
    autovacuum_count,
    analyze_count,
    autoanalyze_count
FROM pg_stat_user_tables
ORDER BY n_live_tup DESC;

-- View index statistics
SELECT 
    schemaname,
    tablename,
    indexname,
    idx_scan,
    idx_tup_read,
    idx_tup_fetch
FROM pg_stat_user_indexes
ORDER BY idx_scan DESC;

Performance Metrics

-- Query performance statistics
SELECT 
    query,
    calls,
    total_time,
    mean_time,
    stddev_time,
    rows,
    100.0 * shared_blks_hit / nullif(shared_blks_hit + shared_blks_read, 0) AS hit_percent
FROM pg_stat_statements 
ORDER BY total_time DESC 
LIMIT 20;

-- Connection statistics
SELECT 
    count(*) as total_connections,
    count(*) FILTER (WHERE state = 'active') as active_connections,
    count(*) FILTER (WHERE state = 'idle') as idle_connections,
    count(*) FILTER (WHERE state = 'idle in transaction') as idle_in_transaction,
    count(*) FILTER (WHERE state = 'idle in transaction (aborted)') as idle_in_transaction_aborted
FROM pg_stat_activity;

-- Lock statistics
SELECT 
    mode,
    count(*) as lock_count
FROM pg_locks 
GROUP BY mode 
ORDER BY lock_count DESC;

Prometheus Integration

PostgreSQL Exporter

# docker-compose.yml
version: '3.8'

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: production
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

  postgres_exporter:
    image: prometheuscommunity/postgres-exporter
    environment:
      DATA_SOURCE_NAME: "postgresql://postgres:postgres@postgres:5432/production?sslmode=disable"
    ports:
      - "9187:9187"
    depends_on:
      - postgres

  prometheus:
    image: prometheus/prometheus
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.path=/prometheus'
      - '--web.console.libraries=/etc/prometheus/console_libraries'
      - '--web.console.templates=/etc/prometheus/consoles'

  grafana:
    image: grafana/grafana
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    volumes:
      - grafana_data:/var/lib/grafana

volumes:
  postgres_data:
  grafana_data:

Prometheus Configuration

# prometheus.yml
global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: 'postgres'
    static_configs:
      - targets: ['postgres_exporter:9187']
    scrape_interval: 5s
    metrics_path: /metrics

  - job_name: 'postgresql'
    static_configs:
      - targets: ['postgres:5432']
    scrape_interval: 5s

rule_files:
  - "postgresql_rules.yml"

alerting:
  alertmanagers:
    - static_configs:
        - targets:
          - alertmanager:9093

Alert Rules

# postgresql_rules.yml
groups:
  - name: postgresql
    rules:
      - alert: PostgreSQLDown
        expr: pg_up == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "PostgreSQL instance is down"
          description: "PostgreSQL instance {{ $labels.instance }} has been down for more than 1 minute."

      - alert: PostgreSQLHighConnections
        expr: pg_stat_database_numbackends / pg_settings_max_connections > 0.8
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "PostgreSQL high connection usage"
          description: "PostgreSQL instance {{ $labels.instance }} has {{ $value | humanizePercentage }} connection usage."

      - alert: PostgreSQLSlowQueries
        expr: rate(pg_stat_statements_total_time[5m]) > 1000
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "PostgreSQL slow queries detected"
          description: "PostgreSQL instance {{ $labels.instance }} has slow queries with {{ $value }}ms average execution time."

      - alert: PostgreSQLHighDeadTuples
        expr: pg_stat_user_tables_n_dead_tup / pg_stat_user_tables_n_live_tup > 0.1
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "PostgreSQL high dead tuple ratio"
          description: "Table {{ $labels.schemaname }}.{{ $labels.relname }} has {{ $value | humanizePercentage }} dead tuple ratio."

      - alert: PostgreSQLReplicationLag
        expr: pg_replication_lag > 300
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "PostgreSQL replication lag high"
          description: "PostgreSQL replication lag is {{ $value }}s on {{ $labels.instance }}."

Custom Metrics Collection

Python Metrics Collector

# monitoring/metrics_collector.py
import psycopg2
import time
import json
from prometheus_client import Gauge, Counter, Histogram, start_http_server
from datetime import datetime, timedelta
import logging

class PostgreSQLMetricsCollector:
    def __init__(self, connection_params):
        self.conn_params = connection_params

        # Prometheus metrics
        self.connections_total = Gauge('postgresql_connections_total', 'Total connections')
        self.active_connections = Gauge('postgresql_active_connections', 'Active connections')
        self.database_size = Gauge('postgresql_database_size_bytes', 'Database size in bytes')
        self.table_size = Gauge('postgresql_table_size_bytes', 'Table size in bytes', ['schema', 'table'])
        self.index_size = Gauge('postgresql_index_size_bytes', 'Index size in bytes', ['schema', 'table', 'index'])
        self.slow_queries = Counter('postgresql_slow_queries_total', 'Total slow queries')
        self.query_duration = Histogram('postgresql_query_duration_seconds', 'Query duration')
        self.dead_tuples = Gauge('postgresql_dead_tuples', 'Dead tuples', ['schema', 'table'])
        self.lock_waits = Counter('postgresql_lock_waits_total', 'Total lock waits')
        self.checkpoints = Counter('postgresql_checkpoints_total', 'Total checkpoints')
        self.wal_generated = Counter('postgresql_wal_generated_bytes', 'WAL generated in bytes')

        self.setup_logging()

    def setup_logging(self):
        """Setup logging."""
        logging.basicConfig(level=logging.INFO)
        self.logger = logging.getLogger(__name__)

    def connect(self):
        """Connect to PostgreSQL."""
        return psycopg2.connect(**self.conn_params)

    def collect_connection_metrics(self, conn):
        """Collect connection metrics."""
        with conn.cursor() as cur:
            # Total connections
            cur.execute("SELECT count(*) FROM pg_stat_activity;")
            total_connections = cur.fetchone()[0]
            self.connections_total.set(total_connections)

            # Active connections
            cur.execute("SELECT count(*) FROM pg_stat_activity WHERE state = 'active';")
            active_connections = cur.fetchone()[0]
            self.active_connections.set(active_connections)

    def collect_database_metrics(self, conn):
        """Collect database metrics."""
        with conn.cursor() as cur:
            # Database size
            cur.execute("SELECT pg_database_size(current_database());")
            db_size = cur.fetchone()[0]
            self.database_size.set(db_size)

            # Table sizes
            cur.execute("""
                SELECT 
                    schemaname,
                    tablename,
                    pg_total_relation_size(schemaname||'.'||tablename) as size
                FROM pg_tables 
                WHERE schemaname = 'public'
                ORDER BY size DESC;
            """)

            for schema, table, size in cur.fetchall():
                self.table_size.labels(schema=schema, table=table).set(size)

            # Index sizes
            cur.execute("""
                SELECT 
                    schemaname,
                    tablename,
                    indexname,
                    pg_relation_size(schemaname||'.'||indexname) as size
                FROM pg_stat_user_indexes
                ORDER BY size DESC;
            """)

            for schema, table, index, size in cur.fetchall():
                self.index_size.labels(schema=schema, table=table, index=index).set(size)

    def collect_performance_metrics(self, conn):
        """Collect performance metrics."""
        with conn.cursor() as cur:
            # Slow queries
            cur.execute("""
                SELECT count(*) FROM pg_stat_statements 
                WHERE mean_time > 1000;
            """)
            slow_queries = cur.fetchone()[0]
            self.slow_queries.inc(slow_queries)

            # Dead tuples
            cur.execute("""
                SELECT 
                    schemaname,
                    tablename,
                    n_dead_tup
                FROM pg_stat_user_tables
                WHERE n_dead_tup > 0;
            """)

            for schema, table, dead_tuples in cur.fetchall():
                self.dead_tuples.labels(schema=schema, table=table).set(dead_tuples)

            # Lock waits
            cur.execute("""
                SELECT count(*) FROM pg_stat_activity 
                WHERE wait_event_type = 'Lock';
            """)
            lock_waits = cur.fetchone()[0]
            self.lock_waits.inc(lock_waits)

    def collect_wal_metrics(self, conn):
        """Collect WAL metrics."""
        with conn.cursor() as cur:
            # WAL generated
            cur.execute("""
                SELECT 
                    pg_current_wal_lsn(),
                    pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0') as wal_bytes;
            """)
            wal_bytes = cur.fetchone()[1]
            self.wal_generated.inc(wal_bytes)

    def collect_all_metrics(self):
        """Collect all metrics."""
        try:
            conn = self.connect()

            self.collect_connection_metrics(conn)
            self.collect_database_metrics(conn)
            self.collect_performance_metrics(conn)
            self.collect_wal_metrics(conn)

            conn.close()
            self.logger.info("Metrics collected successfully")

        except Exception as e:
            self.logger.error(f"Error collecting metrics: {e}")

    def start_metrics_server(self, port=8000):
        """Start Prometheus metrics server."""
        start_http_server(port)
        self.logger.info(f"Metrics server started on port {port}")

        while True:
            self.collect_all_metrics()
            time.sleep(30)

# Usage
if __name__ == "__main__":
    collector = PostgreSQLMetricsCollector({
        'host': 'localhost',
        'database': 'production',
        'user': 'monitor_user',
        'password': 'monitor_password'
    })

    collector.start_metrics_server()

Logging and Analysis

Structured Logging

# logging/structured_logger.py
import psycopg2
import json
import logging
from datetime import datetime
from typing import Dict, Any

class PostgreSQLStructuredLogger:
    def __init__(self, connection_params):
        self.conn_params = connection_params
        self.setup_logging()

    def setup_logging(self):
        """Setup structured logging."""
        logging.basicConfig(
            level=logging.INFO,
            format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
            handlers=[
                logging.FileHandler('/var/log/postgresql/structured.log'),
                logging.StreamHandler()
            ]
        )
        self.logger = logging.getLogger(__name__)

    def log_query_execution(self, query: str, duration: float, rows: int, user: str):
        """Log query execution details."""
        log_entry = {
            'timestamp': datetime.now().isoformat(),
            'event_type': 'query_execution',
            'query': query,
            'duration_ms': duration * 1000,
            'rows_affected': rows,
            'user': user,
            'database': self.conn_params['database']
        }

        self.logger.info(json.dumps(log_entry))

    def log_connection_event(self, event_type: str, user: str, client_ip: str):
        """Log connection events."""
        log_entry = {
            'timestamp': datetime.now().isoformat(),
            'event_type': event_type,
            'user': user,
            'client_ip': client_ip,
            'database': self.conn_params['database']
        }

        self.logger.info(json.dumps(log_entry))

    def log_error(self, error_type: str, error_message: str, query: str = None):
        """Log error events."""
        log_entry = {
            'timestamp': datetime.now().isoformat(),
            'event_type': 'error',
            'error_type': error_type,
            'error_message': error_message,
            'query': query,
            'database': self.conn_params['database']
        }

        self.logger.error(json.dumps(log_entry))

    def log_performance_metrics(self, metrics: Dict[str, Any]):
        """Log performance metrics."""
        log_entry = {
            'timestamp': datetime.now().isoformat(),
            'event_type': 'performance_metrics',
            'metrics': metrics,
            'database': self.conn_params['database']
        }

        self.logger.info(json.dumps(log_entry))

# Usage
if __name__ == "__main__":
    logger = PostgreSQLStructuredLogger({
        'host': 'localhost',
        'database': 'production',
        'user': 'monitor_user',
        'password': 'monitor_password'
    })

    # Log query execution
    logger.log_query_execution(
        query="SELECT * FROM users WHERE active = true",
        duration=0.150,
        rows=1000,
        user="app_user"
    )

    # Log connection event
    logger.log_connection_event(
        event_type="connection_established",
        user="app_user",
        client_ip="192.168.1.100"
    )

Log Analysis

# analysis/log_analyzer.py
import json
import re
from datetime import datetime, timedelta
from collections import defaultdict, Counter
import pandas as pd

class PostgreSQLLogAnalyzer:
    def __init__(self, log_file_path):
        self.log_file_path = log_file_path
        self.logs = []

    def parse_logs(self):
        """Parse structured logs."""
        with open(self.log_file_path, 'r') as f:
            for line in f:
                try:
                    log_entry = json.loads(line.strip())
                    self.logs.append(log_entry)
                except json.JSONDecodeError:
                    continue

    def analyze_slow_queries(self, threshold_ms=1000):
        """Analyze slow queries."""
        slow_queries = []

        for log in self.logs:
            if log.get('event_type') == 'query_execution':
                if log.get('duration_ms', 0) > threshold_ms:
                    slow_queries.append({
                        'query': log.get('query'),
                        'duration_ms': log.get('duration_ms'),
                        'rows': log.get('rows_affected'),
                        'user': log.get('user'),
                        'timestamp': log.get('timestamp')
                    })

        return slow_queries

    def analyze_connection_patterns(self):
        """Analyze connection patterns."""
        connections = []
        disconnections = []

        for log in self.logs:
            if log.get('event_type') == 'connection_established':
                connections.append(log)
            elif log.get('event_type') == 'connection_closed':
                disconnections.append(log)

        # Analyze connection patterns
        connection_stats = {
            'total_connections': len(connections),
            'total_disconnections': len(disconnections),
            'unique_users': len(set(log.get('user') for log in connections)),
            'unique_ips': len(set(log.get('client_ip') for log in connections))
        }

        return connection_stats

    def analyze_error_patterns(self):
        """Analyze error patterns."""
        errors = []

        for log in self.logs:
            if log.get('event_type') == 'error':
                errors.append({
                    'error_type': log.get('error_type'),
                    'error_message': log.get('error_message'),
                    'query': log.get('query'),
                    'timestamp': log.get('timestamp')
                })

        # Group errors by type
        error_counts = Counter(error['error_type'] for error in errors)

        return {
            'total_errors': len(errors),
            'error_types': dict(error_counts),
            'recent_errors': errors[-10:]  # Last 10 errors
        }

    def generate_report(self):
        """Generate comprehensive analysis report."""
        self.parse_logs()

        report = {
            'analysis_timestamp': datetime.now().isoformat(),
            'total_log_entries': len(self.logs),
            'slow_queries': self.analyze_slow_queries(),
            'connection_patterns': self.analyze_connection_patterns(),
            'error_patterns': self.analyze_error_patterns()
        }

        return report

# Usage
if __name__ == "__main__":
    analyzer = PostgreSQLLogAnalyzer('/var/log/postgresql/structured.log')
    report = analyzer.generate_report()

    print(json.dumps(report, indent=2))

Distributed Tracing

OpenTelemetry Integration

# tracing/postgresql_tracer.py
from opentelemetry import trace
from opentelemetry.exporter.jaeger.thrift import JaegerExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor
import psycopg2

class PostgreSQLTracer:
    def __init__(self, service_name="postgresql-service"):
        self.service_name = service_name
        self.setup_tracing()

    def setup_tracing(self):
        """Setup OpenTelemetry tracing."""
        # Create tracer provider
        trace.set_tracer_provider(TracerProvider())
        tracer = trace.get_tracer(__name__)

        # Create Jaeger exporter
        jaeger_exporter = JaegerExporter(
            agent_host_name="localhost",
            agent_port=14268,
        )

        # Create span processor
        span_processor = BatchSpanProcessor(jaeger_exporter)
        trace.get_tracer_provider().add_span_processor(span_processor)

        # Instrument psycopg2
        Psycopg2Instrumentor().instrument()

    def trace_query(self, query: str, params: tuple = None):
        """Trace a database query."""
        tracer = trace.get_tracer(__name__)

        with tracer.start_as_current_span("postgresql_query") as span:
            span.set_attribute("db.statement", query)
            span.set_attribute("db.system", "postgresql")

            if params:
                span.set_attribute("db.parameters", str(params))

            # Execute query
            conn = psycopg2.connect(
                host="localhost",
                database="production",
                user="app_user",
                password="app_password"
            )

            with conn.cursor() as cur:
                cur.execute(query, params)
                result = cur.fetchall()

                span.set_attribute("db.rows_affected", len(result))
                return result

            conn.close()

# Usage
if __name__ == "__main__":
    tracer = PostgreSQLTracer()

    # Trace a query
    result = tracer.trace_query(
        "SELECT * FROM users WHERE active = %s",
        (True,)
    )

    print(f"Query returned {len(result)} rows")

Dashboard and Visualization

Grafana Dashboard

{
  "dashboard": {
    "title": "PostgreSQL Monitoring",
    "panels": [
      {
        "title": "Database Connections",
        "type": "stat",
        "targets": [
          {
            "expr": "postgresql_connections_total",
            "legendFormat": "Total Connections"
          },
          {
            "expr": "postgresql_active_connections",
            "legendFormat": "Active Connections"
          }
        ]
      },
      {
        "title": "Database Size",
        "type": "stat",
        "targets": [
          {
            "expr": "postgresql_database_size_bytes",
            "legendFormat": "Database Size"
          }
        ]
      },
      {
        "title": "Query Performance",
        "type": "graph",
        "targets": [
          {
            "expr": "rate(postgresql_slow_queries_total[5m])",
            "legendFormat": "Slow Queries/sec"
          }
        ]
      },
      {
        "title": "Table Sizes",
        "type": "table",
        "targets": [
          {
            "expr": "postgresql_table_size_bytes",
            "legendFormat": "{{schema}}.{{table}}"
          }
        ]
      }
    ]
  }
}

TL;DR Runbook

Quick Start

# 1. Start PostgreSQL exporter
docker run -d --name postgres_exporter \
  -e DATA_SOURCE_NAME="postgresql://user:pass@localhost:5432/db?sslmode=disable" \
  -p 9187:9187 \
  prometheuscommunity/postgres-exporter

# 2. Start Prometheus
docker run -d --name prometheus \
  -p 9090:9090 \
  -v ./prometheus.yml:/etc/prometheus/prometheus.yml \
  prom/prometheus

# 3. Start Grafana
docker run -d --name grafana \
  -p 3000:3000 \
  -e GF_SECURITY_ADMIN_PASSWORD=admin \
  grafana/grafana

Essential Patterns

# Complete PostgreSQL monitoring setup
def setup_postgresql_monitoring():
    # 1. Metrics collection
    # 2. Prometheus integration
    # 3. Alerting rules
    # 4. Structured logging
    # 5. Log analysis
    # 6. Distributed tracing
    # 7. Dashboard visualization
    # 8. Performance monitoring

    print("PostgreSQL monitoring setup complete!")

This guide provides the complete machinery for PostgreSQL monitoring excellence. Each pattern includes implementation examples, monitoring strategies, and real-world usage patterns for enterprise PostgreSQL monitoring systems.