Every PostgreSQL backend is a separate process. Every process keeps its own private page table to describe the same shared_buffers. Six hundred backends means six hundred copies of that description. Nothing is shared, each copy is built from zero, and all of them use real RAM. This page shows where that memory goes, and what a 2 MB page changes.
Your program never uses a physical memory address. It uses virtual addresses, and something has to translate them. That something is the page table. It lives in RAM and holds one entry for every 4 KB page the process has touched. Each entry is small — only 8 bytes. But a 32 GB shared_buffers has 8,388,608 pages, so the numbers grow fast.
The default page size on x86-64. Linux maps and faults memory in units of 4 KB. Nothing smaller, and by default nothing larger.
Each page needs one entry. It says where the page really is and what you may do with it. 8.4M entries × 8 B = 64 MB to describe 32 GB.
This is the part that hurts. The database does not pay 64 MB once. Every backend pays it separately.
Two numbers show all of this on any running server, and you do not need root to read them. PageTables in /proc/meminfo is the total for the whole system. VmPTE in /proc/<pid>/status is the part that belongs to one process.
$ awk '/VmPTE/ {t += $2; n++} END {if (n) printf "processes: %d\ntotal: %.1f MB\nper proc: %.1f MB\n", n, t/1024, t/n/1024}' \ $(pgrep -x postgres | sed 's|.*|/proc/&/status|') processes: 602 total: 18662.0 MB per proc: 31.0 MB
shared_buffers that after a few days the backend gets close to the ceiling anyway. The server above is real: 602 processes at 31 MB each, about half the ceiling, and still 18.2 GB of RAM used to describe memory the server already owns. Every number on this page is a ceiling. Your own number will be lower, and that is the number that decides if this work is worth it.The CPU keeps recent translations in the TLB, a small and very fast hardware table. A hit costs almost nothing. On a miss the CPU has to walk a tree in memory. Each read depends on the one before it, and only then can your real memory access start. Step through both cases.
shared_buffers goes past 8 MB all the time. It goes past 4 GB much less often.A backend is a process, not a thread. They all map the same physical shared_buffers, but each one keeps its own private page table to describe it. Watch what happens when a second backend touches a page that the first one has already faulted in.
When a backend touches a buffer for the first time, it takes a page fault, even though the data is already in RAM. Nothing is read from disk. The kernel just fills in the missing entry in that process's table and lets the instruction run again. One fault is cheap. The total is not: about 84 million faults to fully warm ten backends against a 32 GB shared_buffers.
In normal work this is not painful: once a backend is warm, it stops faulting. The dangerous moment is when shared_buffers is empty and still filling up, because then every access is a first touch. In 2026 a scheduler change in Linux 7.0 turned that moment into a real outage on servers with a large pool and huge pages off. PostgreSQL became about two times slower. Read the chain below slowly, because no single step in it looks like a problem.
A large shared_buffers with nothing in it yet. Every buffer the backend asks for is a first touch, so the fault rate is as high as it will ever be.
The backend holds a buffer-allocation spinlock when the minor fault happens. Normally this does not matter. The fault takes microseconds, then the lock is free again.
After the scheduler change, the kernel can stop the backend inside the fault, while it still holds the spinlock. Now a process that is not running holds the lock.
A spinlock does not sleep. Every other backend that needs a buffer burns CPU while it waits for a process that is not running. Throughput drops by half.
huge_pages = on did not fix the lock or the scheduler. There were simply 512× fewer first-touch faults, so there were 512× fewer chances to be unlucky inside one. This is the shape of the whole optimisation. It does not make one operation faster. It removes a very large number of small events, and it takes away the risk that lives inside them. The full write-up of the incident →This is simple maths: shared_buffers ÷ page_size × 8 bytes × backends. Move the sliders to your production settings to see the ceiling for both page sizes. Then run the VmPTE command from section 01 and compare the left number with your real server.
VmPTE command in section 01.A huge page is not a different kind of memory. It is the same RAM, described in bigger units. The page table now stops one level earlier, because one entry covers 2 MB directly. The last level disappears completely, and with it 511 of every 512 entries and 511 of every 512 faults.
| For 32 GB of shared_buffers | 4 KB pages | 2 MB huge pages |
|---|---|---|
| Entries needed | 8,388,608 | 16,384 |
| Page table, per backend | 64 MB | 128 KB |
| Faults to fully warm one backend | 8,388,608 | 16,384 |
| TLB reach (2048 entries) | 8 MB | 4 GB |
| Ceiling at 300 backends | 18.75 GB | 37.5 MB |
| Ceiling at 600 backends | 37.5 GB | 75 MB |
shared_buffers. Second, CPU usage usually drops by 5–20%, because the TLB hits more often and the faults stop happening. The RAM is the safe win. The CPU is a bonus, and it depends a lot on how random your access pattern is.The cost is backends × shared_buffers ÷ page_size. Huge pages make the third part smaller. A connection pooler puts a limit on the first part, and it needs no reboot, no kernel settings and no reserved memory. If you do not use one yet, start there.
500 clients, pool_size = 30 → 30 backends. About 1.9 GB of page tables instead of 31 GB.
Server connections are reused much more often. Each backend keeps a hot working set, and its translations stay in memory.
server_lifetime and server_idle_timeout close big backends and drop their page tables. But they also drop warm ones. This is a trade, not a free win.
shared_buffers. They all become hot, and each one carries a full-size table. If it prefers the connection it used last, the traffic stays on a few connections. Only those become hot, and the rest stay cold with small tables until an idle timeout closes them. Drivers and poolers use different names for this, so do not trust the name. Compare VmPTE across your backends instead. Similar numbers mean all of them are hot. Very different numbers mean only a few are.| The claim | Why it is true |
|---|---|
| Page tables cost real RAM | 8 bytes per 4 KB page. A fully warmed 32 GB shared_buffers needs 64 MB of entries to describe it. |
| The cost multiplies by backends | Each process keeps its own table, so the total grows with your connection count. For one 32 GB pool, 300 backends give a ceiling of 18.75 GB. |
| Warm-up is not shared either | Backend B faults on a page that backend A has already faulted in, because B's own table is empty there. These faults are minor. There is no I/O, so nothing in your metrics points at them. |
| Huge pages remove 511 of every 512 entries | One entry covers 2 MB instead of 4 KB. The same memory, 512× fewer descriptions of it, and 512× fewer first-touch faults to build them. |
| Shared memory only | work_mem, maintenance_work_mem, catalog and plan caches all stay on 4 KB pages. Huge pages work on the shared memory segment and nothing else. |
| It only matters at scale | With a small shared_buffers and few connections, none of this is your problem. The measurement tells you which case you are in. |
grep '^PageTables' /proc/meminfo takes one second and answers the only question that matters. Is this costing you a few hundred megabytes, or tens of gigabytes? If it is a few hundred MB, spend your time on something else. If it is several GB, the migration is worth the work. Everything above only explains that one number. The steps to roll it out are in the post.