Skip to content

There Is No Row to Lock

Optimistic locking is one of those techniques you learn once and then use everywhere. A version column, an UPDATE ... WHERE version = 7, and whoever arrives second finds zero rows affected and has to try again. It is cheap, it needs no coordination, and it has kept relational systems correct for decades.

Then you move to Event Sourcing, and the technique has nothing to attach itself to. There is no row that represents the current state, so there is no row to lock. What you would need instead is a condition over a set of records that a query describes – and that turns out to be the one thing a relational database has no single statement for.

How It Works With Rows

Start with the mechanism that does work, because it is the one worth measuring against. You keep a version alongside your data, you read it, you compute your change, and you write it back with the version you saw as part of the condition. If somebody else got there first, the version has moved and your update matches nothing.

The event-sourced variant of that same idea is a unique constraint on stream name and version. Two writers who both believe they are appending version 7 cannot both succeed: one commits, the other collides with the constraint, and you translate that collision into a conflict your domain understands. As PostgreSQL Is Not an Event Store puts it, plenty of production systems run on exactly this, and they are not wrong to.

What makes it work is that the condition and the data live in the same place: the version sits in the row you are writing, so checking it costs nothing extra and cannot be skipped by accident. The technique is not clever, it is well-placed.

There Is Nothing to Lock

Now take a decision from an event-sourced system. May this book be lent out? That does not depend on one row. It depends on every event recorded for that book: acquired, borrowed, returned, withdrawn. Replay them in order and you have your answer.

But the answer is not what you guard. What you guard is the reasoning – that the book was acquired and never withdrawn, and that nobody has borrowed it since you looked. A version number on the state you derived would tell you none of that, because the state you derived is not stored anywhere.

You could put the version on the stream instead and be done with it, and for many decisions that is exactly right. But the interesting decisions do not respect stream boundaries. May this reader borrow another book depends on events across every book they currently hold. Whether this email address is still free depends on every registration ever recorded. The set of events your decision rests on is described by a query, not by an identifier.

And that set does not stand still. Between the moment you read it and the moment you write, other writers are appending – and what they append is precisely what might invalidate the decision you just made. What you need to guard is not a row against modification, but a query against having gained new answers.

The Condition You Actually Want

Translate that back into relational terms and the shape of the problem becomes clear. What you would like to say is: write this row, but only if the set of rows that this SELECT returns has not changed since I last ran it.

Say it out loud and it sounds reasonable. Try to implement it and you find three approaches. One gets you there and does not fit in a single statement; one gets you there only on some engines, and only at a price; the third fits in a single statement and does not get you there on its own.

You can raise the isolation level. In PostgreSQL, SERIALIZABLE detects the conflict for you and then hands you the consequence: a serialization failure is not a domain error, it is a transaction the database refuses to commit, so every path that touches the set needs a retry loop – and that loop has to be safe to run twice, because the first attempt may have already sent an email or charged a card. Some engines reach SERIALIZABLE by locking rather than by detecting, and there you get blocking on top of the retries, because a deadlock victim has to be tried again as well.

You can take the locks yourself with SELECT ... FOR UPDATE. That locks the rows your query returned, which is the smaller half of the problem: in PostgreSQL it cannot lock rows that do not exist yet. The registration that arrives while you are deciding is not among the rows you locked, and it is exactly the one that breaks your decision. Some engines do reach it: InnoDB takes gap locks at REPEATABLE READ and above, given an index it can use, at the price of locking stretches of a key range nobody has written to yet. Either way you are locking rows, or the space where rows would go, to guard a question about a set.

Or you can fold the check into the statement, as INSERT ... SELECT ... WHERE NOT EXISTS (...). Under READ COMMITTED or REPEATABLE READ the subquery reads a snapshot, so two concurrent statements can both find nothing and both insert. It gives you the guarantee only with a unique index behind it to catch the case it lets through – which means the real work is being done by the constraint, and you are back to conditions a constraint can express.

None of this is impossible, and all of it is yours to build and keep correct. The version column worked because it sat where the data sat; none of these three does. In every one of them, the condition lives in the writing code rather than in the write. A new service, a migration script, a well-meant background job – each has to reconstruct the same guard, and nothing checks that it did.

Sending the Condition Along

A store built for Event Sourcing can put the condition somewhere else: into the write request itself.

A POST to /api/v1/write-events carries an events array – the candidates you want stored – and, optionally, a preconditions array beside it, holding any of four condition types.

Here is a write that appends one event to /books/42. That path is the subject, which is what this post has been calling a stream: the identifier that says which thing the event is about and gathers that thing's events. The condition says that the subject's most recent event must still be the one we read:

{
  "events": [
    {
      "source": "https://library.eventsourcingdb.io",
      "subject": "/books/42",
      "type": "io.eventsourcingdb.library.book-borrowed",
      "data": { "borrowedBy": "/readers/23" }
    }
  ],
  "preconditions": [
    {
      "type": "isSubjectOnEventId",
      "payload": { "subject": "/books/42", "eventId": "23" }
    }
  ]
}

The preconditions are evaluated in the same transaction that appends the events. If they all hold, everything is written. If a single one fails, nothing is written at all and the request comes back as 409 Conflict. There is no window between the check and the append, because there is no check separate from the append.

Nothing limits you to one condition. A single request can carry several preconditions over several subjects, alongside events for several subjects, and the whole thing lands or does not land as one unit – borrowing a book while recording it against the reader's account, with a condition on each. DDD, Back to Basics works that all-or-nothing case through.

That is the difference worth naming, and PostgreSQL Is Not an Event Store named it in one sentence: "The difference from a purpose-built store is not who has to remember – both make every writing path declare its expectation – but what you can declare." A unique constraint can only check the number it is handed. A precondition can describe the state of the store. The preconditions documentation has all four types with examples: because the expectation travels inside the request, the store is what enforces it, so a second writer cannot slip between your read and your write no matter which service they came from.

The Everyday Case

Most of the time you do not need anything elaborate. You read a subject, you remember the ID of the last event you saw, you make your decision, and you write with isSubjectOnEventId naming that ID. Event IDs are assigned by the store as one gap-free sequence across everything it holds, which means the IDs inside a single subject are not consecutive – so you name the ID you actually saw rather than computing the one you expect to come next. Checking it means looking at the end of one subject, which the store can do directly. Start With One Table shows the code that picks it. It is the version column, except the version is an event ID and the condition travels in the request.

Does the guarantee hold under contention? We put it to the test: twenty-five writes at once against the same fresh subject, every one of them carrying a precondition that the subject must still be empty. One write succeeded, twenty-four came back with 409, and exactly one event was in the subject afterwards. Three rounds, three different winners – which is the point, because nothing about the order is arranged and the guarantee does not depend on it.

What the store does not do is make the conflict disappear. Those twenty-four writers each have to notice the 409, read the subject again, redo the decision against what is now there, and send a new request – the same loop that SERIALIZABLE would have imposed. The difference is what the loop is told: a 409 from a precondition is a real conflict about your data, whereas a serialization failure can be a false positive that the database could not rule out.

Two details are worth knowing before you need them. The 409 body is a short text, state conflict: precondition failed, and it does not say which precondition failed – fine with one, something to plan for with five. And the read still happens, the decision is still yours, and the retry is still your code. What moves into the store is the check, not the workflow around it.

Two Shortcuts for the Ends of a Subject

Two more of the four types cover the situations where you have no last event ID, because you are at one end of a subject's life.

isSubjectPristine requires the subject to hold no events at all, which is what you want when you create something: acquiring a book, registering a reader. isSubjectPopulated is its mirror image and requires at least one event, which is what you want when you modify something and want to be sure it was ever created. Both express an intent that an event ID cannot: not "the state I read is still current" but "this thing does not exist yet" and "this thing exists".

When the Set Is Genuinely Arbitrary

Which leaves the case we started with: a decision resting on a set that no single subject describes. For that there is isEventQlQueryTrue, the fourth type, which takes an EventQL query and requires it to evaluate to true.

That is exactly the condition from The Condition You Actually Want, now expressible in one request. It is also the mechanism behind Dynamic Consistency Boundaries, as the interview Kill Aggregate describes: append this event, but only if no other event matching a given query has appeared since a given position. You can express uniqueness with it, or a limit across several subjects, or any invariant you can phrase as a query. It is the most flexible of the four, and the only one whose cost you have to think about.

The reason is in the shape of the question. The three cheap types ask about one place – the start of a subject, its end – and the store can look there directly. A query that counts has to consider every event that might match, and there is no index over event payloads for it to use, so the work grows with the number of events stored. We measured it against version 1.2.0 in Docker, taking the median of seven single writes: at twenty-four thousand events, a write carrying such a precondition costs on the order of a hundred times a write carrying none, while all three cheap types stayed within the noise of a write with no precondition at all. Every measurement sits below the free tier's ceiling of twenty-five thousand events, so the figure is what we could observe rather than a curve out to a million – but the mechanism says plainly which way it goes.

And that write does not pay alone. While such a precondition is evaluated, other writes wait: we measured a plain write to an unrelated subject taking around half a second instead of six milliseconds. An expensive precondition is not a slow request, it is a slow store. This is the other half of the bill, and it is the part a comparison against SERIALIZABLE usually leaves out: in this engine every write takes the same store-wide lock anyway, so what an expensive precondition changes is not whether the others wait, but how long.

One consequence is worth spelling out, because it is easy to get backwards. Restricting the query to a single subject does not make it cheaper. A WHERE clause on the subject is another predicate in the same scan, not a way to narrow it. So if your condition concerns one subject, express it with isSubjectOnEventId and not as a query – the same guarantee, none of the cost. And when the invariant really is global, Email Uniqueness in Event Sourcing works through why a query precondition is usually the wrong answer there, and what to reach for instead.

From a Row to a Question

Optimistic locking on a row works because the row is the state. In an event-sourced system the state is a question you ask of the recorded events, and what can invalidate your decision is a new answer to that question. So the guard has to be the question, not a row.

That is what a precondition is: your expectation, written down, sent along, and checked where the append happens rather than in the code that calls it. The four types are not four features. They are four points on a scale from cheap and specific to expensive and general – and the guarantee they enforce is the same, while what they can express, and what it costs, is not.

If you have been carrying that guard around in your application code, it is worth seeing what changes when the store takes the check over: the read stays, the retry stays, and the race disappears. And if you want the reasoning behind all of this rather than the mechanics, Thinking in Events makes the case that a write should land only if the past still looks the way your decision assumed.