All systems
Data

Concurrency Control System Design

Two people editing the same record, and the second one finding out rather than silently winning.

internal/concurrencyinternal/services

1.Problem statement

Two operators open the same invoice. One changes the due date, the other changes the amount. Both save. The second write overwrites the first, and nobody is told. The first operator’s change is simply gone, and they will not find out until they look again, if they ever do.

This is the lost update problem, and it is invisible in testing because it needs two people and a few seconds. In production it happens constantly, and it is reported as "the system lost my change", which is indistinguishable from a bug in saving.

The two families of solution are pessimistic locking, where the first reader takes a lock and the second waits, and optimistic locking, where both proceed and the second write is rejected if the record moved. For a web application the second is almost always right: holding a database lock across a user thinking about a form is not viable.

The system has to be able to:

  • Give every record a version that changes on every write.
  • Let a client declare which version it read, and reject a write against a different one.
  • Tell the rejected client which version the record is at now, so it can show the difference.
  • Leave clients that do not participate working exactly as before.
  • Apply to the generated update and patch paths without each one implementing it.
  • Carry the same guarantee into offline clients that sync later.

2.System requirements

Functional requirements

  • A version column on every generated model, incremented by the database on update.
  • The version returned in responses and as an ETag header.
  • An If-Match header carrying the version the client read.
  • A 409 with the current version when the record has moved.
  • Unconditional writes still accepted, for clients that do not opt in.
  • The same check in the offline sync path, where the gap between read and write is hours.

Non-functional requirements

  • No held locks. nothing is locked while a human is deciding. A form open overnight costs nothing.
  • Atomic. the version check is part of the update statement, not a read followed by a write, or the race simply moves.
  • Opt-in. a client that sends no If-Match behaves as it always did. Making it mandatory would break every existing caller.
  • Actionable. a conflict response carries the current version and the current row, so the interface can show what changed rather than just refusing.
  • Uniform. the same mechanism on every resource, because a guarantee that applies to some records is one nobody can rely on.

3.Capacity estimation

Numbers for a mid-sized deployment. They are here to size the thing, not to predict your traffic: change an assumption and the sums below move with it.

Assumptions

ParameterValue
Writes per second~90 at peak across all resources
Share of writes using If-Matchall admin edits, most API clients
Conflict rate on contended records0.1% to 5%, depending on the workload
Version column4 bytes

Cost of the check

the predicate is added to the existing UPDATE
rows affected is already returned
additional cost: zero queries, one integer comparison

Optimistic locking is free on the happy path. That is the entire argument for it over anything that holds a lock.

Conflict handling

90 writes/s x 1% conflict = ~1 conflict/second
each costs one extra read to report the current version

The extra read happens only on the rejected path, which is rare by construction.

Storage

4 bytes per row x 10,000,000 rows = 40 MB

4.High level design

The version lives on the row, travels in the response, comes back in a header, and becomes a predicate on the update.

Core components

  • Version column. an integer on every generated model, raised by the database as part of the update rather than by the application, so two concurrent writers cannot both read the same number.
  • Precondition parser. reads If-Match and turns it into a predicate, or into nothing when the header is absent.
  • Conditional update. the write, with the version predicate attached. Rows affected is the answer.
  • Conflict reporter. when nothing was updated, reads the row to find out which version it is at and returns both that and the row.
  • ETag support. the version as an HTTP entity tag, so standard clients and caches participate without bespoke fields.

Request flow

Two writers, one record

1234567Client AUpdate HandlerPreconditionServiceClient BConditional UPDATE409 + Current Version200 + New Version
  1. 1Client A reads the invoice. The response carries version 7, both in the body and as an ETag.
  2. 2Client A submits a change with If-Match naming version 7. Client B, who read the same row, is still editing.
  3. 3The precondition is parsed into a predicate. A request without the header produces no predicate and proceeds unconditionally.
  4. 4The service issues an UPDATE with WHERE id = ? AND version = 7. The version is raised by the database as part of the same statement.
  5. 5One row is affected. Client A gets 200 and the new version, 8.
  6. 6Client B now submits, still naming version 7. The same statement runs.
  7. 7No rows match, because the row is at version 8. Rather than reporting a generic failure, the service reads the row and returns 409 with the current version and the current values, so the interface can show what changed and offer to merge.

Data flow

  • The version is raised in the UPDATE statement itself. An application that read, incremented and wrote would have reintroduced the race it is trying to remove.
  • A missing If-Match means no predicate. The write succeeds, which keeps older clients working.
  • The conflict response includes the current row, because a client that only learns it failed can do nothing useful except reload and lose the user’s typing.
  • Offline clients carry the version they last saw, so a sync that happens hours later is checked the same way.

5.Technology stack

ComponentWhat it is
Strategyoptimistic concurrency control
Versionan integer column, raised by the database
TransportIf-Match and ETag, plus the version in the body
Conflict status409, with the current version and row
Scopeevery generated resource, and the sync path

6.Data model

any generated resource

Raised by a BeforeUpdate hook that sets it to version + 1 as a SQL expression rather than a value read in Go, which is what makes it atomic.

ColumnHolds
versioninteger, not null, default 1, raised on every update

7.API design

Conditional writes

MethodEndpointWhat it does
GET/api/v1/invoices/:idReturns the version and an ETag
PUT/api/v1/invoices/:idHonours If-Match; 409 when the version moved
PATCH/api/v1/invoices/:idThe same, for partial writes

A conditional write

PUT /api/v1/invoices/01a11e82-d0bc-7367-9c39-5e9873953dde
If-Match: "7"
Content-Type: application/json
{ "amount": 1250, "due_on": "2026-11-01" }

A conflict that the interface can act on

{
"error": {
"code": "WRITE_CONFLICT",
"message": "This record changed since you loaded it",
"details": {
"your_version": 7,
"current_version": 8
}
},
"data": {
"id": "01a11e82-d0bc-7367-9c39-5e9873953dde",
"amount": 1100,
"due_on": "2026-10-28",
"version": 8
}
}

8.Low level design

Core types

concurrency.Preconditioninternal/concurrency/concurrency.go

The parsed If-Match. Carries a scope that adds the predicate and a Missed method that interprets rows-affected.

ScopeMissed
concurrency.Tag

The ETag for a version. One function, so the format in the response and the format accepted in the request cannot drift.

ErrConflict

Carries the version the row is actually at, so the handler can answer with something more useful than a refusal.

BeforeUpdate

The GORM hook that raises the version as a SQL expression. Written as an expression rather than a Go value on purpose.

Design principles applied

  • Check and write in one statement. a read-then-write check has the same race it is meant to prevent, just narrower. The predicate belongs in the UPDATE.
  • Optional by default. the feature is available to clients that want it and invisible to those that do not, so it could be added without a breaking change.
  • Report the state, not just the failure. a 409 carrying the current row lets the interface offer a merge. One carrying only a code forces a reload and loses the user’s work.

Patterns

PatternWhere it is used
Optimistic concurrencyversion checked in the write predicate
Compare and swapthe database-level equivalent, as one statement
HTTP preconditionsIf-Match and ETag rather than a bespoke field

9.Scalability and performance

  • There is no additional query on the happy path and no lock held across a request, so this costs nothing at any write rate.
  • Conflicts cost one extra read, on a path that is rare by construction.
  • Contention is a property of the workload, not the mechanism. A single row written by many clients will conflict often, and the answer there is a different data model rather than a different locking strategy.
  • The approach works unchanged across replicas and across an offline client syncing hours later, because the version lives on the row rather than in a session.
  • Four bytes a row is immaterial at any table size.

10.Bottlenecks and improvements

What breaks first

  • Hot rows. a counter row that everybody updates conflicts constantly, and optimistic locking turns that into a retry storm rather than a queue.
  • Clients that ignore the conflict. a client that retries a 409 by reloading and resubmitting without showing the user what changed has reimplemented last-write-wins with extra steps.
  • Partial writes across records. the version protects one row. An operation spanning three rows can still half-succeed unless it is in a transaction.
  • Version not surfaced. if a client never sees the version it cannot send If-Match, and the protection is present but unused.

What to do about it

  • Model hot values differently. a counter belongs in an append-and-aggregate shape or an atomic increment, not in a row that every writer must version-check.
  • Show the difference. the conflict response carries the current row precisely so the interface can present both and let the user choose.
  • Wrap multi-row operations. a transaction around the whole operation makes the group atomic, with the per-row versions still preventing lost updates inside it.
  • Return the version everywhere. including it in list responses as well as reads means a client editing from a list has it without a second request.

Read next