Implementing Concurrent Control with ORM - A Deep Dive into Pessimistic and Optimistic Locking
Daniel Hayes
Full-Stack Engineer · Leapcell

Introduction
In today's highly concurrent applications, ensuring data integrity when multiple users or processes attempt to modify the same data simultaneously is paramount. Without proper mechanisms, race conditions can lead to corrupted data, inconsistent states, and ultimately, a breakdown of trust in the system. Database locking strategies are the cornerstone of addressing these challenges, providing a systematic way to manage concurrent access. While traditional SQL offers powerful locking primitives, interacting with them directly can be cumbersome, especially within object-relational mapping (ORM) frameworks. This article delves into how ORMs empower developers to implement crucial locking strategies – specifically pessimistic locking (using SELECT FOR UPDATE
) and optimistic locking (through versioning) – streamlining their application development while maintaining data consistency. Understanding these techniques is not just an academic exercise; it's a practical necessity for building robust and scalable applications.
Core Concepts of Concurrent Control
Before we dive into the implementation details, let's establish a clear understanding of the core concepts related to concurrent data modification and the two primary locking strategies we'll be discussing.
Concurrency Control: This refers to the methods used to manage simultaneous access to shared data. Its goal is to allow multiple transactions to execute concurrently while ensuring that the database remains in a consistent state and that the results of concurrent transactions are correct.
Transaction: A transaction is a single logical unit of work that accesses and possibly modifies the contents of a database. Transactions are characterized by ACID properties (Atomicity, Consistency, Isolation, Durability), which guarantee reliable processing of database operations.
Race Condition: A race condition occurs when multiple operations execute concurrently and the final outcome depends on the particular order in which these operations execute. In database terms, this often means two transactions reading and then attempting to update the same data, leading to one update overwriting the other, or an inconsistent state.
Pessimistic Locking: This strategy assumes that conflicts are frequent, so it prevents concurrent access to data by immediately locking it before modification. Once locked, other transactions are blocked from accessing the data until the lock is released. This approach ensures data integrity but can reduce concurrency and introduce deadlocks if not managed carefully.
Optimistic Locking: This strategy assumes that conflicts are rare. Instead of locking data upfront, it allows concurrent access and checks for conflicts only at the point of saving changes. If a conflict is detected (i.e., the data has been modified by another transaction since it was last read), the transaction is typically rolled back, and the user is prompted to retry. This approach maximizes concurrency but requires developers to handle potential conflicts.
Pessimistic Locking with ORMs (SELECT FOR UPDATE)
Pessimistic locking, particularly using SELECT FOR UPDATE
, is ideal for situations where data integrity is paramount and concurrent modifications are highly probable, or when complex decision-making based on the current state of data is required.
Principle
When a SELECT FOR UPDATE
query is executed, the database locks the selected rows. Any other transaction attempting to read or update these locked rows will either wait until the lock is released or receive an error, depending on the database's isolation level and concurrency settings. The lock is held until the transaction that acquired it is committed or rolled back.
ORM Implementation Example
Most ORMs provide a straightforward way to incorporate SELECT FOR UPDATE
. Let's consider an example with a hypothetical Product
entity and a scenario where we need to decrement its stock, but only if the stock is above a certain threshold.
# Django ORM Example from django.db import transaction from .models import Product def decrement_product_stock_pessimistic(product_id: int, quantity: int): with transaction.atomic(): # Select the product FOR UPDATE, locking it for this transaction product = Product.objects.select_for_update().get(id=product_id) if product.stock >= quantity: product.stock -= quantity product.save() print(f"Product {product.id} stock updated successfully to {product.stock}.") return True else: print(f"Insufficient stock for product {product.id}.") # The transaction will automatically roll back if an exception is raised # or if the block exits without a commit. return False # SQLAlchemy ORM Example from sqlalchemy import create_engine, Column, Integer, String from sqlalchemy.orm import sessionmaker, declarative_base import time Base = declarative_base() class Product(Base): __tablename__ = 'products' id = Column(Integer, primary_key=True) name = Column(String) stock = Column(Integer) def __repr__(self): return f"<Product(id={self.id}, name='{self.name}', stock={self.stock})>" engine = create_engine('sqlite:///:memory:') Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) def decrement_product_stock_pessimistic_sqlalchemy(product_id: int, quantity: int): session = Session() try: # Begin a transaction with session.begin(): # Select the product and lock it using with_for_update() # This is equivalent to SELECT ... FOR UPDATE product = session.query(Product).filter_by(id=product_id).with_for_update().first() if product: if product.stock >= quantity: product.stock -= quantity session.add(product) # Mark product for update # session.commit() is implicitly called by with session.begin() on success print(f"Product {product.id} stock updated successfully to {product.stock}.") return True else: print(f"Insufficient stock for product {product.id}.") # session.rollback() for specific conditions can be done here. # Otherwise, the transaction will roll back on exception. return False else: print(f"Product {product_id} not found.") return False except Exception as e: session.rollback() print(f"An error occurred: {e}") return False finally: session.close() # Initializing product s = Session() p = Product(id=1, name="Widget", stock=100) s.add(p) s.commit() s.close() # Example usage (run these in separate threads/processes to simulate concurrency) # Thread 1: decrement_product_stock_pessimistic_sqlalchemy(1, 50) # Thread 2: decrement_product_stock_pessimistic_sqlalchemy(1, 70)
In both Django and SQLAlchemy, the ORM translates the select_for_update()
call (or with_for_update()
) into the appropriate SELECT ... FOR UPDATE
SQL query. This ensures that the selected Product
record is locked for the duration of the transaction. If another process tries to decrement the same product's stock concurrently, it will either wait for the first transaction to complete or, depending on the database and ORM configuration, receive an error.
Application Scenarios
- Financial Transactions: Preventing double-spending or ensuring that a balance isn't overdrawn.
- Inventory Management: Decrementing stock levels to prevent overselling.
- Booking Systems: Reserving a limited resource like a seat on a flight or a hotel room.
- Order Processing: Ensuring that an item is available before confirming an order.
Optimistic Locking with ORMs (Versioning)
Optimistic locking is generally preferred in scenarios where conflicts are rare, or where high concurrency and responsiveness are more critical than immediate data consistency (as conflicts are handled by retries).
Principle
Optimistic locking doesn't use database-level locks. Instead, it adds a "version" column (often an integer or timestamp) to the table. Every time a record is updated, the version number is incremented. When a transaction attempts to update a record, it first reads the record's current version. Then, in the UPDATE
statement, it includes a WHERE
clause that checks if the version number in the database still matches the version number that was initially read. If they don't match, it means another transaction has modified the record, and the update fails. The application then detects this failure and typically retries the operation or informs the user.
ORM Implementation Example
Most ORMs have built-in support for optimistic locking or allow easy implementation by adding a version field.
# Django ORM Example (Requires a custom field or a package like `django-optimistic-lock`) # For simplicity, we'll demonstrate the principle manually if a package isn't used. from django.db import transaction, models from django.db.models import F class Product(models.Model): name = models.CharField(max_length=100) stock = models.IntegerField(default=0) version = models.IntegerField(default=1) # The version field def decrement_stock_optimistic(self, quantity: int): with transaction.atomic(): # Retrieve the current product data including its version product = Product.objects.get(pk=self.pk) # Check if current stock is sufficient if product.stock < quantity: raise ValueError("Insufficient stock.") # Attempt to update stock and increment version, checking the original version # Use F() expressions for atomic updates where possible to avoid race conditions # in the read-check part, but the critical part here is the version check in save. updated_count = Product.objects.filter( pk=self.pk, version=product.version ).update( stock=F('stock') - quantity, version=F('version') + 1 ) if updated_count == 0: # Update failed because another transaction modified the product raise RuntimeError("Product has been modified by another transaction. Please retry.") # Refresh the product instance to get the new stock and version self.refresh_from_db() print(f"Product {self.id} stock updated successfully to {self.stock} (version: {self.version}).") return self.stock # SQLAlchemy ORM Example from sqlalchemy import create_engine, Column, Integer, String from sqlalchemy.orm import sessionmaker, declarative_base from sqlalchemy.orm.exc import StaleDataError from sqlalchemy.ext.declarative import declared_attr, as_declarative from sqlalchemy.orm import configure_mappers, Mapped, mapped_column # --- SQLAlchemy 2.0+ Style --- @as_declarative() class Base: """Base class which provides automated table name and represents an Optimistic Lock. """ @declared_attr def __tablename__(cls): return cls.__name__.lower() # Define a version ID column for optimistic concurrency. This requires the # Session to check the version ID with each update or delete. version_id: Mapped[int] = mapped_column(Integer, default=1) class Product(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String) stock: Mapped[int] = mapped_column(Integer, default=0) def __repr__(self): return f"<Product(id={self.id}, name='{self.name}', stock={self.stock}, version={self.version_id})>" # Bind to declarative base after all models are defined configure_mappers() engine = create_engine('sqlite:///:memory:') Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) def decrement_product_stock_optimistic_sqlalchemy(product_id: int, quantity: int): session = Session() try: with session.begin(): # Retrieve the product. SQLAlchemy's versioning will track its version_id product = session.query(Product).filter_by(id=product_id).first() if product: if product.stock >= quantity: product.stock -= quantity # When product is saved, SQLAlchemy will automatically include # `WHERE version_id = <original_version_id>` and increment `version_id`. # If the update affects 0 rows, it raises StaleDataError. session.add(product) print(f"Product {product.id} stock updated successfully to {product.stock} (version: {product.version_id}).") return True else: print(f"Insufficient stock for product {product.id}.") return False else: print(f"Product {product_id} not found.") return False except StaleDataError: session.rollback() print(f"Optimistic lock conflict for product {product_id}. Another transaction modified it. Retrying...") # In a real application, you would re-fetch the data, potentially merge # changes, and retry the operation. return False except Exception as e: session.rollback() print(f"An error occurred: {e}") return False finally: session.close() # Initializing product for SQLAlchemy s = Session() p = Product(id=1, name="Widget", stock=100) s.add(p) s.commit() s.close()
In the SQLAlchemy example, by adding a version_id
column and configuring the ORM property with version_id=True
(or using the as_declarative
with version_id
column for 2.0 style), SQLAlchemy automatically handles the version checking during updates. If an update fails because the version in the database doesn't match the loaded version, SQLAlchemy raises a StaleDataError
, which your application can catch and handle (e.g., by retrying the operation).
Application Scenarios
- Content Management Systems: Editing articles or documents where conflicts are less frequent but need robust handling.
- E-commerce Product Descriptions: Updating product details where concurrent changes to the same description are less common.
- User Profile Updates: Modifying user preferences, expecting infrequent direct conflicts.
- Configuration Management: Updating application configurations.
Conclusion
Both pessimistic and optimistic locking strategies are indispensable in the realm of database concurrency control, each offering distinct advantages and trade-offs. Pessimistic locking, implemented through SELECT FOR UPDATE
, provides strong consistency by preventing conflicts upfront, making it suitable for critical operations where data integrity cannot be compromised. Optimistic locking, leveraging version numbers, prioritizes high concurrency and responsiveness, making it ideal for scenarios where conflicts are rare and retries are acceptable. ORMs significantly simplify the integration of these complex database features into application code, abstracting away the low-level SQL details and allowing developers to focus on business logic. The choice between these two strategies depends heavily on the specific application's requirements, the likelihood of concurrent modifications, and the desired balance between data consistency and system throughput. Ultimately, mastering these locking mechanisms through your ORM empowers you to build reliable, high-performance applications that can safely handle concurrent operations.