A practical deep dive into optimistic vs. pessimistic locking in ORMs, covering real-world patterns, common pitfalls, and how to use database locks effectively with PostgreSQL and SQLAlchemy.
If you've ever worked on a web app with a database, you've probably run into weird bugs when two users try to update the same thing at the same time. Sometimes one user’s changes mysteriously disappear. Other times, the app crashes with vague errors about stale data or database locks.
These problems are all about concurrent data access, and ORMs like SQLAlchemy, Django ORM, or Hibernate, try to help you deal with it using locking strategies. The two main ones are:
This post is a deep dive into how these two strategies work in ORMs, what can go wrong, and how you can build more robust systems by understanding the mechanics under the hood.
In a multi-user system, you can’t assume you're the only one updating the data. Locking is a way to manage who gets to update what, and when.
Optimistic locking assumes that most of the time, no one else will touch the same data you're working on. So you:
This is often done with a version column (like version
or updated_at
).
Pessimistic locking assumes conflict is likely, so it prevents others from touching the data until you're done. You tell the database:
“Lock this row — no one else can update or even read it until I’m finished.”
This happens with SQL’s SELECT ... FOR UPDATE
or its variants like FOR UPDATE SKIP LOCKED
.
In PostgreSQL and most relational DBs, locks can happen at different levels:
We'll focus on row-level locks, since they're what ORMs like SQLAlchemy and Django use when you do FOR UPDATE
.
Lock Type | Behavior | Common Use |
---|---|---|
FOR UPDATE |
Locks row for update | Safe updates by one user only |
FOR NO KEY UPDATE |
Like FOR UPDATE , but weaker |
Avoid locking foreign keys |
FOR SHARE |
Other readers allowed, no updates | Long reads, analysis |
FOR KEY SHARE |
Others can read but not update keys | Used with foreign keys |
SKIP LOCKED |
Skip locked rows instead of waiting | Job queues |
NOWAIT |
Raise error if row is already locked | Immediate fail if contention |
Let’s go over these with real examples.
FOR UPDATE
This is the most common and strict lock. It prevents anyone else from updating or deleting the locked row.
session.query(User)
.filter(User.id == 1)
.with_for_update()
.one()
Use this when:
FOR UPDATE SKIP LOCKED
Instead of waiting for locked rows to be free, this just skips them. This is gold for worker queues.
session.query(Job)
.filter(Job.status == 'pending')
.order_by(Job.created_at)
.with_for_update(skip_locked=True)
.limit(1)
.all()
Use this when:
This prevents double-processing and deadlocks.
FOR UPDATE NOWAIT
This one throws an error right away if someone else has the row locked.
session.query(Product)
.filter(Product.id == 42)
.with_for_update(nowait=True)
.one()
Use this when:
FOR SHARE
This allows others to also read (share) the row, but prevents updates. You can use it with PostgreSQL raw SQL or certain advanced ORMs that support it.
Use this for:
SELECT * FROM users WHERE id = 1 FOR SHARE;
FOR NO KEY UPDATE
and FOR KEY SHARE
These are rarely used directly in app-level code, but they matter if you're working with foreign keys or complex relationships.
FOR NO KEY UPDATE
: Prevents update/delete of row, but not FK changes.FOR KEY SHARE
: Prevents FK from pointing to a deleted row.In SQLAlchemy you probably won’t use these explicitly unless you’re doing complex cascading logic or dealing with graph-like data.
Let’s explore both strategies using SQLAlchemy as the example, but the same logic applies to Django ORM or Hibernate.
Add a version
column to your table:
from sqlalchemy.orm import declarative_base, versioned
Base = declarative_base()
class Document(Base):
__tablename__ = 'documents'
id = Column(Integer, primary_key=True)
content = Column(Text)
version = Column(Integer, nullable=False, default=1)
__mapper_args__ = {
"version_id_col": version
}
SQLAlchemy will:
version
in the WHERE
clause when updating0 rows affected
)StaleDataError
Not retrying on failure
Many devs just log the error. Instead, you should show the user a warning, reload data, and offer a retry or merge.
No version column = no conflict detection
ORMs can't protect you without a version
field or similar mechanism.
Silent overwrite in Django/Hibernate if not configured
Without explicit version tracking, updates just overwrite each other with no warning.
FOR UPDATE
Use with_for_update()
to lock rows during queries:
session = Session()
doc = session.query(Document)
.filter_by(id=1)
.with_for_update()
.one()
Now that row is locked, nobody else can select it with a lock, and any update must wait for your transaction to finish.
You're writing a job queue:
session.query(Job)
.filter(Job.status == 'pending')
.order_by(Job.created_at)
.with_for_update(skip_locked=True)
.limit(5)
Forgetting to commit/rollback
If your transaction hangs, the row stays locked = deadlocks and stuck workers.
Locking too many rows
Locking large sets can kill performance. Always limit.
Missing indexes
Locking with ORDER BY
needs proper indexes to avoid full table scans.
with_for_update()
is explicit. You won’t lock anything unless you ask.select_for_update()
similar to SQLAlchemy.Situation | Use |
---|---|
Most updates don’t conflict, but correctness matters | Optimistic Locking |
Heavy write contention (e.g. same row in background jobs) | Pessimistic Locking |
Long-running user edits (like forms, drafts) | Optimistic Locking with retries |
Critical operations (money, inventory) | Pessimistic Locking to avoid risk |
Locking isn’t just a low-level database thing, it’s a design decision. ORMs can help, but only if you understand how they work.
Optimistic locking is great for user-facing apps where you want fast performance and low chance of conflict.
Pessimistic locking shines in backend systems where correctness matters more than speed like job queues, banking, and inventory systems.
Your ORM is only as smart as you configure it. Make sure you pick the right strategy based on your real-world needs and test for concurrency issues early.