Does PostgreSQL COMMIT Free Memory? Hidden Causes & Fixes

If your PostgreSQL server's RAM graph won't come down after COMMIT, this TechSouls guide explains why, and when you should actually worry. Most of the time, nothing is broken.
Does a commit in PostgreSQL release memory? The short answer
Yes and no. COMMIT frees the memory PostgreSQL used for that transaction inside the backend process, so the next query on the same connection can reuse it. It does not reliably hand that memory back to the operating system, and it never touches shared_buffers. The OS gets it back when the connection closes.
That's the whole answer in one paragraph. The trouble is the word "release." Memory that PostgreSQL frees can land in three different places, and only one of them makes your monitoring graph drop:
- Back into a PostgreSQL memory context, ready for the same session to reuse.
- Back to the C library's malloc, which may or may not return it to the kernel.
- Back to the operating system, which is what top, htop and your cloud dashboard measure.
COMMIT reliably does the first. It partly does the second. The third is mostly out of PostgreSQL's hands while the connection stays open.
Most answers online stop at "shared buffers stay allocated" and "run VACUUM." Both true. Neither explains why one idle connection can sit at hundreds of megabytes, which is the part this guide is about.
Most query memory is gone before you even type COMMIT
PostgreSQL rarely calls malloc and free for individual allocations. It hands out memory from memory contexts: named pools arranged in a tree, each with a fixed lifespan. When a lifespan ends, the whole pool is reset or deleted in one move instead of freeing chunks one by one. The memory manager README in the PostgreSQL source describes transaction cleanup exactly this way: reset every context that lives for a transaction or less, and the transient memory comes back.
The detail that changes how you think about COMMIT is when each pool dies.
| Memory context | Lives until | What it holds | Freed by COMMIT? |
| Per-tuple expression contexts | The next row | Temporary values while evaluating WHERE clauses and expressions | No, reset row by row, long before |
| ExecutorState and its children | End of the query (often sooner) | Plan state, plus sort and hash tables limited by work_mem | No, already freed when the statement finished |
| MessageContext | The next client message | Query text and, in simple-query mode, its parse and plan trees | No, reset every message |
| TopTransactionContext / CurTransactionContext | End of the transaction | Transaction state, queued NOTIFY messages, deferred trigger events | Yes, this is what COMMIT resets |
| Portal (cursor) contexts | Cursor close | Cursor plan and state | Ordinary cursors close at transaction end; WITH HOLD cursors survive COMMIT |
| Prepared statement contexts | DEALLOCATE or disconnect | Parse and plan trees for reuse | No |
| CacheMemoryContext | Disconnect | Catalog and relation caches | No, never reset |
| TopMemoryContext | Disconnect | Long-lived bookkeeping | No, never reset |
Read the second row again. If one transaction runs ten heavy reporting queries, the sort and hash memory from query one is released when query one ends, not when you finally commit. COMMIT adds cleanup of transaction-scoped state, which is usually small.
Usually. The exception is anything that queues work for commit time. Deferred foreign keys (DEFERRABLE INITIALLY DEFERRED) and deferred constraint triggers queue one event per affected row, and that queue only clears at COMMIT or ROLLBACK. A ten-million-row update against a table with deferred constraints grows memory for the life of the transaction. Batch those updates.
Two smaller things also end at COMMIT. SET LOCAL values revert, which matters later in this guide. And a failed transaction keeps its memory longer than you'd expect: the README notes that TopTransactionContext isn't cleared at the moment of an error. It survives until the client sends ROLLBACK. A session stuck in idle in transaction (aborted) is holding that memory until somebody ends it.
Why the backend process doesn't shrink after COMMIT
So the memory was freed. Why does top still show the same number?
Because "freed" inside PostgreSQL means handed back to an allocator, not to the kernel. Two layers sit in between.
The first is PostgreSQL's own allocator. When a context is reset, its first block isn't returned to malloc; it's cleared and kept, so a context that gets reset thousands of times per query doesn't hammer malloc on every cycle. The README calls this avoiding malloc thrashing. Small per context, but it's deliberate.
The second layer is the C library. Every other block does go back through free(), and from there the C library's malloc decides what happens. Freed space in the middle of the heap usually stays mapped so the process can reuse it. Tom Lane, one of PostgreSQL's longest-serving core developers, answered a 2003 report about sort memory that stayed allocated by pointing out that on many Unix systems, malloc'd space isn't released back to the OS until the process exits (pgsql-general thread). His advice then still works now: if you need the memory back, start a fresh session.
The practical result is that every backend process settles at its high-water mark, the most it ever needed at once, rather than what it's using right now.
Picture a pooled connection that runs one large hash join during a nightly report. For the rest of the day it serves tiny lookups, yet its process keeps roughly the footprint of that one report. On one connection, nobody cares; the next big query just reuses those pages. Across 200 pooled connections that have each hit their own peak at some point, you get a server that looks like it's leaking when it isn't.
Here's the catch. A real leak looks identical in top. In 2016, a developer on PostgreSQL 9.3 reported that running one query raised a connection's memory by about 50 MB that didn't come back until disconnect. It turned out to be the 9.3 implementation of array_agg(), which was later fixed (write-up and follow-up on GitHub). Same symptom as a harmless high-water mark, different cause. The test that separates them comes a few sections down.
shared_buffers was never going to shrink, and your monitoring counts it twice
shared_buffers is PostgreSQL's main data cache: one block of shared memory that every backend reads table and index pages through. It is sized once, at server start. Per the official resource-consumption docs, the default is typically 128 MB, changing it requires a restart, and a reasonable starting point on a dedicated server with at least 1 GB of RAM is 25% of system memory. The same page says going past 40% is unlikely to help, because PostgreSQL also leans on the operating system's file cache.
COMMIT doesn't empty this cache, and you wouldn't want it to. The pages your transaction read or changed stay cached so the next query finds them in RAM. Modified pages get written out later by the checkpointer and background writer; what COMMIT forces to disk is the WAL record, not the data pages.
Now the measurement trap, which I'd bet causes more "PostgreSQL is leaking memory" tickets than actual leaks do.
On Linux, a process's RSS (resident set size) includes shared memory pages it has touched. A backend that scanned a big table shows a large RSS in top even though most of it is the same shared cache every other backend also points at. Add up RSS across 50 backends and you can "find" far more memory than the server physically has. Tom Lane asked exactly this of someone convinced memory was only freed at backend exit: are you sure you aren't looking at shared-buffer usage? (pgsql-general, 2014).
Measure these instead:
- PSS (proportional set size) splits each shared page among the processes using it, so totals add up honestly. smem reports it, or read /proc/<pid>/smaps_rollup.
- Private / USS memory is what the backend owns alone. This is the number the high-water mark from the previous section shows up in.
- "available" in free -h, not "free". Linux fills idle RAM with file cache and hands it back on demand. A PostgreSQL box with almost nothing in the "free" column is normal. Kevin Grittner made the same point to a user chasing pooled-connection memory back in 2008: lots of people get confused by OS cache usage, and it isn't a problem (thread).
work_mem is what sets each connection's high-water mark
If shared_buffers explains the big number in your dashboard, work_mem explains why individual connections balloon.
The docs define work_mem as the base maximum a single query operation, such as a sort or a hash table, can use before spilling to temporary files on disk. The default is 4 MB. Three multipliers sit on top of that, all documented:
- Per operation, not per query. A complex query can run several sorts and hashes at the same time, each allowed its own work_mem.
- Hash operations get more. Their limit is work_mem × hash_mem_multiplier, which defaults to 2.0. Hash joins, hash aggregation, memoize nodes and hashed IN subqueries all count.
- Per process, including parallel workers. Each parallel worker applies the limits separately. The docs give the example that a query using 4 workers can use up to 5 times the memory of a non-parallel one.
Do the arithmetic once and you'll never set work_mem casually again. Say you raise it to 64 MB. A reporting query with three hash joins and one sort could then reach:
- 3 hash nodes × 128 MB + 1 sort × 64 MB = 448 MB in one process
- With 2 parallel workers plus the leader participating: up to about 1.3 GB for that single query
- Ten users running that report at once: a ceiling around 13 GB
Those are ceilings, not predictions. Most queries use far less. But the ceiling is what the OOM killer cares about.
And here's how it ties back to COMMIT: when that report finishes, whatever it used is freed inside PostgreSQL before you ever commit. Then, per the high-water-mark behavior above, the process often keeps it. A generous global work_mem doesn't just raise peak usage; it raises the resting size of every connection that ever ran a heavy query.
What I'd do instead: leave the global setting modest and raise it only for the transaction that needs it.
BEGIN;
SET LOCAL work_mem = '256MB';
-- the one heavy report query
COMMIT; -- work_mem reverts to the server default here
SET LOCAL lasts until the end of the transaction, so COMMIT is the one moment it definitely releases something: the setting. Better still, route heavy reports through a separate connection pool whose connections get recycled, so their high-water marks don't linger in the pool your web app uses.
How do you know a query needs more? Run it with EXPLAIN (ANALYZE). A sort node reporting Sort Method: external merge Disk: ... spilled to disk; quicksort Memory: ... fit in RAM. Setting log_temp_files = 0 logs every temporary file, which shows you spills across the whole workload. If spills are routine and raising work_mem causes memory pressure, the docs suggest raising hash_mem_multiplier instead, with values from 2.0 up to 8.0 or more where work_mem is already 40 MB or higher.
VACUUM runs on a separate memory budget (and COMMIT doesn't free disk either)
Quick detour, because "memory" in this question sometimes turns out to mean disk. COMMIT doesn't shrink tables. A deleted or updated row leaves a dead row version behind; plain VACUUM marks that space reusable inside the table file, and only VACUUM FULL (which rewrites the table under an exclusive lock) hands disk space back to the OS. Different problem, different fix.
VACUUM's own memory comes from maintenance_work_mem, not work_mem. According to the resource docs:
- It defaults to 64 MB and also covers CREATE INDEX and ALTER TABLE ADD FOREIGN KEY.
- A session runs only one of these operations at a time, so the docs consider it safe to set it well above work_mem.
- Autovacuum is the exception: up to autovacuum_max_workers × this value can be allocated at once.
- autovacuum_work_mem defaults to -1, meaning "use maintenance_work_mem." Set it explicitly and you can give manual maintenance a big budget without multiplying it across every autovacuum worker.
- Parallel utility commands treat maintenance_work_mem as a limit for the whole command, not per worker (the opposite of how work_mem behaves in parallel queries).
The version detail most guides miss: before PostgreSQL 17, VACUUM was silently capped at 1 GB for tracking dead rows, no matter how high you set these parameters. The PostgreSQL 17 release notes list a new memory management system for VACUUM that reduces memory use, and state the 1 GB cap is gone. On 17 or later, a large maintenance_work_mem actually gets used, so budget for it.
One more thing VACUUM won't do: wipe your cache. VACUUM and ANALYZE read through a small ring of shared buffers set by vacuum_buffer_usage_limit (2 MB by default) rather than evicting everything else.
What survives COMMIT on purpose
Some memory was never transaction memory. It belongs to the session, and PostgreSQL keeps it until disconnect because rebuilding it for every transaction would be slower. None of this is a leak, but all of it adds to the resting size of a long-lived connection.
- Catalog and relation caches. These live in CacheMemoryContext, which the mmgr README says is never reset or deleted. Each backend caches metadata for the tables, indexes and functions it has touched. With 30 tables, that's nothing. In a schema-per-tenant SaaS with thousands of tables, a pooled connection that has served many tenants can carry metadata for a lot of them.
- Prepared statements. Each keeps its parse and plan trees in a private context until DEALLOCATE or disconnect. Drivers and ORMs that prepare statements automatically can pile these up on a connection that lives for weeks.
- PL/pgSQL functions. The PL/pgSQL docs say the interpreter builds an instruction tree the first time a function is called in each session, and caches plans for the statements inside it. A trigger function attached to several tables gets a separate cache per table.
- Temporary tables and temp_buffers. Temp tables outlive COMMIT unless you create them with ON COMMIT DROP or ON COMMIT DELETE ROWS. Their pages sit in session-local temp_buffers, up to 8 MB by default, and the docs note you can only change that setting before the session first uses a temp table.
- Cursors declared WITH HOLD. Ordinary cursors close at transaction end. Holdable ones keep their results after COMMIT until you CLOSE them.
- Sequence state. Trivial, but a nice illustration: the currval() value for each sequence a session has used stays until the session exits. Tom Lane described it in 2002 as a few bytes per sequence, not worth worrying about (thread).
Does DISCARD ALL give that memory back?
Partly, and not in the way people hope. DISCARD ALL resets a session to its initial state. The docs list it as equivalent to CLOSE ALL, SET SESSION AUTHORIZATION DEFAULT, RESET ALL, DEALLOCATE ALL, UNLISTEN *, pg_advisory_unlock_all(), DISCARD PLANS, DISCARD TEMP and DISCARD SEQUENCES, and it can't run inside a transaction block.
Look at what's missing from that list: the catalog caches. And whatever DISCARD does free goes back to the allocator, with the same "may or may not reach the OS" caveat as COMMIT. Use DISCARD ALL for clean session state between pool clients. If the goal is reclaiming RAM, the reliable tool is closing the connection.
Leak or normal? Check it with two built-in tools

None of the guides I found ranking for this question show you how to look inside a backend. PostgreSQL 14 added two tools that do exactly that, so stop guessing from top.
1. Find your long-lived connections. High-water marks pile up on connections that have been open for days.
SELECT pid, usename, application_name, state,
now() - backend_start AS connected_for
FROM pg_stat_activity
WHERE backend_type = 'client backend'
ORDER BY backend_start
LIMIT 20;
2. Look inside your own session. The pg_backend_memory_contexts view shows every memory context of the session you're connected with: total, used and free bytes per context. By default, only superusers and members of pg_read_all_stats can read it.
SELECT name, ident,
pg_size_pretty(total_bytes) AS total,
pg_size_pretty(used_bytes) AS used,
pg_size_pretty(free_bytes) AS free
FROM pg_backend_memory_contexts
ORDER BY total_bytes DESC
LIMIT 15;
Run it, then run your heavy query, then run it again. You'll see which context grew, and whether it shrank once the query and transaction ended.
3. Look inside someone else's session. You can't query another backend's view, but pg_log_backend_memory_contexts(pid) asks that backend to write its full context tree to the server log. It's restricted to superusers unless you grant EXECUTE, and the docs warn that calling it often generates a lot of log volume.
SELECT pg_log_backend_memory_contexts(12345); -- pid from step 1
4. Compare with what the OS sees. On Linux:
grep -E '^(Rss|Pss|Private)' /proc/12345/smaps_rollup
If the context totals are small but private memory is large, you're looking at freed memory the allocator kept: the high-water mark. If one named context is large and stays large between transactions, you've found where the memory lives.
The actual test
Run the same workload repeatedly on one connection and record its memory each time.
- It climbs, then flattens. Normal. That's the high-water mark from the biggest thing the session ever did.
- It keeps climbing with identical work. That's a leak. Note which context grows, your exact PostgreSQL version, and any extensions loaded.
Real leaks exist, they're just rarer than the forum threads suggest, and they're usually narrow. In July 2024, Tom Lane committed a fix because committing a transaction left the current memory context pointing at TopMemoryContext, which could turn some short-lived allocations into session-lifetime leaks; the first report involved LISTEN/NOTIFY processing (commit message). Extensions are another source. Babelfish for PostgreSQL, for example, fixed a buffer that wasn't released during bulk imports of decimal data (issue #2455).
The pattern is the same in both: a specific code path, a specific version. Stay current on minor releases and suspect extensions before core.
What I'd actually do about high memory after COMMIT
In order of how much they help, for the typical setup of a web app talking to PostgreSQL through a connection pool:
- Stop adding up RSS. Check "available" memory and per-process PSS or private memory. Plenty of memory alarms disappear at this step.
- Cap the number of backends. Every backend carries its own high-water mark and its own caches. Fewer, busier connections behind a pooler like PgBouncer beat hundreds of idle ones. [INTERNAL LINK: connection pooling for web apps]
- Recycle connections on a timer. Closing a connection is the only thing that reliably returns a backend's private memory to the OS. Most poolers have a max-lifetime setting for this (server_lifetime in PgBouncer, maxLifetime in HikariCP). An hour is a sensible starting point; tune it to your traffic. [YOUR EXAMPLE: the lifetime value you use in production and why]
- Keep global work_mem modest. Raise it per transaction with SET LOCAL for the queries that need it, and send heavy reports through their own short-lived connections.
- Set idle_in_transaction_session_timeout. A session left idle inside an open transaction holds its locks and its transaction memory, and the client-connection docs note it also stops VACUUM from removing recently dead rows. Ending these sessions automatically is almost always the right call.
- Be careful with idle_session_timeout. It ends idle sessions outside a transaction (PostgreSQL 14+). The docs warn that poolers may not react well to connections closing under them, and suggest applying it only to interactive users, e.g. ALTER ROLE analyst SET idle_session_timeout = '15min';.
- Set autovacuum_work_mem explicitly, especially on PostgreSQL 17+, where VACUUM can now use more than 1 GB.
- Read the Linux overcommit section of the docs before sizing anything on a dedicated database server (kernel resources). When memory does run out, you want an error in one query, not the OOM killer taking out the postmaster.
And one thing I wouldn't do: restart PostgreSQL to "free memory." A restart empties shared_buffers, so the next hour of traffic reads from disk while the cache warms back up, and every connection climbs back to its high-water mark once the same workload returns. If one idle backend is genuinely bloated, end just that one with SELECT pg_terminate_backend(pid); and let the pool reconnect. A GitLab user on PostgreSQL 9.6 saw memory drop almost to nothing the moment they terminated idle backends (GitLab issue #25505). That's the surgical version of a restart, without throwing away the cache.
FAQs
Does ROLLBACK release memory the same way COMMIT does?
Yes. Transaction-scoped memory contexts are cleaned up when the transaction ends, whichever way it ends. The one wrinkle: after an error, that memory stays until the client actually sends ROLLBACK, so a session parked in an aborted transaction keeps holding it.
Does COMMIT release locks in PostgreSQL?
Row and table locks taken during the transaction are released at COMMIT or ROLLBACK. Session-level advisory locks taken with pg_advisory_lock() are not; they stay until you unlock them or disconnect, which is why DISCARD ALL includes pg_advisory_unlock_all().
Does COMMIT free disk space after a big DELETE?
No. The deleted rows become dead row versions. VACUUM makes that space reusable inside the table; VACUUM FULL returns it to the operating system but rewrites the table under an exclusive lock.
How much memory does one PostgreSQL connection use?
There's no fixed number: a small baseline, plus caches for whatever the session has touched, plus the high-water mark of its biggest query. Measure it from inside the session with SELECT pg_size_pretty(sum(total_bytes)) FROM pg_backend_memory_contexts; and compare against PSS from the OS.
Is high memory usage after COMMIT a memory leak?
Usually not. If memory rises and then plateaus under a repeated workload, it's the allocator keeping freed memory for reuse. If it keeps rising with identical work, treat it as a leak and find the growing context with pg_backend_memory_contexts or pg_log_backend_memory_contexts().
Should I lower shared_buffers because RAM looks full?
Not until you've checked "available" memory and PSS. shared_buffers pages show up in the RSS of every backend that touched them, and the OS file cache fills "free" memory on purpose. Shrinking a correctly sized cache just sends more reads to disk.
Does closing the connection release the memory?
Yes. Each connection is its own backend process, and when it exits the operating system reclaims all of its private memory. shared_buffers stays, because it belongs to the server, not the connection.
Does VACUUM release memory when it finishes?
VACUUM's working memory comes from maintenance_work_mem (or autovacuum_work_mem for autovacuum) and is freed when the operation ends. The same allocator rules apply to a manual VACUUM in a long-lived session. Autovacuum runs in separate worker processes, so their memory isn't charged to your application's connections.
Your next step: one test, ten minutes
Take the oldest connection from the pg_stat_activity query above and run pg_log_backend_memory_contexts() on it. Then open a fresh connection, check pg_backend_memory_contexts, run your heaviest real query, COMMIT, and check again.
Small contexts inside a large process means the allocator is holding freed memory. Recycle connections on a timer and pull global work_mem back down. One context that keeps growing across identical runs means a leak: write down the context name, your exact PostgreSQL version and loaded extensions, update to the latest minor release, and if it survives that, report it to the pgsql-bugs mailing list with those three details.
Running PostgreSQL for your website or app? Explore our guides on web development, SEO audit services, and AI in mobile app development to improve performance and build a more reliable digital experience.