The first sizing conversation I have with every team moving from MySQL to PostgreSQL goes the same way. Someone looks at a 64 GB server, remembers the InnoDB rule of thumb, and sets shared_buffers to 50 GB. Then the box starts swapping under load, or the OS page cache gets squeezed to nothing, and the conclusion becomes "PostgreSQL memory management is weird." It is not weird. It is a genuinely different caching model, and if you carry the InnoDB mental model across the migration unmodified, you will mis-size the most important memory setting in the database.
I have operated both engines in production. InnoDB's buffer pool design is coherent and battle-tested, and I am not going to pretend otherwise. But PostgreSQL made a different architectural bet, and it changes how you size, how you tune, and how you read cache statistics. This article walks through both models the way I explain them during a migration review.
One cache versus two caches
InnoDB is built around a single large buffer pool. Data pages, secondary index pages, the change buffer, and the adaptive hash index all live inside it. On a dedicated database server, the standard advice is to give the buffer pool most of the machine, commonly somewhere in the range of 70 to 80 percent of RAM, and to set innodb_flush_method to O_DIRECT so data file reads and writes bypass the operating system page cache. The result is one authority over cached data: InnoDB decides what stays in memory, InnoDB tracks its own hit behavior, and the OS cache is deliberately kept out of the picture for data files so the same page is not held in memory twice.
PostgreSQL made the opposite bet. It keeps its own shared memory cache, shared_buffers, deliberately modest, and leans on the operating system page cache for the rest. All reads and writes to data files go through buffered file I/O, so a page that is not in shared_buffers may still be served from the OS cache without touching the disk. The long-standing starting point is shared_buffers at around 25 percent of RAM, with the expectation that the kernel will use most of the remaining memory to cache the same relation files.
That means a PostgreSQL server intentionally runs with two cooperating caches, and yes, a hot page can exist in both at once. People call this the double-buffering problem, and it is a real inefficiency, but it is also the price of a design that delegates a large part of cache management, read-ahead, and write scheduling to the kernel. In practice the model works well, and fighting it by inflating shared_buffers to InnoDB-style proportions usually makes things worse, not better.
How InnoDB runs its buffer pool
Inside the buffer pool, InnoDB manages pages with a modified LRU that splits the list into a young sublist and an old sublist. New reads land at the head of the old sublist and are only promoted if they are touched again after a configurable window, which protects the hot working set from being flushed out by a one-off table scan. You can split the pool into multiple instances with innodb_buffer_pool_instances to reduce mutex contention on large machines, resize it online, and even dump and reload the list of cached page identities across restarts so a warm working set survives a planned bounce.
Dirty page handling is also InnoDB's job. Background flushing is paced against redo log capacity and the innodb_io_capacity settings, and when checkpointing falls behind, write stalls show up directly in query latency. If you have run MySQL at scale, you have probably tuned this loop at least once. The point for a migration is that all of this machinery is internal to the engine, and all of the observability for it is internal too.
Why shared_buffers stays small on purpose
PostgreSQL's shared_buffers is a simple shared memory arena of 8 KB buffers managed with a clock-sweep algorithm. It has its own scan-resistance mechanisms, such as ring buffers for large sequential scans and bulk writes, so a big table scan does not evict the entire working set. But PostgreSQL does not implement O_DIRECT for normal data file I/O, does not do its own read-ahead scheduling the way InnoDB does, and expects the kernel to be good at those jobs.
This is why the sizing advice diverges so sharply. Giving shared_buffers 75 percent of RAM starves the OS page cache that PostgreSQL depends on, increases the volume of dirty data the checkpointer has to write in each cycle, and on some workloads measurably hurts. The 25 percent starting point is not superstition; it reflects the architecture. There are workloads where a larger value helps, particularly when the working set fits entirely inside shared_buffers, but you should treat anything beyond the default guidance as a hypothesis to test, not a rule to apply. If you enable it, huge_pages support is worth configuring for large shared_buffers values to cut page table overhead. The PostgreSQL sizing guide covers how memory settings interact with connection counts and work_mem, which is the other half of the memory budget conversation.
effective_cache_size is a hint, not an allocation
The setting that most confuses MySQL veterans is effective_cache_size. It allocates nothing. It is a planner input that tells PostgreSQL roughly how much memory is available for caching across both shared_buffers and the OS page cache combined, and the planner uses it to estimate whether an index scan is likely to be served from memory. Set it too low and the planner leans toward sequential scans because it assumes index pages will be cold on disk; set it to a realistic value, commonly 50 to 75 percent of RAM on a dedicated server, and index scans price correctly.
There is no InnoDB equivalent because there is no need for one: the buffer pool size is both the allocation and the planner's reality. In PostgreSQL, the split model means the database cannot directly observe the kernel's cache, so you have to tell the planner what to assume. Getting this wrong does not crash anything, which is exactly why it goes unnoticed; it just quietly produces worse plans.
Monitoring cache behavior in MySQL
On the MySQL side, buffer pool efficiency is visible through status counters. The two I always compare are logical read requests versus reads that had to go to disk.
SELECT variable_name, variable_value
FROM performance_schema.global_status
WHERE variable_name IN (
'Innodb_buffer_pool_read_requests',
'Innodb_buffer_pool_reads',
'Innodb_buffer_pool_pages_free',
'Innodb_buffer_pool_pages_dirty',
'Innodb_buffer_pool_wait_free'
);
Innodb_buffer_pool_reads counts requests that could not be satisfied from the pool. Because InnoDB typically bypasses the OS cache with O_DIRECT, a miss here really is a disk read, which makes the hit ratio an honest number. Innodb_buffer_pool_wait_free climbing means sessions are waiting for clean pages, a flushing problem rather than a sizing problem. SHOW ENGINE INNODB STATUS and information_schema.INNODB_BUFFER_POOL_STATS add page-level detail, including young and old sublist movement.
Monitoring cache behavior in PostgreSQL
PostgreSQL exposes the equivalent counters in pg_stat_database and, per relation, in pg_statio_user_tables. The classic hit ratio query looks like this.
SELECT datname,
blks_hit,
blks_read,
round(100.0 * blks_hit / nullif(blks_hit + blks_read, 0), 2)
AS shared_buffers_hit_pct
FROM pg_stat_database
WHERE datname NOT IN ('template0', 'template1')
ORDER BY blks_read DESC;
Here is the interpretation trap: this ratio measures shared_buffers only. A blks_read in PostgreSQL is a request the database sent to the operating system, and thanks to the page cache, many of those never reach the disk. So a 92 percent hit ratio on PostgreSQL can coexist with almost zero physical disk I/O, while the same number on InnoDB would mean real trouble. Never compare the two ratios directly across engines. To see where I/O time actually goes, PostgreSQL 16 and 17 add pg_stat_io, which breaks I/O down by backend type, object, and context, and if you enable track_io_timing you get actual read and write timing in pg_stat_statements and pg_stat_database. The pg_buffercache extension shows exactly which relations occupy shared_buffers when you want to inspect the working set.
Sizing advice I actually give during migrations
Start PostgreSQL with shared_buffers at 25 percent of RAM capped in the low tens of gigabytes, effective_cache_size at about 70 percent of RAM, and track_io_timing on. Watch pg_stat_io and query latency for a few weeks before touching anything. Resist the instinct to recreate the 75 percent buffer pool; the memory you leave to the kernel is not wasted, it is your second cache tier. Budget separately for work_mem times expected concurrent sorts, because unlike MySQL's mostly-global memory model, PostgreSQL's per-operation memory can multiply quickly across connections. The broader PostgreSQL overview covers where these knobs sit in the wider configuration picture.
Also accept that some InnoDB comforts do not carry over. There is no built-in equivalent of the buffer pool dump and reload for instant warm restarts, though pg_prewarm can reload specific relations. And cache observability requires assembling a few views rather than reading one status page.
Where MonPG fits after you land on PostgreSQL
MonPG monitors PostgreSQL only, so it does not help you watch the InnoDB side during the transition. What it does is make the PostgreSQL cache model legible once you are running on it. MonPG tracks buffer hit behavior, I/O timing, checkpoint activity, and per-query block statistics over time, so you can see whether a latency regression is a cache-fit problem, a checkpoint spike, or one query family suddenly reading far more blocks than before. That history is exactly what the raw counters above lack. If you are building out your post-migration observability baseline, start with the PostgreSQL monitoring guide and make cache and I/O evidence part of the first dashboard, not an afterthought once the first incident forces the issue.