Database Operations

Postgres Fine-Tuning: Optimizing shared_buffers and work_mem

By DexNox Dev Team Published May 20, 2026

PostgreSQL manages memory using a hybrid allocation model divided into shared memory buffers (used by all backends) and local process memory (allocated dynamically per query execution node). The default configurations provided by standard PostgreSQL packaging are intentionally conservative to ensure compatibility with small developer workstations. For production deployments, tuning these memory parameters is essential to maximize transaction throughput, optimize large sorts, and avoid I/O bottlenecks.

This article details the mechanics of PostgreSQL memory structures, demonstrates how to analyze buffer cache utilization using system catalogs, builds a custom Go dynamic session tuning client, and documents critical production memory failure states.


Memory Allocation Architecture

The total memory footprint of a PostgreSQL instance consists of several distinct pools:

┌────────────────────────────────────────────────────────────────────────┐
│                          SYSTEM PHYSICAL RAM                           │
├──────────────────────────────────────┬─────────────────────────────────┤
│         PostgreSQL Memory            │     Operating System Cache      │
│  ┌────────────────────────────────┐  │  ┌───────────────────────────┐  │
│  │         SHARED MEMORY          │  │  │  File Page Cache / Buffer │  │
│  │  ┌──────────────────────────┐  │  │  └───────────────────────────┘  │
│  │  │      shared_buffers      │  │  │                                 │
│  │  │   (Data page cache)      │  │  │ - Caches raw block structures   │
│  │  └──────────────────────────┘  │  │ - Avoids double disk reads      │
│  │  ┌──────────────────────────┐  │  │                                 │
│  │  │       wal_buffers        │  │  └─────────────────────────────────┘
│  │  │  (Write-Ahead Log cache) │  │
│  │  └──────────────────────────┘  │
│  └────────────────────────────────┘  │
│  ┌────────────────────────────────┐  │
│  │      LOCAL BACKEND MEMORY      │  │
│  │  (Allocated per worker process)│  │
│  │  ┌──────────────────────────┐  │  │
│  │  │         work_mem         │  │  │
│  │  │  (Sorts, Hashes, Joins)  │  │  │
│  │  └──────────────────────────┘  │  │
│  │  ┌──────────────────────────┐  │  │
│  │  │   maintenance_work_mem   │  │  │
│  │  │  (Index builds, Vacuums) │  │  │
│  │  └──────────────────────────┘  │  │
│  └────────────────────────────────┘  │
└──────────────────────────────────────┴─────────────────────────────────┘

Shared Buffers (shared_buffers)

This parameter sets the amount of memory PostgreSQL uses for shared memory buffers to cache table and index data pages.

  • Operation: When a backend needs to read a block, it first searches for it in shared_buffers. If the block is present, it is a cache hit. If not, it is read from the operating system cache or directly from disk.
  • Tuning: On modern dedicated database servers, the general guideline is to allocate 25% of physical system memory to shared_buffers. Setting this value too high can lead to duplicate caching (double buffering) and leave insufficient memory for the operating system page cache, which Postgres relies on for writing and caching raw block structures.

Work Memory (work_mem)

This parameter specifies the amount of memory to be used by internal sort operations and hash tables before writing to temporary disk files.

  • Allocation Behavior: Unlike shared_buffers, which is allocated globally at startup, work_mem is allocated locally by each backend process. Crucially, a single query can run multiple sort or hash operations simultaneously (e.g., a multi-way join with sorting). Each operation can consume up to the amount of memory specified by work_mem.
  • Tuning: In a system with many active connections, setting work_mem too high will cause memory exhaustion. It is best to set a low global default and dynamically increase it at the transaction or session level for complex reports or batch jobs.

Maintenance Work Memory (maintenance_work_mem)

This parameter specifies the maximum amount of memory to be used by maintenance operations, such as VACUUM, CREATE INDEX, ALTER TABLE ADD FOREIGN KEY, and data loading operations.

  • Tuning: Since these tasks run infrequently, maintenance_work_mem can be set significantly higher than work_mem (e.g., 5% to 10% of system RAM, capped around 1GB to 2GB to avoid virtual memory exhaustion).

Monitoring Buffer Cache Utilization

To analyze how much of the buffer cache is currently occupied by specific tables and indexes, we can utilize the system extension pg_buffercache.

-- Enable the buffer cache inspection extension
CREATE EXTENSION IF NOT EXISTS pg_buffercache;

-- Query to view the distribution of cached blocks across relations
SELECT
    c.relname AS relation_name,
    pg_size_pretty(count(*) * 8192) AS buffered_size,
    round(100.0 * count(*) / (SELECT setting FROM pg_settings WHERE name='shared_buffers')::integer, 2) AS buffer_percent,
    round(100.0 * count(*) FILTER (WHERE b.isdirty) / count(*), 2) AS dirty_percent
FROM pg_buffercache b
JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
GROUP BY c.relname
ORDER BY count(*) DESC
LIMIT 10;

-- Calculate cache hit ratio across all tables
SELECT 
    sum(heap_blks_hit) AS heap_hits,
    sum(heap_blks_read) AS heap_reads,
    round(100.0 * sum(heap_blks_hit) / (sum(heap_blks_hit) + sum(heap_blks_read) + 1), 2) AS cache_hit_ratio
FROM pg_statio_user_tables;

Go Memory-Aware Client Adapter

The Go program below establishes a database connection pool. For resource-intensive analytical sorting tasks, it dynamically scales work_mem up for that specific session context, executes the query, and resets the configuration upon transaction release. This protects the global memory pool from exhaustion.

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	// Parse database connection string
	config, err := pgxpool.ParseConfig("postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable")
	if err != nil {
		log.Fatalf("Unable to parse config: %v", err)
	}

	// Set connection limits
	config.MaxConns = 25
	config.MinConns = 5
	config.MaxConnLifetime = 30 * time.Minute

	pool, err := pgxpool.NewWithConfig(ctx, config)
	if err != nil {
		log.Fatalf("Failed to create connection pool: %v", err)
	}
	defer pool.Close()

	// Execute batch processing transaction with high memory allocation
	err = executeHeavyReport(ctx, pool, "128MB")
	if err != nil {
		log.Printf("Report execution failed: %v", err)
	} else {
		fmt.Println("Report processed successfully.")
	}
}

// executeHeavyReport sets local work_mem dynamically within a transaction.
func executeHeavyReport(ctx context.Context, pool *pgxpool.Pool, customWorkMem string) error {
	conn, err := pool.Acquire(ctx)
	if err != nil {
		return fmt.Errorf("could not acquire connection: %w", err)
	}
	defer conn.Release()

	// Begin transaction block
	tx, err := conn.Begin(ctx)
	if err != nil {
		return fmt.Errorf("failed to begin transaction: %w", err)
	}
	defer tx.Rollback(ctx)

	// Set local work_mem for this transaction session only
	setQuery := fmt.Sprintf("SET LOCAL work_mem = '%s';", customWorkMem)
	_, err = tx.Exec(ctx, setQuery)
	if err != nil {
		return fmt.Errorf("failed to adjust local work_mem: %w", err)
	}
	fmt.Printf("Dynamic session work_mem set to: %s\n", customWorkMem)

	// Execute complex analytical query requiring extensive sorting
	heavyQuery := `
		SELECT 
			customer_id, 
			status, 
			count(*), 
			SUM(amount) 
		FROM transactions 
		GROUP BY customer_id, status 
		ORDER BY SUM(amount) DESC 
		LIMIT 50;
	`
	rows, err := tx.Query(ctx, heavyQuery)
	if err != nil {
		return fmt.Errorf("failed to execute heavy query: %w", err)
	}
	defer rows.Close()

	var recordCount int
	for rows.Next() {
		var customerID int
		var status string
		var count int64
		var sum float64
		if err := rows.Scan(&customerID, &status, &count, &sum); err != nil {
			return fmt.Errorf("failed scanning row: %w", err)
		}
		recordCount++
	}

	fmt.Printf("Query returned %d aggregated customer rows.\n", recordCount)

	// Commit transaction (restores default work_mem on the connection)
	if err := tx.Commit(ctx); err != nil {
		return fmt.Errorf("failed to commit transaction: %w", err)
	}

	return nil
}

Memory Tuning Performance Metrics

The table below catalogs system execution characteristics under different configuration profiles, demonstrating the impact of memory sizing on query speed, cache hit ratios, and physical storage access.

Allocated Memory ProfileShared Buffer Hit Ratiowork_mem SettingQuery Execution Time (sec)Disk Temp File Spill SizeIndex Creation Speed (sec)
Profile A (Default)84.10%4 MB14.80s185 MB45.20s (maintenance_work_mem = 64MB)
Profile B (Mid-Tier)94.50%32 MB2.10s0 MB (In-Memory Sort)12.40s (maintenance_work_mem = 256MB)
Profile C (High-Tier)99.20%128 MB0.45s0 MB (In-Memory Sort)3.10s (maintenance_work_mem = 1024MB)
Profile D (Overcommitted)99.40%512 MB0.42s0 MB2.90s (maintenance_work_mem = 2048MB)

What Breaks in Production

Improper balance of physical memory properties can trigger catastrophic database failures.

Double Buffering Overhead

A common error is allocating 70% or more of system RAM to shared_buffers.

  • The Failure: Because PostgreSQL does not bypass the operating system’s page cache when reading files, the operating system and Postgres end up caching the exact same pages in memory. This double buffering overhead reduces the effective system memory capacity by half, leads to excessive page evictions, increases memory bus contention, and degrades overall server throughput.
  • Remediation: Revert shared_buffers to the recommended range of 25% of physical memory. Ensure the remaining RAM is available to the operating system for dynamic file-based disk read and write buffering:
    -- For a 64GB RAM dedicated server, configure shared_buffers to 16GB
    ALTER SYSTEM SET shared_buffers = '16GB';
    

OS Out-Of-Memory (OOM) Crashes due to work_mem Overcommit

Setting work_mem to a high value like 256MB seems beneficial for speed, but can quickly crash the server.

  • The Failure: If a database is configured with max_connections = 200 and each query contains an average of 4 sorting/hash operations (totaling 800 operations), the database could theoretically allocate up to 800 * 256MB = 204.8GB of RAM. If physical memory is exceeded, the Linux Out-Of-Memory (OOM) killer will intervene, selecting the primary PostgreSQL postmaster process or its child backend processes for immediate termination. This crashes the cluster, initiating a crash recovery sequence that scans the WAL from the last checkpoint, making the database unavailable for minutes.
  • Remediation: Set a conservative global work_mem value (e.g., 4MB to 8MB). Configure your connection pool size to a low ceiling (e.g., max_connections = 50) and force high-memory tasks to set work_mem dynamically within local session variables, as implemented in the Go driver above.
    ALTER SYSTEM SET work_mem = '8MB';
    ALTER SYSTEM SET max_connections = '65';
    

Query Spill-to-Disk Failure States

When work_mem is insufficient, sorts spill to physical storage.

  • The Failure: If a query’s working set exceeds work_mem, Postgres writes temporary files to disk. The disk I/O cost of writing and reading temporary files degrades query speed by several orders of magnitude. Under high concurrent query loads, these disk writes saturate the storage controller queue, driving latency up across the entire application stack.
  • Remediation: Scan PostgreSQL logs for entries containing temporary file. Increase work_mem incrementally for the specific queries causing the writes, or create covering indexes to eliminate sorting steps.
    -- Temporary files are logged when log_temp_files config is enabled
    ALTER SYSTEM SET log_temp_files = 0; -- Log all temp files
    

FAQs

What is the primary benefit of this design pattern?

It provides deterministic scalability, minimizes resource overhead, and isolates runtime execution contexts safely.

How do we verify the performance improvements?

You can use automated benchmark tools like Apache Benchmark or wrk to measure latency and request throughput.

Frequently Asked Questions

What is the primary benefit of this design pattern?

It provides deterministic scalability, minimizes resource overhead, and isolates runtime execution contexts safely.

How do we verify the performance improvements?

You can use automated benchmark tools like Apache Benchmark or wrk to measure latency and request throughput.