Claude Skill

postgres-ops

PostgreSQL operations, optimization, and administration. Use for: schema design, index selection, query tuning with EXPLAIN ANALYZE, postgresql.conf configuration, backup and restore (pg_dump, pg_basebackup, WAL, PITR), vacuum and autovacuum tuning, connection pooling (pgBouncer,

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download 0xdarkmatter-claude-mods-skills_postgres-ops-3dfaf0b.zip · 50 KB
Part of 0xdarkmatter/claude-mods — 94 skills

Install

skills CLI npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/postgres-ops
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
Git git clone https://github.com/0xDarkMatter/claude-mods.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

PostgreSQL Operations

Comprehensive PostgreSQL skill covering schema design through production operations.

Quick Connection

# Standard connection
psql "postgresql://user:pass@localhost:5432/dbname"

# With SSL
psql "postgresql://user:pass@host:5432/dbname?sslmode=require"

# Environment variables (libpq)
export PGHOST=localhost PGPORT=5432 PGDATABASE=mydb PGUSER=myuser PGPASSWORD=secret
psql

# Connection pooling (pgBouncer default)
psql "postgresql://user:pass@localhost:6432/dbname"
-- Check current connection
SELECT current_database(), current_user, inet_server_addr(), inet_server_port();

-- Active connections
SELECT count(*) FROM pg_stat_activity WHERE state = 'active';

Index Type Selection

What query pattern are you optimizing?
│
├─ Equality (WHERE col = val)
│  └─ B-tree (default, almost always right)
│
├─ Range (WHERE col > val, ORDER BY, BETWEEN)
│  └─ B-tree
│
├─ Array/JSONB containment (@>, ?, ?|, ?&)
│  └─ GIN
│
├─ Full-text search (@@)
│  └─ GIN with tsvector
│
├─ Geometric/range overlap (&&, <->)
│  └─ GiST
│
├─ Pattern matching (LIKE '%text%', similarity)
│  └─ GIN with pg_trgm (gin_trgm_ops)
│
├─ Large table, few distinct values, append-only
│  └─ BRIN (tiny index, good for timestamps)
│
└─ Exact equality only, no range/sort needed
   └─ Hash (rare - B-tree usually better)

Quick Index Reference

Index Best For Size Write Cost
B-tree Equality, range, sort Medium Low
GIN Arrays, JSONB, FTS, trigrams Large High
GiST Geometry, ranges, FTS Medium Medium
BRIN Correlated data (timestamps) Tiny Very low
Hash Exact equality only Medium Low

Deep dive: Load ./references/indexing.md for composite, partial, expression, and covering index strategies.

EXPLAIN ANALYZE Workflow

-- Step 1: Run with ANALYZE and BUFFERS
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT ...;

-- Step 2: Read bottom-up. Find the slowest node.
-- Step 3: Check estimates vs actuals
--   actual rows=10000, rows=100  -> bad estimate, run ANALYZE
-- Step 4: Look for these red flags:
Red Flag Meaning Fix
Seq Scan on large table No usable index Add index matching WHERE/JOIN
actual rows >> estimated rows Stale statistics ANALYZE tablename
Nested Loop with high rows O(n*m) join Check join conditions, add index
Sort with external merge work_mem too small Increase work_mem for session
Buffers: shared read >> hit Cold cache or table too large Check shared_buffers, add covering index
Hash Batch > 1 Hash join spilling to disk Increase work_mem

Deep dive: Load ./references/query-tuning.md for plan node reference and optimization patterns.

Workload Profiles

Setting OLTP OLAP Notes
shared_buffers 25% RAM 25% RAM Same baseline
work_mem 4-16 MB 256 MB-1 GB OLAP needs big sorts
effective_cache_size 75% RAM 75% RAM Planner hint
random_page_cost 1.1 (SSD) 1.1 (SSD) Lower for SSD
max_parallel_workers_per_gather 2 4-8 OLAP benefits more
checkpoint_completion_target 0.9 0.9 Spread checkpoint I/O
wal_buffers 64 MB 64 MB -1 for auto
maintenance_work_mem 512 MB 1-2 GB For VACUUM, CREATE INDEX

Deep dive: Load ./references/config-tuning.md for full postgresql.conf walkthrough and extension setup.

Common Operations

Backup & Restore

# Logical backup (single database)
pg_dump -Fc dbname > backup.dump

# Restore
pg_restore -d dbname backup.dump

# Parallel backup (faster for large DBs)
pg_dump -Fc -j4 dbname > backup.dump

# Base backup for PITR
pg_basebackup -D /backup/base -Ft -Xs -P

Vacuum & Maintenance

-- Manual vacuum (reclaim space, update stats)
VACUUM (VERBOSE, ANALYZE) tablename;

-- Full vacuum (rewrites table, exclusive lock)
VACUUM FULL tablename;  -- CAUTION: locks table

-- Reindex without downtime
REINDEX INDEX CONCURRENTLY idx_name;

-- Update statistics only
ANALYZE tablename;

Monitor Key Metrics

-- Slow queries (requires pg_stat_statements)
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10;

-- Table bloat indicator
SELECT schemaname, relname, n_dead_tup, n_live_tup,
       round(n_dead_tup::numeric / NULLIF(n_live_tup, 0) * 100, 1) AS dead_pct
FROM pg_stat_user_tables WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC;

-- Lock contention
SELECT pid, relation::regclass, mode, granted, query
FROM pg_locks JOIN pg_stat_activity USING (pid)
WHERE NOT granted;

-- Cache hit ratio (should be > 99%)
SELECT sum(heap_blks_hit) / NULLIF(sum(heap_blks_hit) + sum(heap_blks_read), 0) AS ratio
FROM pg_statio_user_tables;

Deep dive: Load ./references/operations.md for WAL archiving, PITR, autovacuum tuning, connection pooling.

Data Types Quick Reference

Type Use When Example
JSONB Semi-structured data, flexible schema '{"tags": ["a","b"]}'::jsonb
ARRAY Fixed-type lists ARRAY['a','b','c']
tsrange Time periods, scheduling '[2024-01-01, 2024-12-31)'::tsrange
tsvector Full-text search to_tsvector('english', body)
uuid Distributed IDs gen_random_uuid()
inet/cidr IP addresses, networks '192.168.1.0/24'::cidr

Deep dive: Load ./references/schema-design.md for normalization, constraints, RLS, generated columns, table inheritance.

Gotchas & Anti-Patterns

Mistake Why It's Bad Fix
SELECT * in production Wastes bandwidth, blocks covering index scans List columns explicitly
Function on indexed column (WHERE UPPER(email) = ...) Prevents index use Expression index: CREATE INDEX ... ON (UPPER(email))
NOT IN (subquery) with NULLs Returns no rows if subquery has NULL Use NOT EXISTS
Missing ANALYZE after bulk load Planner uses stale row estimates Run ANALYZE tablename
VACUUM FULL in production Exclusive lock on entire table Regular VACUUM + pg_repack
LIMIT without ORDER BY Non-deterministic results Always pair with ORDER BY
Offset pagination on large tables Scans and discards rows Keyset pagination: WHERE id > last_id
Too many indexes Slows writes, wastes space Audit with pg_stat_user_indexes
Single shared connection pool Contention across services Per-service pools via pgBouncer
default_transaction_isolation = serializable Excessive serialization failures Keep read committed, use explicit SERIALIZABLE where needed

Row-Level Security (RLS) Quick Start

-- Enable RLS on table
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

-- Policy: users see only their own rows
CREATE POLICY user_isolation ON documents
    USING (owner_id = current_setting('app.current_user_id')::int);

-- Policy: admins see everything
CREATE POLICY admin_access ON documents
    USING (current_setting('app.role') = 'admin');

-- Set context per request (from app layer)
SET app.current_user_id = '42';
SET app.role = 'user';

Full-Text Search Quick Start

-- Add search column
ALTER TABLE articles ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;

-- Index it
CREATE INDEX idx_articles_fts ON articles USING gin(search_vector);

-- Search with ranking
SELECT title, ts_rank(search_vector, query) AS rank
FROM articles, to_tsquery('english', 'database & optimization') AS query
WHERE search_vector @@ query
ORDER BY rank DESC;

LISTEN/NOTIFY

-- Publisher
NOTIFY order_events, '{"order_id": 123, "status": "shipped"}';

-- Subscriber (in psql or app)
LISTEN order_events;

-- Check for notifications (app code)
-- Python: conn.poll(); conn.notifies
-- Node: client.on('notification', callback)

Reference Files

Load these for deep-dive topics. Each is self-contained.

Reference When to Load
./references/schema-design.md Designing tables, choosing types, constraints, RLS policies, JSONB modeling
./references/indexing.md Choosing index types, composite/partial/expression indexes, index maintenance
./references/query-tuning.md Reading EXPLAIN plans, pg_stat_statements, optimizing specific query patterns
./references/operations.md Backup/restore, WAL/PITR, vacuum tuning, monitoring, connection pooling
./references/replication.md Streaming/logical replication, failover, partitioning, FDW
./references/config-tuning.md postgresql.conf settings, OLTP/OLAP profiles, extension setup

See Also

  • sql-ops - Vendor-neutral SQL patterns (CTEs, window functions, JOINs)
  • sqlite-ops - SQLite-specific patterns and operations
  • python-database-ops - SQLAlchemy ORM and async database patterns
Files (claude-mods)
  • assets
    • .gitkeep 0 B · in bundle
  • references
    • config-tuning.md 21.3 KB
      # PostgreSQL Configuration & Tuning Reference
      
      ## Table of Contents
      
      1. [Memory Settings](#memory-settings)
         - shared_buffers
         - work_mem
         - maintenance_work_mem
         - effective_cache_size
         - huge_pages
      2. [WAL & Checkpoint Settings](#wal--checkpoint-settings)
         - wal_level
         - wal_buffers
         - checkpoint_completion_target
         - max_wal_size and min_wal_size
         - full_page_writes
      3. [Query Planner Settings](#query-planner-settings)
         - random_page_cost and seq_page_cost
         - effective_io_concurrency
         - JIT compilation
      4. [Parallelism Settings](#parallelism-settings)
      5. [Connection Settings](#connection-settings)
      6. [Logging](#logging)
      7. [OLTP vs OLAP Profiles](#oltp-vs-olap-profiles)
      8. [Extensions](#extensions)
         - pg_stat_statements
         - pg_trgm
         - PostGIS
         - timescaledb
         - pgcrypto
         - auto_explain
      
      ---
      
      ## Memory Settings
      
      ### shared_buffers
      
      The PostgreSQL buffer cache: how much memory the server reserves for caching data pages.
      
      ```ini
      shared_buffers = 8GB   # Recommended: 25% of total RAM
      ```
      
      Rules of thumb:
      - Start at 25% of RAM. Going above 40% rarely helps and can hurt because the OS page cache also buffers the same pages.
      - On dedicated database servers, 25% is conservative but safe. Profile with `pg_buffercache` to measure actual cache hit rates.
      - Requires a server restart to take effect.
      
      Check cache hit ratio:
      
      ```sql
      SELECT
          sum(heap_blks_hit)  AS heap_hit,
          sum(heap_blks_read) AS heap_read,
          round(
              sum(heap_blks_hit)::numeric /
              nullif(sum(heap_blks_hit) + sum(heap_blks_read), 0) * 100, 2
          ) AS hit_ratio_pct
      FROM pg_statio_user_tables;
      -- Target: > 99% for OLTP, > 95% for OLAP
      ```
      
      Identify which tables consume the most buffer space (requires `pg_buffercache`):
      
      ```sql
      CREATE EXTENSION pg_buffercache;
      
      SELECT
          relname,
          count(*) * 8192 / 1024 / 1024 AS cached_mb,
          round(count(*) * 100.0 / (SELECT count(*) FROM pg_buffercache), 2) AS pct_of_cache
      FROM pg_buffercache bc
      JOIN pg_class c ON bc.relfilenode = c.relfilenode
      WHERE c.relkind = 'r'
      GROUP BY relname
      ORDER BY cached_mb DESC
      LIMIT 20;
      ```
      
      ### work_mem
      
      Memory granted per sort, hash, or merge operation. Each query node (sort, hash join, hash aggregate) can use up to `work_mem` individually.
      
      ```ini
      work_mem = 64MB   # Default 4MB is usually too low
      ```
      
      Critical nuance: if a query has 5 sort nodes and 20 parallel workers, it can consume `5 * 20 * work_mem` = 100x `work_mem`. For a 32GB server running 100 connections, setting `work_mem = 320MB` is catastrophic.
      
      Sizing strategy:
      1. Estimate concurrent queries: `max_connections * avg_active_fraction`
      2. Reserve memory for OS + shared_buffers + maintenance_work_mem
      3. Divide remainder: `work_mem = remaining / (active_connections * avg_nodes_per_query)`
      
      For most OLTP systems: 16-64MB. For analytics: 256MB-1GB with fewer connections.
      
      Override per session for specific heavy queries:
      
      ```sql
      SET work_mem = '512MB';
      SELECT ... FROM large_table ORDER BY ...;
      RESET work_mem;
      ```
      
      Monitor actual temporary file creation to detect under-allocation:
      
      ```ini
      log_temp_files = 0   # Log all temp files (0 = log everything, N = only above N bytes)
      ```
      
      ```sql
      -- Check existing temp file usage stats
      SELECT query, temp_blks_written
      FROM pg_stat_statements
      WHERE temp_blks_written > 0
      ORDER BY temp_blks_written DESC
      LIMIT 10;
      ```
      
      ### maintenance_work_mem
      
      Memory for maintenance operations: VACUUM, ANALYZE, CREATE INDEX, ALTER TABLE ADD FOREIGN KEY, CLUSTER.
      
      ```ini
      maintenance_work_mem = 2GB   # Recommended: up to 10% RAM or 1-4GB
      ```
      
      Larger values dramatically speed up `CREATE INDEX` and VACUUM on large tables. Unlike `work_mem`, there are never many concurrent maintenance operations, so you can set this aggressively.
      
      Override per session before a large index build:
      
      ```sql
      SET maintenance_work_mem = '4GB';
      CREATE INDEX CONCURRENTLY idx_events_created_at ON events (created_at);
      RESET maintenance_work_mem;
      ```
      
      ### effective_cache_size
      
      A hint to the query planner about total memory available for caching (RAM + OS page cache). It does not allocate memory; it only influences cost estimates.
      
      ```ini
      effective_cache_size = 24GB   # Recommended: 75% of total RAM
      ```
      
      Higher values make the planner prefer index scans (which benefit from caching) over sequential scans. Too low a value causes the planner to choose sequential scans even when an index scan would be faster.
      
      ### huge_pages
      
      Huge pages (2MB pages on Linux instead of 4KB) reduce TLB pressure and can improve throughput on large `shared_buffers` values (above 8GB).
      
      ```ini
      huge_pages = try    # 'try' falls back gracefully; use 'on' to enforce
      ```
      
      Linux OS setup (must be done before starting PostgreSQL):
      
      ```bash
      # Calculate pages needed: shared_buffers / 2MB
      # For shared_buffers = 16GB: 16384 MB / 2 MB = 8192 huge pages, add 10% buffer
      echo 9000 > /proc/sys/vm/nr_hugepages
      
      # Persist across reboots
      echo "vm.nr_hugepages = 9000" >> /etc/sysctl.conf
      sysctl -p
      
      # Verify allocation
      grep HugePages /proc/meminfo
      ```
      
      ---
      
      ## WAL & Checkpoint Settings
      
      ### wal_level
      
      Controls how much information is written to WAL.
      
      ```ini
      wal_level = replica    # Minimum for streaming replication
      wal_level = logical    # Required for logical replication (writes more)
      ```
      
      `wal_level = minimal` disables replication and reduces WAL volume slightly. Use only for standalone servers where you never need PITR.
      
      ### wal_buffers
      
      Memory for WAL writes before flushing to disk. PostgreSQL auto-tunes this to 1/32 of `shared_buffers`, capped at 16MB.
      
      ```ini
      wal_buffers = 64MB   # Manual override; auto value is usually fine
      ```
      
      Rarely needs manual tuning. Increase only if you see contention on `WALBufMappingLock` in `pg_stat_activity`.
      
      ### checkpoint_completion_target
      
      Fraction of the checkpoint interval over which to spread checkpoint I/O. Reduces I/O spikes at checkpoint time.
      
      ```ini
      checkpoint_completion_target = 0.9   # Recommended (default is 0.9 in PG14+)
      ```
      
      With `max_wal_size = 4GB` and `checkpoint_completion_target = 0.9`, PostgreSQL spreads writes over 90% of the checkpoint interval instead of flushing all at once.
      
      ### max_wal_size and min_wal_size
      
      Control WAL retention between checkpoints. Larger values reduce checkpoint frequency (less I/O) at the cost of more WAL on disk and longer crash recovery time.
      
      ```ini
      min_wal_size = 1GB     # Minimum WAL to retain (default 80MB)
      max_wal_size = 8GB     # Triggers checkpoint when exceeded (default 1GB)
      ```
      
      For write-heavy workloads, increase `max_wal_size` to reduce checkpoint frequency. Monitor checkpoint frequency:
      
      ```sql
      SELECT checkpoints_timed, checkpoints_req, checkpoint_write_time, checkpoint_sync_time
      FROM pg_stat_bgwriter;
      -- checkpoints_req >> checkpoints_timed means max_wal_size is too small
      ```
      
      ### full_page_writes
      
      After a checkpoint, PostgreSQL writes the full page image of a modified page the first time it is touched. This protects against torn page writes when the OS crashes mid-write.
      
      ```ini
      full_page_writes = on   # NEVER disable this
      ```
      
      Disabling `full_page_writes` can cause unrecoverable data corruption after an OS crash. The only safe way to reduce full-page write overhead is to use a filesystem or storage that guarantees atomic page writes (ZFS, some SAN configurations) and you fully understand the implications.
      
      ---
      
      ## Query Planner Settings
      
      ### random_page_cost and seq_page_cost
      
      Control the planner's cost model for I/O. Lower values make the planner favor the corresponding access method.
      
      ```ini
      # For NVMe/SSD storage:
      random_page_cost = 1.1
      seq_page_cost = 1.0
      
      # For traditional HDD:
      random_page_cost = 4.0
      seq_page_cost = 1.0
      ```
      
      The default `random_page_cost = 4.0` is calibrated for spinning disk. On SSD, it causes the planner to undervalue index scans, leading to unnecessary sequential scans. Always set `random_page_cost = 1.1` on SSD-based servers.
      
      Override per session to diagnose planner choices:
      
      ```sql
      SET random_page_cost = 1.1;
      EXPLAIN ANALYZE SELECT ...;
      ```
      
      ### effective_io_concurrency
      
      Number of concurrent I/O operations the planner assumes the storage can handle. Affects bitmap index scan prefetching.
      
      ```ini
      effective_io_concurrency = 200   # NVMe SSD (high parallelism)
      effective_io_concurrency = 2     # Traditional HDD (low parallelism)
      effective_io_concurrency = 1     # NFS/SAN (conservative)
      ```
      
      ### JIT Compilation
      
      JIT (Just-In-Time compilation via LLVM) can speed up CPU-intensive queries (complex aggregations, many expressions) but adds compilation overhead that hurts short OLTP queries.
      
      ```ini
      jit = on                  # Enable JIT globally (default on in PG11+)
      jit_above_cost = 100000   # Only JIT-compile queries above this cost
      jit_optimize_above_cost = 500000  # Apply expensive optimizations above this cost
      jit_inline_above_cost = 500000    # Inline functions above this cost
      ```
      
      For OLTP workloads where queries are fast and simple:
      
      ```ini
      jit = off   # Disable entirely to avoid overhead
      ```
      
      Check if JIT was used in a query:
      
      ```sql
      EXPLAIN (ANALYZE, VERBOSE, FORMAT TEXT)
      SELECT sum(total) FROM orders WHERE created_at > now() - interval '1 year';
      -- Look for "JIT:" section in output
      ```
      
      ---
      
      ## Parallelism Settings
      
      PostgreSQL can parallelize sequential scans, aggregations, joins, and index scans.
      
      ```ini
      # Total background workers available to the instance
      max_worker_processes = 16           # Default 8; should be >= CPU cores
      
      # Maximum parallel workers available for queries at any time
      max_parallel_workers = 8            # Default 8; cap at physical CPU cores
      
      # Workers per individual query node
      max_parallel_workers_per_gather = 4 # Default 2; practical limit 4-8
      
      # Minimum table size before considering parallel scan
      min_parallel_table_scan_size = 8MB  # Default; lower to enable on smaller tables
      min_parallel_index_scan_size = 512kB
      
      # Include leader process in parallel work (default on)
      parallel_leader_participation = on
      ```
      
      Force parallelism for testing (dangerous in production):
      
      ```sql
      SET max_parallel_workers_per_gather = 8;
      SET parallel_setup_cost = 0;
      SET parallel_tuple_cost = 0;
      EXPLAIN ANALYZE SELECT count(*) FROM large_table;
      ```
      
      Disable parallelism for a session (useful when debugging):
      
      ```sql
      SET max_parallel_workers_per_gather = 0;
      ```
      
      ---
      
      ## Connection Settings
      
      ### max_connections
      
      PostgreSQL creates one process per connection. High connection counts waste memory and cause lock contention.
      
      ```ini
      max_connections = 200   # Keep below 300; use pgBouncer for more
      ```
      
      Each idle connection consumes ~5MB RAM just for the process overhead. With `work_mem = 64MB` and a sort-heavy query, one connection can briefly use 64MB * N sort nodes.
      
      Use PgBouncer in transaction mode for OLTP:
      
      ```ini
      # pgbouncer.ini
      pool_mode = transaction
      max_client_conn = 2000
      default_pool_size = 20   # Connections to PostgreSQL per database/user pair
      ```
      
      ```ini
      # Reserve connections for superusers (DBA access during emergencies)
      superuser_reserved_connections = 5
      ```
      
      ### TCP Keepalives
      
      Detect dead connections (e.g., after network partition) without relying on the application:
      
      ```ini
      tcp_keepalives_idle = 60      # Start keepalives after 60s idle
      tcp_keepalives_interval = 10  # Retry every 10s
      tcp_keepalives_count = 6      # Drop connection after 6 failed probes (1 minute)
      ```
      
      Monitor current connections and their state:
      
      ```sql
      SELECT
          state,
          count(*),
          max(now() - state_change) AS longest_in_state
      FROM pg_stat_activity
      WHERE datname = current_database()
      GROUP BY state
      ORDER BY count DESC;
      
      -- Find idle connections older than 10 minutes
      SELECT pid, usename, application_name, state, state_change, query
      FROM pg_stat_activity
      WHERE state = 'idle'
        AND state_change < now() - interval '10 minutes';
      ```
      
      ---
      
      ## Logging
      
      ### Slow Query Logging
      
      ```ini
      log_min_duration_statement = 1000   # Log queries taking > 1 second (ms)
                                           # Set to 0 to log all; -1 to disable
      ```
      
      ### Statement-Level Logging
      
      ```ini
      log_statement = 'ddl'   # Recommended for most production servers
      # Options: none | ddl | mod | all
      # 'ddl'  = CREATE, DROP, ALTER, TRUNCATE
      # 'mod'  = ddl + INSERT, UPDATE, DELETE, COPY
      # 'all'  = everything (very verbose, for debugging only)
      ```
      
      ### Lock Logging
      
      ```ini
      log_lock_waits = on       # Log if a query waits for a lock
      deadlock_timeout = 1s     # Time before checking for deadlock (and logging wait)
      ```
      
      Deadlocks are logged automatically at `log_error_verbosity` level. Lock waits (not deadlocks) require `log_lock_waits = on`:
      
      ```ini
      # Also useful for identifying lock contention:
      log_min_duration_statement = 500    # Catch queries slow due to lock waits
      ```
      
      Query current lock waits:
      
      ```sql
      SELECT
          blocked.pid                   AS blocked_pid,
          blocked.query                 AS blocked_query,
          blocking.pid                  AS blocking_pid,
          blocking.query                AS blocking_query,
          now() - blocked.query_start   AS wait_duration
      FROM pg_stat_activity blocked
      JOIN pg_stat_activity blocking
          ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
      WHERE blocked.cardinality(pg_blocking_pids(blocked.pid)) > 0;
      ```
      
      ### auto_explain
      
      Automatically log EXPLAIN ANALYZE for slow queries without modifying application code:
      
      ```ini
      # Load as a shared library (requires restart)
      shared_preload_libraries = 'pg_stat_statements, auto_explain'
      
      # auto_explain settings (in postgresql.conf or per session)
      auto_explain.log_min_duration = 5000    # Log plans for queries > 5 seconds
      auto_explain.log_analyze = on           # Include ANALYZE (actual vs estimated rows)
      auto_explain.log_buffers = on           # Include buffer usage
      auto_explain.log_format = text          # text | json | yaml | xml
      auto_explain.log_verbose = off          # Include column-level output (very noisy)
      auto_explain.log_nested_statements = off # Exclude PL/pgSQL internal queries
      auto_explain.sample_rate = 1.0          # Sample 100% of queries; set lower under load
      ```
      
      Enable per session without restart:
      
      ```sql
      LOAD 'auto_explain';
      SET auto_explain.log_min_duration = '1s';
      SET auto_explain.log_analyze = true;
      ```
      
      ---
      
      ## OLTP vs OLAP Profiles
      
      Two complete configuration profiles showing key differences.
      
      ### OLTP Profile (32GB RAM, NVMe SSD, 200 connections)
      
      ```ini
      # Memory
      shared_buffers = 8GB
      work_mem = 32MB
      maintenance_work_mem = 1GB
      effective_cache_size = 24GB
      huge_pages = try
      
      # WAL & Checkpoints
      wal_level = replica
      wal_buffers = 64MB
      checkpoint_completion_target = 0.9
      max_wal_size = 4GB
      min_wal_size = 1GB
      full_page_writes = on
      
      # Planner
      random_page_cost = 1.1
      seq_page_cost = 1.0
      effective_io_concurrency = 200
      jit = off                        # Short queries don't benefit; avoid overhead
      
      # Parallelism - conservative for OLTP
      max_worker_processes = 16
      max_parallel_workers = 4
      max_parallel_workers_per_gather = 2
      
      # Connections
      max_connections = 200
      superuser_reserved_connections = 5
      tcp_keepalives_idle = 60
      tcp_keepalives_interval = 10
      tcp_keepalives_count = 6
      
      # Logging
      log_min_duration_statement = 500
      log_statement = 'ddl'
      log_lock_waits = on
      deadlock_timeout = 1s
      
      # Extensions
      shared_preload_libraries = 'pg_stat_statements, auto_explain'
      pg_stat_statements.track = all
      auto_explain.log_min_duration = 2000
      auto_explain.log_analyze = on
      auto_explain.log_buffers = on
      ```
      
      ### OLAP Profile (128GB RAM, NVMe SSD, 20 connections, analytics workload)
      
      ```ini
      # Memory - larger allocations per query
      shared_buffers = 32GB
      work_mem = 2GB                   # Large sorts and hash joins for analytics
      maintenance_work_mem = 4GB
      effective_cache_size = 96GB
      huge_pages = on
      
      # WAL & Checkpoints - less frequent, larger checkpoints
      wal_level = replica
      wal_buffers = 64MB
      checkpoint_completion_target = 0.9
      max_wal_size = 16GB              # Fewer checkpoints for write-heavy ETL
      min_wal_size = 4GB
      full_page_writes = on
      
      # Planner - favor parallel plans and large scans
      random_page_cost = 1.1
      seq_page_cost = 1.0
      effective_io_concurrency = 200
      jit = on                         # CPU-heavy aggregations benefit from JIT
      jit_above_cost = 50000           # Lower threshold to engage JIT sooner
      
      # Parallelism - aggressive for analytics
      max_worker_processes = 32
      max_parallel_workers = 24
      max_parallel_workers_per_gather = 12
      min_parallel_table_scan_size = 1MB
      min_parallel_index_scan_size = 128kB
      parallel_leader_participation = on
      
      # Connections - low count, use pooling at application layer
      max_connections = 50
      superuser_reserved_connections = 5
      tcp_keepalives_idle = 60
      tcp_keepalives_interval = 10
      tcp_keepalives_count = 6
      
      # Logging
      log_min_duration_statement = 5000   # Only log very slow queries
      log_statement = 'ddl'
      log_lock_waits = on
      deadlock_timeout = 5s
      
      # Extensions
      shared_preload_libraries = 'pg_stat_statements, auto_explain'
      pg_stat_statements.track = all
      auto_explain.log_min_duration = 10000
      auto_explain.log_analyze = on
      auto_explain.log_buffers = on
      auto_explain.log_verbose = on        # Column-level detail useful for analytics
      ```
      
      ---
      
      ## Extensions
      
      ### pg_stat_statements
      
      Tracks cumulative execution statistics for all SQL statements. Essential for identifying slow queries.
      
      ```ini
      # postgresql.conf
      shared_preload_libraries = 'pg_stat_statements'
      pg_stat_statements.track = all          # top | all (includes nested statements)
      pg_stat_statements.max = 10000          # Max distinct statements tracked
      pg_stat_statements.track_utility = on   # Track VACUUM, CREATE, etc.
      ```
      
      ```sql
      CREATE EXTENSION pg_stat_statements;
      
      -- Top 10 queries by total execution time
      SELECT
          round(total_exec_time::numeric, 2) AS total_ms,
          calls,
          round(mean_exec_time::numeric, 2) AS mean_ms,
          round(stddev_exec_time::numeric, 2) AS stddev_ms,
          rows,
          round(100.0 * total_exec_time / sum(total_exec_time) OVER (), 2) AS pct,
          left(query, 120) AS query
      FROM pg_stat_statements
      ORDER BY total_exec_time DESC
      LIMIT 10;
      
      -- Queries with high I/O (temp file usage)
      SELECT query, calls, total_exec_time, temp_blks_written
      FROM pg_stat_statements
      WHERE temp_blks_written > 0
      ORDER BY temp_blks_written DESC
      LIMIT 10;
      
      -- Reset statistics
      SELECT pg_stat_statements_reset();
      ```
      
      ### pg_trgm
      
      Trigram similarity enables fast fuzzy text search and LIKE/ILIKE acceleration with GIN or GiST indexes.
      
      ```sql
      CREATE EXTENSION pg_trgm;
      
      -- Similarity search (0 to 1 score)
      SELECT name, similarity(name, 'PostgreSQL') AS sim
      FROM products
      WHERE similarity(name, 'PostgreSQL') > 0.3
      ORDER BY sim DESC;
      
      -- Accelerate LIKE/ILIKE with GIN index
      CREATE INDEX idx_products_name_trgm ON products USING gin (name gin_trgm_ops);
      
      -- Now this query uses the index:
      EXPLAIN ANALYZE SELECT * FROM products WHERE name ILIKE '%ostgre%';
      
      -- Word similarity (better for phrase matching)
      SELECT word_similarity('PostgreSQL', 'Postgres SQL tutorial');
      ```
      
      ### PostGIS
      
      Spatial and geographic data types, indexing, and functions. Use GiST indexes for geometry columns.
      
      ```sql
      CREATE EXTENSION postgis;
      
      -- Spatial columns
      CREATE TABLE locations (
          id      bigserial PRIMARY KEY,
          name    text,
          geom    geometry(Point, 4326)   -- WGS84 lat/lng
      );
      
      CREATE INDEX idx_locations_geom ON locations USING gist (geom);
      
      -- Find points within 10km of a given point
      SELECT name, ST_Distance(geom::geography, ST_MakePoint(-73.9857, 40.7484)::geography) AS dist_m
      FROM locations
      WHERE ST_DWithin(geom::geography, ST_MakePoint(-73.9857, 40.7484)::geography, 10000)
      ORDER BY dist_m;
      ```
      
      ### timescaledb
      
      Automatically partitions time-series data into chunks, enables continuous aggregates, and provides compression.
      
      ```sql
      CREATE EXTENSION timescaledb;
      
      -- Convert a regular table to a hypertable (partitioned by time)
      CREATE TABLE metrics (
          time        timestamptz NOT NULL,
          device_id   int,
          temperature double precision
      );
      SELECT create_hypertable('metrics', 'time', chunk_time_interval => interval '1 day');
      
      -- Automatic compression for old chunks
      ALTER TABLE metrics SET (
          timescaledb.compress,
          timescaledb.compress_orderby = 'time DESC',
          timescaledb.compress_segmentby = 'device_id'
      );
      SELECT add_compression_policy('metrics', interval '7 days');
      
      -- Continuous aggregate (materialized, auto-refreshed)
      CREATE MATERIALIZED VIEW metrics_hourly
      WITH (timescaledb.continuous) AS
      SELECT time_bucket('1 hour', time) AS bucket, device_id, avg(temperature) AS avg_temp
      FROM metrics
      GROUP BY bucket, device_id;
      ```
      
      ### pgcrypto
      
      Cryptographic functions for hashing, encryption, and key generation.
      
      ```sql
      CREATE EXTENSION pgcrypto;
      
      -- Password hashing (bcrypt)
      INSERT INTO users (email, password_hash)
      VALUES ('user@example.com', crypt('user_password', gen_salt('bf', 12)));
      
      -- Verify password
      SELECT id FROM users
      WHERE email = 'user@example.com'
        AND password_hash = crypt('supplied_password', password_hash);
      
      -- Symmetric encryption (AES via pgp_sym_encrypt)
      SELECT pgp_sym_encrypt('sensitive data', 'encryption_key');
      SELECT pgp_sym_decrypt(encrypted_col, 'encryption_key') FROM secrets;
      
      -- Generate random UUID
      SELECT gen_random_uuid();
      
      -- Generate cryptographically secure random bytes
      SELECT encode(gen_random_bytes(32), 'hex');
      ```
      
      ### auto_explain
      
      Logs query execution plans automatically for slow queries. Configured as a shared library (see [Logging](#logging) section). No SQL setup required beyond loading the library.
      
      Load temporarily in a session for debugging without a server restart:
      
      ```sql
      LOAD 'auto_explain';
      SET auto_explain.log_min_duration = 0;    -- Log everything in this session
      SET auto_explain.log_analyze = true;
      SET auto_explain.log_buffers = true;
      
      -- Run your query; check PostgreSQL logs for the plan
      SELECT * FROM orders WHERE customer_id = 12345 ORDER BY created_at DESC LIMIT 100;
      ```
      
      Sample only a fraction of queries under high load to reduce log volume:
      
      ```ini
      auto_explain.sample_rate = 0.01   # Log plans for ~1% of qualifying queries
      ```
      
    • indexing.md 23.8 KB
      # PostgreSQL Indexing Reference
      
      ## Table of Contents
      
      1. [Index Types Overview](#index-types-overview)
      2. [B-tree Indexes](#b-tree-indexes)
      3. [Hash Indexes](#hash-indexes)
      4. [GIN Indexes](#gin-indexes)
      5. [GiST Indexes](#gist-indexes)
      6. [BRIN Indexes](#brin-indexes)
      7. [Composite Indexes](#composite-indexes)
      8. [Partial Indexes](#partial-indexes)
      9. [Expression Indexes](#expression-indexes)
      10. [Covering Indexes (INCLUDE)](#covering-indexes-include)
      11. [GIN Specifics](#gin-specifics)
      12. [GiST Specifics](#gist-specifics)
      13. [BRIN Specifics](#brin-specifics)
      14. [Index Maintenance](#index-maintenance)
      15. [Anti-Patterns](#anti-patterns)
      
      ---
      
      ## Index Types Overview
      
      | Type | Best For | Operators Supported | Notes |
      |------|----------|---------------------|-------|
      | B-tree | Equality, range, sorting | `=`, `<`, `>`, `<=`, `>=`, `BETWEEN`, `LIKE 'foo%'` | Default; works for most cases |
      | Hash | Equality only | `=` | Smaller than B-tree for pure equality |
      | GIN | Multi-valued columns | `@>`, `<@`, `&&`, `?`, `@@` | JSONB, arrays, FTS, tsvector |
      | GiST | Geometric, range, custom | `&&`, `@>`, `<@`, `<->` | Ranges, PostGIS, exclusion constraints |
      | BRIN | Append-only correlated data | `=`, `<`, `>` range | Tiny size, ideal for time-series |
      | SP-GiST | Partitioned/hierarchical data | Varies | IP addresses, phone trees, quadtrees |
      
      ---
      
      ## B-tree Indexes
      
      The default index type. Keeps values in sorted order, enabling equality lookups, range scans, and ORDER BY satisfaction without a sort step.
      
      ```sql
      -- Basic B-tree (implicit)
      CREATE INDEX idx_orders_customer_id ON orders(customer_id);
      
      -- Explicit declaration
      CREATE INDEX idx_orders_customer_id ON orders USING btree(customer_id);
      
      -- Descending sort order (useful when ORDER BY col DESC is common)
      CREATE INDEX idx_events_created_desc ON events(created_at DESC);
      
      -- NULLS FIRST / NULLS LAST (match your ORDER BY for index-only scan benefit)
      CREATE INDEX idx_tasks_due_date ON tasks(due_date ASC NULLS LAST);
      ```
      
      B-tree supports prefix matching on text columns with `LIKE 'prefix%'` (but NOT `LIKE '%suffix'`). Requires `text_pattern_ops` if the column uses a non-C locale:
      
      ```sql
      CREATE INDEX idx_users_name_prefix ON users(name text_pattern_ops);
      -- Now: WHERE name LIKE 'Joh%'  uses the index
      ```
      
      ---
      
      ## Hash Indexes
      
      Hash indexes store a hash of the indexed value. They are smaller than B-tree and marginally faster for pure equality lookups, but cannot satisfy range queries or sorting.
      
      ```sql
      CREATE INDEX idx_sessions_token ON sessions USING hash(token);
      
      -- Only useful for:
      SELECT * FROM sessions WHERE token = 'abc123';
      
      -- Useless for:
      SELECT * FROM sessions WHERE token > 'abc123';   -- cannot use hash index
      SELECT * FROM sessions ORDER BY token;            -- cannot use hash index
      ```
      
      Hash indexes are WAL-logged since PG10 and safe for production use. Choose hash only when you are certain the column will never participate in range queries or ORDER BY.
      
      ---
      
      ## GIN Indexes
      
      Generalized Inverted Index. Designed for columns that contain multiple values (arrays, JSONB, tsvector). GIN maps each element value to the set of rows containing it.
      
      ```sql
      -- Array column
      CREATE INDEX idx_articles_tags ON articles USING gin(tags);
      
      -- JSONB column (default operator class)
      CREATE INDEX idx_products_attrs ON products USING gin(attributes);
      
      -- Full-text search
      CREATE INDEX idx_posts_fts ON posts USING gin(to_tsvector('english', body));
      
      -- Pre-computed tsvector column (faster updates)
      ALTER TABLE posts ADD COLUMN search_vector tsvector
          GENERATED ALWAYS AS (to_tsvector('english', coalesce(title,'') || ' ' || coalesce(body,''))) STORED;
      CREATE INDEX idx_posts_search ON posts USING gin(search_vector);
      ```
      
      GIN indexes have high build cost and write overhead (each element is indexed separately) but excellent read performance for containment queries.
      
      ---
      
      ## GiST Indexes
      
      Generalized Search Tree. A framework supporting custom data types with custom operators. Suitable for geometric data, range types, and exclusion constraints.
      
      ```sql
      -- Range type
      CREATE INDEX idx_bookings_during ON bookings USING gist(during);
      
      -- PostGIS geometry
      CREATE INDEX idx_locations_geom ON locations USING gist(geom);
      
      -- Exclusion constraint (requires GiST index internally)
      CREATE EXTENSION btree_gist;
      ALTER TABLE bookings ADD CONSTRAINT no_overlap
          EXCLUDE USING gist (room_id WITH =, during WITH &&);
      ```
      
      GiST is lossy (may return false positives that are then rechecked), making it slightly less precise than GIN but more flexible for custom types.
      
      ---
      
      ## BRIN Indexes
      
      Block Range INdex. Stores min/max values per block range rather than per row. Extremely small (often 1000x smaller than B-tree) but only useful when physical row order correlates with query values.
      
      ```sql
      -- Time-series table where rows are appended in timestamp order
      CREATE INDEX idx_events_created_brin ON events USING brin(created_at);
      
      -- Adjust pages_per_range (default 128): smaller = more precise, larger index
      CREATE INDEX idx_events_created_brin ON events USING brin(created_at)
      WITH (pages_per_range = 32);
      ```
      
      ---
      
      ## Composite Indexes
      
      A composite (multi-column) index covers multiple columns. Column ordering is critical.
      
      ### Ordering Rules
      
      1. **Equality conditions first** - columns used with `=` should come before range columns
      2. **Most selective first** - among equality columns, put highest cardinality first
      3. **Leftmost prefix rule** - an index on `(a, b, c)` can also serve queries on `(a)` and `(a, b)` but NOT `(b)` or `(c)` alone
      
      ```sql
      -- Query pattern: WHERE status = 'active' AND created_at > '2024-01-01'
      -- Equality (status) before range (created_at)
      CREATE INDEX idx_orders_status_created ON orders(status, created_at);
      
      -- Query pattern: WHERE tenant_id = 1 AND user_id = 42 AND created_at > '2024-01-01'
      CREATE INDEX idx_events_tenant_user_created ON events(tenant_id, user_id, created_at);
      
      -- This index CANNOT be used for: WHERE user_id = 42 (skips leftmost column)
      -- This index CAN be used for: WHERE tenant_id = 1 (leftmost prefix only)
      -- This index CAN be used for: WHERE tenant_id = 1 AND user_id = 42 ORDER BY created_at
      
      -- Verify index is being used
      EXPLAIN (ANALYZE, BUFFERS)
      SELECT * FROM events
      WHERE tenant_id = 1 AND user_id = 42 AND created_at > now() - interval '7 days';
      ```
      
      ### Selectivity Check
      
      ```sql
      -- Estimate selectivity per column before deciding order
      SELECT
          count(DISTINCT status)::float / count(*) AS status_selectivity,
          count(DISTINCT customer_id)::float / count(*) AS customer_selectivity
      FROM orders;
      -- Higher ratio = more selective = put earlier in composite index
      ```
      
      ---
      
      ## Partial Indexes
      
      A partial index indexes only the rows satisfying a WHERE predicate. Results in a smaller, faster index.
      
      ```sql
      -- Index only active users (WHERE status = 'active' is common)
      CREATE INDEX idx_users_active_email ON users(email) WHERE status = 'active';
      
      -- Index only unprocessed jobs (queue pattern)
      CREATE INDEX idx_jobs_pending ON jobs(created_at) WHERE processed_at IS NULL;
      
      -- Soft-delete pattern: exclude deleted rows from index
      CREATE INDEX idx_products_name ON products(name) WHERE deleted_at IS NULL;
      
      -- Partial unique: only one active record per external_id
      CREATE UNIQUE INDEX idx_subscriptions_active
      ON subscriptions(external_id) WHERE cancelled_at IS NULL;
      ```
      
      For the planner to use a partial index, the query WHERE clause must be **semantically implied** by the index predicate:
      
      ```sql
      -- Index: WHERE status = 'active'
      -- Query must include: WHERE status = 'active' (explicitly, not implied by a join)
      SELECT * FROM users WHERE status = 'active' AND email = 'foo@example.com';
      -- Planner can use idx_users_active_email above
      
      -- This query CANNOT use it (predicate not present):
      SELECT * FROM users WHERE email = 'foo@example.com';
      ```
      
      ### Size Savings Example
      
      ```sql
      -- Measure savings
      SELECT
          pg_size_pretty(pg_relation_size('idx_jobs_all')) AS full_index,
          pg_size_pretty(pg_relation_size('idx_jobs_pending')) AS partial_index;
      
      -- Often 10-100x smaller when condition filters 90%+ of rows
      ```
      
      ---
      
      ## Expression Indexes
      
      Index on the result of an expression or function rather than a raw column value. The expression must be **immutable** (same input always produces same output).
      
      ```sql
      -- Case-insensitive email lookup
      CREATE INDEX idx_users_email_lower ON users(lower(email));
      -- Query must use the same expression:
      SELECT * FROM users WHERE lower(email) = lower('User@Example.com');
      
      -- Date extraction (find all orders on a given day)
      CREATE INDEX idx_orders_date ON orders(date_trunc('day', created_at));
      SELECT * FROM orders WHERE date_trunc('day', created_at) = '2024-03-01';
      
      -- JSONB field extraction (use when you query a specific key frequently)
      CREATE INDEX idx_users_plan ON users((data ->> 'subscription_plan'));
      SELECT * FROM users WHERE data ->> 'subscription_plan' = 'enterprise';
      
      -- Numeric cast from JSONB text field
      CREATE INDEX idx_orders_amount ON orders(((data ->> 'amount')::numeric));
      SELECT * FROM orders WHERE (data ->> 'amount')::numeric > 1000;
      
      -- Partial expression index: only index non-null computed values
      CREATE INDEX idx_products_lower_name ON products(lower(name))
      WHERE name IS NOT NULL;
      ```
      
      ### Immutability Requirement
      
      Functions used in expression indexes must be declared `IMMUTABLE`. PostgreSQL will reject `STABLE` or `VOLATILE` functions.
      
      ```sql
      -- This fails: now() is STABLE, not IMMUTABLE
      CREATE INDEX bad ON events(date_trunc('day', now()));  -- ERROR
      
      -- Custom function must be explicitly IMMUTABLE
      CREATE FUNCTION clean_phone(text) RETURNS text
      LANGUAGE sql IMMUTABLE STRICT AS $$
          SELECT regexp_replace($1, '[^0-9]', '', 'g')
      $$;
      
      CREATE INDEX idx_contacts_phone ON contacts(clean_phone(phone_raw));
      ```
      
      ---
      
      ## Covering Indexes (INCLUDE)
      
      The `INCLUDE` clause adds non-key columns to the index leaf pages. These columns are not searchable but allow index-only scans, avoiding heap fetches entirely.
      
      ```sql
      -- Without INCLUDE: planner must fetch heap to get email
      CREATE INDEX idx_users_name ON users(name);
      
      -- With INCLUDE: index-only scan possible
      CREATE INDEX idx_users_name_covering ON users(name) INCLUDE (email, status);
      SELECT email, status FROM users WHERE name = 'Alice';  -- no heap access
      ```
      
      ### When to Use INCLUDE vs Composite
      
      | Scenario | Use |
      |----------|-----|
      | Column needed in SELECT but not WHERE/ORDER BY | `INCLUDE` |
      | Column used in WHERE or ORDER BY | Add as key column |
      | Column has high write churn | Prefer key column (INCLUDE columns still updated) |
      | Need to cover a few extra cheap columns | `INCLUDE` |
      | Covering a large text column | Avoid; inflates index; use composite carefully |
      
      ```sql
      -- Index-only scan verification in EXPLAIN output
      EXPLAIN (ANALYZE, BUFFERS)
      SELECT name, email FROM users WHERE name LIKE 'A%';
      -- Look for "Index Only Scan" and "Heap Fetches: 0" (or low count if visibility map not up to date)
      
      -- Force visibility map update to enable index-only scans
      VACUUM users;
      ```
      
      ---
      
      ## GIN Specifics
      
      ### Operator Classes
      
      ```sql
      -- Default operator class: supports @>, <@, ?, ?|, ?& on jsonb
      -- Indexes all key-value pairs; larger index; supports more operators
      CREATE INDEX idx_data_gin ON records USING gin(data);
      
      -- jsonb_path_ops: supports ONLY @> (containment)
      -- Indexes only values (not keys); ~30% smaller; faster for containment queries
      CREATE INDEX idx_data_gin_path ON records USING gin(data jsonb_path_ops);
      
      -- Choose jsonb_path_ops when:
      -- - You only query with @> (containment)
      -- - Index size is a concern
      -- - Write throughput needs improvement
      
      -- Choose default when:
      -- - You use ?, ?|, ?& (key existence checks)
      -- - You need to query nested structures with multiple operators
      ```
      
      ### Trigram Search (pg_trgm)
      
      ```sql
      CREATE EXTENSION pg_trgm;
      
      -- GIN trigram index for LIKE, ILIKE, and regex
      CREATE INDEX idx_products_name_trgm ON products USING gin(name gin_trgm_ops);
      
      -- Now these use the index (unlike standard B-tree):
      SELECT * FROM products WHERE name ILIKE '%widget%';
      SELECT * FROM products WHERE name ~ 'wid.*et';
      
      -- Similarity search
      SELECT name, similarity(name, 'wiget') AS sim
      FROM products
      WHERE name % 'wiget'   -- % operator: similarity > threshold (default 0.3)
      ORDER BY sim DESC;
      
      -- GiST alternative (smaller index, slightly slower queries)
      CREATE INDEX idx_products_name_trgm_gist ON products USING gist(name gist_trgm_ops);
      ```
      
      ### Array Operators with GIN
      
      ```sql
      CREATE INDEX idx_articles_tags ON articles USING gin(tags);
      
      -- Supported operators with this index:
      SELECT * FROM articles WHERE tags @> ARRAY['postgresql'];   -- contains
      SELECT * FROM articles WHERE tags <@ ARRAY['a','b','c'];   -- is contained by
      SELECT * FROM articles WHERE tags && ARRAY['postgresql'];  -- overlap
      SELECT * FROM articles WHERE 'postgresql' = ANY(tags);    -- equivalent to @>
      ```
      
      ### Full-Text Search
      
      ```sql
      -- Index a computed tsvector
      CREATE INDEX idx_posts_fts ON posts USING gin(to_tsvector('english', title || ' ' || body));
      
      -- Or index a stored tsvector column (faster updates, more storage)
      ALTER TABLE posts ADD COLUMN fts tsvector
          GENERATED ALWAYS AS (
              setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
              setweight(to_tsvector('english', coalesce(body, '')), 'B')
          ) STORED;
      
      CREATE INDEX idx_posts_fts ON posts USING gin(fts);
      
      -- Query
      SELECT title, ts_rank(fts, query) AS rank
      FROM posts, to_tsquery('english', 'postgresql & index') query
      WHERE fts @@ query
      ORDER BY rank DESC;
      ```
      
      ### GIN Tuning
      
      ```sql
      -- gin_pending_list_limit: GIN uses a fast-update pending list
      -- Larger = fewer full index updates during writes, more reads deferred
      -- Default: 4MB
      ALTER INDEX idx_posts_fts SET (fastupdate = on);
      
      -- Force pending list flush (useful before a read-heavy period)
      SELECT gin_clean_pending_list('idx_posts_fts');
      ```
      
      ---
      
      ## GiST Specifics
      
      ### Range Type Indexing
      
      ```sql
      CREATE EXTENSION btree_gist;  -- required for scalar types in EXCLUDE
      
      CREATE TABLE schedules (
          id       serial PRIMARY KEY,
          staff_id integer,
          shift    tsrange
      );
      
      CREATE INDEX idx_schedules_shift ON schedules USING gist(shift);
      
      -- Supported operators:
      -- && overlap, @> contains, <@ is contained by, = equal, << strictly left, >> strictly right
      SELECT * FROM schedules WHERE shift && '[2024-03-01 08:00, 2024-03-01 16:00)';
      SELECT * FROM schedules WHERE shift @> '2024-03-01 10:00'::timestamptz;
      
      -- Exclusion constraint: no staff member double-booked
      ALTER TABLE schedules ADD CONSTRAINT no_double_shift
          EXCLUDE USING gist (staff_id WITH =, shift WITH &&);
      ```
      
      ### PostGIS with GiST
      
      ```sql
      -- Bounding-box spatial index (default, fast)
      CREATE INDEX idx_locations_geom ON locations USING gist(geom);
      
      -- KNN search: find 5 nearest stores to a point
      SELECT name, geom <-> ST_MakePoint(-87.6298, 41.8781)::geography AS distance
      FROM stores
      ORDER BY distance
      LIMIT 5;
      
      -- Bounding-box overlap (fast, approximate)
      SELECT * FROM polygons WHERE geom && ST_MakeEnvelope(-88, 41, -87, 42, 4326);
      
      -- Exact intersection (uses index for bbox pre-filter, then rechecks)
      SELECT * FROM polygons WHERE ST_Intersects(geom, ST_MakeEnvelope(-88, 41, -87, 42, 4326));
      ```
      
      ### GiST vs GIN Trade-offs
      
      | Property | GiST | GIN |
      |----------|------|-----|
      | Build time | Faster | Slower |
      | Index size | Larger | Smaller (for same data) |
      | Query speed | Slightly slower (lossy, recheck) | Faster for exact lookups |
      | Concurrent writes | Better | GIN pending list helps |
      | Use for exclusion constraints | Yes | No |
      
      ---
      
      ## BRIN Specifics
      
      ### How BRIN Works
      
      BRIN stores the minimum and maximum values for each block range (group of consecutive pages). Effective when the physical storage order of rows correlates with the query predicate.
      
      ```sql
      -- Ideal: append-only log table; rows inserted in timestamp order
      CREATE TABLE application_logs (
          id          bigserial,
          recorded_at timestamptz NOT NULL DEFAULT now(),
          level       text,
          message     text
      );
      
      -- BRIN is tiny: 1 page per 128 pages of heap (default)
      CREATE INDEX idx_logs_recorded_brin ON application_logs USING brin(recorded_at);
      
      -- Dramatically smaller than B-tree for the same column:
      SELECT
          pg_size_pretty(pg_relation_size('idx_logs_recorded_btree')) AS btree_size,
          pg_size_pretty(pg_relation_size('idx_logs_recorded_brin'))  AS brin_size;
      -- Typical ratio: 1000:1 in favor of BRIN for correlated data
      ```
      
      ### Tuning pages_per_range
      
      ```sql
      -- Default pages_per_range = 128 (coarse, very small index)
      -- Smaller value = more precise (fewer false positives), larger index
      -- Larger value = less precise, smaller index
      
      -- For high-precision time ranges on a large table
      CREATE INDEX idx_logs_brin_precise ON application_logs USING brin(recorded_at)
      WITH (pages_per_range = 16);
      
      -- Query still requires a sequential scan of matching block ranges
      -- followed by heap fetch and recheck; BRIN shines when most blocks are skipped
      ```
      
      ### Ideal BRIN Workloads
      
      - Time-series and IoT data inserted in timestamp order
      - Append-only audit tables
      - Log tables where records are never updated out of order
      - Data warehouse fact tables loaded in date sequence
      
      ### When BRIN Is NOT Appropriate
      
      - Tables with random INSERT patterns (poor correlation)
      - Frequently updated rows that change index key values
      - Small tables (B-tree overhead is trivial; BRIN gains are minimal)
      - When precise, low-latency lookups are required (BRIN may still scan many pages)
      
      ---
      
      ## Index Maintenance
      
      ### Finding Unused Indexes
      
      ```sql
      -- Indexes with zero or low scans since last statistics reset
      SELECT
          schemaname,
          tablename,
          indexname,
          pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
          idx_scan,
          idx_tup_read,
          idx_tup_fetch
      FROM pg_stat_user_indexes
      WHERE idx_scan = 0
        AND pg_relation_size(indexrelid) > 1024 * 1024  -- larger than 1MB
      ORDER BY pg_relation_size(indexrelid) DESC;
      
      -- When were statistics last reset?
      SELECT stats_reset FROM pg_stat_bgwriter;
      ```
      
      ### Detecting Index Bloat
      
      ```sql
      -- Approximate bloat using pgstattuple extension
      CREATE EXTENSION pgstattuple;
      
      SELECT * FROM pgstatindex('idx_orders_customer_id');
      -- Look at: avg_leaf_density (below ~70% means bloat)
      
      -- Or use the bloat query from check_postgres
      SELECT
          schemaname,
          tablename,
          indexname,
          pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
          round(100 * (1 - avg_leaf_density / 90.0), 1) AS bloat_pct
      FROM pg_stat_user_indexes
      JOIN pg_index USING (indexrelid)
      CROSS JOIN pgstatindex(indexrelid::regclass::text)
      WHERE NOT indisprimary
      ORDER BY bloat_pct DESC;
      ```
      
      ### Rebuilding Indexes
      
      ```sql
      -- Rebuild without locking reads/writes (PG12+)
      REINDEX INDEX CONCURRENTLY idx_orders_customer_id;
      
      -- Rebuild all indexes on a table concurrently
      REINDEX TABLE CONCURRENTLY orders;
      
      -- Classic REINDEX (takes ShareLock, blocks writes):
      REINDEX INDEX idx_orders_customer_id;
      
      -- Rebuild as new index, then swap (manual CONCURRENTLY approach, pre-PG12)
      CREATE INDEX CONCURRENTLY idx_orders_customer_id_new ON orders(customer_id);
      DROP INDEX idx_orders_customer_id;
      ALTER INDEX idx_orders_customer_id_new RENAME TO idx_orders_customer_id;
      ```
      
      ### Monitoring Index Size and Growth
      
      ```sql
      -- All index sizes for a table, sorted descending
      SELECT
          indexname,
          pg_size_pretty(pg_relation_size(indexrelid)) AS size,
          indexdef
      FROM pg_indexes
      JOIN pg_stat_user_indexes USING (schemaname, tablename, indexname)
      WHERE tablename = 'orders'
      ORDER BY pg_relation_size(indexrelid) DESC;
      
      -- Total index overhead vs table size
      SELECT
          relname AS table_name,
          pg_size_pretty(pg_relation_size(oid)) AS table_size,
          pg_size_pretty(pg_indexes_size(oid)) AS indexes_size,
          round(100.0 * pg_indexes_size(oid) / nullif(pg_relation_size(oid), 0), 1) AS index_ratio_pct
      FROM pg_class
      WHERE relkind = 'r'
        AND relnamespace = 'public'::regnamespace
      ORDER BY pg_indexes_size(oid) DESC;
      ```
      
      ### Monitoring Index Usage in Queries
      
      ```sql
      -- Enable pg_stat_statements for query-level stats
      CREATE EXTENSION pg_stat_statements;
      
      -- Find slow queries that do sequential scans on large tables
      SELECT
          query,
          calls,
          round(mean_exec_time::numeric, 2) AS avg_ms,
          round(total_exec_time::numeric, 2) AS total_ms
      FROM pg_stat_statements
      ORDER BY mean_exec_time DESC
      LIMIT 20;
      
      -- Check for sequential scans on a specific table
      SELECT
          seq_scan,
          seq_tup_read,
          idx_scan,
          idx_tup_fetch,
          n_live_tup
      FROM pg_stat_user_tables
      WHERE relname = 'orders';
      
      -- High seq_scan with high n_live_tup = missing index candidate
      ```
      
      ---
      
      ## Anti-Patterns
      
      ### Over-Indexing
      
      Every index adds overhead to INSERT, UPDATE, and DELETE operations. Index only columns that appear in WHERE, JOIN, or ORDER BY clauses of frequent or critical queries.
      
      ```sql
      -- Bad: indexing every column "just in case"
      CREATE INDEX ON orders(id);           -- already the PK
      CREATE INDEX ON orders(created_at);   -- only used in one monthly report
      CREATE INDEX ON orders(notes);        -- free-text, rarely filtered
      CREATE INDEX ON orders(updated_at);   -- only used in batch maintenance jobs
      
      -- Measure write amplification
      SELECT
          relname,
          n_tup_ins,
          n_tup_upd,
          n_tup_del,
          (SELECT count(*) FROM pg_indexes WHERE tablename = relname) AS index_count
      FROM pg_stat_user_tables
      WHERE relname = 'orders';
      ```
      
      ### Wrong Index Type Selection
      
      ```sql
      -- Bad: B-tree on a column used only with @> (JSONB containment)
      CREATE INDEX idx_bad ON products USING btree(attributes);
      -- attributes @> '{"color": "red"}' will NOT use this index
      
      -- Good: GIN for containment queries
      CREATE INDEX idx_good ON products USING gin(attributes jsonb_path_ops);
      
      -- Bad: GIN on a column used only for equality
      CREATE INDEX idx_bad2 ON sessions USING gin(token);
      -- token is text, not multi-valued; GIN has no benefit here
      
      -- Good: B-tree or Hash for equality on scalar
      CREATE INDEX idx_good2 ON sessions USING hash(token);
      ```
      
      ### Indexing Low-Cardinality Columns Without Partial
      
      ```sql
      -- Bad: B-tree index on a boolean column (only 2 distinct values)
      -- Planner will likely choose a seq scan anyway for common value
      CREATE INDEX idx_orders_is_paid ON orders(is_paid);
      
      -- Bad: B-tree on status with 3-4 values and one dominant
      CREATE INDEX idx_orders_status ON orders(status);
      -- If 95% of rows have status = 'completed', this index is useless for that value
      
      -- Good: Partial index targeting the rare, actionable value
      CREATE INDEX idx_orders_unpaid ON orders(created_at) WHERE is_paid = false;
      CREATE INDEX idx_orders_pending ON orders(created_at) WHERE status = 'pending';
      ```
      
      ### Redundant Indexes
      
      ```sql
      -- Bad: (a) is made redundant by (a, b) for queries filtering on a alone
      CREATE INDEX idx_a   ON t(a);
      CREATE INDEX idx_a_b ON t(a, b);
      
      -- Check for prefix-redundant indexes
      SELECT
          i1.indexname AS redundant,
          i2.indexname AS superseded_by
      FROM pg_indexes i1
      JOIN pg_indexes i2 ON i1.tablename = i2.tablename
          AND i1.indexname != i2.indexname
          AND position(replace(i1.indexdef, i1.indexname, '') IN i2.indexdef) > 0
      WHERE i1.tablename = 'orders';
      ```
      
      ### Missing Indexes on Foreign Keys
      
      Unindexed foreign keys cause sequential scans during CASCADE deletes and parent-table updates.
      
      ```sql
      -- Find foreign key columns without an index
      SELECT
          tc.table_name,
          kcu.column_name,
          ccu.table_name AS referenced_table
      FROM information_schema.table_constraints AS tc
      JOIN information_schema.key_column_usage AS kcu
          ON tc.constraint_name = kcu.constraint_name
      JOIN information_schema.constraint_column_usage AS ccu
          ON ccu.constraint_name = tc.constraint_name
      WHERE tc.constraint_type = 'FOREIGN KEY'
        AND NOT EXISTS (
            SELECT 1 FROM pg_index pi
            JOIN pg_attribute pa ON pa.attrelid = pi.indrelid
                AND pa.attnum = ANY(pi.indkey)
            WHERE pi.indrelid = (tc.table_name)::regclass
              AND pa.attname = kcu.column_name
        );
      ```
      
      ### Forgetting to Run ANALYZE After Bulk Load
      
      ```sql
      -- After COPY or bulk INSERT, statistics are stale; planner makes bad choices
      COPY orders FROM '/tmp/orders.csv' CSV HEADER;
      ANALYZE orders;  -- always run this after bulk loads
      
      -- Or with autovacuum disabled during load:
      SET session_replication_role = replica;  -- disable FK checks for speed
      COPY orders FROM '/tmp/orders.csv' CSV HEADER;
      SET session_replication_role = DEFAULT;
      ANALYZE orders;
      ```
      
    • operations.md 24.6 KB
      # PostgreSQL Operations Reference
      
      ## Table of Contents
      
      1. [Backup Strategies](#backup-strategies)
      2. [Vacuum Deep Dive](#vacuum-deep-dive)
      3. [Monitoring](#monitoring)
      4. [Connection Pooling](#connection-pooling)
      
      ---
      
      ## Backup Strategies
      
      ### pg_dump
      
      Logical backup of a single database. Consistent snapshot via a single transaction.
      Does not back up roles, tablespaces, or server-level configuration.
      
      ```bash
      # Custom format (-Fc): compressed, parallel-restorable, most versatile
      pg_dump -h localhost -U postgres -d mydb -Fc -f mydb.dump
      
      # Plain SQL format (-Fp): human-readable, pipe-friendly, not parallel-restorable
      pg_dump -h localhost -U postgres -d mydb -Fp -f mydb.sql
      
      # Directory format (-Fd): one file per table, supports parallel dump and restore
      pg_dump -h localhost -U postgres -d mydb -Fd -f mydb_dir/
      
      # Parallel dump (directory format required, -j = number of workers)
      pg_dump -h localhost -U postgres -d mydb -Fd -j 4 -f mydb_dir/
      
      # Compressed with explicit compression level (PG16+ supports --compress=lz4)
      pg_dump -h localhost -U postgres -d mydb -Fc --compress=9 -f mydb.dump
      
      # Dump only specific tables
      pg_dump -h localhost -U postgres -d mydb -Fc -t orders -t customers -f subset.dump
      
      # Dump only schema (no data)
      pg_dump -h localhost -U postgres -d mydb -Fc --schema-only -f schema.dump
      
      # Dump only data (no DDL)
      pg_dump -h localhost -U postgres -d mydb -Fc --data-only -f data.dump
      
      # Exclude specific tables (e.g., large log tables)
      pg_dump -h localhost -U postgres -d mydb -Fc -T audit_logs -T event_stream -f mydb.dump
      ```
      
      #### pg_restore
      
      ```bash
      # Restore custom/directory format
      pg_restore -h localhost -U postgres -d mydb_restore -Fc mydb.dump
      
      # Parallel restore (directory format)
      pg_restore -h localhost -U postgres -d mydb_restore -Fd -j 4 mydb_dir/
      
      # Restore single table from full dump
      pg_restore -h localhost -U postgres -d mydb -t orders mydb.dump
      
      # Restore schema only, then data (useful for pre-creating indexes)
      pg_restore -h localhost -U postgres -d mydb --schema-only mydb.dump
      pg_restore -h localhost -U postgres -d mydb --data-only mydb.dump
      
      # --no-owner / --no-privileges: skip ownership and ACL statements
      pg_restore -h localhost -U postgres -d mydb --no-owner --no-privileges mydb.dump
      ```
      
      ### pg_dumpall
      
      Backs up all databases plus server-level objects (roles, tablespaces).
      Output is always plain SQL (no custom/directory format support).
      
      ```bash
      # Full cluster backup
      pg_dumpall -h localhost -U postgres -f cluster_backup.sql
      
      # Globals only (roles and tablespaces, no database data)
      pg_dumpall -h localhost -U postgres --globals-only -f globals.sql
      
      # Restore
      psql -h localhost -U postgres -f cluster_backup.sql
      ```
      
      ### pg_basebackup
      
      Physical backup of the entire cluster. Required for PITR and streaming replication setup.
      Much faster than pg_dump for large databases since it copies raw files.
      
      ```bash
      # Basic base backup (plain format, WAL streamed during backup)
      pg_basebackup -h localhost -U replicator -D /backup/base -P
      
      # Include WAL files in backup (-Xs = stream WAL during backup)
      pg_basebackup -h localhost -U replicator -D /backup/base -Xs -P
      
      # Tar format with gzip compression (one .tar.gz per tablespace)
      pg_basebackup -h localhost -U replicator -D /backup/base -Ft -z -P
      
      # Tar format with LZ4 (PG15+, faster than gzip)
      pg_basebackup -h localhost -U replicator -D /backup/base -Ft --compress=lz4 -P
      
      # Checkpoint mode: fast = force immediate checkpoint, spread = rate-limited I/O
      pg_basebackup -h localhost -U replicator -D /backup/base -Xs --checkpoint=fast -P
      
      # Required postgresql.conf settings for pg_basebackup:
      # wal_level = replica          (minimum)
      # max_wal_senders = 3          (at least 1 available sender)
      # archive_mode = on            (for PITR)
      ```
      
      ### PITR: Point-in-Time Recovery
      
      PITR combines a base backup with WAL archive segments to restore to any point in time.
      
      #### WAL Archiving Setup
      
      ```bash
      # postgresql.conf settings
      wal_level = replica
      archive_mode = on
      archive_command = 'test ! -f /wal_archive/%f && cp %p /wal_archive/%f'
      # %p = full path to WAL file, %f = filename only
      
      # With AWS S3 (using WAL-E or pgBackRest in production)
      archive_command = 'aws s3 cp %p s3://my-bucket/wal-archive/%f'
      
      # Verify archive is working
      SELECT pg_switch_wal();  -- force WAL segment switch to test archive_command
      -- Check /wal_archive for new .wal files
      ```
      
      #### Recovery Configuration
      
      Create `recovery.signal` file in PGDATA to trigger recovery mode (PG12+).
      Recovery parameters go in `postgresql.conf` (PG12+) or `recovery.conf` (pre-PG12).
      
      ```bash
      # postgresql.conf additions for recovery
      restore_command = 'cp /wal_archive/%f %p'
      # or from S3:
      restore_command = 'aws s3 cp s3://my-bucket/wal-archive/%f %p'
      
      # Recovery target options (pick one):
      recovery_target_time = '2024-03-15 14:30:00'        # time-based
      recovery_target_xid = '1234567'                       # transaction ID
      recovery_target_lsn = '0/15D5A50'                    # LSN
      recovery_target_name = 'before_migration'             # named restore point
      recovery_target = 'immediate'                         # as soon as consistent
      
      # After reaching target:
      recovery_target_action = 'promote'   # promote to primary (default)
      recovery_target_action = 'pause'     # pause, inspect, then pg_wal_replay_resume()
      recovery_target_action = 'shutdown'  # stop after recovery
      
      # Named restore points (create before risky operations)
      SELECT pg_create_restore_point('before_bulk_delete');
      ```
      
      ```bash
      # Full PITR procedure:
      # 1. Stop PostgreSQL
      # 2. Move PGDATA aside: mv /var/lib/postgresql/14/main /var/lib/postgresql/14/main.bak
      # 3. Restore base backup: pg_basebackup ... or extract tar
      # 4. Add recovery settings to postgresql.conf
      # 5. Touch recovery.signal: touch $PGDATA/recovery.signal
      # 6. Start PostgreSQL -- it will replay WAL until target, then promote
      ```
      
      ### Backup Verification
      
      ```bash
      # pg_verifybackup (PG13+): verify base backup integrity
      pg_verifybackup /backup/base
      
      # Check backup manifest
      pg_verifybackup --no-manifest-checksums /backup/base  # skip slow checksum verify
      
      # Test restore (do this regularly in staging)
      pg_restore --list mydb.dump | head -20   # check contents without restoring
      
      # Verify dump readability
      pg_restore -l mydb.dump > /dev/null && echo "Dump is readable"
      ```
      
      ---
      
      ## Vacuum Deep Dive
      
      ### Regular VACUUM vs VACUUM FULL vs pg_repack
      
      **Regular VACUUM**: marks dead tuples as reusable space. Does not shrink table on disk.
      Non-blocking (shares table with readers and writers). Run this routinely.
      
      **VACUUM FULL**: rewrites entire table to new file, reclaiming disk space.
      Requires exclusive lock (blocks all access). Causes table/index bloat to disappear.
      Rarely needed if autovacuum is tuned correctly.
      
      **pg_repack**: rewrites table without long exclusive lock (builds new table in background,
      swaps at end with brief lock). Preferred over VACUUM FULL for large production tables.
      
      ```sql
      -- Regular VACUUM (non-blocking)
      VACUUM orders;
      
      -- VACUUM with ANALYZE (update statistics too)
      VACUUM ANALYZE orders;
      
      -- VERBOSE output to understand what was cleaned
      VACUUM VERBOSE orders;
      
      -- VACUUM FULL (requires AccessExclusiveLock -- schedule maintenance window)
      VACUUM FULL orders;
      
      -- Check what VACUUM would do (dry run via visibility info)
      SELECT relname, n_dead_tup, n_live_tup,
             round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
             last_vacuum, last_autovacuum
      FROM pg_stat_user_tables
      ORDER BY n_dead_tup DESC;
      ```
      
      ```bash
      # pg_repack (must install extension)
      pg_repack -h localhost -U postgres -d mydb -t orders
      
      # Repack entire database
      pg_repack -h localhost -U postgres -d mydb
      
      # Repack only indexes (faster, lower risk)
      pg_repack -h localhost -U postgres -d mydb -t orders --only-indexes
      ```
      
      ### Autovacuum Tuning
      
      Autovacuum triggers when: `n_dead_tup > autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * n_live_tup`
      
      ```bash
      # postgresql.conf global settings
      autovacuum = on                              # never disable
      autovacuum_max_workers = 5                   # default 3; increase for many tables
      autovacuum_vacuum_threshold = 50            # min dead tuples before trigger
      autovacuum_vacuum_scale_factor = 0.02       # 2% of table (default 0.2 = 20%)
      autovacuum_analyze_threshold = 50
      autovacuum_analyze_scale_factor = 0.01      # 1% (default 0.1 = 10%)
      autovacuum_vacuum_cost_delay = 2ms          # throttle I/O (default 2ms in PG13+)
      autovacuum_vacuum_cost_limit = 200          # I/O budget per delay cycle
      autovacuum_naptime = 30s                    # check interval for each worker
      ```
      
      ```sql
      -- Per-table autovacuum override (large tables need lower scale factor)
      -- For a 100M row table, 20% = 20M dead tuples before vacuum -- too late
      ALTER TABLE orders SET (
          autovacuum_vacuum_scale_factor = 0.01,   -- 1% instead of 20%
          autovacuum_vacuum_threshold = 1000,
          autovacuum_analyze_scale_factor = 0.005,
          autovacuum_vacuum_cost_delay = 10        -- ms; slow down to reduce I/O impact
      );
      
      -- High-churn tables (logs, queues): more aggressive
      ALTER TABLE job_queue SET (
          autovacuum_vacuum_scale_factor = 0.001,
          autovacuum_vacuum_threshold = 100
      );
      ```
      
      ### Transaction ID Wraparound
      
      PostgreSQL uses 32-bit transaction IDs (XIDs). After ~2 billion transactions, XID wraps.
      PostgreSQL will stop accepting writes when age reaches `autovacuum_freeze_max_age` (default 200M).
      
      ```sql
      -- Monitor XID age across all databases (run as superuser)
      SELECT datname,
             age(datfrozenxid) AS xid_age,
             2000000000 - age(datfrozenxid) AS remaining_xids,
             round(100.0 * age(datfrozenxid) / 2000000000, 2) AS pct_used
      FROM pg_database
      ORDER BY age(datfrozenxid) DESC;
      
      -- Monitor per-table XID age (find tables that need freezing)
      SELECT relname,
             age(relfrozenxid) AS xid_age,
             pg_size_pretty(pg_total_relation_size(oid)) AS size
      FROM pg_class
      WHERE relkind = 'r'
      ORDER BY age(relfrozenxid) DESC
      LIMIT 20;
      
      -- Emergency response when approaching wraparound:
      -- 1. Check if autovacuum is running: SELECT * FROM pg_stat_activity WHERE query LIKE 'autovacuum%';
      -- 2. Manual aggressive freeze:
      VACUUM FREEZE orders;        -- force freeze all tuples in table
      -- 3. For cluster-wide freeze:
      -- vacuumdb -a -F -j 4       -- freeze all databases, 4 parallel workers
      
      -- Relevant postgresql.conf settings
      vacuum_freeze_min_age = 50000000        -- freeze tuples older than this (50M XIDs)
      vacuum_freeze_table_age = 150000000     -- force full table scan at this age
      autovacuum_freeze_max_age = 200000000   -- emergency autovacuum triggered here
      ```
      
      ### Dead Tuple Accumulation
      
      Long-running transactions and `idle in transaction` sessions prevent VACUUM from removing dead tuples
      because those old snapshots may still need to see pre-update versions.
      
      ```sql
      -- Find sessions holding old snapshots (preventing dead tuple cleanup)
      SELECT pid, usename, application_name, state,
             now() - xact_start AS xact_age,
             now() - query_start AS query_age,
             left(query, 80) AS current_query
      FROM pg_stat_activity
      WHERE state != 'idle'
        AND xact_start < now() - interval '5 minutes'
      ORDER BY xact_start;
      
      -- Find the oldest active transaction (this limits vacuum)
      SELECT min(xact_start), max(now() - xact_start) AS max_age
      FROM pg_stat_activity
      WHERE xact_start IS NOT NULL;
      
      -- Check if replication slots are holding back WAL (another source of bloat)
      SELECT slot_name, active, pg_size_pretty(
          pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
      ) AS lag
      FROM pg_replication_slots;
      
      -- Kill long-running idle-in-transaction sessions (use carefully)
      SELECT pg_terminate_backend(pid)
      FROM pg_stat_activity
      WHERE state = 'idle in transaction'
        AND now() - xact_start > interval '1 hour';
      
      -- Prevent accumulation: set statement/transaction timeouts
      -- postgresql.conf or ALTER ROLE:
      -- idle_in_transaction_session_timeout = '10min'
      -- statement_timeout = '30s'
      ```
      
      ---
      
      ## Monitoring
      
      ### pg_stat_activity
      
      ```sql
      -- Connection overview by state
      SELECT state, count(*), max(now() - state_change) AS max_time_in_state
      FROM pg_stat_activity
      GROUP BY state
      ORDER BY count DESC;
      
      -- Long-running queries (over 30 seconds)
      SELECT pid, usename, application_name, client_addr, state,
             now() - query_start AS duration,
             wait_event_type, wait_event,
             left(query, 120) AS query
      FROM pg_stat_activity
      WHERE state != 'idle'
        AND query_start < now() - interval '30 seconds'
      ORDER BY query_start;
      
      -- Wait events (what are connections waiting for)
      SELECT wait_event_type, wait_event, count(*)
      FROM pg_stat_activity
      WHERE state != 'idle'
      GROUP BY wait_event_type, wait_event
      ORDER BY count DESC;
      -- Common wait events:
      -- Lock/relation = waiting for table lock
      -- Client/ClientRead = waiting for client to send data
      -- IO/DataFileRead = reading from disk
      -- IPC/BgWorkerShutdown = parallel query coordination
      ```
      
      ### pg_stat_user_tables
      
      ```sql
      -- Tables with high sequential scan rates (missing indexes?)
      SELECT relname,
             seq_scan,
             idx_scan,
             round(100.0 * idx_scan / nullif(seq_scan + idx_scan, 0), 2) AS idx_pct,
             n_live_tup,
             n_dead_tup,
             last_vacuum::date,
             last_autovacuum::date,
             last_analyze::date
      FROM pg_stat_user_tables
      WHERE seq_scan > 100
      ORDER BY seq_scan DESC;
      
      -- Tables most in need of VACUUM
      SELECT relname,
             n_dead_tup,
             n_live_tup,
             round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
             last_autovacuum,
             last_vacuum
      FROM pg_stat_user_tables
      ORDER BY n_dead_tup DESC
      LIMIT 20;
      ```
      
      ### Unused Index Detection
      
      ```sql
      -- Indexes that are never used (candidates for removal)
      SELECT schemaname, relname AS table, indexrelname AS index,
             pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size,
             idx_scan AS scans
      FROM pg_stat_user_indexes ui
      JOIN pg_index i ON i.indexrelid = ui.indexrelid
      WHERE idx_scan = 0
        AND NOT indisunique           -- keep unique constraints
        AND NOT indisprimary          -- keep primary keys
      ORDER BY pg_relation_size(i.indexrelid) DESC;
      
      -- Indexes with low usage relative to writes (more overhead than benefit)
      SELECT relname AS table,
             indexrelname AS index,
             idx_scan AS reads,
             pg_stat_get_tuples_inserted(relid) + pg_stat_get_tuples_updated(relid)
                 + pg_stat_get_tuples_deleted(relid) AS writes,
             pg_size_pretty(pg_relation_size(indexrelid)) AS size
      FROM pg_stat_user_indexes
      WHERE idx_scan < 100
      ORDER BY pg_relation_size(indexrelid) DESC;
      
      -- Note: reset stats after index creation or major data loads
      -- SELECT pg_stat_reset();  -- resets ALL stats for this database
      ```
      
      ### pg_stat_bgwriter
      
      ```sql
      -- Checkpoint health and buffer writer activity
      SELECT checkpoints_timed,
             checkpoints_req,                          -- forced (bad: I/O spike risk)
             round(100.0 * checkpoints_req /
                 nullif(checkpoints_timed + checkpoints_req, 0), 2) AS forced_pct,
             buffers_checkpoint,                        -- written at checkpoint
             buffers_clean,                             -- written by bgwriter
             maxwritten_clean,                          -- bgwriter hit write limit (increase bgwriter_lru_maxpages)
             buffers_backend,                           -- written by backend directly (bad)
             buffers_backend_fsync,                     -- backend had to fsync (very bad)
             buffers_alloc,                             -- new buffers allocated
             stats_reset::date
      FROM pg_stat_bgwriter;
      
      -- High checkpoints_req: reduce checkpoint_completion_target or increase max_wal_size
      -- High buffers_backend: shared_buffers too small or checkpoint interval too short
      -- Ideal: checkpoints_req / total < 10%, buffers_backend / total < 5%
      
      -- postgresql.conf tuning:
      -- checkpoint_completion_target = 0.9   -- spread checkpoint I/O over 90% of interval
      -- max_wal_size = 4GB                   -- larger = fewer forced checkpoints
      -- checkpoint_timeout = 10min           -- default
      ```
      
      ### Lock Contention and Deadlocks
      
      ```sql
      -- Active locks and what is blocking what
      SELECT blocked.pid AS blocked_pid,
             blocked.usename AS blocked_user,
             blocking.pid AS blocking_pid,
             blocking.usename AS blocking_user,
             blocked_activity.wait_event,
             blocked_activity.query AS blocked_query,
             blocking_activity.query AS blocking_query
      FROM pg_locks blocked
      JOIN pg_stat_activity blocked_activity ON blocked_activity.pid = blocked.pid
      JOIN pg_locks blocking ON blocking.locktype = blocked.locktype
          AND blocking.database IS NOT DISTINCT FROM blocked.database
          AND blocking.relation IS NOT DISTINCT FROM blocked.relation
          AND blocking.page IS NOT DISTINCT FROM blocked.page
          AND blocking.tuple IS NOT DISTINCT FROM blocked.tuple
          AND blocking.classid IS NOT DISTINCT FROM blocked.classid
          AND blocking.objid IS NOT DISTINCT FROM blocked.objid
          AND blocking.objsubid IS NOT DISTINCT FROM blocked.objsubid
          AND blocking.pid != blocked.pid
          AND blocking.granted
      JOIN pg_stat_activity blocking_activity ON blocking_activity.pid = blocking.pid
      WHERE NOT blocked.granted;
      
      -- Lock types by table (what mode of locks are held)
      SELECT relname, mode, count(*)
      FROM pg_locks l
      JOIN pg_class c ON c.oid = l.relation
      WHERE l.granted
      GROUP BY relname, mode
      ORDER BY relname, mode;
      
      -- Deadlock investigation: enable logging in postgresql.conf
      -- log_lock_waits = on          -- log waits over deadlock_timeout
      -- deadlock_timeout = 1s        -- time before deadlock check runs
      -- log_min_duration_statement = 5000  -- log queries taking over 5s
      ```
      
      ### Cache Hit Ratio
      
      ```sql
      -- Database-level buffer cache hit ratio (target: > 99% for OLTP)
      SELECT datname,
             blks_hit,
             blks_read,
             round(100.0 * blks_hit / nullif(blks_hit + blks_read, 0), 2) AS hit_ratio
      FROM pg_stat_database
      WHERE datname = current_database();
      
      -- Table-level cache hit ratio
      SELECT relname,
             heap_blks_hit,
             heap_blks_read,
             round(100.0 * heap_blks_hit / nullif(heap_blks_hit + heap_blks_read, 0), 2) AS hit_ratio
      FROM pg_statio_user_tables
      ORDER BY heap_blks_read DESC;
      
      -- Index cache hit ratio
      SELECT relname, indexrelname,
             idx_blks_hit,
             idx_blks_read,
             round(100.0 * idx_blks_hit / nullif(idx_blks_hit + idx_blks_read, 0), 2) AS hit_ratio
      FROM pg_statio_user_indexes
      ORDER BY idx_blks_read DESC;
      ```
      
      ### Table and Index Bloat Estimation
      
      ```sql
      -- Table bloat estimate (uses pgstattuple extension if available, else heuristic)
      -- Heuristic approach (no extension required):
      SELECT
          schemaname,
          relname AS table,
          pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) AS total_size,
          pg_size_pretty(pg_relation_size(schemaname || '.' || relname)) AS table_size,
          round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 2) AS dead_tup_pct
      FROM pg_stat_user_tables
      ORDER BY pg_total_relation_size(schemaname || '.' || relname) DESC;
      
      -- Using pgstattuple for precise bloat (requires extension, scans full table)
      CREATE EXTENSION IF NOT EXISTS pgstattuple;
      SELECT * FROM pgstattuple('orders');
      -- dead_tuple_percent > 20% warrants VACUUM or pg_repack
      
      -- Index bloat via pgstatindex
      SELECT * FROM pgstatindex('orders_pkey');
      -- avg_leaf_density < 70% suggests bloat; REINDEX or pg_repack --only-indexes
      
      -- Quick bloat estimate without extension (from check_postgres project):
      SELECT
          tablename,
          pg_size_pretty(real_size) AS real_size,
          pg_size_pretty(bloat_size) AS bloat_size,
          round(bloat_ratio::numeric, 2) AS bloat_ratio
      FROM (
          SELECT tablename,
                 pg_total_relation_size(tablename::regclass) AS real_size,
                 pg_total_relation_size(tablename::regclass) -
                     (pg_relation_size(tablename::regclass) * (1.0 - n_dead_tup::float / nullif(n_live_tup + n_dead_tup, 0))) AS bloat_size,
                 100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0) AS bloat_ratio
          FROM pg_stat_user_tables
      ) t
      WHERE bloat_ratio > 10
      ORDER BY bloat_size DESC;
      ```
      
      ---
      
      ## Connection Pooling
      
      ### pgBouncer Modes
      
      **Session mode**: client holds server connection for entire session duration.
      Same behavior as direct connection. Use for: apps using session-level features
      (temp tables, prepared statements with protocol-level binding, SET LOCAL, advisory locks).
      
      **Transaction mode**: server connection returned to pool after each transaction.
      Much higher multiplexing. Use for: most web applications using short transactions.
      Limitations: SET, LISTEN, NOTIFY, prepared statements (without `server_reset_query`), temp tables.
      
      **Statement mode**: connection returned after each statement. Rarely used.
      Limitation: no multi-statement transactions. Useful only for simple read-only workloads.
      
      ```ini
      ; pgbouncer.ini
      [databases]
      mydb = host=localhost port=5432 dbname=mydb
      
      [pgbouncer]
      listen_port = 6432
      listen_addr = *
      auth_type = scram-sha-256
      auth_file = /etc/pgbouncer/userlist.txt
      
      pool_mode = transaction          ; session|transaction|statement
      max_client_conn = 1000           ; max connections from clients
      default_pool_size = 25           ; server connections per database/user pair
      min_pool_size = 5                ; keep this many open even when idle
      reserve_pool_size = 5            ; extra connections for pool_mode=session bursts
      reserve_pool_timeout = 5         ; seconds to wait before using reserve pool
      
      server_reset_query = DISCARD ALL ; run after each session return (session mode)
      server_check_query = SELECT 1    ; health check query
      server_idle_timeout = 600        ; close idle server connections after 10min
      client_idle_timeout = 0          ; 0 = never close idle clients (set in app instead)
      
      ; Logging
      log_connections = 0              ; reduce noise in production
      log_disconnections = 0
      log_pooler_errors = 1
      stats_period = 60                ; log stats every 60 seconds
      ```
      
      ```bash
      # pgBouncer monitoring via admin console
      psql -h localhost -p 6432 -U pgbouncer pgbouncer
      
      SHOW POOLS;      -- pool stats: cl_active, cl_waiting, sv_active, sv_idle
      SHOW CLIENTS;    -- connected clients
      SHOW SERVERS;    -- server connections
      SHOW STATS;      -- request rates, query times
      SHOW CONFIG;     -- current configuration
      
      PAUSE mydb;      -- pause pool (for maintenance)
      RESUME mydb;     -- resume pool
      RELOAD;          -- reload config without restart
      ```
      
      ### Application-Level Pooling (SQLAlchemy)
      
      ```python
      from sqlalchemy import create_engine
      
      engine = create_engine(
          "postgresql+psycopg2://user:pass@localhost:5432/mydb",
          pool_size=10,           # persistent connections in pool
          max_overflow=20,        # extra connections beyond pool_size (temporary)
          pool_timeout=30,        # seconds to wait for available connection
          pool_recycle=1800,      # recycle connections after 30min (avoid stale connections)
          pool_pre_ping=True,     # test connection before using from pool
      )
      
      # With pgBouncer in transaction mode, use NullPool or StaticPool
      # (pgBouncer handles pooling, app should not pool on top of pooler)
      from sqlalchemy.pool import NullPool
      engine = create_engine(
          "postgresql+psycopg2://user:pass@pgbouncer:6432/mydb",
          poolclass=NullPool       # no application-level pooling
      )
      ```
      
      ### Connection Sizing Guidelines
      
      The classic formula: `connections = (core_count * 2) + effective_spindle_count`
      
      For SSD storage (spindles = 1), a 16-core server: `(16 * 2) + 1 = 33` PostgreSQL connections.
      
      ```sql
      -- Check current connection usage
      SELECT count(*) AS total,
             count(*) FILTER (WHERE state = 'active') AS active,
             count(*) FILTER (WHERE state = 'idle') AS idle,
             count(*) FILTER (WHERE state = 'idle in transaction') AS idle_txn,
             max_conn
      FROM pg_stat_activity
      CROSS JOIN (SELECT setting::int AS max_conn FROM pg_settings WHERE name = 'max_connections') s
      GROUP BY max_conn;
      
      -- Connections by application
      SELECT application_name, count(*), max(now() - state_change) AS longest_idle
      FROM pg_stat_activity
      GROUP BY application_name
      ORDER BY count DESC;
      ```
      
      ```ini
      ; postgresql.conf connection settings
      max_connections = 100            ; total connections (including superuser)
      superuser_reserved_connections = 3  ; reserved for superuser access
      
      ; Memory implication: each connection uses ~5-10MB of RAM
      ; At max_connections = 200: budget 1-2GB RAM for connection overhead alone
      ; Use pgBouncer to keep max_connections low (50-100) and serve thousands of clients
      
      ; Recommended approach for most web apps:
      ; App -> pgBouncer (transaction mode, max_client_conn=1000, pool_size=25)
      ;     -> PostgreSQL (max_connections=50)
      ```
      
      ### Monitoring Pool Health
      
      ```sql
      -- Alert conditions to monitor:
      -- 1. Connections near max_connections
      SELECT count(*) * 100.0 / current_setting('max_connections')::int AS pct_used
      FROM pg_stat_activity;
      
      -- 2. Idle-in-transaction accumulating (connection leak or slow clients)
      SELECT count(*)
      FROM pg_stat_activity
      WHERE state = 'idle in transaction'
        AND now() - state_change > interval '5 minutes';
      
      -- 3. Connection wait (pgBouncer cl_waiting > 0 sustained = under-provisioned pool)
      
      -- Set timeouts to prevent connection leaks:
      -- ALTER ROLE myapp SET idle_in_transaction_session_timeout = '5min';
      -- ALTER ROLE myapp SET statement_timeout = '30s';
      ```
      
    • query-tuning.md 21.7 KB
      # PostgreSQL Query Tuning Reference
      
      ## Table of Contents
      
      1. [EXPLAIN Output Reference](#explain-output-reference)
      2. [Plan Node Reference](#plan-node-reference)
      3. [pg_stat_statements](#pg_stat_statements)
      4. [Common Optimization Patterns](#common-optimization-patterns)
      5. [Parallel Query](#parallel-query)
      6. [Statistics and Planner](#statistics-and-planner)
      
      ---
      
      ## EXPLAIN Output Reference
      
      ### Format Options
      
      ```sql
      -- Default text format (human readable)
      EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
      
      -- With actual execution stats (runs the query)
      EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE customer_id = 42;
      
      -- Full verbose output with all options
      EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT) SELECT * FROM orders WHERE customer_id = 42;
      
      -- JSON format for programmatic parsing
      EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT * FROM orders WHERE customer_id = 42;
      
      -- YAML format
      EXPLAIN (ANALYZE, BUFFERS, FORMAT YAML) SELECT * FROM orders WHERE customer_id = 42;
      ```
      
      ### Key Fields Decoded
      
      ```
      Seq Scan on orders  (cost=0.00..4821.00 rows=1000 width=64)
                                ^      ^       ^         ^
                                |      |       |         estimated avg row width (bytes)
                                |      |       estimated output rows
                                |      total cost (return last row)
                                startup cost (return first row)
      
      (actual time=0.042..18.340 rows=987 loops=1)
                    ^       ^     ^        ^
                    |       |     |        number of times node executed
                    |       |     actual rows returned
                    |       actual time to return last row (ms)
                    actual time to return first row (ms)
      ```
      
      **Startup cost vs total cost**: An index scan on a large table may have a high startup cost
      (building the bitmap) but low total cost per row. Nested loops favor low startup cost.
      A sort node has startup cost = full sort cost because no rows are returned until sorted.
      
      **Loops**: When a node has `loops=N`, the `actual time` is per-loop average and `actual rows`
      is per-loop average. Multiply by loops to get totals. This matters for nested loop inners.
      
      ```sql
      -- Identify row estimate errors (poor estimates = bad plans)
      -- Look for large divergence between "rows=X" and "actual rows=Y"
      -- A 10x+ difference warrants investigation via ANALYZE or statistics adjustments
      ```
      
      ### Buffer Information
      
      ```
      Buffers: shared hit=1024 read=256 dirtied=10 written=5
                ^              ^         ^           ^
                |              |         |           pages written to disk
                |              |         pages modified during query
                |              pages read from disk (cache miss)
                pages served from shared_buffers (cache hit)
      ```
      
      Cache hit ratio for a single query:
      - `hit / (hit + read)` -- aim for > 0.99 in OLTP workloads
      
      ### Reading Execution Time
      
      ```sql
      -- Planning time vs execution time appear at bottom of EXPLAIN ANALYZE output
      -- Planning Time: 1.234 ms
      -- Execution Time: 45.678 ms
      -- High planning time relative to execution suggests query plan caching issues
      -- or extremely complex queries with many joins
      ```
      
      ---
      
      ## Plan Node Reference
      
      ### Scan Types
      
      **Sequential Scan** -- reads entire table from disk in order.
      Chosen when: selectivity is high (returning large fraction of rows), no suitable index,
      small table fits in a few pages, or planner estimates index overhead exceeds benefit.
      
      ```sql
      -- Force/prevent seq scan for testing (session level)
      SET enable_seqscan = off;   -- discourages seq scan
      SET enable_seqscan = on;    -- restore default
      ```
      
      **Index Scan** -- follows index B-tree to find heap row pointers, fetches each heap page.
      Chosen when: high selectivity (few rows), index covers filter column, ORDER BY matches index.
      Drawback: random I/O on heap. Can be slower than seq scan on spinning disk for > ~5% of table.
      
      **Index Only Scan** -- satisfies query entirely from index, no heap fetch (if visibility map allows).
      Requires: all SELECT and WHERE columns in the index. Needs up-to-date visibility map (regular VACUUM).
      
      ```sql
      -- Check if index only scan is blocked by visibility map
      SELECT relname, n_dead_tup, last_vacuum, last_autovacuum
      FROM pg_stat_user_tables
      WHERE relname = 'orders';
      
      -- Create covering index to enable index only scan
      CREATE INDEX idx_orders_covering ON orders (customer_id) INCLUDE (total, status, created_at);
      ```
      
      **Bitmap Index Scan + Bitmap Heap Scan** -- builds bitmap of matching pages in memory,
      then fetches those pages in order (reduces random I/O vs plain Index Scan for moderate selectivity).
      Two-phase: BitmapIndexScan builds the bitmap, BitmapHeapScan fetches heap pages.
      
      ```sql
      -- Bitmap scans combine multiple indexes via BitmapAnd / BitmapOr
      -- Useful when query has multiple filter conditions each with their own index
      EXPLAIN SELECT * FROM orders WHERE status = 'pending' AND region = 'EU';
      -- May show: BitmapAnd -> BitmapIndexScan on idx_status + BitmapIndexScan on idx_region
      ```
      
      ### Join Types
      
      **Nested Loop** -- for each outer row, scan inner relation.
      Cost: O(outer_rows * inner_scan_cost). Best when outer is small and inner lookup is fast (indexed).
      Chosen when: outer result set is small, inner has index on join column.
      
      ```sql
      -- Nested loop is ideal for:
      -- SELECT * FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.id = 99;
      -- (single order -> single customer lookup via PK)
      
      SET enable_nestloop = off;  -- force alternative join type for testing
      ```
      
      **Hash Join** -- build hash table from smaller relation, probe with larger.
      Cost: O(build + probe). Best for large unsorted relations with no useful index on join key.
      Chosen when: joining large tables, no index on join columns, equality join only.
      Memory: controlled by `work_mem`. If hash table exceeds work_mem, spills to disk (batch mode).
      
      ```sql
      -- Check for hash join disk spills in EXPLAIN ANALYZE
      -- Batches: 4 means spilled to disk in 4 batches -- increase work_mem to fix
      -- Hash Batches: 1 is ideal (all in memory)
      SET work_mem = '256MB';  -- session level for large analytical queries
      ```
      
      **Merge Join** -- sort both relations on join key, merge in order.
      Cost: O(N log N + M log M) for sorting. Best when inputs are already sorted (index).
      Chosen when: both sides are large, inputs already sorted, range or equality join.
      
      ```sql
      SET enable_hashjoin = off;
      SET enable_mergejoin = off;
      -- Use sparingly in production; better to fix the cause (add index, fix statistics)
      ```
      
      ### Aggregation
      
      **HashAggregate** -- builds hash table of group keys, accumulates aggregates.
      Chosen for: unsorted input, many distinct groups. Memory: bounded by `work_mem`.
      When it exceeds work_mem, spills to disk (check `Disk: XkB` in EXPLAIN ANALYZE output).
      
      **GroupAggregate** -- streams sorted input, emits group when key changes.
      Chosen when: input already sorted on GROUP BY columns (index), or few distinct groups.
      Zero memory overhead but requires sorted input.
      
      ```sql
      -- Force sorted approach by ensuring index on GROUP BY columns
      CREATE INDEX idx_orders_customer ON orders (customer_id, created_at);
      -- Now GROUP BY customer_id may use GroupAggregate instead of HashAggregate
      ```
      
      ### Sort Operations
      
      ```sql
      EXPLAIN ANALYZE SELECT * FROM orders ORDER BY created_at DESC LIMIT 100;
      
      -- In-memory sort: Sort Method: quicksort  Memory: 2048kB
      -- Disk sort:      Sort Method: external merge  Disk: 512000kB  -- bad, increase work_mem
      
      -- Top-N Heapsort: Sort Method: top-N heapsort  Memory: 64kB  -- efficient for LIMIT
      -- Top-N heapsort is optimal for ORDER BY ... LIMIT N patterns
      ```
      
      ---
      
      ## pg_stat_statements
      
      ### Setup
      
      ```sql
      -- postgresql.conf (requires restart)
      shared_preload_libraries = 'pg_stat_statements'
      pg_stat_statements.max = 10000          -- number of query fingerprints tracked
      pg_stat_statements.track = all          -- top|all|none (all includes nested queries)
      pg_stat_statements.track_utility = on   -- track COPY, CREATE TABLE, etc.
      
      -- After restart, create extension in each database you want to monitor
      CREATE EXTENSION pg_stat_statements;
      ```
      
      ### Key Columns (PostgreSQL 14+)
      
      ```sql
      SELECT
          queryid,                          -- internal hash identifier
          query,                            -- normalized query text (params replaced with $1, $2)
          calls,                            -- number of times executed
          total_exec_time,                  -- total execution time (ms)
          mean_exec_time,                   -- avg execution time (ms)
          stddev_exec_time,                 -- std deviation (high = inconsistent)
          min_exec_time,
          max_exec_time,
          rows,                             -- total rows returned/affected
          shared_blks_hit,                  -- buffer cache hits
          shared_blks_read,                 -- disk reads
          shared_blks_dirtied,
          shared_blks_written,
          temp_blks_read,                   -- temp file reads (work_mem overflow)
          temp_blks_written,
          wal_bytes,                        -- WAL generated (high = write-heavy)
          toplevel                          -- true if called at top level (PG14+)
      FROM pg_stat_statements;
      ```
      
      ### Finding Problem Queries
      
      ```sql
      -- Top 10 queries by total time (cumulative load on server)
      SELECT
          round(total_exec_time::numeric, 2) AS total_ms,
          calls,
          round(mean_exec_time::numeric, 2) AS mean_ms,
          round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 2) AS pct_total,
          left(query, 80) AS query_snippet
      FROM pg_stat_statements
      ORDER BY total_exec_time DESC
      LIMIT 10;
      
      -- Top 10 by mean execution time (slowest individual queries)
      SELECT
          calls,
          round(mean_exec_time::numeric, 2) AS mean_ms,
          round(stddev_exec_time::numeric, 2) AS stddev_ms,
          round(max_exec_time::numeric, 2) AS max_ms,
          left(query, 80) AS query_snippet
      FROM pg_stat_statements
      WHERE calls > 10                   -- ignore rarely-run queries
      ORDER BY mean_exec_time DESC
      LIMIT 10;
      
      -- Queries with worst cache hit ratio (I/O bound candidates)
      SELECT
          calls,
          round(mean_exec_time::numeric, 2) AS mean_ms,
          shared_blks_hit + shared_blks_read AS total_blks,
          round(
              100.0 * shared_blks_hit / nullif(shared_blks_hit + shared_blks_read, 0),
              2
          ) AS hit_pct,
          left(query, 80) AS query_snippet
      FROM pg_stat_statements
      WHERE shared_blks_hit + shared_blks_read > 1000
      ORDER BY hit_pct ASC
      LIMIT 10;
      
      -- Queries generating most temp files (work_mem too low or bad query)
      SELECT
          calls,
          temp_blks_written,
          round(mean_exec_time::numeric, 2) AS mean_ms,
          left(query, 80) AS query_snippet
      FROM pg_stat_statements
      WHERE temp_blks_written > 0
      ORDER BY temp_blks_written DESC
      LIMIT 10;
      ```
      
      ### Reset Strategy
      
      ```sql
      -- Reset stats for all queries (do after tuning to get fresh baseline)
      SELECT pg_stat_statements_reset();
      
      -- Reset stats for specific query (PG12+ by queryid)
      SELECT pg_stat_statements_reset(userid, dbid, queryid)
      FROM pg_stat_statements
      WHERE query LIKE '%orders%'
      LIMIT 1;
      ```
      
      ---
      
      ## Common Optimization Patterns
      
      ### CTE Materialization (PostgreSQL 12+)
      
      ```sql
      -- Pre-PG12: CTEs were always materialized (optimization fence)
      -- PG12+: planner decides, but you can force behavior
      
      -- MATERIALIZED: always execute CTE once and cache result
      -- Use when: CTE is expensive but referenced multiple times
      WITH expensive_agg AS MATERIALIZED (
          SELECT customer_id, sum(total) AS lifetime_value
          FROM orders
          GROUP BY customer_id
      )
      SELECT c.name, e.lifetime_value
      FROM customers c
      JOIN expensive_agg e ON e.customer_id = c.id;
      
      -- NOT MATERIALIZED: inline the CTE (allow planner to push predicates in)
      -- Use when: CTE is referenced once, or predicate pushdown is important
      WITH recent_orders AS NOT MATERIALIZED (
          SELECT * FROM orders WHERE status = 'complete'
      )
      SELECT * FROM recent_orders WHERE customer_id = 42;
      -- Planner can now push "customer_id = 42" into the subquery and use an index
      ```
      
      ### EXISTS vs IN vs JOIN
      
      ```sql
      -- EXISTS: short-circuits on first match, good for correlated checks
      -- Best when: checking existence only, inner side can be large
      SELECT c.id, c.name
      FROM customers c
      WHERE EXISTS (
          SELECT 1 FROM orders o WHERE o.customer_id = c.id AND o.status = 'pending'
      );
      
      -- IN with subquery: similar to EXISTS in modern PG (planner converts to semi-join)
      -- Bad when: subquery returns NULLs (IN with NULLs behaves unexpectedly)
      SELECT c.id, c.name
      FROM customers c
      WHERE c.id IN (SELECT customer_id FROM orders WHERE status = 'pending');
      
      -- JOIN (semi-join via DISTINCT): explicit, predictable
      -- Needed when: you want columns from both sides, or deduplication matters
      SELECT DISTINCT c.id, c.name
      FROM customers c
      JOIN orders o ON o.customer_id = c.id AND o.status = 'pending';
      
      -- NOT IN danger with NULLs: returns zero rows if subquery has any NULL
      -- Always use NOT EXISTS for negation checks
      SELECT * FROM customers WHERE id NOT IN (SELECT customer_id FROM orders);
      -- If ANY customer_id in orders is NULL, returns no rows!
      -- Use instead:
      SELECT * FROM customers c
      WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
      ```
      
      ### Lateral Join vs Subquery
      
      ```sql
      -- LATERAL: allows subquery to reference columns from preceding FROM items
      -- Useful for: top-N per group, correlated row-limited subqueries
      
      -- Top 3 orders per customer (lateral is clean and indexed)
      SELECT c.name, o.id, o.total
      FROM customers c
      CROSS JOIN LATERAL (
          SELECT id, total
          FROM orders
          WHERE customer_id = c.id
          ORDER BY total DESC
          LIMIT 3
      ) o;
      
      -- Equivalent window function approach (often similar performance)
      SELECT name, order_id, total
      FROM (
          SELECT c.name, o.id AS order_id, o.total,
                 row_number() OVER (PARTITION BY c.id ORDER BY o.total DESC) AS rn
          FROM customers c
          JOIN orders o ON o.customer_id = c.id
      ) ranked
      WHERE rn <= 3;
      ```
      
      ### Pagination: OFFSET vs Keyset
      
      ```sql
      -- OFFSET pagination: simple but degrades at high page numbers
      -- At page 1000 with LIMIT 20, PostgreSQL fetches 20020 rows and discards 20000
      SELECT id, name, created_at FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 20000;
      
      -- Keyset (cursor) pagination: O(1) regardless of page depth
      -- Requires: sorting by unique+indexed column(s), no arbitrary page jumping
      -- After receiving last row of previous page with (created_at='2024-01-15', id=9876):
      SELECT id, name, created_at
      FROM orders
      WHERE (created_at, id) < ('2024-01-15', 9876)  -- uses row comparison
      ORDER BY created_at DESC, id DESC
      LIMIT 20;
      
      -- Index to support keyset:
      CREATE INDEX idx_orders_keyset ON orders (created_at DESC, id DESC);
      ```
      
      ### DISTINCT ON vs Window Function Deduplication
      
      ```sql
      -- DISTINCT ON: PostgreSQL extension, returns first row per group (by ORDER BY)
      -- Fast, single pass, leverages index on distinct columns
      SELECT DISTINCT ON (customer_id)
          customer_id, id AS order_id, total, created_at
      FROM orders
      ORDER BY customer_id, created_at DESC;   -- gets most recent order per customer
      
      -- Create index to support: (customer_id, created_at DESC)
      CREATE INDEX idx_orders_latest ON orders (customer_id, created_at DESC);
      
      -- Window function equivalent (more portable, more flexible)
      SELECT customer_id, order_id, total, created_at
      FROM (
          SELECT customer_id, id AS order_id, total, created_at,
                 row_number() OVER (PARTITION BY customer_id ORDER BY created_at DESC) AS rn
          FROM orders
      ) t
      WHERE rn = 1;
      ```
      
      ### Bulk Operations
      
      ```sql
      -- COPY is fastest for bulk insert (bypasses most overhead)
      -- From file:
      COPY orders (customer_id, total, status) FROM '/tmp/orders.csv' WITH (FORMAT csv, HEADER);
      
      -- From stdin (psql):
      \COPY orders (customer_id, total, status) FROM 'orders.csv' CSV HEADER
      
      -- unnest trick for bulk insert from application (avoids N round trips)
      -- Send arrays of values, unnest server-side
      INSERT INTO orders (customer_id, total, status)
      SELECT * FROM unnest(
          ARRAY[1, 2, 3],              -- customer_ids
          ARRAY[100.00, 200.00, 50.00], -- totals
          ARRAY['pending', 'complete', 'pending']::text[]
      ) AS t(customer_id, total, status);
      
      -- For very large bulk loads, disable indexes and re-add after:
      ALTER TABLE orders DISABLE TRIGGER ALL;
      -- ... COPY ...
      ALTER TABLE orders ENABLE TRIGGER ALL;
      -- Or: drop indexes, load, recreate (faster than incremental index updates)
      
      -- Batch INSERT with ON CONFLICT (UPSERT)
      INSERT INTO order_status_log (order_id, status, updated_at)
      VALUES (1, 'shipped', now()), (2, 'delivered', now())
      ON CONFLICT (order_id) DO UPDATE
          SET status = EXCLUDED.status,
              updated_at = EXCLUDED.updated_at;
      ```
      
      ---
      
      ## Parallel Query
      
      ### Configuration Settings
      
      ```sql
      -- Key settings (postgresql.conf or ALTER SYSTEM)
      max_parallel_workers_per_gather = 4      -- max workers per Gather node (default: 2)
      max_parallel_workers = 8                  -- total parallel workers across all queries
      max_worker_processes = 16                 -- total background workers (includes parallel)
      min_parallel_table_scan_size = '8MB'     -- table must be > this for parallel seq scan
      min_parallel_index_scan_size = '512kB'   -- index must be > this for parallel index scan
      parallel_tuple_cost = 0.1                -- cost of passing tuple between workers
      parallel_setup_cost = 1000               -- overhead of launching workers
      ```
      
      ### When Parallel Query Engages
      
      ```sql
      -- Parallel is chosen when: large table, high work_mem not limiting, no write operations
      -- Check if parallel is being used:
      EXPLAIN SELECT count(*), avg(total) FROM orders;
      -- Should show: Gather -> Partial Aggregate -> Parallel Seq Scan on orders
      
      -- Force parallel for testing (lower thresholds):
      SET min_parallel_table_scan_size = 0;
      SET parallel_setup_cost = 0;
      SET max_parallel_workers_per_gather = 4;
      ```
      
      ### When Parallel Does NOT Kick In
      
      - Queries that write (INSERT, UPDATE, DELETE, MERGE)
      - Queries inside functions marked `PARALLEL UNSAFE` (default for user functions)
      - Queries using cursors (`DECLARE ... CURSOR FOR`)
      - Queries called from another parallel worker
      - When `max_parallel_workers_per_gather = 0`
      - When `LIMIT` is small relative to table size (planner avoids parallel startup cost)
      
      ```sql
      -- Mark functions parallel safe to allow parallel plans that call them
      CREATE OR REPLACE FUNCTION calculate_discount(total numeric) RETURNS numeric
      LANGUAGE sql
      PARALLEL SAFE    -- only if function has no side effects and is truly safe
      AS $$
          SELECT total * 0.9;
      $$;
      ```
      
      ---
      
      ## Statistics and Planner
      
      ### Column Statistics
      
      ```sql
      -- Default statistics target is 100 (samples ~30000 rows per column)
      -- Increase for columns with many distinct values or skewed distributions
      
      -- Check current statistics targets
      SELECT attname, attstattarget
      FROM pg_attribute
      WHERE attrelid = 'orders'::regclass AND attnum > 0;
      
      -- Increase statistics for a specific column
      ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;
      ANALYZE orders;  -- must re-run ANALYZE to collect new statistics
      
      -- Check what the planner knows about a column
      SELECT * FROM pg_stats
      WHERE tablename = 'orders' AND attname = 'status';
      -- Key fields: n_distinct, most_common_vals, most_common_freqs, histogram_bounds
      ```
      
      ### Extended Statistics
      
      ```sql
      -- When two columns are correlated, single-column stats mislead the planner
      -- Example: city and zip_code are correlated; planner underestimates after filtering both
      
      -- Create extended statistics to capture column correlations
      CREATE STATISTICS orders_region_status_stats (dependencies, ndistinct)
          ON region, status FROM orders;
      
      ANALYZE orders;
      
      -- Check extended statistics
      SELECT * FROM pg_statistic_ext;
      SELECT * FROM pg_statistic_ext_data;
      
      -- MCV (most common values) extended statistics
      CREATE STATISTICS orders_mcv ON region, status FROM orders
          WITH (kind = mcv);
      ANALYZE orders;
      ```
      
      ### n_distinct Overrides
      
      ```sql
      -- When planner guesses wrong number of distinct values
      -- Positive value = exact count, negative = fraction of total rows
      
      -- Tell planner there are exactly 50 distinct statuses
      ALTER TABLE orders ALTER COLUMN status SET (n_distinct = 50);
      
      -- Tell planner distinct count is 10% of table rows
      ALTER TABLE orders ALTER COLUMN customer_id SET (n_distinct = -0.1);
      
      ANALYZE orders;  -- re-analyze to apply
      ```
      
      ### pg_hint_plan (Last Resort)
      
      ```sql
      -- Install pg_hint_plan extension (not in core, must compile or use package)
      -- Use only when statistics fixes and index changes are insufficient
      
      -- Hints are embedded in comments before the query
      /*+ SeqScan(orders) */ SELECT * FROM orders WHERE status = 'pending';
      
      /*+ IndexScan(orders idx_orders_status) */ SELECT * FROM orders WHERE status = 'pending';
      
      /*+ HashJoin(orders customers) Leading(orders customers) */
      SELECT * FROM orders o JOIN customers c ON c.id = o.customer_id;
      
      -- Available hint types:
      -- Scan: SeqScan, IndexScan, IndexOnlyScan, BitmapScan, NoSeqScan, NoIndexScan
      -- Join: NestLoop, HashJoin, MergeJoin, NoNestLoop, NoHashJoin, NoMergeJoin
      -- Join order: Leading(table1 table2 table3)
      -- Parallel: Parallel(table N)  -- N = number of workers
      
      -- Always document WHY a hint is needed and create a ticket to fix root cause
      -- Hints become stale as data grows and can cause regressions after schema changes
      ```
      
      ### Diagnosing Estimate vs Actual Divergence
      
      ```sql
      -- Large divergence between estimated and actual rows is the #1 cause of bad plans
      -- Use this query pattern to identify problem queries via pg_stat_statements + EXPLAIN
      
      -- Step 1: find high-variance queries in pg_stat_statements
      -- Step 2: run EXPLAIN ANALYZE and look for nodes where rows estimate is off by 10x+
      -- Step 3: check pg_stats for the filtered columns
      
      -- Example: orders table filtered on two correlated columns
      EXPLAIN (ANALYZE, FORMAT JSON)
      SELECT * FROM orders WHERE region = 'US' AND status = 'pending';
      
      -- If estimated rows = 10 but actual rows = 50000, investigate:
      SELECT n_distinct, most_common_vals, most_common_freqs
      FROM pg_stats
      WHERE tablename = 'orders' AND attname IN ('region', 'status');
      
      -- Fix options in priority order:
      -- 1. ANALYZE (if stats are stale)
      -- 2. Increase statistics target: ALTER TABLE ... ALTER COLUMN ... SET STATISTICS 500
      -- 3. Create extended statistics for correlated columns
      -- 4. Rewrite query to give planner better information
      -- 5. pg_hint_plan as absolute last resort
      ```
      
    • replication.md 16.9 KB
      # PostgreSQL Replication, Partitioning & FDW Reference
      
      ## Table of Contents
      
      1. [Streaming Replication](#streaming-replication)
         - Primary Configuration
         - Replica Configuration
         - Synchronous vs Asynchronous
         - Monitoring Replication
         - Replication Slots
      2. [Logical Replication](#logical-replication)
         - Publications
         - Subscriptions
         - Row Filters and Column Lists (PG15+)
         - Use Cases and Limitations
      3. [Failover](#failover)
         - Promoting a Standby
         - Timeline Switches
         - Connection Routing
      4. [Table Partitioning](#table-partitioning)
         - RANGE Partitioning
         - LIST Partitioning
         - HASH Partitioning
         - Sub-partitioning
         - Partition Maintenance
         - When to Partition
      5. [Foreign Data Wrappers](#foreign-data-wrappers)
         - postgres_fdw Setup
         - IMPORT FOREIGN SCHEMA
         - Performance and Pushdown
      
      ---
      
      ## Streaming Replication
      
      ### Primary Configuration
      
      Edit `postgresql.conf` on the primary:
      
      ```ini
      # Minimum required for streaming replication
      wal_level = replica          # or 'logical' if you also need logical replication
      max_wal_senders = 10         # number of concurrent standby connections
      wal_keep_size = 1GB          # retain WAL to prevent standby falling behind
                                   # prefer replication slots over this setting
      
      # Optional but recommended
      hot_standby_feedback = on    # prevents primary from vacuuming rows standby needs
      ```
      
      Create a replication role on the primary:
      
      ```sql
      CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'secret';
      ```
      
      Allow the standby in `pg_hba.conf` on the primary:
      
      ```
      # TYPE  DATABASE        USER         ADDRESS          METHOD
      host    replication     replicator   192.168.1.0/24   scram-sha-256
      ```
      
      Reload after editing `pg_hba.conf`:
      
      ```sql
      SELECT pg_reload_conf();
      ```
      
      ### Replica Configuration
      
      Take a base backup from the primary (run on standby host):
      
      ```bash
      pg_basebackup \
        --host=primary-host \
        --username=replicator \
        --pgdata=/var/lib/postgresql/data \
        --wal-method=stream \
        --checkpoint=fast \
        --progress
      ```
      
      Create `postgresql.conf` overrides or `postgresql.auto.conf` on the replica:
      
      ```ini
      primary_conninfo = 'host=primary-host port=5432 user=replicator password=secret'
      primary_slot_name = 'replica1_slot'   # if using replication slots
      hot_standby = on                       # allow read queries on replica
      recovery_min_apply_delay = 0           # set to e.g. '30min' for delayed replica
      ```
      
      Create the standby signal file (PG12+):
      
      ```bash
      touch /var/lib/postgresql/data/standby.signal
      ```
      
      ### Synchronous vs Asynchronous Replication
      
      **Asynchronous** (default): primary commits without waiting for standby. Risk of data loss on primary failure equal to replication lag.
      
      **Synchronous**: primary waits for at least one standby to confirm WAL receipt before returning to client.
      
      ```ini
      # On primary postgresql.conf
      synchronous_standby_names = 'replica1'
      # or for ANY 1 of multiple standbys:
      synchronous_standby_names = 'ANY 1 (replica1, replica2, replica3)'
      # or require ALL listed:
      synchronous_standby_names = 'FIRST 2 (replica1, replica2, replica3)'
      ```
      
      Standby names come from the `application_name` in `primary_conninfo`:
      
      ```ini
      primary_conninfo = 'host=primary port=5432 user=replicator application_name=replica1'
      ```
      
      Trade-offs:
      
      | Mode | Durability | Write Latency | Throughput |
      |------|-----------|---------------|------------|
      | Async | Data loss possible | Low | Highest |
      | Sync (remote_write) | WAL received, not flushed | Medium | High |
      | Sync (on) | WAL flushed to disk | Higher | Lower |
      | Sync (remote_apply) | Changes applied | Highest | Lowest |
      
      ```ini
      # Control sync level (default is 'on' = flush to standby disk)
      synchronous_commit = remote_write   # faster, slight durability trade-off
      ```
      
      ### Monitoring Replication
      
      On the primary, query `pg_stat_replication`:
      
      ```sql
      SELECT
          application_name,
          client_addr,
          state,                          -- startup, catchup, streaming
          sync_state,                     -- async, sync, potential
          sent_lsn,
          write_lsn,
          flush_lsn,
          replay_lsn,
          -- Replication lag in bytes
          (sent_lsn - replay_lsn) AS replay_lag_bytes,
          -- Replication lag in time (PG10+)
          write_lag,
          flush_lag,
          replay_lag
      FROM pg_stat_replication;
      ```
      
      On the replica, check if it is in recovery and its LSN position:
      
      ```sql
      SELECT
          pg_is_in_recovery(),
          pg_last_wal_receive_lsn(),
          pg_last_wal_replay_lsn(),
          pg_last_xact_replay_timestamp(),
          -- Time lag (approximate)
          now() - pg_last_xact_replay_timestamp() AS replication_delay;
      ```
      
      Alert when lag exceeds threshold:
      
      ```sql
      -- Alert if replay lag > 30 seconds
      SELECT application_name, replay_lag
      FROM pg_stat_replication
      WHERE replay_lag > interval '30 seconds';
      ```
      
      ### Replication Slots
      
      Replication slots prevent the primary from removing WAL segments needed by a standby, eliminating the need for `wal_keep_size` tuning. The risk is unbounded WAL accumulation if a slot is abandoned.
      
      Create a physical slot on the primary:
      
      ```sql
      SELECT pg_create_physical_replication_slot('replica1_slot');
      ```
      
      List all slots and check for lag:
      
      ```sql
      SELECT
          slot_name,
          slot_type,
          active,
          restart_lsn,
          confirmed_flush_lsn,
          -- WAL retained by this slot in bytes
          pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS retained_bytes,
          pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_size
      FROM pg_replication_slots;
      ```
      
      Drop an abandoned slot to reclaim disk:
      
      ```sql
      SELECT pg_drop_replication_slot('replica1_slot');
      ```
      
      Set a safety limit to prevent disk exhaustion (PG13+):
      
      ```ini
      max_slot_wal_keep_size = 10GB   # drop slot if WAL retention exceeds this
      ```
      
      ---
      
      ## Logical Replication
      
      Logical replication decodes WAL into row-level change streams. It allows selective table sync and works across major versions.
      
      ### Publications
      
      A publication defines what changes to export:
      
      ```sql
      -- All tables, all operations
      CREATE PUBLICATION pub_all FOR ALL TABLES;
      
      -- Specific tables
      CREATE PUBLICATION pub_orders FOR TABLE orders, order_items;
      
      -- Specific operations only
      CREATE PUBLICATION pub_inserts FOR TABLE events WITH (publish = 'insert');
      
      -- With row filter (PG15+): only published rows matching WHERE
      CREATE PUBLICATION pub_active_orders FOR TABLE orders
          WHERE (status != 'cancelled');
      
      -- With column list (PG15+): only publish selected columns
      CREATE PUBLICATION pub_orders_summary FOR TABLE orders (id, status, total, created_at);
      ```
      
      Manage publications:
      
      ```sql
      ALTER PUBLICATION pub_orders ADD TABLE shipments;
      ALTER PUBLICATION pub_orders DROP TABLE order_items;
      DROP PUBLICATION pub_orders;
      
      -- Inspect
      SELECT * FROM pg_publication;
      SELECT * FROM pg_publication_tables;
      ```
      
      The publisher must have `wal_level = logical`:
      
      ```ini
      wal_level = logical
      max_replication_slots = 10
      max_wal_senders = 10
      ```
      
      ### Subscriptions
      
      On the subscriber database:
      
      ```sql
      CREATE SUBSCRIPTION sub_orders
          CONNECTION 'host=primary-host dbname=mydb user=replicator password=secret'
          PUBLICATION pub_orders;
      ```
      
      The subscriber creates a replication slot on the publisher automatically. The target tables must already exist with compatible schemas.
      
      ```sql
      -- Disable/re-enable a subscription
      ALTER SUBSCRIPTION sub_orders DISABLE;
      ALTER SUBSCRIPTION sub_orders ENABLE;
      
      -- Refresh after publisher adds tables
      ALTER SUBSCRIPTION sub_orders REFRESH PUBLICATION;
      
      -- Skip copying initial data (for ongoing sync only)
      CREATE SUBSCRIPTION sub_orders
          CONNECTION '...'
          PUBLICATION pub_orders
          WITH (copy_data = false);
      
      -- Drop subscription (also drops remote slot)
      DROP SUBSCRIPTION sub_orders;
      ```
      
      Monitor subscriptions:
      
      ```sql
      -- On subscriber
      SELECT * FROM pg_stat_subscription;
      
      -- On publisher - logical slots
      SELECT slot_name, active, confirmed_flush_lsn
      FROM pg_replication_slots
      WHERE slot_type = 'logical';
      ```
      
      ### Limitations of Logical Replication
      
      - DDL changes are not replicated. Schema changes must be applied manually to subscribers before altering the publisher.
      - Sequences are not replicated. After failover, reset sequences on the new primary.
      - Large objects (`pg_largeobject`) are not replicated.
      - Conflict resolution is basic: by default, subscriber errors on unique constraint conflicts. Use `ALTER SUBSCRIPTION ... SKIP` to advance past a conflict LSN.
      - Requires `REPLICA IDENTITY` on tables without primary keys:
      
      ```sql
      -- Full row image (slow, safe for tables without PK)
      ALTER TABLE events REPLICA IDENTITY FULL;
      
      -- Use a unique index as identity
      ALTER TABLE events REPLICA IDENTITY USING INDEX events_uuid_idx;
      ```
      
      ---
      
      ## Failover
      
      ### Promoting a Standby
      
      Trigger promotion using `pg_promote()` (PG12+, no file touch needed):
      
      ```sql
      -- Connect to the standby and run:
      SELECT pg_promote();
      ```
      
      Or use `pg_ctl`:
      
      ```bash
      pg_ctl promote -D /var/lib/postgresql/data
      ```
      
      After promotion, the former standby becomes a normal read-write primary. Update `primary_conninfo` on remaining standbys to point to the new primary and restart them.
      
      ### Timeline Switches
      
      Every promotion increments the timeline ID. PostgreSQL uses timelines to track branching histories, allowing standbys to follow the correct WAL history.
      
      ```sql
      -- Check current timeline on any server
      SELECT timeline_id FROM pg_control_checkpoint();
      
      -- View WAL segment filenames: first 8 hex chars = timeline
      -- 000000020000000000000001 = timeline 2, segment 1
      ```
      
      When a former primary comes back, configure it as a new standby using `recovery_target_timeline = 'latest'` (the default), which lets it follow the new timeline.
      
      ### Connection Routing
      
      **HAProxy** (layer 4, health-check based):
      
      ```
      frontend postgres_write
          bind *:5432
          default_backend postgres_primary
      
      backend postgres_primary
          option httpchk GET /primary  # Patroni health endpoint
          server pg1 192.168.1.1:5432 check port 8008
          server pg2 192.168.1.2:5432 check port 8008
      
      backend postgres_replica
          option httpchk GET /replica
          server pg1 192.168.1.1:5432 check port 8008
          server pg2 192.168.1.2:5432 check port 8008
      ```
      
      **PgBouncer** target switch: update `[databases]` section and reload:
      
      ```ini
      [databases]
      mydb = host=new-primary-ip port=5432 dbname=mydb
      ```
      
      ```bash
      psql -p 6432 pgbouncer -c "RELOAD"
      ```
      
      **DNS-based**: Update the DNS record for `pg-primary.internal` to point to the new primary's IP. Works well with short TTLs (30s) and application-level retry logic.
      
      ---
      
      ## Table Partitioning
      
      Declarative partitioning (PG10+) uses `PARTITION BY` on the parent table. The parent table itself holds no rows.
      
      ### RANGE Partitioning
      
      Most common for time-series and log data:
      
      ```sql
      CREATE TABLE orders (
          id          bigserial,
          created_at  timestamptz NOT NULL,
          customer_id bigint,
          total       numeric(12,2)
      ) PARTITION BY RANGE (created_at);
      
      -- Create partitions for each month
      CREATE TABLE orders_2024_01
          PARTITION OF orders
          FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
      
      CREATE TABLE orders_2024_02
          PARTITION OF orders
          FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
      
      -- Catch-all default partition
      CREATE TABLE orders_default
          PARTITION OF orders DEFAULT;
      ```
      
      ### LIST Partitioning
      
      Useful for discrete categorical values:
      
      ```sql
      CREATE TABLE products (
          id     bigserial,
          region text NOT NULL,
          name   text
      ) PARTITION BY LIST (region);
      
      CREATE TABLE products_us   PARTITION OF products FOR VALUES IN ('us', 'ca');
      CREATE TABLE products_eu   PARTITION OF products FOR VALUES IN ('de', 'fr', 'uk');
      CREATE TABLE products_apac PARTITION OF products FOR VALUES IN ('au', 'jp', 'sg');
      CREATE TABLE products_other PARTITION OF products DEFAULT;
      ```
      
      ### HASH Partitioning
      
      Distributes rows evenly when there is no natural range or list split:
      
      ```sql
      CREATE TABLE sessions (
          id      uuid NOT NULL,
          user_id bigint,
          data    jsonb
      ) PARTITION BY HASH (id);
      
      -- 8 partitions, modulus = total count, remainder = partition number
      CREATE TABLE sessions_0 PARTITION OF sessions FOR VALUES WITH (modulus 8, remainder 0);
      CREATE TABLE sessions_1 PARTITION OF sessions FOR VALUES WITH (modulus 8, remainder 1);
      -- ... through remainder 7
      ```
      
      ### Sub-partitioning
      
      Combine strategies: partition by month, then by region within each month:
      
      ```sql
      CREATE TABLE events (
          id         bigserial,
          created_at timestamptz NOT NULL,
          region     text NOT NULL
      ) PARTITION BY RANGE (created_at);
      
      CREATE TABLE events_2024_01
          PARTITION OF events
          FOR VALUES FROM ('2024-01-01') TO ('2024-02-01')
          PARTITION BY LIST (region);
      
      CREATE TABLE events_2024_01_us
          PARTITION OF events_2024_01
          FOR VALUES IN ('us');
      ```
      
      ### Partition Pruning
      
      The planner eliminates irrelevant partitions at plan time (static) or execution time (dynamic):
      
      ```sql
      -- Enable/disable for debugging
      SET enable_partition_pruning = on;  -- default on
      
      EXPLAIN SELECT * FROM orders WHERE created_at >= '2024-06-01' AND created_at < '2024-07-01';
      -- Should show only orders_2024_06 in the plan, not all partitions
      ```
      
      Each partition should have its own indexes. Indexes on the parent do not cascade automatically (they do in PG11+ for primary keys and unique constraints created on the parent):
      
      ```sql
      -- Create index on all existing partitions at once (PG11+ creates on parent + all children)
      CREATE INDEX ON orders (customer_id);
      ```
      
      ### Partition Maintenance
      
      ```sql
      -- Add a new partition (no locking on existing data)
      CREATE TABLE orders_2025_01
          PARTITION OF orders
          FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
      
      -- Detach a partition (it becomes a standalone table, no data movement)
      ALTER TABLE orders DETACH PARTITION orders_2023_01;
      -- PG14+: detach concurrently (non-blocking)
      ALTER TABLE orders DETACH PARTITION orders_2023_01 CONCURRENTLY;
      
      -- Drop old data instantly (no vacuum needed)
      DROP TABLE orders_2023_01;
      
      -- Attach an existing table as a partition (verify constraint first)
      ALTER TABLE orders_old ADD CONSTRAINT orders_old_check
          CHECK (created_at >= '2022-01-01' AND created_at < '2023-01-01');
      ALTER TABLE orders ATTACH PARTITION orders_old
          FOR VALUES FROM ('2022-01-01') TO ('2023-01-01');
      ```
      
      ### When to Partition
      
      Partition when:
      - Table exceeds ~100M rows or 100GB and queries frequently filter on the partition key
      - You need instant bulk deletes (drop a partition vs DELETE + VACUUM)
      - You want to spread data across tablespaces on different disks
      - Autovacuum cannot keep up with a single large table
      
      Do not partition just because a table is large. Partitioning adds overhead for queries that scan all partitions (no partition key filter). A well-indexed single table often outperforms a partitioned one for OLTP workloads.
      
      ---
      
      ## Foreign Data Wrappers
      
      FDWs allow PostgreSQL to query external data sources as if they were local tables.
      
      ### postgres_fdw Setup
      
      ```sql
      -- 1. Install extension
      CREATE EXTENSION postgres_fdw;
      
      -- 2. Define the remote server
      CREATE SERVER remote_analytics
          FOREIGN DATA WRAPPER postgres_fdw
          OPTIONS (
              host 'analytics-db.internal',
              port '5432',
              dbname 'analytics'
          );
      
      -- 3. Map local user to remote credentials
      CREATE USER MAPPING FOR current_user
          SERVER remote_analytics
          OPTIONS (user 'readonly_user', password 'secret');
      
      -- 4. Create individual foreign tables
      CREATE FOREIGN TABLE remote_events (
          id         bigint,
          event_type text,
          created_at timestamptz,
          payload    jsonb
      )
      SERVER remote_analytics
      OPTIONS (schema_name 'public', table_name 'events');
      ```
      
      ### IMPORT FOREIGN SCHEMA
      
      Import all (or selected) tables from a remote schema at once:
      
      ```sql
      -- Import entire remote schema
      IMPORT FOREIGN SCHEMA public
          FROM SERVER remote_analytics
          INTO local_remote_schema;
      
      -- Import only specific tables
      IMPORT FOREIGN SCHEMA public
          LIMIT TO (events, pageviews, sessions)
          FROM SERVER remote_analytics
          INTO local_remote_schema;
      
      -- Exclude specific tables
      IMPORT FOREIGN SCHEMA public
          EXCEPT (internal_audit_log)
          FROM SERVER remote_analytics
          INTO local_remote_schema;
      ```
      
      ### Performance and Pushdown
      
      postgres_fdw pushes WHERE clauses, ORDER BY, LIMIT, and aggregates to the remote server when possible, reducing data transfer.
      
      ```sql
      -- Check what gets pushed down with EXPLAIN VERBOSE
      EXPLAIN (VERBOSE, ANALYZE)
      SELECT event_type, count(*)
      FROM remote_events
      WHERE created_at > now() - interval '7 days'
      GROUP BY event_type;
      -- Look for "Remote SQL:" in the output
      ```
      
      Join pushdown (PG14+): joins between two foreign tables on the same server are pushed down to a single remote query:
      
      ```sql
      -- Both tables on same server -> single remote query
      SELECT e.event_type, s.user_id
      FROM remote_events e
      JOIN remote_sessions s ON e.session_id = s.id
      WHERE e.created_at > now() - interval '1 day';
      ```
      
      Control pushdown behavior per server:
      
      ```sql
      ALTER SERVER remote_analytics OPTIONS (
          use_remote_estimate 'true',   -- fetch remote row estimates for better plans
          fetch_size '10000'             -- rows fetched per round-trip (default 100)
      );
      ```
      
      Inspect all configured FDW objects:
      
      ```sql
      SELECT srvname, srvfdw, srvoptions FROM pg_foreign_server;
      SELECT * FROM pg_user_mappings;
      SELECT foreign_table_schema, foreign_table_name, foreign_server_name
      FROM information_schema.foreign_tables;
      ```
      
    • schema-design.md 20.1 KB
      # PostgreSQL Schema Design Reference
      
      ## Table of Contents
      
      1. [Normalization Quick Guide](#normalization-quick-guide)
      2. [Data Types Deep Dive](#data-types-deep-dive)
         - [JSONB](#jsonb)
         - [Arrays](#arrays)
         - [Range Types](#range-types)
         - [Composite Types](#composite-types)
         - [Domain Types](#domain-types)
      3. [Constraints](#constraints)
      4. [Generated Columns](#generated-columns)
      5. [Table Inheritance and Partitioning](#table-inheritance-and-partitioning)
      6. [Row-Level Security](#row-level-security)
      
      ---
      
      ## Normalization Quick Guide
      
      ### 1NF - First Normal Form
      Each column holds atomic values; no repeating groups; each row uniquely identified.
      
      ```sql
      -- Violates 1NF: phone_numbers is a comma-separated list
      CREATE TABLE contacts_bad (
          id      integer PRIMARY KEY,
          name    text,
          phones  text   -- "555-1234, 555-5678"
      );
      
      -- 1NF compliant: one phone per row
      CREATE TABLE contacts (
          id   integer PRIMARY KEY,
          name text NOT NULL
      );
      
      CREATE TABLE contact_phones (
          contact_id integer REFERENCES contacts(id),
          phone      text NOT NULL,
          PRIMARY KEY (contact_id, phone)
      );
      ```
      
      ### 2NF - Second Normal Form
      Must be 1NF. Every non-key column depends on the *entire* primary key (eliminates partial dependencies in composite-key tables).
      
      ```sql
      -- Violates 2NF: product_name depends only on product_id, not the full key
      CREATE TABLE order_items_bad (
          order_id     integer,
          product_id   integer,
          product_name text,    -- partial dependency
          quantity     integer,
          PRIMARY KEY (order_id, product_id)
      );
      
      -- 2NF compliant: move product_name to products table
      CREATE TABLE products (
          id   integer PRIMARY KEY,
          name text NOT NULL
      );
      
      CREATE TABLE order_items (
          order_id   integer,
          product_id integer REFERENCES products(id),
          quantity   integer NOT NULL,
          PRIMARY KEY (order_id, product_id)
      );
      ```
      
      ### 3NF - Third Normal Form
      Must be 2NF. No transitive dependencies (non-key columns depending on other non-key columns).
      
      ```sql
      -- Violates 3NF: zip_code -> city, zip_code -> state (transitive)
      CREATE TABLE employees_bad (
          id        integer PRIMARY KEY,
          name      text,
          zip_code  text,
          city      text,   -- depends on zip_code, not id
          state     text    -- depends on zip_code, not id
      );
      
      -- 3NF compliant
      CREATE TABLE zip_codes (
          zip   text PRIMARY KEY,
          city  text NOT NULL,
          state text NOT NULL
      );
      
      CREATE TABLE employees (
          id       integer PRIMARY KEY,
          name     text NOT NULL,
          zip_code text REFERENCES zip_codes(zip)
      );
      ```
      
      ### When to Denormalize
      
      Denormalization trades write complexity for read performance. Justify it with EXPLAIN ANALYZE evidence, not intuition.
      
      | Scenario | Denormalization Approach |
      |----------|--------------------------|
      | Frequent aggregate reads | Materialized view or stored summary column |
      | Immutable reference data | Embed directly (e.g., country name at order time) |
      | Hot join path with no writes | Redundant column with trigger to keep in sync |
      | Reporting / OLAP workload | Star schema, wide fact tables |
      
      ```sql
      -- Example: store calculated total on order to avoid summing line items every read
      ALTER TABLE orders ADD COLUMN total_cents integer NOT NULL DEFAULT 0;
      
      -- Keep in sync via trigger
      CREATE FUNCTION recalc_order_total() RETURNS trigger LANGUAGE plpgsql AS $$
      BEGIN
          UPDATE orders
          SET total_cents = (
              SELECT COALESCE(SUM(unit_price_cents * quantity), 0)
              FROM order_items
              WHERE order_id = COALESCE(NEW.order_id, OLD.order_id)
          )
          WHERE id = COALESCE(NEW.order_id, OLD.order_id);
          RETURN NEW;
      END;
      $$;
      
      CREATE TRIGGER trg_order_items_total
      AFTER INSERT OR UPDATE OR DELETE ON order_items
      FOR EACH ROW EXECUTE FUNCTION recalc_order_total();
      ```
      
      ---
      
      ## Data Types Deep Dive
      
      ### JSONB
      
      JSONB stores JSON as a binary decomposed format. Supports indexing; operators work directly on the stored value. Use `jsonb` over `json` unless you need to preserve key order or duplicate keys.
      
      #### Operators
      
      ```sql
      -- @>  containment: does left contain right?
      SELECT * FROM products WHERE attributes @> '{"color": "red"}';
      
      -- ->  extract field as jsonb
      SELECT data -> 'address' FROM users;
      
      -- ->> extract field as text
      SELECT data ->> 'email' FROM users;
      
      -- #>  extract at path as jsonb
      SELECT data #> '{address, city}' FROM users;
      
      -- #>> extract at path as text
      SELECT data #>> '{address, city}' FROM users;
      
      -- jsonb_path_query (SQL/JSON path, PG12+)
      SELECT jsonb_path_query(data, '$.orders[*].amount ? (@ > 100)') FROM users;
      
      -- jsonb_path_exists
      SELECT * FROM users WHERE jsonb_path_exists(data, '$.tags[*] ? (@ == "premium")');
      
      -- Modifying JSONB
      UPDATE users SET data = data || '{"verified": true}';         -- merge/overwrite key
      UPDATE users SET data = data - 'temp_field';                   -- remove key
      UPDATE users SET data = jsonb_set(data, '{address,zip}', '"90210"');
      ```
      
      #### Indexing JSONB
      
      ```sql
      -- GIN default: supports @>, ?, ?|, ?& on all keys and values
      CREATE INDEX idx_products_attrs ON products USING gin(attributes);
      
      -- GIN jsonb_path_ops: supports only @> but uses less space and is faster for containment
      CREATE INDEX idx_products_attrs_path ON products USING gin(attributes jsonb_path_ops);
      
      -- B-tree on extracted scalar: for equality/range on a known field
      CREATE INDEX idx_users_email ON users ((data ->> 'email'));
      
      -- B-tree on cast extracted value
      CREATE INDEX idx_orders_amount ON orders ((data ->> 'amount')::numeric);
      ```
      
      #### When to Use JSONB vs Relational Columns
      
      | Use JSONB When | Use Relational Columns When |
      |----------------|----------------------------|
      | Schema varies per row (EAV alternative) | Column is queried in WHERE, JOIN, or ORDER BY frequently |
      | Optional metadata with sparse keys | Column participates in foreign key |
      | Storing external API payloads as-is | Strong type enforcement required |
      | Prototyping before schema stabilizes | Aggregate functions (SUM, AVG) on the field |
      
      ---
      
      ### Arrays
      
      PostgreSQL native arrays allow storing multiple values of the same type in a single column.
      
      ```sql
      CREATE TABLE articles (
          id   integer PRIMARY KEY,
          tags text[]
      );
      
      INSERT INTO articles (id, tags) VALUES (1, ARRAY['postgres', 'sql', 'performance']);
      INSERT INTO articles (id, tags) VALUES (2, '{"nosql","databases"}');  -- literal syntax
      ```
      
      #### Operators
      
      ```sql
      -- ANY: value matches any element
      SELECT * FROM articles WHERE 'postgres' = ANY(tags);
      
      -- ALL: condition holds for every element
      SELECT * FROM articles WHERE 5 > ALL(ARRAY[1,2,3,4]);
      
      -- @>  contains (left contains right)
      SELECT * FROM articles WHERE tags @> ARRAY['sql', 'postgres'];
      
      -- <@  is contained by
      SELECT * FROM articles WHERE ARRAY['sql'] <@ tags;
      
      -- &&  overlap (share at least one element)
      SELECT * FROM articles WHERE tags && ARRAY['postgres', 'mysql'];
      
      -- Appending / removing
      UPDATE articles SET tags = tags || ARRAY['new-tag'] WHERE id = 1;
      UPDATE articles SET tags = array_remove(tags, 'old-tag') WHERE id = 1;
      
      -- Array length and access
      SELECT array_length(tags, 1), tags[1] FROM articles;  -- 1-indexed
      ```
      
      #### Indexing Arrays
      
      ```sql
      -- GIN index for @>, <@, &&, ANY equality
      CREATE INDEX idx_articles_tags ON articles USING gin(tags);
      ```
      
      #### Arrays vs Junction Tables
      
      | Use Arrays When | Use Junction Tables When |
      |-----------------|--------------------------|
      | List is small and bounded | Elements have their own attributes |
      | No referential integrity needed | Many-to-many with query filters on the joined entity |
      | Queries use containment/overlap operators | Need to query "all articles for a tag" efficiently |
      | Ordering within the list matters | Cardinality is high or unbounded |
      
      ---
      
      ### Range Types
      
      Range types represent a range of values of a base type. Built-in range types: `int4range`, `int8range`, `numrange`, `tsrange`, `tstzrange`, `daterange`.
      
      ```sql
      CREATE TABLE room_bookings (
          id          serial PRIMARY KEY,
          room_id     integer NOT NULL,
          booked_at   tsrange NOT NULL
      );
      
      INSERT INTO room_bookings (room_id, booked_at) VALUES
          (1, '[2024-03-01 09:00, 2024-03-01 11:00)'),  -- inclusive start, exclusive end
          (1, '[2024-03-01 14:00, 2024-03-01 16:00)');
      ```
      
      #### Operators
      
      ```sql
      -- && overlap
      SELECT * FROM room_bookings WHERE booked_at && '[2024-03-01 10:00, 2024-03-01 12:00)';
      
      -- @> contains a point
      SELECT * FROM room_bookings WHERE booked_at @> '2024-03-01 10:30'::timestamptz;
      
      -- <@ is contained by
      SELECT * FROM room_bookings WHERE booked_at <@ '[2024-03-01 00:00, 2024-03-02 00:00)';
      
      -- Boundary extraction
      SELECT lower(booked_at), upper(booked_at) FROM room_bookings;
      
      -- Adjacency
      SELECT * FROM schedules WHERE period1 -|- period2;  -- ranges are adjacent
      
      -- daterange example
      SELECT * FROM subscriptions
      WHERE validity @> CURRENT_DATE::date;
      ```
      
      #### Exclusion Constraints (prevent overlaps)
      
      ```sql
      -- Requires btree_gist extension for non-geometric types
      CREATE EXTENSION IF NOT EXISTS btree_gist;
      
      ALTER TABLE room_bookings
      ADD CONSTRAINT no_double_booking
      EXCLUDE USING gist (room_id WITH =, booked_at WITH &&);
      
      -- Multi-column exclusion with additional equality condition
      ALTER TABLE room_bookings
      ADD CONSTRAINT no_double_booking_per_tenant
      EXCLUDE USING gist (tenant_id WITH =, room_id WITH =, booked_at WITH &&);
      ```
      
      #### Custom Range Types
      
      ```sql
      CREATE TYPE floatrange AS RANGE (subtype = float8, subtype_diff = float8mi);
      
      SELECT '[1.5, 2.5]'::floatrange @> 2.0;  -- true
      ```
      
      ---
      
      ### Composite Types
      
      Composite types group multiple fields into a single reusable type.
      
      ```sql
      -- Define a composite type
      CREATE TYPE address AS (
          street  text,
          city    text,
          state   text,
          zip     text
      );
      
      -- Use in a table
      CREATE TABLE customers (
          id              serial PRIMARY KEY,
          name            text NOT NULL,
          billing_address address,
          shipping_address address
      );
      
      -- Insert and access
      INSERT INTO customers (name, billing_address)
      VALUES ('Acme Corp', ROW('123 Main St', 'Springfield', 'IL', '62701'));
      
      SELECT (billing_address).city FROM customers;
      SELECT * FROM customers WHERE (billing_address).state = 'IL';
      
      -- Update a field within composite
      UPDATE customers
      SET billing_address.zip = '62702'
      WHERE id = 1;
      ```
      
      Composite types are also implicitly created for every table and are used as the row type in PL/pgSQL functions.
      
      ---
      
      ### Domain Types
      
      Domains are named data types with optional constraints, providing centralized validation logic.
      
      ```sql
      -- Email domain with CHECK constraint
      CREATE DOMAIN email_address AS text
      CHECK (VALUE ~ '^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$');
      
      -- Non-negative money (in cents)
      CREATE DOMAIN positive_cents AS integer
      CHECK (VALUE > 0);
      
      -- Non-empty text
      CREATE DOMAIN nonempty_text AS text
      CHECK (VALUE <> '' AND VALUE IS NOT NULL)
      NOT NULL;
      
      -- Use domains in tables
      CREATE TABLE invoices (
          id            serial PRIMARY KEY,
          customer_email email_address NOT NULL,
          amount_cents   positive_cents NOT NULL,
          description    nonempty_text
      );
      
      -- Domain constraints can be altered without modifying tables
      ALTER DOMAIN positive_cents ADD CONSTRAINT allow_zero CHECK (VALUE >= 0);
      ```
      
      ---
      
      ## Constraints
      
      ### CHECK Constraints
      
      ```sql
      -- Column-level
      CREATE TABLE products (
          id         serial PRIMARY KEY,
          price      numeric CHECK (price >= 0),
          status     text CHECK (status IN ('active', 'inactive', 'archived'))
      );
      
      -- Table-level (can reference multiple columns)
      CREATE TABLE discounts (
          id              serial PRIMARY KEY,
          discount_pct    numeric,
          discount_flat   numeric,
          CONSTRAINT one_discount_type CHECK (
              (discount_pct IS NULL) != (discount_flat IS NULL)
          )
      );
      
      -- Named constraint for clearer error messages
      ALTER TABLE orders ADD CONSTRAINT chk_positive_total
      CHECK (total_cents > 0);
      ```
      
      ### UNIQUE Constraints
      
      ```sql
      -- Single column
      CREATE TABLE users (
          id    serial PRIMARY KEY,
          email text UNIQUE NOT NULL
      );
      
      -- Composite unique
      CREATE TABLE team_members (
          team_id integer,
          user_id integer,
          UNIQUE (team_id, user_id)
      );
      
      -- Partial unique (unique only within a condition)
      CREATE UNIQUE INDEX idx_users_active_email
      ON users (email) WHERE deleted_at IS NULL;
      ```
      
      ### EXCLUDE Constraints
      
      Exclusion constraints generalize UNIQUE by allowing any operator, not just equality. Require a GiST or SP-GiST index.
      
      ```sql
      -- No two bookings for the same room may overlap
      CREATE EXTENSION btree_gist;
      
      CREATE TABLE bookings (
          id      serial PRIMARY KEY,
          room    text,
          during  tsrange,
          EXCLUDE USING gist (room WITH =, during WITH &&)
      );
      ```
      
      ### Foreign Key Options
      
      ```sql
      CREATE TABLE orders (
          id          serial PRIMARY KEY,
          customer_id integer,
      
          -- ON DELETE options:
          -- CASCADE     - delete order when customer deleted
          -- SET NULL    - set customer_id to NULL
          -- SET DEFAULT - set to column default
          -- RESTRICT    - error if customer has orders (default behavior)
          -- NO ACTION   - like RESTRICT but deferred-constraint-friendly
      
          CONSTRAINT fk_orders_customer
              FOREIGN KEY (customer_id)
              REFERENCES customers(id)
              ON DELETE RESTRICT
              ON UPDATE CASCADE
      );
      ```
      
      ### Deferrable Constraints
      
      Deferrable constraints are checked at transaction commit instead of statement time, enabling circular references and bulk data loading.
      
      ```sql
      -- Define as deferrable
      ALTER TABLE employees ADD CONSTRAINT fk_manager
      FOREIGN KEY (manager_id) REFERENCES employees(id)
      DEFERRABLE INITIALLY DEFERRED;
      
      -- Or defer within a transaction
      BEGIN;
      SET CONSTRAINTS fk_manager DEFERRED;
      -- Insert records that temporarily violate the constraint
      INSERT INTO employees (id, manager_id, name) VALUES (1, 2, 'Alice');
      INSERT INTO employees (id, manager_id, name) VALUES (2, 1, 'Bob');
      COMMIT;  -- constraint checked here, both records now exist
      ```
      
      ---
      
      ## Generated Columns
      
      Generated columns compute their value automatically from other columns. PG12+ supports STORED (persisted to disk). PG16+ added experimental VIRTUAL (computed on read, not stored).
      
      ```sql
      -- STORED generated column
      CREATE TABLE measurements (
          id            serial PRIMARY KEY,
          value_celsius numeric NOT NULL,
          -- Automatically computed and stored
          value_fahrenheit numeric GENERATED ALWAYS AS (value_celsius * 9/5 + 32) STORED
      );
      
      INSERT INTO measurements (value_celsius) VALUES (100);
      SELECT value_celsius, value_fahrenheit FROM measurements;
      -- Returns: 100, 212
      
      -- Full name from parts
      CREATE TABLE persons (
          id         serial PRIMARY KEY,
          first_name text NOT NULL,
          last_name  text NOT NULL,
          full_name  text GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED
      );
      
      -- Searchable slug from title
      CREATE TABLE posts (
          id    serial PRIMARY KEY,
          title text NOT NULL,
          slug  text GENERATED ALWAYS AS (
              lower(regexp_replace(trim(title), '[^a-zA-Z0-9]+', '-', 'g'))
          ) STORED
      );
      
      CREATE INDEX idx_posts_slug ON posts(slug);
      ```
      
      Restrictions: generation expression cannot reference other generated columns, user-defined functions must be IMMUTABLE, cannot have a DEFAULT, cannot be written to directly.
      
      ---
      
      ## Table Inheritance and Partitioning
      
      ### Traditional Inheritance (pre-PG10)
      
      ```sql
      CREATE TABLE events (
          id         bigserial PRIMARY KEY,
          occurred_at timestamptz NOT NULL,
          payload    jsonb
      );
      
      CREATE TABLE click_events (
          element_id text NOT NULL
      ) INHERITS (events);
      
      -- Queries on parent include child rows
      SELECT count(*) FROM events;  -- includes click_events rows
      SELECT count(*) FROM ONLY events;  -- excludes child tables
      ```
      
      Traditional inheritance is largely superseded by declarative partitioning for the partition use case.
      
      ### Declarative Partitioning (PG10+)
      
      #### Range Partitioning
      
      ```sql
      CREATE TABLE events (
          id          bigint NOT NULL,
          occurred_at timestamptz NOT NULL,
          payload     jsonb
      ) PARTITION BY RANGE (occurred_at);
      
      CREATE TABLE events_2024_q1 PARTITION OF events
      FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
      
      CREATE TABLE events_2024_q2 PARTITION OF events
      FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');
      
      -- Default partition catches unmatched rows
      CREATE TABLE events_default PARTITION OF events DEFAULT;
      
      -- Index on partition key (propagates to all partitions)
      CREATE INDEX ON events (occurred_at);
      ```
      
      #### List Partitioning
      
      ```sql
      CREATE TABLE orders (
          id      bigint NOT NULL,
          region  text NOT NULL,
          total   numeric
      ) PARTITION BY LIST (region);
      
      CREATE TABLE orders_us PARTITION OF orders FOR VALUES IN ('US', 'CA');
      CREATE TABLE orders_eu PARTITION OF orders FOR VALUES IN ('DE', 'FR', 'GB');
      CREATE TABLE orders_other PARTITION OF orders DEFAULT;
      ```
      
      #### Hash Partitioning
      
      ```sql
      CREATE TABLE user_events (
          user_id bigint NOT NULL,
          event   text
      ) PARTITION BY HASH (user_id);
      
      CREATE TABLE user_events_0 PARTITION OF user_events FOR VALUES WITH (MODULUS 4, REMAINDER 0);
      CREATE TABLE user_events_1 PARTITION OF user_events FOR VALUES WITH (MODULUS 4, REMAINDER 1);
      CREATE TABLE user_events_2 PARTITION OF user_events FOR VALUES WITH (MODULUS 4, REMAINDER 2);
      CREATE TABLE user_events_3 PARTITION OF user_events FOR VALUES WITH (MODULUS 4, REMAINDER 3);
      ```
      
      #### Sub-partitioning
      
      ```sql
      CREATE TABLE metrics (
          tenant_id integer NOT NULL,
          recorded_at date NOT NULL,
          value numeric
      ) PARTITION BY LIST (tenant_id);
      
      CREATE TABLE metrics_tenant1 PARTITION OF metrics
      FOR VALUES IN (1) PARTITION BY RANGE (recorded_at);
      
      CREATE TABLE metrics_tenant1_2024 PARTITION OF metrics_tenant1
      FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
      ```
      
      ---
      
      ## Row-Level Security
      
      RLS restricts which rows a user can see or modify. Enabled per table; policies define the filter predicate.
      
      ### Enabling RLS
      
      ```sql
      ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
      
      -- Without this, the table owner bypasses all policies!
      ALTER TABLE documents FORCE ROW LEVEL SECURITY;
      ```
      
      ### Policy Types
      
      ```sql
      -- PERMISSIVE (default): policies are OR'd together; user sees rows matching ANY policy
      -- RESTRICTIVE: policies are AND'd; user must match ALL restrictive policies
      
      -- Allow users to see only their own rows
      CREATE POLICY user_isolation ON documents
      AS PERMISSIVE
      FOR ALL
      TO application_role
      USING (owner_id = current_setting('app.user_id')::integer);
      
      -- Separate read and write policies
      CREATE POLICY documents_select ON documents
      FOR SELECT
      TO application_role
      USING (owner_id = current_setting('app.user_id')::integer OR is_public = true);
      
      CREATE POLICY documents_insert ON documents
      FOR INSERT
      TO application_role
      WITH CHECK (owner_id = current_setting('app.user_id')::integer);
      
      CREATE POLICY documents_update ON documents
      FOR UPDATE
      TO application_role
      USING (owner_id = current_setting('app.user_id')::integer)
      WITH CHECK (owner_id = current_setting('app.user_id')::integer);
      
      CREATE POLICY documents_delete ON documents
      FOR DELETE
      TO application_role
      USING (owner_id = current_setting('app.user_id')::integer);
      ```
      
      ### Multi-Tenant Pattern
      
      ```sql
      -- Set tenant context at session start (via connection pooler or app middleware)
      SET app.tenant_id = '42';
      
      -- RLS policy using session variable
      CREATE POLICY tenant_isolation ON orders
      USING (tenant_id = current_setting('app.tenant_id')::integer);
      
      -- Superuser bypass: use a dedicated non-superuser role for the app
      CREATE ROLE app_user NOLOGIN;
      GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO app_user;
      
      -- Service role that bypasses RLS (for admin tasks)
      CREATE ROLE service_role BYPASSRLS LOGIN;
      ```
      
      ### RESTRICTIVE Policies
      
      ```sql
      -- Combine PERMISSIVE (what user owns) AND RESTRICTIVE (not deleted)
      CREATE POLICY only_active ON documents
      AS RESTRICTIVE
      FOR ALL
      USING (deleted_at IS NULL);
      
      CREATE POLICY owner_access ON documents
      AS PERMISSIVE
      FOR ALL
      USING (owner_id = current_setting('app.user_id')::integer);
      
      -- Result: user sees rows where deleted_at IS NULL AND owner_id matches
      ```
      
      ### Common Pitfalls
      
      | Pitfall | Fix |
      |---------|-----|
      | Table owner bypasses RLS silently | Add `FORCE ROW LEVEL SECURITY` to the table |
      | No policy defined means no rows visible | Always define at least one PERMISSIVE policy per operation |
      | Superuser always bypasses RLS | Use a non-superuser application role |
      | `current_user` vs session variable | Use `current_setting()` for app-set context; `current_user` reflects DB login role |
      | Performance: predicate not pushed down | Create index on the tenant/owner column used in policy USING clause |
      
      ```sql
      -- Verify your policies are working
      SET ROLE app_user;
      SET app.user_id = '1';
      SELECT count(*) FROM documents;  -- should only return user 1's documents
      RESET ROLE;
      ```
      
  • scripts
    • .gitkeep 0 B · in bundle
  • SKILL.md 9.7 KB
    ---
    name: postgres-ops
    description: "PostgreSQL operations, optimization, and administration. Use for: schema design, index selection, query tuning with EXPLAIN ANALYZE, postgresql.conf configuration, backup and restore (pg_dump, pg_basebackup, WAL, PITR), vacuum and autovacuum tuning, connection pooling (pgBouncer, pgPool), replication (streaming, logical), partitioning, monitoring (pg_stat_statements, pg_stat_activity), JSONB operations, full-text search (tsvector, tsquery), row-level security (RLS), extensions (PostGIS, pg_trgm, timescaledb), GiST/GIN/BRIN indexes, materialized views, foreign data wrappers, LISTEN/NOTIFY."
    license: MIT
    allowed-tools: "Read Write Bash"
    metadata:
      author: claude-mods
      related-skills: sql-ops, sqlite-ops, python-database-ops
    ---
    
    # PostgreSQL Operations
    
    Comprehensive PostgreSQL skill covering schema design through production operations.
    
    ## Quick Connection
    
    ```bash
    # Standard connection
    psql "postgresql://user:pass@localhost:5432/dbname"
    
    # With SSL
    psql "postgresql://user:pass@host:5432/dbname?sslmode=require"
    
    # Environment variables (libpq)
    export PGHOST=localhost PGPORT=5432 PGDATABASE=mydb PGUSER=myuser PGPASSWORD=secret
    psql
    
    # Connection pooling (pgBouncer default)
    psql "postgresql://user:pass@localhost:6432/dbname"
    ```
    
    ```sql
    -- Check current connection
    SELECT current_database(), current_user, inet_server_addr(), inet_server_port();
    
    -- Active connections
    SELECT count(*) FROM pg_stat_activity WHERE state = 'active';
    ```
    
    ## Index Type Selection
    
    ```
    What query pattern are you optimizing?
    │
    ├─ Equality (WHERE col = val)
    │  └─ B-tree (default, almost always right)
    │
    ├─ Range (WHERE col > val, ORDER BY, BETWEEN)
    │  └─ B-tree
    │
    ├─ Array/JSONB containment (@>, ?, ?|, ?&)
    │  └─ GIN
    │
    ├─ Full-text search (@@)
    │  └─ GIN with tsvector
    │
    ├─ Geometric/range overlap (&&, <->)
    │  └─ GiST
    │
    ├─ Pattern matching (LIKE '%text%', similarity)
    │  └─ GIN with pg_trgm (gin_trgm_ops)
    │
    ├─ Large table, few distinct values, append-only
    │  └─ BRIN (tiny index, good for timestamps)
    │
    └─ Exact equality only, no range/sort needed
       └─ Hash (rare - B-tree usually better)
    ```
    
    ### Quick Index Reference
    
    | Index | Best For | Size | Write Cost |
    |-------|----------|------|------------|
    | B-tree | Equality, range, sort | Medium | Low |
    | GIN | Arrays, JSONB, FTS, trigrams | Large | High |
    | GiST | Geometry, ranges, FTS | Medium | Medium |
    | BRIN | Correlated data (timestamps) | Tiny | Very low |
    | Hash | Exact equality only | Medium | Low |
    
    **Deep dive**: Load `./references/indexing.md` for composite, partial, expression, and covering index strategies.
    
    ## EXPLAIN ANALYZE Workflow
    
    ```sql
    -- Step 1: Run with ANALYZE and BUFFERS
    EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT ...;
    
    -- Step 2: Read bottom-up. Find the slowest node.
    -- Step 3: Check estimates vs actuals
    --   actual rows=10000, rows=100  -> bad estimate, run ANALYZE
    -- Step 4: Look for these red flags:
    ```
    
    | Red Flag | Meaning | Fix |
    |----------|---------|-----|
    | `Seq Scan` on large table | No usable index | Add index matching WHERE/JOIN |
    | `actual rows` >> `estimated rows` | Stale statistics | `ANALYZE tablename` |
    | `Nested Loop` with high rows | O(n*m) join | Check join conditions, add index |
    | `Sort` with `external merge` | work_mem too small | Increase `work_mem` for session |
    | `Buffers: shared read` >> `hit` | Cold cache or table too large | Check `shared_buffers`, add covering index |
    | `Hash Batch` > 1 | Hash join spilling to disk | Increase `work_mem` |
    
    **Deep dive**: Load `./references/query-tuning.md` for plan node reference and optimization patterns.
    
    ## Workload Profiles
    
    | Setting | OLTP | OLAP | Notes |
    |---------|------|------|-------|
    | `shared_buffers` | 25% RAM | 25% RAM | Same baseline |
    | `work_mem` | 4-16 MB | 256 MB-1 GB | OLAP needs big sorts |
    | `effective_cache_size` | 75% RAM | 75% RAM | Planner hint |
    | `random_page_cost` | 1.1 (SSD) | 1.1 (SSD) | Lower for SSD |
    | `max_parallel_workers_per_gather` | 2 | 4-8 | OLAP benefits more |
    | `checkpoint_completion_target` | 0.9 | 0.9 | Spread checkpoint I/O |
    | `wal_buffers` | 64 MB | 64 MB | -1 for auto |
    | `maintenance_work_mem` | 512 MB | 1-2 GB | For VACUUM, CREATE INDEX |
    
    **Deep dive**: Load `./references/config-tuning.md` for full postgresql.conf walkthrough and extension setup.
    
    ## Common Operations
    
    ### Backup & Restore
    
    ```bash
    # Logical backup (single database)
    pg_dump -Fc dbname > backup.dump
    
    # Restore
    pg_restore -d dbname backup.dump
    
    # Parallel backup (faster for large DBs)
    pg_dump -Fc -j4 dbname > backup.dump
    
    # Base backup for PITR
    pg_basebackup -D /backup/base -Ft -Xs -P
    ```
    
    ### Vacuum & Maintenance
    
    ```sql
    -- Manual vacuum (reclaim space, update stats)
    VACUUM (VERBOSE, ANALYZE) tablename;
    
    -- Full vacuum (rewrites table, exclusive lock)
    VACUUM FULL tablename;  -- CAUTION: locks table
    
    -- Reindex without downtime
    REINDEX INDEX CONCURRENTLY idx_name;
    
    -- Update statistics only
    ANALYZE tablename;
    ```
    
    ### Monitor Key Metrics
    
    ```sql
    -- Slow queries (requires pg_stat_statements)
    SELECT query, calls, mean_exec_time, total_exec_time
    FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10;
    
    -- Table bloat indicator
    SELECT schemaname, relname, n_dead_tup, n_live_tup,
           round(n_dead_tup::numeric / NULLIF(n_live_tup, 0) * 100, 1) AS dead_pct
    FROM pg_stat_user_tables WHERE n_dead_tup > 1000
    ORDER BY n_dead_tup DESC;
    
    -- Lock contention
    SELECT pid, relation::regclass, mode, granted, query
    FROM pg_locks JOIN pg_stat_activity USING (pid)
    WHERE NOT granted;
    
    -- Cache hit ratio (should be > 99%)
    SELECT sum(heap_blks_hit) / NULLIF(sum(heap_blks_hit) + sum(heap_blks_read), 0) AS ratio
    FROM pg_statio_user_tables;
    ```
    
    **Deep dive**: Load `./references/operations.md` for WAL archiving, PITR, autovacuum tuning, connection pooling.
    
    ## Data Types Quick Reference
    
    | Type | Use When | Example |
    |------|----------|---------|
    | `JSONB` | Semi-structured data, flexible schema | `'{"tags": ["a","b"]}'::jsonb` |
    | `ARRAY` | Fixed-type lists | `ARRAY['a','b','c']` |
    | `tsrange` | Time periods, scheduling | `'[2024-01-01, 2024-12-31)'::tsrange` |
    | `tsvector` | Full-text search | `to_tsvector('english', body)` |
    | `uuid` | Distributed IDs | `gen_random_uuid()` |
    | `inet`/`cidr` | IP addresses, networks | `'192.168.1.0/24'::cidr` |
    
    **Deep dive**: Load `./references/schema-design.md` for normalization, constraints, RLS, generated columns, table inheritance.
    
    ## Gotchas & Anti-Patterns
    
    | Mistake | Why It's Bad | Fix |
    |---------|-------------|-----|
    | `SELECT *` in production | Wastes bandwidth, blocks covering index scans | List columns explicitly |
    | Function on indexed column (`WHERE UPPER(email) = ...`) | Prevents index use | Expression index: `CREATE INDEX ... ON (UPPER(email))` |
    | `NOT IN (subquery)` with NULLs | Returns no rows if subquery has NULL | Use `NOT EXISTS` |
    | Missing `ANALYZE` after bulk load | Planner uses stale row estimates | Run `ANALYZE tablename` |
    | `VACUUM FULL` in production | Exclusive lock on entire table | Regular `VACUUM` + `pg_repack` |
    | `LIMIT` without `ORDER BY` | Non-deterministic results | Always pair with `ORDER BY` |
    | Offset pagination on large tables | Scans and discards rows | Keyset pagination: `WHERE id > last_id` |
    | Too many indexes | Slows writes, wastes space | Audit with `pg_stat_user_indexes` |
    | Single shared connection pool | Contention across services | Per-service pools via pgBouncer |
    | `default_transaction_isolation = serializable` | Excessive serialization failures | Keep `read committed`, use explicit `SERIALIZABLE` where needed |
    
    ## Row-Level Security (RLS) Quick Start
    
    ```sql
    -- Enable RLS on table
    ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
    
    -- Policy: users see only their own rows
    CREATE POLICY user_isolation ON documents
        USING (owner_id = current_setting('app.current_user_id')::int);
    
    -- Policy: admins see everything
    CREATE POLICY admin_access ON documents
        USING (current_setting('app.role') = 'admin');
    
    -- Set context per request (from app layer)
    SET app.current_user_id = '42';
    SET app.role = 'user';
    ```
    
    ## Full-Text Search Quick Start
    
    ```sql
    -- Add search column
    ALTER TABLE articles ADD COLUMN search_vector tsvector
        GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
    
    -- Index it
    CREATE INDEX idx_articles_fts ON articles USING gin(search_vector);
    
    -- Search with ranking
    SELECT title, ts_rank(search_vector, query) AS rank
    FROM articles, to_tsquery('english', 'database & optimization') AS query
    WHERE search_vector @@ query
    ORDER BY rank DESC;
    ```
    
    ## LISTEN/NOTIFY
    
    ```sql
    -- Publisher
    NOTIFY order_events, '{"order_id": 123, "status": "shipped"}';
    
    -- Subscriber (in psql or app)
    LISTEN order_events;
    
    -- Check for notifications (app code)
    -- Python: conn.poll(); conn.notifies
    -- Node: client.on('notification', callback)
    ```
    
    ## Reference Files
    
    Load these for deep-dive topics. Each is self-contained.
    
    | Reference | When to Load |
    |-----------|-------------|
    | `./references/schema-design.md` | Designing tables, choosing types, constraints, RLS policies, JSONB modeling |
    | `./references/indexing.md` | Choosing index types, composite/partial/expression indexes, index maintenance |
    | `./references/query-tuning.md` | Reading EXPLAIN plans, pg_stat_statements, optimizing specific query patterns |
    | `./references/operations.md` | Backup/restore, WAL/PITR, vacuum tuning, monitoring, connection pooling |
    | `./references/replication.md` | Streaming/logical replication, failover, partitioning, FDW |
    | `./references/config-tuning.md` | postgresql.conf settings, OLTP/OLAP profiles, extension setup |
    
    ## See Also
    
    - `sql-ops` - Vendor-neutral SQL patterns (CTEs, window functions, JOINs)
    - `sqlite-ops` - SQLite-specific patterns and operations
    - `python-database-ops` - SQLAlchemy ORM and async database patterns
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related