PostgreSQL Memory and Buffer Cache

PostgreSQL uses memory aggressively too, but its memory model is not SQL Server’s buffer-pool-plus-clerk design. The core surfaces are shared_buffers, per-backend working memory such as work_mem, maintenance memory such as maintenance_work_mem, and the operating-system page cache that PostgreSQL expects to exist outside the server process. The operator has to reason across all of them together; tuning one in isolation is how memory work turns into folklore instead of engineering.


flowchart TD
  START["Memory concern"] --> SETTINGS{"Core settings sane?"}
  SETTINGS -->|No| FIX["Fix shared_buffers / work_mem / maintenance_work_mem first"]
  SETTINGS -->|Yes| HOST{"OS / container memory tight?"}
  HOST -->|Yes| HOSTFIX["Check /proc/meminfo, cgroup, colocated pressure"]
  HOST -->|No| CACHE{"Shared buffers used well?"}
  CACHE -->|No| CACHEFIX["Inspect pg_buffercache and workload shape"]
  CACHE -->|Yes| SPILL{"Sorts or hashes spilling?"}
  SPILL -->|Yes| SPILLFIX["Inspect work_mem, temp_bytes, and plan shape"]
  SPILL -->|No| BACKEND{"Backend-local memory excessive?"}
  BACKEND -->|Yes| CTX["Inspect pg_backend_memory_contexts and query shape"]
  BACKEND -->|No| HEALTHY["Memory posture looks healthy"]

Reproducible Baseline

Check the memory knobs that shape runtime behavior

SELECT name, setting, unit, source
FROM pg_settings
WHERE name IN (
  'block_size',
  'effective_cache_size',
  'huge_pages',
  'maintenance_work_mem',
  'shared_buffers',
  'temp_buffers',
  'work_mem'
)
ORDER BY name;
namesettingunitsource
block_size8192default
effective_cache_size5242888kBdefault
huge_pagestrydefault
maintenance_work_mem65536kBdefault
shared_buffers163848kBconfiguration file
temp_buffers10248kBdefault
work_mem4096kBdefault

These settings translate to:

SettingEffective sizeOperational meaning
shared_buffers128 MBvery small shared cache for a 30+ GB host; the OS page cache is expected to carry more of the working set
effective_cache_size4 GBplanner assumes a moderate amount of data may already be cached
work_mem4 MB per sort/hash nodeeasy to exhaust in wide sorts or multi-join plans
maintenance_work_mem64 MBmodest budget for vacuum and index maintenance
temp_buffers8 MB per sessiontemp-table local buffer budget

PostgreSQL | pg_stat_bgwriter and pg_stat_io | inspect write-side memory pressure signals

Read checkpoint and buffer allocation counters

pg_stat_bgwriter and pg_stat_io tell you whether the server is cycling buffers aggressively and who is doing the I/O work.

SELECT * FROM pg_stat_bgwriter;
checkpoints_timedcheckpoints_reqcheckpoint_write_timecheckpoint_sync_timebuffers_checkpointbuffers_cleanmaxwritten_cleanbuffers_backendbuffers_backend_fsyncbuffers_allocstats_reset
33938935415166580023620189212026-04-18 20:37:33.679131+00
SELECT backend_type, object, context,
       reads, read_time, writes, write_time,
       extends, extend_time, hits, evictions, reuses, fsyncs, fsync_time
FROM pg_stat_io
WHERE backend_type IN ('checkpointer', 'background writer', 'client backend')
  AND object IN ('relation', 'temp relation')
ORDER BY backend_type, object, context;
backend_typeobjectcontextreadsread_timewriteswrite_timeextendsextend_timehitsevictionsreusesfsyncsfsync_time
background writerrelationnormal0000
checkpointerrelationnormal665806290
client backendrelationbulkread920000140136
client backendrelationbulkwrite000080580731400
client backendrelationnormal872400011560882839000
client backendrelationvacuum000000000
client backendtemp relationnormal5000110860

The main signal here is not “memory is broken”. It is that shared_buffers is small enough that PostgreSQL still relies heavily on client-backend hits and the OS cache, while checkpoint and relation-extension activity remain visible in the write counters.


Linux Host Memory Boundaries

Linux | /proc/meminfo and cgroup files | read the memory envelope seen by PostgreSQL

Inspect host-visible RAM, swap, and container limits

MemTotal:       31656120 kB
MemAvailable:   25192732 kB
SwapTotal:       8388608 kB
SwapFree:        8064460 kB
---
max
---
30457856

Interpretation:

SurfaceObserved valueMeaning
/proc/meminfo MemTotal31,656,120 kBcontainer sees roughly 30.2 GB of RAM
/proc/meminfo MemAvailable25,192,732 kBhost-visible free memory is currently high
/sys/fs/cgroup/memory.maxmaxno hard cgroup memory cap is being applied
/sys/fs/cgroup/memory.current30,457,856 bytescurrent container usage is only about 29 MB at capture time

This is the same conclusion the SQL Server source note reaches through different interfaces: before tuning the database, confirm the operating environment is not already the true bottleneck.


Buffer Cache Health

PostgreSQL | pg_buffercache | inspect used versus unused buffers

Summarize the current state of shared buffers

pg_buffercache was enabled in stoxx for this inspection:

CREATE EXTENSION IF NOT EXISTS pg_buffercache;
SELECT extname, extversion FROM pg_extension WHERE extname = 'pg_buffercache';
extnameextversion
pg_buffercache1.4
SELECT * FROM pg_buffercache_summary();
SELECT * FROM pg_buffercache_usage_counts();
buffers_usedbuffers_unusedbuffers_dirtybuffers_pinnedusagecount_avg
448159364303.658482142857143
usage_countbuffersdirtypinned
01593600
19560
24840
32410
42780
5254240

The key point is how little of shared buffers is actually occupied: only 448 buffers, or about 3.5 MB, were used at capture time. The cache is not under pressure. It is mostly empty.

PostgreSQL | pg_buffercache | rank cached pages by database

See which databases currently own shared-buffer space

SELECT COALESCE(d.datname, 'shared/catalog') AS database_name,
       COUNT(*) AS buffers,
       pg_size_pretty(COUNT(*) * current_setting('block_size')::int::bigint) AS cached_size,
       SUM(CASE WHEN b.isdirty THEN 1 ELSE 0 END) AS dirty_buffers
FROM pg_buffercache AS b
LEFT JOIN pg_database AS d ON d.oid = b.reldatabase
GROUP BY COALESCE(d.datname, 'shared/catalog')
ORDER BY COUNT(*) DESC;
database_namebufferscached_sizedirty_buffers
shared/catalog15951125 MB1
stoxx2742192 kB42
postgres1591272 kB0
WITH current_db AS (
  SELECT oid AS db_oid FROM pg_database WHERE datname = current_database()
)
SELECT n.nspname || '.' || c.relname AS relation_name,
       c.relkind,
       COUNT(*) AS buffers,
       pg_size_pretty(COUNT(*) * current_setting('block_size')::int::bigint) AS cached_size,
       SUM(CASE WHEN b.isdirty THEN 1 ELSE 0 END) AS dirty_buffers
FROM pg_buffercache AS b
JOIN current_db AS d ON b.reldatabase IN (0, d.db_oid)
JOIN pg_class AS c ON pg_relation_filenode(c.oid) = b.relfilenode
JOIN pg_namespace AS n ON n.oid = c.relnamespace
GROUP BY n.nspname || '.' || c.relname, c.relkind
ORDER BY COUNT(*) DESC
LIMIT 15;

At capture time, the hottest cached relations were mostly catalog tables such as pg_catalog.pg_attribute, pg_catalog.pg_proc, and pg_catalog.pg_class. That is consistent with a lightly loaded lab where metadata access dominates over sustained large-table scans.


Memory Consumers

PostgreSQL | pg_backend_memory_contexts | inspect backend-local memory

Read the memory contexts of the current backend

pg_backend_memory_contexts is a per-session view, not a server-wide inventory. That makes it narrower than SQL Server memory clerks, but it is still the right place to inspect unusual per-backend allocations.

SELECT name, ident, parent, level,
       pg_size_pretty(total_bytes) AS total_bytes,
       pg_size_pretty(free_bytes) AS free_bytes,
       pg_size_pretty(used_bytes) AS used_bytes
FROM pg_backend_memory_contexts
ORDER BY total_bytes DESC
LIMIT 15;
nameidentparentleveltotal_bytesfree_bytesused_bytes
TopMemoryContext095 kB13 kB83 kB
ExprContextExecutorState48192 bytes6296 bytes1896 bytes
printtupExecutorState48192 bytes7928 bytes264 bytes
TupleSort sortTupleSort main58192 bytes7928 bytes264 bytes
Table function argumentsExecutorState48192 bytes7888 bytes304 bytes

The absence of large contexts here is a healthy signal. The session used to capture this note is not itself consuming unusual local memory.

PostgreSQL has no direct memory-clerk analogue

The closest mapping is:

  • shared_buffers and related shared memory for server-wide cache
  • per-backend memory contexts for session-local allocations
  • temp-file spill metrics for work that exceeded local memory budgets

PostgreSQL also does not have SQL Server’s instance-wide plan cache bloat pattern in the same form; prepared plans are typically scoped to sessions or explicit prepared statements rather than a global ad hoc plan cache.


Work-Memory Spill Behavior

PostgreSQL | low work_mem sort | reproduce a temp-file spill

Force an external merge sort and confirm the temp I/O

The session started from this baseline in pg_stat_database:

SELECT temp_files, pg_size_pretty(temp_bytes) AS temp_bytes_before
FROM pg_stat_database
WHERE datname = 'stoxx';
temp_filestemp_bytes_before
11888 kB

Then work_mem was forced down to 64kB and a wide sort was executed:

SET work_mem = '64kB';
 
EXPLAIN (ANALYZE, BUFFERS, SUMMARY)
SELECT *
FROM silver.eurostoxx50_ohlcv
ORDER BY close DESC, date DESC;
 
RESET work_mem;
Sort  (cost=16012.27..16180.16 rows=67155 width=80) (actual time=31.388..36.597 rows=67155 loops=1)
  Sort Key: close DESC, date DESC
  Sort Method: external merge  Disk: 6056kB
  Buffers: shared hit=6 read=1000, temp read=2260 written=2443
  ->  Seq Scan on eurostoxx50_ohlcv  (cost=0.00..1671.55 rows=67155 width=80) (actual time=0.017..10.254 rows=67155 loops=1)
        Buffers: shared read=1000
Planning:
  Buffers: shared hit=78 read=5
Planning Time: 0.366 ms
Execution Time: 38.575 ms

A fresh stats read after the spill showed the cumulative temp counters advance:

SELECT temp_files, temp_bytes, pg_size_pretty(temp_bytes) AS temp_bytes_pretty
FROM pg_stat_database
WHERE datname = 'stoxx';
temp_filestemp_bytestemp_bytes_pretty
281346567944 kB

This is the PostgreSQL memory-pressure pattern to watch:

SignalMeaning
Sort Method: external merge Disk: 6056kBexecutor ran out of memory for the sort and spilled to temp
temp read=2260 written=2443temp-file I/O was real, not theoretical
pg_stat_database.temp_bytes increasedspill cost is now visible in cumulative database stats

Operational Guidance

PostgreSQL | memory priorities | tune in the right order

Change the smallest number of things that explain the symptom

PriorityFocusWhy
1shared_bufferssets the size of PostgreSQL’s own shared cache
2work_memdrives per-operation spill behavior and can multiply dangerously
3maintenance_work_memhelps vacuum and index maintenance without inflating every query
4host and container memory limitsdefine whether database tuning can even succeed

Practical rules from the live lab:

ObservationGuidance
shared_buffers = 128 MB on a ~30 GB hostthis is conservative and keeps PostgreSQL dependent on the OS cache; good for a small lab, not a production default
most buffers unused at capture timelow occupancy is not automatically bad; it only matters if the workload is actually cache-thrashing
low work_mem created a visible spill immediatelytune work_mem from real spill evidence, not from generic blog defaults
cgroup memory.max = maxthere is no orchestrator-level hard cap protecting the instance today

PostgreSQL | things not to port blindly from SQL Server

Respect the engine differences

SQL Server habitPostgreSQL reality
Treat one large central buffer pool as the whole memory storyPostgreSQL also depends heavily on the OS page cache and backend-local memory
Look for memory clerks to rank consumersuse shared-buffer inspection, backend contexts, and spill evidence instead
Look for a central grant queue before tuning sortsPostgreSQL spills per operator when work_mem is too small
Assume buffer cache hit ratio tells the whole truthbuffer occupancy, workload shape, temp I/O, and checkpoint behavior matter more

Next: 12-postgresql-audit-logging turns from memory and cache behavior to evidence trails: PostgreSQL logs, statement capture, role and DDL auditing patterns, and what the server can and cannot record by default.