Skip to content
Talk to our solutions team

Concurrency

Two callers that update the same row are last-writer-wins unless the entity declares a concurrency strategy. With one, every row carries a token, a read hands it to the client, and a write may hand it back: the update or delete applies only if the row still carries that token. Nothing is locked while the client thinks — the check happens at write time.

entities:
- name: sales_order
concurrency:
strategy: version # none (default) | version | timestamp
column: _rowversion # version only; this is the default name
required: false # true: an update or delete without a token is refused
KeyDefaultMeaning
strategynoneversion keeps a counter column the service increments on every write. timestamp uses the updatedon column every entity inherits from the common trait.
column_rowversionThe counter’s column name. version only; timestamp always reads updatedon.
requiredfalsetrue makes the token mandatory: an update or delete that carries none is refused with 428.
  • version adds a read-only, not-null integer column (default 1) to the entity. It is 1 on create and moves on every update, soft delete and restore. A client cannot write it — a payload that carries it is a 422 READONLY validation failure.
  • timestamp needs no migration, but two writes in the same microsecond are indistinguishable and a clock that goes backwards breaks it. Use it for entities that cannot take a new column.
  • Neither strategy is available on an entity with a versioned history: strategy (type2, type6): every version row already carries _version_num, and the block fails the load.

The token is the column’s value rendered as a string: the counter in decimal, the timestamp as RFC 3339 with nanoseconds, UTC.

DirectionWhere
ReadEvery row carries the column in data, so a list gives one token per row. Single-row responses — get by id, and the row a create or update returns — also carry it as ETag: "<token>".
WriteIf-Match: "<token>" on PATCH /data/rest/{entity}/id/{id} and DELETE /data/rest/{entity}/id/{id}. A W/ prefix is tolerated; If-Match: * means “must exist”, which a bare write already means, and is ignored.
Write, in the body"expect": {"version": 7} or "expect": {"updatedon": "2026-09-09T10:00:00.123456Z"} on the update body, and on each update / delete row of the bulk envelope. expect never reaches the row.

Both forms may be present when they agree.

Terminal window
# read: the token comes back as ETag
curl -si https://<host>/data/rest/sales_order/id/01JC8… -H 'Authorization: Bearer <token>'
# HTTP/1.1 200 OK
# ETag: "7"
# write: hand it back
curl -sX PATCH https://<host>/data/rest/sales_order/id/01JC8… \
-H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
-H 'If-Match: "7"' \
-d '{"status":"shipped"}'
Statuscodedetails.codeWhen
200The row still carried the token; the write applied and the response ETag is the new token
412update_failed / delete_failedversion_mismatchThe row exists with another token. details.expected is what the caller sent, details.current the row’s token now, so a client can show the newer state or retry
404update_failed / delete_failedThe precondition named a row that does not exist — or one the caller may not see: the check re-reads as the caller, so a row hidden by a row-level rule is missing, never revealed
428update_failed / delete_failedprecondition_requiredThe entity says required: true and the write carried no token
400update_failed / delete_failedprecondition_unsupportedA token on an entity with no strategy
400update_failed / delete_failedprecondition_invalidA token of the wrong kind — a timestamp on a version entity, or the reverse
400bad_preconditionIf-Match or expect does not parse as a token
400precondition_conflictIf-Match and expect are both present and disagree

A bulk envelope with a mismatched operation fails as a batch, as every bulk failure does.

Optimistic concurrency needs no locks and works on every backend. Two other kinds of lock exist for callers that need to hold something:

KindWhat it doesWhere it is available
Row locksA query holds the rows it returns for the rest of the transaction (FOR UPDATE / FOR SHARE, waiting, NOWAIT or SKIP LOCKED) — the pattern for claiming the oldest queued item without racing another workerPostgreSQL. SQLite inside a write transaction, where the whole database is held. Refused on DuckDB and ClickHouse
Named locksOne critical section per key, scoped to the tenant (stock:42), held until the transaction endsPostgreSQL (advisory locks). SQLite inside a write transaction. Refused on DuckDB and ClickHouse

Both live only inside a transaction. On the HTTP surface that means a declared action with transaction: true (and lock: "<name>" for a named lock); plain GET and QUERY never lock, and a lock key in a query sent to them is refused. A wait that runs past the transaction’s lock timeout, or a deadlock the database broke, is reported with details.retryable: true.

details.codeStatusWhen
lock_requires_transaction400A row or named lock outside a transaction
lock_unsupported400The backend has no such lock; details.backend names it
lock_timeout409The wait ran out, or NOWAIT met a held row; details.retryable: true
deadlock409The database broke a deadlock; details.retryable: true