ArmorDB Logo
ArmorDB
Postgresql Isolation Levels Comparison
PostgreSQL Isolation Levels Compared for SaaS Applications
Back to Blog
Data-Specs
June 24, 2026
8 min read

PostgreSQL Isolation Levels Compared for SaaS Applications

Compare PostgreSQL Read Committed, Repeatable Read, and Serializable isolation for production SaaS workloads, including retries, locks, and when each level fits.

AE
ArmorDB EngineeringArmorDB engineering
PostgreSQLTransactionsIsolation Levels

Transaction isolation is one of those PostgreSQL settings that only gets attention after a strange production bug: two users reserve the last seat, a balance check passes even though the final state is invalid, or a report changes underneath a long-running job. The database did not necessarily lose data. More often, the application assumed a stronger transaction model than the one it was actually using.

For most SaaS applications, PostgreSQL's default Read Committed isolation is the right starting point. It keeps ordinary request latency predictable and works well when each transaction updates a small, explicit set of rows. The hard part is knowing when the default is not enough, when a row lock or constraint is the better tool, and when Serializable isolation is worth the retry logic it requires.

This comparison focuses on production application decisions rather than textbook anomalies. The goal is to pick the smallest isolation strategy that protects the business rule without turning every request into a serialization bottleneck.

The practical model: what can change while your transaction runs

PostgreSQL implements the SQL isolation levels with multiversion concurrency control. Readers normally do not block writers, and writers normally do not block readers. Instead, each statement or transaction sees a snapshot of committed data according to the selected isolation level. That makes PostgreSQL pleasant for web workloads, but it also means isolation is about visibility rules as much as locks.

At Read Committed, every statement sees a fresh snapshot as of the start of that statement. A transaction that runs two SELECT statements can see different committed data in the second SELECT if another transaction committed between them. That is acceptable for many request handlers because each statement is short and the application is usually updating rows by primary key. It is risky when the code first checks a cross-row condition and later writes based on the assumption that the condition stayed true.

At Repeatable Read, PostgreSQL gives the whole transaction a stable snapshot. Re-running the same query sees the same committed rows from the transaction's point of view, and PostgreSQL's implementation prevents phantom reads at this level. This is useful for consistent reads and some batch decisions, but it is not a universal replacement for business constraints. Two concurrent transactions can still make decisions from snapshots that are individually consistent but collectively invalid if the invariant spans rows that neither transaction locks.

Serializable is the strongest option. PostgreSQL's serializable isolation is designed so concurrent transactions behave as if they ran one at a time in some order. When PostgreSQL detects that this cannot be guaranteed, one transaction can fail with a serialization error and the application must retry the whole transaction. That failure is not a database outage; it is the mechanism that preserves correctness under concurrency.

Isolation levels compared

The right choice depends on the shape of the invariant. If the rule can be expressed as a unique constraint, foreign key, exclusion constraint, or row-level update, prefer the schema or lock that directly protects the data. Isolation level becomes most important when the rule depends on a set of rows read before writing.

Isolation levelSnapshot behavior in PostgreSQLGood fitOperational cost
Read CommittedEach statement gets its own snapshot of committed dataNormal web requests, single-row updates, idempotent inserts guarded by constraintsLowest surprise for latency, but check-then-write logic needs care
Repeatable ReadOne stable transaction snapshot; PostgreSQL prevents phantom reads at this levelConsistent reports, export jobs, multi-step reads that should not shift mid-transactionLonger transactions can hold old row versions visible and may still need locks for write invariants
SerializableTransactions must be equivalent to some serial order, or one failsCross-row business rules where conflicting decisions must not both commitRequires retry loops and careful transaction boundaries
Explicit row locksLocks selected rows with clauses such as FOR UPDATEInventory counters, account state transitions, queues, ordered workflowsCan create waits or deadlocks if rows are locked in inconsistent order
Database constraintsLets PostgreSQL reject invalid final statesUniqueness, referential integrity, non-overlap rules, simple invariantsBest protection when the rule is expressible in the schema

A useful rule of thumb is to start with constraints, then locks, then stronger isolation. Constraints are usually the clearest because they protect the data no matter which application path writes it. Row locks are appropriate when the application really is coordinating work around specific rows. Serializable isolation is strongest when the dangerous condition is a predicate over a changing set of rows.

Where Read Committed is enough

Read Committed is a good fit for the ordinary shape of SaaS traffic: fetch the current user or workspace, insert an event, update one invoice row, mark a job complete, or change a record by primary key. These transactions are short and usually rely on constraints for correctness. A unique index prevents duplicate slugs. A foreign key prevents references to missing parents. A check constraint prevents invalid enum-like states or negative quantities when the rule is local to one row.

The mistake is treating a SELECT result as a promise about the future. Suppose an app counts active seats, sees that the account has one seat left, and then inserts a new membership. Under Read Committed, two transactions can both observe that one seat appears available unless the code takes a lock, uses a constraint-backed design, or moves the seat count into a row that both transactions update. The database is behaving correctly; the application has not protected the decision it cares about.

For many product flows, the best fix is not raising isolation. It is redesigning the write so PostgreSQL can arbitrate the conflict directly. A unique constraint with ON CONFLICT, an update that includes the guard in the WHERE clause, or a parent account row locked before the membership insert can be easier to reason about than broad Serializable use across the whole service.

When Repeatable Read helps

Repeatable Read is valuable when the problem is a shifting read view. A billing export, analytics snapshot, or reconciliation job may need to see a stable version of the database while normal traffic continues. The stable snapshot avoids confusing output where page one was read before a customer update and page two was read after it.

It is less useful as a blanket safety switch for request handlers. A stable snapshot can make a transaction feel safer because repeated reads agree with each other, but the outside world is still moving. If two transactions read overlapping data and then update different rows, both may commit even though a higher-level invariant is violated. If the invariant matters, use a constraint, lock the rows that represent the decision, or move to Serializable with retries.

Long Repeatable Read transactions also have maintenance consequences. Because PostgreSQL must preserve row versions that are still visible to the snapshot, long-running transactions can delay cleanup and make vacuum work harder. That does not mean never use it; it means exports and reports should be bounded, observable, and separated from latency-sensitive request paths when possible.

When Serializable is worth it

Serializable isolation is the right tool when correctness depends on transactions not making incompatible decisions from overlapping predicates. Classic examples include enforcing a maximum number of active records, scheduling resources where conflicts are not captured by one locked row, or approving workflows where several rows together determine whether an action is allowed.

The price is retry behavior. PostgreSQL can return SQLSTATE 40001 when a serializable transaction cannot safely commit. Applications must retry the whole transaction from the beginning, not just the final statement, because the original decision was made from a snapshot that may no longer be safe. Retrying is usually straightforward for short, idempotent units of work and painful for transactions that call external services, send email, or mix database work with slow network operations.

A healthy serializable transaction is small, deterministic, and free of side effects outside PostgreSQL until after commit. If payment capture, email delivery, or webhook emission happens inside the transaction, retries can duplicate real-world actions. Put those effects behind an outbox table or perform them only after the database commit has succeeded.

Locks and constraints often beat stronger isolation

Many concurrency bugs are easier to solve with a narrower tool. If all conflicting transactions touch the same account row, SELECT FOR UPDATE on that row can serialize the decision without making the whole transaction Serializable. If the invariant is uniqueness, let a unique index reject duplicates. If the invariant is non-overlapping time ranges, an exclusion constraint or PostgreSQL 18 temporal constraint may express the rule directly.

Locking has its own discipline. Lock rows in a consistent order, keep transactions short, and set timeouts appropriate to the workload. A lock that protects a checkout flow should not also wait behind a long report. If lock waits become visible, inspect pg_stat_activity and pg_locks before increasing pool sizes; more concurrent sessions can make the queue longer rather than safer.

For managed PostgreSQL users, connection pooling does not change isolation semantics. PgBouncer can reduce connection pressure, but the transaction still runs under the isolation level selected on the PostgreSQL session or transaction. In transaction pooling modes, be especially careful to set transaction-level options explicitly at the start of the transaction rather than relying on session state that may not stick to one backend connection. The ArmorDB PgBouncer guide at /docs/pgbouncer is useful background when isolation choices and connection management meet.

A decision path for SaaS teams

Start by naming the invariant in plain English. If the invariant is local to one row or one key, try to encode it as a constraint. If it depends on one parent object, make that parent the coordination point and lock it for the shortest practical time. If it depends on a predicate across a set of rows that can change concurrently, evaluate Serializable and build a retry path before enabling it in production.

Then test the unhappy path. Open two sessions and run the conflicting transactions by hand or in an integration test. Confirm whether one waits, one fails, both commit, or a constraint rejects the second write. This exercise is more useful than debating isolation in the abstract because it reveals what the application will actually experience under concurrent traffic.

Finally, keep transactions small. Isolation is not a substitute for clean transaction boundaries. A short Serializable transaction with a clear retry loop is often safer than a long Read Committed transaction that checks a condition, calls an API, waits on the network, and then writes. The longer the transaction, the more likely it is to hold locks, conflict with other work, or preserve old row versions that vacuum cannot clean yet.

Takeaway

Use Read Committed as the default for ordinary SaaS requests, but do not rely on it to protect check-then-write business rules. Use constraints whenever PostgreSQL can express the final valid state. Use row locks when the conflict naturally centers on specific rows. Use Repeatable Read for stable read snapshots and bounded reports. Use Serializable for predicate-based correctness, but only with whole-transaction retries and side effects moved outside the transaction.

The best isolation strategy is usually not the strongest setting everywhere. It is a small set of deliberate patterns that match the data invariant, the user experience, and the operational cost your team is ready to own.

Sources and further reading

Topic

Data-Specs

Updated

Jun 24, 2026

Read time

8 min read

About the author

ArmorDB Engineering writes about PostgreSQL operations, security, and infrastructure decisions for teams building production apps on ArmorDB.