You can implement atomic read-write operations in a single call instead of an explicit transaction, reducing both round trips and the window for concurrent conflicts. In PostgreSQL, use UPDATE ... RETURNING instead of SELECT FOR UPDATE followed by UPDATE. In MongoDB, use findOneAndUpdate() instead of updateOne() followed by find(). This enables a single ACID read-write operation that is failure-resilient and safely retryable in MongoDB because it is idempotent. Here is an example.
Demo: two withdrawals and a receipt
Bob withdraws using a debit card (no negative balance allowed). The application first does updateOne to subtract the amount only if the balance is enough, then does a separate find() to print the receipt with the balance. Between those two calls, Alice uses a credit card (allowing a negative balance) and withdraws money from the same account, so Bob's printed balance becomes inconsistent with the balance checked for his withdrawal.
To fix this, we'll use findOneAndUpdate() to return the post-update balance atomically with the withdrawal.
Connection and collection
Here’s a Python + PyMongo program simulating this race condition and showing the fix:
from pymongo import MongoClient, ReturnDocument
import threading
import time
# Connect to MongoDB
client = MongoClient("mongodb://127.0.0.1:27017/?directConnection=true")
db = client.bank
accounts = db.accounts
# Prepare test account: Bob & Alice share this account
def reset_account():
accounts.drop()
accounts.insert_one({
"_id": "b0b4l3ce",
"owner": ["Bob", "Alice"],
"balance": 100
})
reset_account()
I have only one document for this example:
bank> db.accounts.find()
[ { _id: 'b0b4l3ce', owner: [ 'Bob', 'Alice' ], balance: 100 } ]
The balance is set at 100.
Scenario with updateOne() followed by find()
Bob's withdrawal process:
def bob_withdraw_debit(amount):
print("[Bob] Attempting debit withdrawal", amount)
# Application logic checks balance then updates
result = accounts.update_one(
{"_id": "b0b4l3ce", "balance": {"$gte": amount}}, # must have enough money
{"$inc": {"balance": -amount}}
)
# If no document was updated, the filter didn't find enough in the amount
if result.modified_count == 0:
print("[Bob] Withdrawal denied - insufficient funds")
return
# Simulate some processing delay before printing the ticket - to be sure to reproduce the race condition
time.sleep(1)
# query the balance for the receipt
balance = accounts.find_one({"_id": "b0b4l3ce"})["balance"]
print(f"[Bob] Debit card ticket: withdrew {amount}, balance after withdrawal: {balance}")
Alice's withdrawal process:
# Alice withdraws using credit card (negative balance allowed)
def alice_withdraw_credit(amount, delay=0):
time.sleep(delay) # let Bob start first
print("[Alice] Attempting credit withdrawal", amount)
# There's no balance check for credit cards
accounts.update_one(
{"_id": "b0b4l3ce"},
{"$inc": {"balance": -amount}}
)
print("[Alice] Credit withdrawal complete")
Demo script to run in two threads and get the race condition:
def demo():
reset_account()
t_bob = threading.Thread(target=bob_withdraw_debit, args=(80,))
t_alice = threading.Thread(target=alice_withdraw_credit, args=(30, 0.5)) # starts just after Bob update
t_bob.start()
t_alice.start()
t_bob.join()
t_alice.join()
The output is:
>>> demo()
[Bob] Attempting debit withdrawal 80
[Alice] Attempting credit withdrawal 30
[Alice] Credit withdrawal complete
[Bob] Ticket: withdrew 80, balance after withdrawal: -10
Bob received a ticket showing a withdrawal with a negative balance from his debit card — this is a bug ❌
We must get the balance at the time of the update, before, and subtract the amount, or after.
Scenario with findOneAndUpdate() and returnDocument: "after"
Bob's withdrawal process:
def bob_withdraw_debit(amount):
print("[Bob] Attempting debit withdrawal", amount)
doc = accounts.find_one_and_update(
{"_id": "b0b4l3ce", "balance": {"$gte": amount}},
{"$inc": {"balance": -amount}},
return_document=ReturnDocument.AFTER # get post-update document atomically
)
# No need to check the update count, we have the document if it was updated
if not doc:
print("[Bob] Withdrawal denied - insufficient funds")
return
# Ticket immediately shows consistent balance
print(f"[Bob] Ticket: withdrew {amount}, balance after withdrawal: {doc['balance']}")
Running the demo again:
>>> demo()
[Bob] Attempting debit withdrawal 80
[Bob] Ticket: withdrew 80, balance after withdrawal: 20
[Alice] Attempting credit withdrawal 30
[Alice] Credit withdrawal complete
Bob received a ticket showing the balance at the exact time of withdrawal ✅
The update write and post-update read occurred as a single atomic operation on the document, leaving no opportunity for another write between the update and the displayed read result.
After-image is saved for consistency and resilience
In MongoDB, reads and writes do not acquire transaction locks like they can in relational databases, but document updates are still atomic at the document level, even without staring an explicit transaction. MongoDB uses lightweight document-level locks internally to ensure ACID guarantees for a single document, since even one update can involve multiple internal reads and writes, such as enforcing unique constraints and updating indexes.
updateOne() returns only metadata (such as the number of documents updated), whereas findOneAndUpdate() returns the updated document itself, with the read and write occurring in the same atomic operation at the single-document level. This atomicity is preserved even in the event of failures.
If the network is lost or the primary crashes and a secondary is promoted, the MongoDB driver retries the operation as part of retryable writes. Because retries must be idempotent, find_one_and_update() returns the same document image on retry.
To support this, MongoDB stores a document image—either the after image (as in this example, using returnDocument: "after") or the before image—in an internal system collection (config.image_collection) that is replicated independently of the oplog, as part of the same transaction:
bank> use config
config> db.image_collection.find()
[
{
_id: {
id: UUID('d04e10d6-c61d-42ad-9a44-5bb226a898d8'),
uid: Binary.createFromBase64('47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=', 0)
},
txnNum: Long('15'),
ts: Timestamp({ t: 1767478784, i: 5 }),
imageKind: 'postImage',
image: { _id: 'b0b4l3ce', owner: [ 'Bob', 'Alice' ], balance: 20 },
invalidated: false
}
]
config>
When you enable write retries, this image collection is used internally to make write operations resilient to failures, which are handled transparently by the database driver and server, not by the application, and with the strongest consistency.
Comparison with PostgreSQL
In PostgreSQL, you would use the following queries to do the equivalent:
CREATE TABLE accounts (
id TEXT PRIMARY KEY,
balance NUMERIC,
owners TEXT[]
);
INSERT INTO accounts VALUES ('b0b4l3ce', ARRAY['Bob','Alice'], 100);
-- Alice transaction
UPDATE accounts
SET balance = balance - 30
WHERE id = 'shared_account';
-- Bob transaction
UPDATE accounts
SET balance = balance - 80
WHERE id = 'b0b4l3ce' AND balance >= 80
RETURNING balance AS new_balance;
PostgreSQL drivers do not automatically retry failures and rely on MVCC and locks to ensure ACID properties.
With Repeatable Read isolation—appropriate because the write (SET balance = balance - 80) depends on the read (WHERE balance >= 80)—Bob's transaction takes a snapshot at the start and, when run concurrently with Alice's, still sees balance = 100. If Alice commits first and reduces the balance to 70, Bob's transaction fails with: ERROR: could not serialize access due to concurrent update. The application must handle this by retrying the entire transaction. The driver does not do this automatically.
With the default Read Committed isolation level, Bob’s transaction waits if Alice’s update has locked the row. After Alice commits, PostgreSQL reevaluates Bob’s WHERE clause. The balance is now 70, which no longer satisfies balance >= 80, so the UPDATE affects 0 rows and the withdrawal is refused, preventing a negative balance. This is fine when only one row is affected, but in a multi-row statement it could be inconsistent, operating on rows from different transactional states.
Conclusion
MongoDB supports both multi-document transactions and single-document atomic operations, but strongly encourages using single-document operations when possible. If you design your schema so that business logic fits in a single document, findOneAndUpdate() can perform conditional checks, apply updates, and return the updated document atomically, avoiding race condition anomalies and enabling idempotent retries.
In some cases—such as the one described in the earlier blog post on FOR UPDATE SKIP LOCKED in MongoDB—an updateOne() followed by a find() with appropriate conditions is enough and avoids storing before- or after-images.
Top comments (0)