Monitor PostgreSQL Table Cache Hit Ratio with SQL
Measuring PostgreSQL Table Cache Efficiency with pg_statio_user_tables
PostgreSQL keeps frequently accessed data pages in shared_buffers to avoid hitting disk. When a backend reads a data block, PostgreSQL first checks shared_buffers; if the block is there, it counts as a heap hit. If it is not, PostgreSQL reads from disk and counts it as a heap read. The ratio of hits to total reads — the table cache hit ratio — is a primary indicator of how well your shared_buffers setting matches your working data set.
The pg_statio_user_tables view tracks these heap block reads and hits per table since the last statistics reset. A healthy cluster-wide ratio sits at or above 0.99 (99%). Ratios below 0.90 are a strong signal that shared_buffers is too small for the active working set and that a significant fraction of page reads are going to disk.
Sample Code
1-- Cluster-wide table cache hit ratio
2SELECT
3 SUM(heap_blks_read) AS heap_blks_read,
4 SUM(heap_blks_hit) AS heap_blks_hit,
5 SUM(heap_blks_hit) /
6 GREATEST(
7 SUM(heap_blks_hit) + SUM(heap_blks_read),
8 1
9 )::float AS cache_hit_ratio
10FROM pg_statio_user_tables;
Notes: Available on all supported PostgreSQL versions. Statistics accumulate since the last pg_stat_reset() call or server restart. Run as any role with access to pg_statio_user_tables; superuser not required.
Code Breakdown
pg_statio_user_tables— one row per user table; tracks block-level I/O counts for heap, TOAST, and index activityheap_blks_read— number of disk block reads from the table's heap (cache miss)heap_blks_hit— number of block reads served fromshared_buffers(cache hit)SUM(...)— aggregates across all user tables for a cluster-wide viewGREATEST(..., 1)— guards against division by zero when statistics have just been reset or no tables have been accessed::float— ensures floating-point division; without this, integer division truncates to 0 or 1
Key Points
- Target ratio ≥ 0.99; below 0.90 warrants investigating
shared_bufferssizing - A new cluster or a cluster after a
pg_stat_reset()will show a low ratio until a representative workload has run — allow time for the buffer cache to warm up before drawing conclusions - The ratio reflects cumulative activity since the last reset, not current load; a recently started cluster may show artificially low ratios
- A high cluster-wide ratio does not guarantee all tables are well-cached; always check the per-table breakdown to identify cold outliers
Insights and Explanations
The cluster-wide ratio masks individual table performance. Use the per-table query to find specific tables with low cache hit ratios:
1SELECT
2 schemaname,
3 relname AS table_name,
4 heap_blks_read,
5 heap_blks_hit,
6 CASE heap_blks_hit + heap_blks_read
7 WHEN 0 THEN NULL
8 ELSE ROUND(
9 heap_blks_hit::numeric /
10 (heap_blks_hit + heap_blks_read),
11 4
12 )
13 END AS cache_hit_ratio
14FROM pg_statio_user_tables
15WHERE heap_blks_hit + heap_blks_read > 0
16ORDER BY cache_hit_ratio ASC NULLS LAST
17LIMIT 20;
Tables with low per-table ratios are candidates for investigation. Common causes:
- Large tables exceeding
shared_buffers— full sequential scans on tables larger thanshared_bufferswill always show low ratios; these do not benefit from increasing the buffer cache and should instead be addressed with partial indexes or partitioning - Infrequently accessed tables — tables accessed rarely will accumulate high read counts relative to hits because their pages are evicted between accesses
- Recent data load — a freshly loaded table has never been in cache; its ratio rises as queries warm the buffer
To compare table cache hit ratio alongside index cache hit ratio for a complete I/O picture:
1SELECT
2 t.schemaname,
3 t.relname AS table_name,
4 CASE t.heap_blks_hit + t.heap_blks_read
5 WHEN 0 THEN NULL
6 ELSE ROUND(t.heap_blks_hit::numeric /
7 (t.heap_blks_hit + t.heap_blks_read), 4)
8 END AS table_cache_ratio,
9 CASE i.idx_blks_hit + i.idx_blks_read
10 WHEN 0 THEN NULL
11 ELSE ROUND(i.idx_blks_hit::numeric /
12 (i.idx_blks_hit + i.idx_blks_read), 4)
13 END AS index_cache_ratio
14FROM pg_statio_user_tables t
15JOIN pg_statio_user_indexes i USING (schemaname, relname)
16WHERE t.heap_blks_hit + t.heap_blks_read > 0
17GROUP BY t.schemaname, t.relname,
18 t.heap_blks_hit, t.heap_blks_read,
19 i.idx_blks_hit, i.idx_blks_read
20ORDER BY table_cache_ratio ASC NULLS LAST
21LIMIT 20;
If both table and index cache ratios are low for the same table, increasing shared_buffers is the primary remedy. The standard starting point for a dedicated PostgreSQL server is 25% of total RAM, with diminishing returns above 8 GB on most workloads.
Additional Considerations
- Permissions:
pg_statio_user_tablesis accessible to all roles by default; no superuser required - Statistics reset:
SELECT pg_stat_reset()resets allpg_stat_*andpg_statio_*counters; after a reset, wait for a full workload cycle before evaluating ratios - TOAST tables:
pg_statio_user_tablestracks the main heap only; TOAST heap I/O appears in separatetoast_blks_readandtoast_blks_hitcolumns in the same view - OS page cache: heap block reads counted by PostgreSQL reflect misses from
shared_buffers, but Linux may serve them from the OS page cache — actual disk I/O may be lower thanheap_blks_readimplies on systems with large RAM
References
Posts in this series
- Identify Underutilized PostgreSQL Columns for Optimization
- Identifying Unused PostgreSQL Indexes to Declutter & Optimize
- Monitor PostgreSQL HOT Updates and Fillfactor
- Find PostgreSQL Index Bloat and Wasted Space
- Estimate PostgreSQL Table Bloat with SQL
- Find Missing Indexes in PostgreSQL with SQL
- Monitor PostgreSQL Table Cache Hit Ratio with SQL