Blog/PostgreSQL Row-Level Locking Explained
Database Concurrency & Transactions August 31, 2026 11 min read

PostgreSQL Row-Level Locking Explained Through a Real Enrollment System

How I used PostgreSQL row-level locking to prevent race conditions when multiple users attempted to enroll in a limited-capacity cohort at the same time.

Tamirat Fereja, Backend Systems Engineer
Tamirat Fereja
Backend Systems Engineer | Specializing in NestJS, Kafka & PostgreSQL

Architectural Highlights

  • SELECT … FOR UPDATE serializes access to the critical cohort row
  • Capacity invariant protected inside a single atomic transaction
  • Unique constraint prevents duplicate student enrollments
  • Downstream side-effects pushed to Kafka after commit

At first glance, student enrollment looks like a simple CRUD operation.

User clicks Enroll
Check available seats
Create enrollment

That implementation works perfectly when one user makes one request at a time. Production systems don’t work that way.

Two users can click Enroll at almost exactly the same moment. If the system is not designed for concurrency, both requests can observe the same available seat and both can successfully enroll. That is a classic race condition.

For an EdTech platform with capacity-limited cohorts, I needed the database itself to protect the business invariant:

enrolled_students ≤ cohort_capacity

1. The Enrollment Problem

Imagine a cohort with:

30
Capacity
29
Enrolled
1
Available

Now two students submit enrollment requests simultaneously.

Concurrent Requests (Race Condition)
Student A
→ SELECT available_seats
→ sees 1
→ Create enrollment
→ Update seats
Student B
→ SELECT available_seats
→ sees 1
→ Create enrollment
→ Update seats
Both believe a seat is available → over-enrollment possible

2. The Naive Implementation

A naive service might look like:

const cohort = await cohortRepository.findOne({
  where: { id: cohortId },
});

if (cohort.availableSeats <= 0) {
  throw new Error('Cohort is full');
}

await enrollmentRepository.save({ userId, cohortId });

cohort.availableSeats -= 1;
await cohortRepository.save(cohort);

The code looks reasonable. The problem is the gap between the READ and the WRITE. Another request can execute during that gap.

3. Understanding the Race Condition

Consider this timeline:

Time →
User A
User B
t1
SELECT seats → 1
t2
SELECT seats → 1
t3
Create enrollment
t4
Update seats = 0
Create enrollment
t5
Update seats = -1?
Both saw 1 seat available. Final state may look numerically plausible while the business invariant is already violated.

4. The Invariant

Before designing the transaction, define what must never become false.

Capacity
enrolled_count ≤ capacity
Uniqueness
one enrollment per student per cohort

5. Row-Level Locking

PostgreSQL provides row-level locking. The key operation is:

SELECT *
FROM cohorts
WHERE id = $1
FOR UPDATE;

FOR UPDATE tells PostgreSQL: lock the selected row so that other transactions attempting conflicting modifications must wait until this transaction completes.

Without Lock
A reads → B reads → A writes → B writes
With FOR UPDATE
A locks → B waits → A commits → B continues

6. The Correct Transaction

BEGIN
↓ Lock cohort row
↓ Check available seats
├── No seats → ROLLBACK
↓ Create enrollment
↓ Decrease available seats
COMMIT

The entire critical operation is performed inside one database transaction.

7. What Happens With Two Concurrent Requests?

Available seats = 1

Student A (acquires lock first)
BEGIN → SELECT … FOR UPDATE
Sees 1 seat → Create enrollment → Seats = 0 → COMMIT
Student B (waits, then proceeds)
SELECT … FOR UPDATE (blocked until A commits)
Sees 0 seats → Reject enrollment

The invariant survives.

8. The Important Part: Lock Before Checking

Incorrect
SELECT seats
IF seats > 0
SELECT … FOR UPDATE
Race already happened
Correct
SELECT … FOR UPDATE
IF seats > 0
enroll
Lock protects the decision

9. TypeORM Implementation

In a NestJS application using TypeORM, the transaction is controlled through a QueryRunner:

const queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();

try {
  const cohort = await queryRunner.manager
    .createQueryBuilder(Cohort, 'cohort')
    .setLock('pessimistic_write')
    .where('cohort.id = :id', { id: cohortId })
    .getOne();

  if (!cohort) throw new Error('Cohort not found');
  if (cohort.availableSeats <= 0) throw new Error('Cohort is full');

  const enrollment = queryRunner.manager.create(Enrollment, {
    userId,
    cohortId,
  });
  await queryRunner.manager.save(enrollment);

  cohort.availableSeats -= 1;
  await queryRunner.manager.save(cohort);

  await queryRunner.commitTransaction();
  return enrollment;
} catch (error) {
  await queryRunner.rollbackTransaction();
  throw error;
} finally {
  await queryRunner.release();
}

The important pattern is: Transaction + Pessimistic write lock + Capacity check + Enrollment + Seat update + Commit.

10. Why pessimistic_write?

TypeORM’s pessimistic_write maps to a database-level SELECT … FOR UPDATE. The application is telling PostgreSQL: “I am about to make a decision based on this row, and I need this row protected until my transaction finishes.”

11. Why Not Just Use Optimistic Locking?

Optimistic concurrency (version column + conditional update) is a valid strategy and works extremely well in many workloads. Enrollment, however, has a particularly clear contention point: one cohort row with a limited number of seats.

When multiple users compete for the same scarce resource, explicit locking can be a straightforward and predictable solution. The correct choice depends on the workload and contention characteristics.

12. Database Constraints Are Still Necessary

Row locking solves the capacity race. It does not automatically prevent the same student from enrolling twice. Enforce a unique constraint:

UNIQUE (user_id, cohort_id)

Application-level checks can also race. A database unique constraint closes that hole.

13. Defense in Depth

Application validation
Useful business errors
Database transaction
Atomic grouping of changes
Row-level lock
Protects the critical section
Unique constraint
Makes the invariant unbreakable

14. Keep the Transaction Small

Bad
BEGIN → Lock cohort
Call external APIs
Send email / generate cert
COMMIT
Better
BEGIN → Lock → Check → Enroll → Update
COMMIT
Then publish events / notify

15. Event-Driven Downstream Processing

Once enrollment succeeds, other parts of the system can react asynchronously:

Enrollment Created
Kafka
CalendarNotificationAnalytics

16–17. Rollback & Crash Safety

If the enrollment insert fails, the transaction rolls back and the seat is not consumed. If the application crashes before COMMIT, PostgreSQL does not treat the incomplete transaction as successfully committed. Critical state changes should always be handled transactionally.

18. Lock Contention

If many users try to enroll in the same cohort simultaneously, they contend for the same row lock. That is expected. The goal is not to eliminate contention completely — it is to ensure that contention produces correct behavior.

Cohort Row
User A (holds lock)User B (waiting)User C (waiting)

19. Why Row-Level Instead of Table Locking?

A table-level lock would be unnecessarily aggressive. Enrolling in Cohort A should not block enrollment into Cohort B, C, or D. Row-level locking provides much finer-grained concurrency.

20. The Real Business Invariant

The most important part of this architecture isn’t FOR UPDATE. It is identifying the invariant first:

A cohort cannot have more successful enrollments than its capacity.

Once that is clear, the architecture follows:

Business InvariantConcurrency ProblemTransaction BoundaryRow-Level LockConstraint

21. Common Mistakes

1
Checking capacity outside a transaction
SELECT → Application check → INSERT is vulnerable to races.
2
Locking after the business decision
SELECT → Check → FOR UPDATE is too late.
3
Holding the lock during network calls
Increases contention and latency unnecessarily.
4
Relying only on frontend validation
Two clients can both see “1 seat remaining”.
5
Relying only on application uniqueness checks
Always consider whether the database itself should enforce the invariant.

22. The Complete Architecture

Client
  │
  ▼
Cohort Service
  │
  ▼
Begin Transaction
  │
  ▼
Lock Cohort Row (FOR UPDATE)
  │
  ▼
Check Capacity
  │
  ├── Full → ROLLBACK
  │
  ▼
Create Enrollment
  │
  ▼
Decrease Seats
  │
  ▼
COMMIT
  │
  ▼
Kafka Event
  │
  ├── Calendar
  ├── Notification
  └── Analytics

23. The Result

Concurrent requests
↓ Serialized access to the critical row
↓ Capacity checked against current state
↓ Atomic enrollment + seat update
↓ No over-enrollment

24. When Should You Use Row-Level Locking?

Particularly useful when multiple requests compete to modify the same scarce resource:

Limited seatsInventoryAccount balancesResource reservationsQuota allocationJob claimingOrder processing

It should not be the default answer to every concurrency problem. Locking creates waiting; waiting creates contention. The correct approach depends on transaction duration, contention level, workload, consistency requirements, acceptable latency, and failure behavior.

25. The Broader Lesson

Concurrency bugs are difficult because they often disappear during local development. A system that works sequentially is not necessarily a system that works concurrently. Concurrency needs to be designed explicitly.

Final Takeaway

PostgreSQL row-level locking is not simply a database feature. It is a tool for protecting business invariants under concurrency.

Limited seatsConcurrent requestsDefine invariantTransaction + FOR UPDATEAtomic check + modify

When multiple requests can modify the same scarce resource, correctness must be designed for concurrency — not assumed from sequential code. The database is often the strongest place to enforce that correctness.