The short answer
In FHIR R4, a resource’s meta.versionId changes each time its content changes, and the server manages it.1 Each version can be read on its own with vread, at [base]/[type]/[id]/_history/[vid], and all of a resource’s versions with _history.2 The version also travels in the HTTP ETag header, such as W/"3", and a create returns a Location header with the new id and version.2
Together these let a client do two things that matter for safety: read back exactly the version its write created, and update only the version it last read, using If-Match, so a concurrent change isn’t silently overwritten.2 The catch: versioning is a SHOULD, not a SHALL. Servers may not support it at all.2
Deedglass
The server said “saved”. Deedglass looked.
Deedglass reads it back
The pieces
| Piece | Where | What it’s for |
|---|---|---|
meta.versionId |
In the resource | Which version this is1 |
meta.lastUpdated |
In the resource | When the server last changed it1 |
ETag: W/"vid" |
Response header | The version, for HTTP caching and If-Match2 |
Location |
Create response | New id and version2 |
vread |
GET …/_history/[vid] |
Read one version2 |
_history |
GET …/[id]/_history |
List versions, newest first2 |
If-Match |
Update request header | Update only if the version still matches2 |
CapabilityStatement versioning |
Server metadata | no-version, versioned or versioned-update2 |
Version ids are strings the server chooses. The spec says there’s no fixed order for them, so a client can’t assume a later version sorts higher.1 Compare them for equality, never with “greater than”.
A write and its version
A create, then an update:
Code A request one program sends to another over the web.
POST /fhir/Observation
→ 201 Created
Location: https://fhir.example/fhir/Observation/123/_history/1
ETag: W/"1"
PUT /fhir/Observation/123
If-Match: W/"1"
→ 200 OK
ETag: W/"2"
The Location on the create gives you the exact version you wrote.2 Reading that version back with vread and comparing the fields you sent is the simplest way to confirm the server stored what you meant, rather than trusting the status code. Our FHIR write benchmark found 11 of 17 synthetic writes where the status said success and the stored data differed from what the client meant; reading back the reported version is one of the checks that caught some of them.
Reading history
_history works at three levels: one resource ([base]/Observation/123/_history), a type ([base]/Observation/_history) and the whole server ([base]/_history). It returns a Bundle of type history, sorted with the oldest versions last, and including deleted resources.2 A vread of a version where the resource was deleted should return 410.2
History answers questions a single read can’t:
- Did someone else write in between? If you updated version 2 and history shows a version 3 you didn’t make, another writer was there.
- Did my retry create a duplicate version? Count the versions your client made.
- Was something deleted? The deleted version is in the list.
Our benchmark’s checklist puts it plainly: count versions, not answers. Overwrites, duplicates and partial Bundles show up in history, not in the resource you wrote.2
Updating only what you read
Without If-Match, the last writer wins. Two clients read version 2; each updates; the second update silently replaces the first. With If-Match, the second update carries W/"2", the server sees the resource is now at version 3, and answers 412 Precondition Failed without updating.2
A server that declares versioned-update in its CapabilityStatement expects clients to send the right version on updates.2 For agents, If-Match should be the default on every update: an agent rarely knows who else is writing to the same patient’s record, and a 412 is a cheap, clear signal to read again and decide.
Why this matters for agents
An agent writing to a FHIR server is a client like any other, with two differences. It often retries on its own when something looks wrong, and it summarises what it did in words. Both make versioning more useful, not less. A retry that created a second version, or a second resource, shows up in history even when the agent’s summary says “updated once”. And an agent that sends If-Match gets a clear 412 when a clinician or another system changed the record first, instead of quietly replacing their work. Build both into the agent’s FHIR tool, so the model doesn’t have to remember them.
What versioning doesn’t promise
- It may not exist. Servers are allowed not to version. Those that don’t must leave
meta.versionIdout, and should answer 404 when history isn’t available.2 Check the CapabilityStatement before you build on it. - A no-op update may not create a version. HAPI FHIR JPA, for example, doesn’t create a new version for an update that changes nothing; the setting is on by default.3 A client expecting version 3 will find version 2. That’s correct behaviour, but a check that insists on a new version will raise a false alarm.
- Version ids aren’t counters. Don’t compute the next one; read it from the response.1
lastUpdatedisn’t the version. Version-aware updates don’t depend on it.1
A checklist for clients and agents
- Read the CapabilityStatement and note the
versioningvalue. - Keep the
LocationorETagfrom every write. - Read back that version and compare the fields you sent.
- Send
If-Matchon every update; treat 412 as “read again”. - Count versions in
_historyafter anything that might have retried. - Expect no new version for a no-op update on servers that suppress them.
This is how our own FHIR connector checks writes. Each write is read back at the version the server reported and the fields the agent sent are compared one by one, after dropping what the server adds on its own; an update the server kept at the same version carries a note, and a stale If-Match fails as a version conflict without a retry.4 In a run against HAPI FHIR JPA, an update the policy denied never reached the server: HAPI’s own history held exactly the two versions that were let through.4 Transactions add their own versioning rules, covered in FHIR transaction vs batch, and the general idea is in the read-back check entry. To try checks like this on a real database in your browser, open the playground; to grade what an agent can reach on your machine, run npx deedglass scan (the grade, explained).
Written with AI assistance and checked against our claims register.