> ## Content Index
> Fetch the complete content index at: https://debugly.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# The Idle Transaction That Held the Lock for Six Hours
- URL: https://debugly.dev/idle-in-transaction-held-the-lock/
- Published: 2026-10-09T15:01:00.000Z
- Updated: 2026-10-10T13:59:27.000Z
- Description: A connection checked out from the pool was never committed, so the transaction sat idle holding a row lock, and everything behind it queued until the pool…
- Author: Rohit Bhadani
- Tags: Error Autopsy, PostgreSQL, Database

I started at the incident, because that is where the symptom landed, and walked it backwards link by link until the stall stopped looking innocent.

The idle-in-transaction lock has a single weak property, and here it is: the transaction holds the lock while doing nothing, so the database looks healthy, the connection looks alive, and the only evidence is a row in a system view that nobody queries until the stall.

This was Postgres 16 behind a connection pooler, and the mechanics are the same for any database with explicit transactions and a pool.

## Why the transaction never ended

The code opened a transaction, and the transaction was the begin, and the begin was the lock, and the lock was the row, and the row was the update, and the update was the work, and the work was followed by an external call, and the external call was the HTTP request, and the request was the wait, and the wait was the minutes, and the minutes were the transaction's duration, and the duration was the lock's hold, and the hold was the queue behind it.

The transaction was not idle because the code was broken, it was idle because the code was waiting, and the waiting was inside the transaction, and the inside was the problem, because the transaction boundary was drawn around the work and the wait, and the wait should have been outside, and the outside is the fix, and the fix is the boundary, and the boundary is the one line that commits before the call.

The specific failure was the exception path, because the external call raised, and the raised exception skipped the commit, and the skipped commit left the transaction open, and the open transaction was returned to the pool, and the pool reused the connection, and the reused connection carried the open transaction, and the carried transaction was the permanent lock, and the permanent was the six hours, and the six hours was the incident.

## Why the database looked healthy

The connection was alive, and the alive was the pool's check, and the check was the ping, and the ping succeeded, because the connection was fine, and the fine was the transaction, and the transaction was the problem, and the problem was not the connection's health, it was the transaction's state, and the state was idle in transaction, and the state is in a system view, and the view was not on the dashboard, and the not on the dashboard was the invisible, and the invisible was the six hours.

The query throughput looked normal, and the normal was the other queries, and the other queries were the unaffected tables, and the unaffected was the false health, and the false health was the reason nobody looked, and the not looking was the duration, and the duration was the incident, and the incident is the reason the transaction state belongs on the dashboard.

This is the same invisibility as [the Postgres checkpoint storm that looked like a slow disk](https://debugly.dev/postgres-checkpoint-storm/), and the shared property is the one worth fixing.

## The fix, in order

**Commit before the external call.** The transaction boundary moved, so the database work commits and then the HTTP call happens, and the commit releases the lock, and the released is the queue's end, and the end is the fix, and the fix is the ordering, and the ordering is the discipline, because the transaction should cover the database work and nothing else.

**Set idle\_in\_transaction\_session\_timeout.** The database kills a transaction idle beyond the limit, and the killed is the released lock, and the released is the fix, and the fix is the setting, and the setting is the guard, and the guard is the prevention, because the runaway transaction is terminated by the database rather than by the incident.

**Roll back on every exit path.** The transaction is closed in a finally block, and the finally is the guarantee, and the guarantee is the fix, and the fix is the structure, and the structure is the context manager, and the context manager is the idiom, and the idiom is the discipline, because the exception path is the one that leaks, and the leaked is the lock.

**Alert on the transaction state.** The dashboard shows the count of idle-in-transaction sessions and the oldest transaction's age, and the age is the alert, and the alert is the detection, and the detection is the minutes rather than the hours, and the minutes are the fix, because the state is visible before the stall.

**Do not return uncommitted connections to the pool.** The pool resets the connection on return, and the reset is the rollback, and the rollback is the release, and the release is the fix, and the fix is the pool's configuration, and the configuration is the guard, and the guard is the prevention, because the pool should never hand out a connection carrying someone else's transaction.

## The rule I keep

A transaction holds its locks until it commits or rolls back, so an external call inside the transaction extends the hold for the duration of the call, and an exception that skips the commit makes the hold permanent. Commit before external calls, set idle\_in\_transaction\_session\_timeout, roll back on every exit path, alert on transaction age, and reset connections on pool return.

The service stalled for six hours behind a lock held by a transaction that was doing nothing, because an exception skipped the commit and the pool handed the open transaction to the next caller. The lesson I keep is the property, not the incident.