- 01Parallel tool calls are the default execution model, not an advanced case. Concurrency is opt-out, not opt-in.
- 02A model has no transaction boundary. Nothing in the loop tells it two calls must not interleave.
- 03The reasoning step between read and write takes seconds, which makes lost updates far likelier than in ordinary code.
- 04Optimistic concurrency — version on read, required on write — turns a silent overwrite into a handleable conflict.
- 05Never hold a lock across a model call. Test against a real database; a mock serialises access and hides the bug.
Why agents race
That alone would be manageable. What makes agent concurrency distinctly hazardous is a second property: the model has no concept of a transaction. When it emits three calls in one turn it is not asserting they are independent — it has no vocabulary for saying they must not interleave, and no mechanism for asking.
“Nothing in the protocol lets a model say ‘these two calls must not interleave’. It cannot request a transaction, so it cannot be blamed for not having one.”
And a third property makes the window unusually wide. In ordinary code the gap between a read and a dependent write is microseconds. In an agent the gap includes a model call — the agent reads a record, spends several seconds reasoning about it, then writes. The classic lost-update race has a window thousands of times larger than the one your database was tuned for.
| Ordinary service | Agent loop | |
|---|---|---|
| Concurrency | You chose it, deliberately | Default; the harness parallelises |
| Transaction boundary | Explicit — BEGIN/COMMIT in your code | None expressible by the caller |
| Read → write gap | Microseconds | Seconds — a model call sits in the middle |
| Retry semantics | Identical request replayed | Re-reasoned, possibly different arguments |
| Who notices a conflict | The code that wrote it | Nobody, unless the tool reports it |
Four races you will hit
| Race | Shape | What the user sees |
|---|---|---|
| Lost update | Read A, read A, write A, write A | One person's change silently vanishes |
| Double action | Two identical calls in one turn | Two refunds, two emails, two orders |
| Inconsistent read | Two reads in one turn, state changed between | The agent reasons over a world that never existed |
| Phantom dependency | Call 2 assumes what call 1 saw | An action valid when planned, invalid when executed |
The third is the one people do not think of as a race at all, and it is peculiar to agents. If an agent reads a customer’s balance and their order status in two parallel calls, and something changes between them, it now reasons over a combination of facts that was never simultaneously true — and produces a conclusion that is defensible given its inputs and wrong given reality.
- Phantom dependency
- A later tool call whose correctness depends on a condition established by an earlier call, without anything enforcing that the condition still holds. The agent checked the seat was free, then booked it; between the two, someone else took it. Nothing in the loop revalidates.
Races by agent pattern
| Pattern | Dominant races | Mitigation |
|---|---|---|
| Single loop, sequential tools | Lost update, double action | Version-checked writes; idempotency keys |
| Single loop, parallel tools | + inconsistent read | Read a consistent snapshot in one call |
| Orchestrator-workers | + cross-worker write conflict | Partition writes by worker; single writer per record |
| Multi-agent, shared state | + everything, with no shared context | Treat as distributed systems: explicit ownership |
The last row deserves the emphasis. Subagents typically do not share conversation history — each gets a fresh context and only the prompt it was handed. So two subagents working on the same objective have no way to know what the other has already done, and no shared memory in which a lock or a claim could be observed. Coordination has to be external, in the data layer, or it does not exist.
The cheapest structural fix is to give each record a single writer. If exactly one worker type may write orders, cross-worker order conflicts become impossible by construction rather than by discipline — and that is worth designing for before you have many workers.
Optimistic concurrency is the right default
Optimistic concurrency suits agents unusually well because the expensive part — the reasoning — happens outside any lock, and conflicts are rare enough that paying for a retry when one occurs is cheaper than serialising everything.
// The read hands the agent the version. This is not optional metadata —
// it is the token that makes the subsequent write safe.
const readOrder = tool(
"read_order",
"Read an order. Returns a 'version' you MUST pass to update_order.",
{ orderId: z.string() },
async ({ orderId }) => {
const row = await db.orders.findById(orderId);
return { content: [{ type: "text", text: JSON.stringify({
...row.data,
version: row.version, // ← surfaced to the model deliberately
})}]};
},
);
const updateOrder = tool(
"update_order",
"Update an order. Requires the 'version' from read_order. If the order " +
"changed since you read it, this fails and you must read again.",
{ orderId: z.string(), version: z.number().int(), patch: z.record(z.unknown()) },
async ({ orderId, version, patch }) => {
const result = await db.orders.updateWhere(
{ id: orderId, version }, // conditional write — the whole trick
{ ...patch, version: version + 1 },
);
if (result.rowsAffected === 0) {
// Not an exception: a structured, actionable result the model can use.
const current = await db.orders.findById(orderId);
return { content: [{ type: "text", text: JSON.stringify({
error: "version_conflict",
message: "The order changed since you read it. Re-read and reapply.",
your_version: version,
current_version: current.version,
})}], isError: true };
}
return { content: [{ type: "text", text: JSON.stringify({ ok: true, version: version + 1 })}]};
},
);Two details make the difference between this working and merely existing. The tool description tells the model that the version is mandatory and what to do on failure, because the model only learns the contract from the description. And the conflict result is structured and instructiverather than a bare failure — an agent given “update failed” will try something creative; an agent given “re-read and reapply” will do that.
When you actually need a lock
The rule is simple to state and frequently violated: locks may be held inside a tool call, never across one. A tool that acquires a lock, returns to the model for a decision, and expects to still hold the lock on the next call has invented a distributed lock with a multi-second hold time and no lease — which is a well-known way to build an outage.
// ✗ WRONG — the lock spans a model call. The agent may take 10s to decide,
// or crash, or never call release_seat at all.
lock_seat(seat) → model thinks → book_seat(seat) → release_seat(seat)
// ✓ RIGHT — the entire critical section lives inside ONE tool call.
// The check and the write are atomic; the model never holds anything.
book_seat_if_free(seat) {
BEGIN;
SELECT ... FROM seats WHERE id = $1 AND status = 'free' FOR UPDATE;
if (not found) { ROLLBACK; return { error: "seat_taken" }; }
UPDATE seats SET status = 'booked', booking_id = $2 WHERE id = $1;
COMMIT;
}
// ✓ ALSO RIGHT — no lock at all. A conditional write does the same job.
UPDATE seats SET status = 'booked' WHERE id = $1 AND status = 'free';
// rowsAffected === 0 ⇒ someone else got it. Report that, don't retry blindly.Notice what the correct versions have in common: the decision and the mutation happen together, in one call, without consulting the model in between. That is the general shape of the fix — collapse the critical section into a single tool rather than trying to coordinate the model across several.
The read-modify-write trap
The canonical example is anything cumulative — a balance, a counter, a list of notes, a set of tags.
// ✗ The trap. Two agents (or two parallel calls) both read 100.
// Both compute 100 - 30 = 70. Both write 70. One deduction vanished.
const { balance } = await read_account(id); // 100
const next = balance - 30; // model reasons here — SECONDS
await write_account(id, { balance: next }); // 70
// ✓ 1. Make it a relative operation. The database does the arithmetic.
await adjust_balance(id, { delta: -30 }); // UPDATE ... SET balance = balance - 30
// no read, no window, no race
// ✓ 2. Conditional write with the version (optimistic concurrency).
await write_account(id, { balance: next, version }); // fails if version moved
// ✓ 3. Conditional on the value you actually saw (compare-and-set).
await write_account(id, { balance: next, expect_balance: 100 });Prefer option 1 wherever the operation is genuinely relative. Agents are strongly drawn to read-then-write because it mirrors how a person would describe the task, so if a relative operation exists it is worth exposing it as its own tool and saying in the description that it should be used instead of read-then-write. Otherwise the model will reach for the pair every time.
Set operations, not list rewrites
The same argument applies to collections. An agent that reads a list of tags, appends one, and writes the whole list back will drop any tag added concurrently. Expose add_tag and remove_tag rather than set_tags, and the race disappears because the operations commute.
Making tools safe by construction
The fourth is the bridge to a related problem. A race makes two concurrent calls collide; idempotency handles the case where the same call happens twice because the agent retried. Both produce duplicate effects, and they need different mechanisms — see keying on intent rather than the request for why an agent’s retry is not a byte-identical replay.
The fifth is easy to overlook and cheap to add. Marking a tool read-only is not just a hint for the harness — it is a statement in the tool contract that a reviewer can check, and it makes the write surface of your agent enumerable. Anything that is not marked read-only is part of the blast radius.
Testing for races
// 1. Simultaneous identical mutation. Exactly one effect must result.
test("double invocation produces one effect", async () => {
await Promise.all([
tools.issue_refund({ orderId: "o1", lineId: "l1", amountCents: 500 }),
tools.issue_refund({ orderId: "o1", lineId: "l1", amountCents: 500 }),
]);
expect(await countRefunds("o1", "l1")).toBe(1);
});
// 2. Stale write must be rejected, not silently applied.
test("version conflict is reported", async () => {
const a = await tools.read_order({ orderId: "o1" });
await tools.update_order({ orderId: "o1", version: a.version, patch: { note: "first" } });
const stale = await tools.update_order({
orderId: "o1", version: a.version, patch: { note: "second" }, // stale version
});
expect(stale.isError).toBe(true);
expect(await getNote("o1")).toBe("first"); // the first write survived
});
// 3. Run it against the REAL database. This is the test that matters.
// An in-memory mock cannot fail (1) — it serialises by construction.The comment on the third point is the practical lesson and it generalises beyond agents: a concurrency test against a mock tests the mock. Any store that guarantees serial access removes exactly the property you are trying to exercise, which is why this class of bug survives comprehensive-looking test suites and appears in production the first busy afternoon.
One more test worth having, at the agent layer rather than the tool layer: give the model a task that invites parallel calls on the same record and assert on the number of effects. That exercises the harness’s parallelisation, your tool contracts, and the model’s tendency to batch — which is the combination that actually runs.
Finally, the structural point. Most of these races exist because the control flow was handed to a model that has no way to express a transaction. Where the sequence is knowable in advance, writing it as code removes the entire class of problem — which is one more entry in the ledger for building a workflow instead of an agent.
Frequently asked questions
Can AI agents have race conditions?
Yes, and they are easy to hit because most agent runtimes issue tool calls in parallel by default. A model can emit several calls in one turn with no notion of a transaction boundary between them, so two calls in the same turn can read and write the same record concurrently.
Why do agents make parallel tool calls?
Because it is faster and the runtimes encourage it — a single assistant turn may contain multiple tool-use blocks, and harnesses execute them concurrently and return all results together. That is good for latency and it means concurrency is the default execution model rather than an advanced case you opt into.
What is the read-modify-write problem for agents?
An agent reads a value, reasons about it, then writes a new value based on what it read. If anything changes the record in between — another tool call in the same turn, a human, another agent — the write silently overwrites that change. It is the classic lost-update problem, made more likely because an agent's reasoning step takes seconds.
How do you prevent lost updates in agent tool calls?
Use optimistic concurrency control: return a version or ETag with every read, require it on write, and reject the write if the version has moved. The agent then re-reads and retries with current state. This turns a silent overwrite into an explicit conflict the agent can handle.
Should agent tools use database locks?
Rarely, and never held across a model call. An agent's reasoning step can take many seconds, so a lock spanning it will serialise your system and risk timeouts. Prefer optimistic concurrency for records and short, narrowly-scoped locks only for operations that genuinely cannot be expressed as a conditional write.
How do you test for race conditions in an agent?
Force the concurrency rather than hoping to observe it: invoke the same tool twice simultaneously with identical arguments, invoke a read and a conflicting write together, and run the whole turn against a real database rather than a mock. Mocked stores serialise access and will pass code that production fails.
Sources
- 01Tool use — parallel tool calls — Anthropic, 2026
- 02Transaction isolation and concurrency control — PostgreSQL, 2026