Research · 26 October 2026
Do FHIR servers store what they say they stored?
For valid data, yes. We sent 51 synthetic writes and 37 official ABDM and NHCX example bundles to four FHIR server setups and read every one back. The gaps sit in invalid input, which some setups accept and quietly trim, and in success replies that cover more than one outcome.
Run on 26 October 2026. HAPI FHIR 8.12.0 (twice: defaults, validation on), the HAPI public test server and the Firely Server public sandbox. Synthetic data only. Data integrity only, not clinical advice. How we did it · Download the data (CSV)
29 of 72
invalid FHIR writes got a success reply across the four setups; 8 of them were stored with part of what was sent removed.
Six findings.
-
Valid data came back as sent: 14 of 14 fidelity and extension writes round-tripped on HAPI’s defaults, 14 on the HAPI public server and 14 on the Firely sandbox.
Decimal precision (72.10), a +05:30 time offset, Devanagari and Tamil names, unknown and nested extensions, meta tags and narrative were all read back as sent. With HAPI’s request validation on, 13 of 14: it refused an extension whose URL it couldn’t resolve. The differences start when the input isn’t valid FHIR, or when “success” covers more than one outcome.
-
29 of 72 invalid writes got a success reply across the four setups, and 8 of those were stored with part of what was sent removed.
Per setup, out of 18: HAPI 8.12.0 on its defaults 12 (4 changed), with request validation on 1, the HAPI public server 11 (4 changed), the Firely sandbox 5. R4 lets each server choose how strictly it validates, so this is configuration, and HAPI documents its lenient parser as the default.
-
One malformed field, two different results: a status sent as ["final"] was stored with no status on HAPI’s defaults, and kept as an array on the Firely sandbox.
Both answered 201 and 201. HAPI’s lenient parser left the required element out of the stored Observation; Firely returned the field as a JSON array on read, a shape R4 JSON doesn’t allow for a single value (its XML form showed a single value). With HAPI’s validation on, the write was refused (422). An agent reading only the status code would report the same thing in all three cases.
-
All four setups answered 200 to a conditional create that stored nothing, and to an update that created no new version.
This is R4 behaviour, not a defect: a conditional create with one match is ignored, and an update that changes nothing may keep the version. But the reply alone can’t tell a caller whether anything was written; only a read-back or the version id can. A stale If-Match was refused by all four, with 409, where R4 names 412.
-
The version matters: HAPI 8.12.0 applied a conditional update whose body id didn’t match (200); the public server’s 8.13.10-SNAPSHOT build refused it (400), as R4 says.
HAPI’s maintainers track this case (issue #8389). Firely refused it too (400). If you run an older HAPI, check what your version does before you rely on the id in the body.
-
India: all 37 official ABDM and NHCX example bundles were stored and read back unchanged on three setups; with validation on and the Indian guide not loaded, none were accepted.
301 of 301 entries came back identical on HAPI’s defaults, 301 on the HAPI public server and 301 on Firely, both as stored Bundles and unpacked as transactions. With HAPI’s request validation on, 0 of 74 writes were accepted: 44 replies named an NRCeS profile the server didn’t have. Validation that hasn’t loaded ndhm.in refuses Indian records it can’t check.
What each setup did with 51 writes.
Each bar is one setup. Stored as sent, a success reply after which the read-back differed from what was sent, invalid input accepted and stored as sent, and refused.
- HAPI 8.12.0 (defaults)HAPI FHIR JPA Server 8.12.0 · run locally 22 as sent · 8 differed · 9 invalid kept · 12 refused
- HAPI 8.12.0 (validation on)HAPI FHIR JPA Server 8.12.0 · run locally 20 as sent · 4 differed · 1 invalid kept · 26 refused
- HAPI public test serverHAPI FHIR Server 8.13.10-SNAPSHOT · public server 22 as sent · 8 differed · 8 invalid kept · 13 refused
- Firely public sandboxFirely Server 6.11.0 · public server 25 as sent · 5 differed · 5 invalid kept · 16 refused
“Read-back differed” includes R4 behaviour such as a conditional create that stores nothing, the server replacing a client id on create, and HAPI removing versions from references by default. It is a difference between the reply and the stored state, not a defect.
Invalid writes that got a success reply.
Out of 18 writes that aren’t valid FHIR R4, or that R4 says a server refuses. Validation strictness is a server setting.
Every probe, every setup.
“As sent”: a success reply and an identical read-back. “Changed”: a success reply, and the read-back differs. “Accepted”: invalid input answered with success. “Nothing new”: a success reply with no new resource or version. The CSV has the HTTP status, the paths that were dropped or changed, and the server’s message for each cell.
| Probe | HAPI 8.12.0 (defaults) | HAPI 8.12.0 (validation on) | HAPI public test server | Firely public sandbox |
|---|---|---|---|---|
| Field fidelity | ||||
| Valid Patient with many elements | As sent 201 | As sent 201 | As sent 201 | As sent 201 |
| Names in Devanagari and Tamil script | As sent 201 | As sent 201 | As sent 201 | As sent 201 |
| Decimal 72.10 (trailing zero is significant in FHIR) | As sent 201 | As sent 201 | As sent 201 | As sent 201 |
| dateTime with +05:30 offset | As sent 201 | As sent 201 | As sent 201 | As sent 201 |
| meta.tag, meta.security and meta.profile sent by client | As sent 201 | As sent 201 | As sent 201 | As sent 201 |
| Client-supplied narrative (text.div) | As sent 201 | As sent 201 | As sent 201 | As sent 201 |
| Narrative text kept on read-back | As sent 201 | As sent 201 | As sent 201 | As sent 201 |
| Contained Practitioner referenced from performer | As sent 201 | As sent 201 | As sent 201 | As sent 201 |
| POST with an id in the body (R4: server ignores it) | Changed 201 | Changed 201 | Changed 201 | Changed 201 |
| Extensions | ||||
| Core extension (patient-birthPlace) | As sent 201 | As sent 201 | As sent 201 | As sent 201 |
| Extension with an unknown, unresolvable URL | As sent 201 | As sent 201 | As sent 201 | As sent 201 |
| Extension on a primitive (_birthDate) | As sent 201 | As sent 201 | As sent 201 | As sent 201 |
| Complex extension with nested extensions | As sent 201 | As sent 201 | As sent 201 | As sent 201 |
| Unknown modifierExtension (changes meaning; R4 lets a server refuse) | As sent 201 | As sent 201 | As sent 201 | As sent 201 |
| Extension with neither value nor children (invariant ext-1) · invalid input | Accepted 201 | Refused 422 | Accepted 201 | Refused 400 |
| Extension with a relative URL (extension.url must be absolute) · invalid input | Accepted 201 | Refused 422 | Accepted 201 | Accepted 201 |
| NRCeS-style profile extension URL on Patient | As sent 201 | Refused 422 | As sent 201 | As sent 201 |
| References | ||||
| Observation.subject points to a Patient that does not exist | Refused 400 | Refused 400 | Refused 400 | As sent 201 |
| Observation.subject points to a Medication (not an allowed target type) · invalid input | Refused 422 | Refused 422 | Refused 422 | Accepted 201 |
| Absolute reference to another server | Refused 400 | Refused 400 | As sent 201 | As sent 201 |
| Logical reference (identifier only, no literal reference) | As sent 201 | As sent 201 | As sent 201 | As sent 201 |
| Contained resource nobody references (invariant dom-3) · invalid input | Accepted 201 | Refused 422 | Accepted 201 | Refused 400 |
| Versioned reference Patient/x/_history/1 | Changed 201 | Changed 201 | Changed 201 | As sent 201 |
| Conditional create and update | ||||
| Conditional create, one match (R4: ignore, answer 200) | Nothing new 200 | Nothing new 200 | Nothing new 200 | Nothing new 200 |
| Conditional create, two matches (R4: 412) | Refused 412 | Refused 412 | Refused 412 | Refused 412 |
| Conditional update, no match (R4: server creates) | As sent 201 | As sent 201 | As sent 201 | As sent 201 |
| Conditional update, two matches (R4: 412) | Refused 412 | Refused 412 | Refused 412 | Refused 412 |
| Update that changes nothing: does a new version appear? | Nothing new 200 | Nothing new 200 | Nothing new 200 | Nothing new 200 |
| Update with a stale If-Match (R4: 412) | Refused 409 | Refused 409 | Refused 409 | Refused 409 |
| Update with no If-Match over a version the client never read | As sent 200 | As sent 200 | As sent 200 | As sent 200 |
| PUT to a new id (update-as-create) | As sent 201 | As sent 201 | As sent 201 | As sent 201 |
| Same create sent twice (a retry after a lost answer) | As sent 201 | As sent 201 | Refused 412 | As sent 201 |
| Conditional update whose body id differs from the one match (R4: 400) · invalid input | Accepted 200 | Accepted 200 | Refused 400 | Refused 400 |
| Transactions and batches | ||||
| Transaction: 3 Observations, the 3rd has no status (R4: all or nothing) · invalid input | Accepted 200 | Refused 422 | Accepted 200 | Refused 400 |
| Batch: same 3 entries (R4: each entry on its own) · invalid input · batch control | Accepted 200 | Refused 422 | Accepted 200 | Partial 200 |
| Transaction where one entry references a missing Patient | Refused 400 | Refused 400 | Refused 400 | As sent 200 |
| Transaction: urn:uuid reference rewritten to the new Patient id | As sent 200 | As sent 200 | As sent 200 | As sent 200 |
| Transaction: 3 creates plus one update with a stale If-Match · invalid input | Refused 409 | Refused 409 | Refused 409 | Refused 409 |
| Transaction with two conditional creates on the same identifier | As sent 200 | As sent 200 | As sent 200 | Changed 200 |
| Validation strictness | ||||
| Observation with no status (1..1) · invalid input | Accepted 201 | Refused 422 | Accepted 201 | Refused 400 |
| Observation.status = "done" (not in required value set) · invalid input | Refused 400 | Refused 422 | Refused 400 | Refused 400 |
| birthDate "21/10/1980" · invalid input | Refused 400 | Refused 422 | Refused 400 | Refused 400 |
| birthDate "1980-02-30" · invalid input | Refused 400 | Refused 422 | Refused 400 | Accepted 201 |
| valueQuantity.value sent as the string "72" · invalid input | Accepted 201 | Refused 422 | Accepted 201 | Accepted 201 |
| Unknown element "favouriteColour" on Patient · invalid input | Accepted 201 | Refused 422 | Accepted 201 | Refused 400 |
| Empty string in name.family (R4: strings must not be empty) · invalid input | Accepted 201 | Refused 422 | Accepted 201 | Refused 400 |
| gender "M" (required binding: male|female|other|unknown) · invalid input | Refused 400 | Refused 422 | Refused 400 | Refused 400 |
| Observation with both a value and dataAbsentReason (invariant obs-6) · invalid input | Accepted 201 | Refused 422 | Accepted 201 | Refused 400 |
| status sent as an array ["final"] · invalid input | Accepted 201 | Refused 422 | Accepted 201 | Accepted 201 |
| meta.profile naming a profile the server does not have | As sent 201 | Refused 422 | As sent 201 | As sent 201 |
| Observation with no code (1..1) · invalid input | Accepted 201 | Refused 422 | Accepted 201 | Refused 400 |
India: ABDM health records and NHCX claim bundles.
The 37 example bundles published with the NRCeS FHIR Implementation Guide for ABDM 6.5.0: discharge summaries, prescriptions, lab and imaging reports, immunization records, invoices, and NHCX claim, pre-authorisation, coverage-eligibility and task bundles. Names, phone numbers, addresses and identifiers were replaced with synthetic values first.
| Setup | Stored as a Bundle, read back unchanged | Unpacked as a transaction, accepted | Entries read back unchanged |
|---|---|---|---|
| HAPI 8.12.0 (defaults) | 37 of 37 | 37 of 37 | 301 of 301 |
| HAPI 8.12.0 (validation on) | 0 of 37 | 0 of 37 | 0 of 301 |
| HAPI public test server | 37 of 37 | 37 of 37 | 301 of 301 |
| Firely public sandbox | 37 of 37 | 37 of 37 | 301 of 301 |
With HAPI’s request validation on, the server validated against the base R4 profiles only; the ndhm.in package wasn’t loaded, and 44 of its 74 refusals name an NRCeS profile it didn’t have. We didn’t run a setup with the package loaded (see the limits). HAPI returned narrative with its attributes in a different order, and left out an entry’s resource id where it repeated the entry’s urn:uuid; we count both as unchanged.
How we did it.
- Servers. HAPI FHIR JPA Server 8.12.0, from the official hapiproject/hapi:v8.12.0-2 image’s application archive, run on Java 21 with its in-memory database and the starter’s defaults, then again with hapi.fhir.validation.requests_enabled=true. The HAPI public test server at hapi.fhir.org/baseR4 (build 8.13.10-SNAPSHOT/6bd250944e/2026-09-25) and the Firely Server public sandbox at server.fire.ly/R4 (6.11.0), each as its operator configured it. All FHIR 4.0.1.
- Probes. 51 writes in six groups: field fidelity, extensions, references, conditional create and update, transactions and batches, and validation strictness. Each carries an identifier unique to the run. Then the 37 NRCeS examples, each stored as a Bundle and unpacked into a transaction.
- Read back. After each write the probe reads the resource back and compares it with what it sent, element by element. It ignores id, meta.versionId, meta.lastUpdated and meta.source, and treats three things as the same value: narrative with attributes in another order, a relative reference returned as an absolute URL on the server’s own base, and a number sent as a numeric string and stored as a number.
- Public servers. The HAPI test server says it is “not a production server” and is reset regularly; Firely’s documentation calls its sandbox “intended for testing and educational purposes”. We wrote only synthetic data, paused 400 ms before each request, and deleted every resource we created. Searches for the study’s identifiers on both servers returned none afterwards.
Its limits.
- Two implementations, four setups. Medplum, Blaze, Microsoft’s FHIR server and the managed cloud services weren’t in this run: Blaze needs a native library our machine lacked without Docker, and Medplum is covered by our agent write benchmark.
- Every result depends on configuration. The public servers run settings we can’t see; the HAPI public server, for one, refuses a create whose content repeats an existing resource (HAPI-2840), which changed our retry probe there.
- We didn’t run HAPI with the ndhm.in package loaded: the machine’s disk limit stopped that setup mid-start. Whether the examples pass a server that has the guide loaded is open.
- A read-back sees what the server returns, not its tables. A server could store more than it returns, or return what it cached.
- Deletes on both public servers keep earlier versions in history, as R4 allows. The data was synthetic, and HAPI resets its test server regularly.
- The HAPI public run used the final probe; the first pass there was set aside because the server’s duplicate check blocked two seeds. Both runs are in the raw files. The run marker we added to each ABDM entry isn’t valid on Binary resources, so its removal there isn’t counted.
- We make Deedglass, which checks agent writes by reading them back, so we have an interest in this question. The probe, the raw results and the aggregate are published so anyone can rerun it.
Responsible disclosure.
Before publishing, we checked each behaviour named here against the project’s documentation and its issue tracker, and re-ran the two Firely results by hand. The conditional-update id case is already tracked by HAPI (#8389). For the rest, we have drafted notes to the maintainers and haven’t filed them; nothing here is a security issue. If you maintain one of these servers and read a result differently, write to us and we will correct the page.
Download the CSVSee the agent write benchmark Deedglass for healthcare
Cite as: Deedglass, “FHIR write integrity 2026: do FHIR servers store what they say they stored?”, deedglass.com/research/fhir-write-integrity-2026, run 2026-10-26. The scripts: probe, aggregate.
Questions.
Do FHIR servers store what they say they stored?
For valid data, in our 26 October 2026 run, yes: the fidelity and extension writes came back as sent on every setup that accepted them. The gaps were in invalid input (29 of 72 invalid writes got a success reply across four setups, 8 stored with parts removed) and in replies like a 200 for a conditional create that stores nothing.
Which FHIR servers and versions were tested?
HAPI FHIR JPA Server 8.12.0 run locally twice (starter defaults, then with request validation on), the HAPI public test server (build 8.13.10-SNAPSHOT/6bd250944e/2026-09-25) and the Firely Server public sandbox (6.11.0). Each setup is named with its configuration; results depend on configuration.
Can ABDM and NHCX bundles be stored on an open-source FHIR server?
In this run, all 37 example bundles from the NRCeS FHIR Implementation Guide for ABDM 6.5.0 were stored and read back unchanged on HAPI’s defaults, the HAPI public server and the Firely sandbox. With HAPI’s request validation on and the guide’s package not loaded, none were accepted.
Is this medical advice or a clinical safety test?
No. It measures data integrity only: whether what a server stored matches what was sent. Every resource was synthetic, and nothing here judges clinical content.