PostgreSQL can scale extremely well in production, but many deployments run on conservative defaults that are safe yet far from optimal. The crux of performance optimization is to understand what each setting really controls, how settings interact under concurrency, and how to verify impact with real metrics.
This guide walks through the two most important memory parameters:
- shared_buffers
- work_mem
shared_buffers
Let’s start with shared_buffers, because this is one of the most important concepts in PostgreSQL. When a client connects to PostgreSQL and asks for data, PostgreSQL does not read directly from disk and stream it back to the client. Instead, PostgreSQL does something that pulls the required data page into shared memory first and then serves it from there. The same design applies to writes. When the client updates a row, PostgreSQL does not immediately write that change to disk. It loads the page into memory, updates it in RAM, and marks that page as dirty. Disk writes come later.
And this design is intentional because reading and writing in memory are orders of magnitude faster than reading from or writing to disk, and it dramatically reduces random I/O overhead.
So what exactly is shared_buffers?
shared_buffers defines the size of the shared memory region that PostgreSQL uses as its internal buffer cache. And all the reads and writes go through shared_buffers. Disk interaction happens later asynchronously through background writing and checkpoints. So shared_buffers is the layer between the database processes and the disk.

By default, PostgreSQL sets shared_buffers to 128MB. That might be fine for local environments; however, it is not enough cache for real working sets, which means more disk reads, more I/O pressure, and less stable latency.
How do we size shared_buffers?
A common starting rule of thumb is:
If the server has more than 1GB RAM, start with 20–25% of total RAM on a dedicated PostgreSQL server and increase gradually if needed. Values above ~40% usually stop helping much.
There’s a reason we don’t just set it to ‘as high as possible’. If you give PostgreSQL too much buffer cache, you can start competing with the OS page cache, and you can also increase the volume of dirty data that must be flushed during checkpoints, which can increase checkpoint pressure and write spikes.
One more important thing to remember is that shared_buffers is a postmaster-level parameter. That means PostgreSQL allocates it at startup, and changing it requires a server restart.
How do I know if my current value is good?
As database engineers, our job is to size shared_buffers correctly:
- large enough to reduce disk reads
- but not so large that it harms the OS cache or makes checkpoints heavier
Step 1: Look at the cache hit ratio
One simple way is to look at the cache hit ratio using pg_stat_database.
SELECT
sum(blks_hit) / nullif(sum(blks_hit + blks_read), 0) AS cache_hit_ratio
FROM
Pg_stat_database;
If the cache hit ratio is close to 1, it means most reads are being served from memory, and this is generally what we want. If it’s low, it means PostgreSQL is doing more physical reads from disk, and that’s a signal to investigate.
Step 2: Verify it at the query level
To see whether a specific query is using cache, run:
EXPLAIN (ANALYZE, BUFFERS)
SELECT …

In the output, look for :
- buffer hits – served from shared_buffers
- buffer reads – pulled from disk
If you run the same query again, most of the time, the second run shows far more hits because now the pages are already in shared_buffers.
Important Note
In large production workloads, not everything can or should fit in memory. So you will see disk reads, and that’s normal. The goal isn’t that everything must be a cache hit. The goal is :
- Disk I/O shouldn’t be your bottleneck, and
- Reads and writes should be smooth and
- Latency shouldn’t spike because the cache is too small or mis-sized
If you want deeper visibility into what is currently stored in shared_buffers or which tables are occupying memory, PostgreSQL gives you tools for that. Extensions like:
- pg_buffercache
- pginspect
let you inspect shared buffers directly and understand memory usage patterns.
work_mem
After shared_buffers, the next memory parameter we need to focus on is work_mem.
And this is probably the most dangerous memory setting in PostgreSQL if you don’t fully understand how it works – not because it’s bad, but because it multiplies quietly. Many production outages caused by out-of-memory errors can be traced back to a misunderstanding of work_mem.
work_mem defines the limit or the maximum amount of memory allocated for executing operations such as:
- Sorting, when performing operations like ORDER BY, DISTINCT, and GROUP BY.
- JOINs usage (with hashing to build in-memory hash-tables, for example, for the hash join).
- Set operations like UNION, INTERSECT, and EXCEPT.
- Creating the bitmap arrays for the bitmap scan method
This parameter affects the efficiency of query execution and the overall performance of the database. It’s important to note that work_mem is allocated for each operation, not per the PostgreSQL session. This is a crucial detail, as a single SQL query can perform multiple sorting or join operations, each of which will consume its own area of memory. And some of these can be paralleized by PostgreSQL, and when that happens, each parallel worker uses up to work_mem per operation. If an operation runs sequentially, it can use up to work_mem. But if the same operation runs under a Gather node with, say, five parallel workers, then that single operation can consume:
5 × work_mem
This is exactly how databases run out of memory, even when the application hasn’t changed, because work_mem multiplies across:
- Parallel workers
- Multiple memory-intensive operations in a query
- Concurrent queries running at the same time
This is why the most important thing to remember is that work_mem is per operation, and it can be used multiple times inside a single query, across many concurrent queries.
How do we tune work_mem?
By default, PostgreSQL sets work_mem to 4MB. For many simple OLTP workloads with high concurrency, this is actually fine. But for analytical or reporting queries, 4MB is often too small.
If the work_mem is too small, PostgreSQL starts spilling to disk, and you’ll typically see:
- Temporary files are being created
- Sorts switching to disk-based algorithms
- Increased disk I/O and latency spikes
If the work_mem is too large, it will cause memory pressure or worst OOM kills.
We can measure if work_mem needs tuning using:
EXPLAIN (ANALYZE, BUFFERS)
SELECT …

If you look for:
- Sort Method: external merge
- temp file usage
- temp reads and writes
- disk usage reported in the plan
These signals describe exactly which operations are memory-bound, and those are the places worth tuning. Good thing about work_mem is that it’s is that it is not a postmaster-level parameter and you can tune it:
- per session
- per role
- per transaction
For systems with less than 64 GB of RAM, you can start with:
work_mem = 0.25% of total system RAMOn smaller systems, this translates to ~3 MB per GB of RAM. This is because on smaller machines, concurrency and parallelism are usually limited, so this sizing is aggressive enough to reduce unnecessary disk spills without creating memory risk. On large machines, however, scaling work_mem linearly with RAM becomes dangerous. Parallel queries, many concurrent sessions, and multiple operations can cause memory usage to grow exponentially. So for larger systems(>64GB), we can switch to a more conservative formula:
work_mem = max(162MB, 0.125% of RAM + 80MB)This approach does two important things:
- It still allows work_mem to grow with system size
- But it slows down the growth rate as RAM increases. In other words, it avoids giving every query an unnecessarily large memory area just because the machine is big.
Conclusion
Start with conservative, safe defaults. Measure behavior using real metrics like EXPLAIN (ANALYZE, BUFFERS) and system statistics. Tune selectively, especially for high-impact queries, instead of applying aggressive global changes.


