FHIR Transaction vs Batch Bundles: Atomicity Is the Difference
FHIR Transaction vs Batch Bundles: Atomicity Is the Difference
Version: dev
Source revision: e261f5e8c976d5017141e83cab1882f73d47b884
Last verified: September 25, 2026
FHIR version: R4 4.0.1
Examples: synthetic data only
If you need the short answer: a FHIR transaction is one atomic act — every entry succeeds or none of them do. A FHIR batch is a group of independent acts — one entry may fail while the others remain committed.
That distinction is not naming. It is the contract a client is relying on.
Ilavrita implements both at POST /fhir/R4, but they deliberately take different failure paths. Transactions share one commit boundary. Batch entries each run inside a savepoint. The response shape also differs: successful transactions return a transaction-response; batches return a batch-response with an answer for every entry, including failures.
This post is about that boundary, not about claiming complete Bundle support. Ilavrita is still a developer preview and should not hold patient data. The implementation described here is pinned to the source revision above.
What FHIR R4 means by transaction and batch
FHIR R4 defines Bundle.type = transaction as an atomic commit. The server must either accept the whole transaction or reject it as a whole.
A batch, by contrast, is explicitly a group of independent actions. A batch response can therefore contain a mixture of successful and failed entries.
The specification also requires a response entry for each request entry in a transaction or batch response.
Primary references:
That gives us the testable difference:
If one entry fails, a transaction must not leave the successful entries behind. A batch should.
Everything else follows from that.
Ilavrita uses one endpoint for two different promises
Both Bundle types are submitted to:
POST /fhir/R4
The request body decides which contract applies:
{
"resourceType": "Bundle",
"type": "transaction",
"entry": []
}
or:
{
"resourceType": "Bundle",
"type": "batch",
"entry": []
}
The shared wire shape is useful, but internally these cannot be treated as aliases.
The transaction path is designed around one commit boundary. The batch path deliberately creates a smaller rollback boundary around each entry.
That difference is visible directly in the implementation:
A transaction is one commit boundary
The transaction handler states its rule plainly: every entry happens or none does.
The important implementation detail is where the boundary lives.
Ilavrita's request/audit layer already opens the database transaction around the route. Transaction entries join that same database transaction. Their resource writes, entry-level audit records, and queued notifications therefore share the same fate.
If entry three fails after entries one and two have written, the handler returns an error and the outer transaction rolls back.
The test suite checks the property directly.
TestATransactionIsAllOrNothing submits three entries:
- a valid Organization,
- an invalid Observation,
- another valid Organization.
The request fails, and the test then searches for Organizations. It expects zero.
That is stronger evidence than seeing a function named performTransaction. The behavior is asserted from the HTTP surface down to what remains stored.
See transaction_test.go.
A failed transaction also rolls back entry audit records
Atomicity gets less useful if the resource rows roll back but their associated side effects do not.
Ilavrita writes an audit record for each transaction entry it actually performs. Those entry audit records live inside the same transaction as the entry write.
The suite includes TestATransactionThatFailsRecordsNothingEither. A transaction performs a valid first entry and then reaches an invalid Observation. The transaction fails. The test checks that audit records for writes that did not ultimately happen are not left behind.
That does not mean failed requests disappear from operational history altogether. The route-level refusal can still be recorded according to the audit model. The narrower claim here is this: an entry write that was rolled back does not leave an audit row claiming that write survived.
That distinction matters during incident reconstruction.
Transactions execute in FHIR's semantic order, not request order
FHIR R4 defines a processing order for transaction interactions so that the result does not depend on the order in which entries happen to appear in the Bundle.
Ilavrita follows the write-side ordering relevant to the interactions it currently supports:
- deletes,
- creates,
- updates.
The implementation settles identities before performing the writes, then executes entries in semantic order.
There is a test for the non-obvious case: a client submits an update before a delete for the same identity. The server performs the delete first, then the update. The final resource exists with the replacement content rather than remaining deleted.
That behavior is covered by TestADeleteRunsBeforeACreateInTheSameTransaction.
One important limit: Ilavrita does not currently implement every interaction FHIR permits inside a transaction. The current transaction entry implementation handles create, update, and delete. Unsupported entry interactions are refused rather than silently ignored.
Response order still matches request order
Execution order and response order are different problems.
Even though the server may perform a delete before a create that appeared earlier in the request, a client still needs the response to line up with the entries it submitted.
Ilavrita allocates the response array in request order and writes each performed result back into its original index. The generated transaction-response therefore corresponds entry-for-entry with the submitted Bundle.
That matches the R4 response model: a transaction or batch response carries one response entry for each request entry.
The Bundle types and response construction live in packages/fhir/transaction.go.
urn:uuid: lets entries refer to resources that do not have IDs yet
A common transaction shape is:
- create or update a Patient,
- create an Observation referring to that Patient,
- submit both together.
The Observation cannot know a server-assigned resource ID before the request is processed.
FHIR transactions solve this with Bundle-local fullUrl identities such as:
urn:uuid:the-patient
Ilavrita first settles the identities of all entries. It builds a mapping from Bundle-local placeholders to their final FHIR references. Resource content is then rewritten only where a FHIR reference field contains one of those known placeholders.
For example:
{
"resourceType": "Observation",
"subject": {
"reference": "urn:uuid:the-patient"
}
}
can be stored with a reference such as:
{
"subject": {
"reference": "Patient/abc123"
}
}
The test TestATransactionResolvesItsOwnReferences verifies that the placeholder does not survive into the stored Observation and that the final Patient reference does.
A urn:uuid: string that no Bundle entry claims is deliberately left alone rather than rewritten to nothing. That behavior has its own test as well.
Conditional create is supported inside a transaction
Bundle entry preconditions are an area where "supports transactions" can hide important gaps.
At this revision, Ilavrita supports ifNoneExist for a transaction entry. That is the conditional-create case: create the resource only if the search condition does not already identify one.
The implementation settles that condition before writes begin so that other entries referring to the conditional-create entry can resolve to the resource that already exists instead of creating a duplicate.
The test TestATransactionEntryCreatesOnlyWhatIsNotThere submits the same conditional create twice and verifies the resource is not duplicated.
Other Bundle request preconditions are not silently accepted. ifMatch, ifNoneMatch, and ifModifiedSince are refused for transaction entries at this revision.
That is the kind of limit clients need to know before depending on Bundle semantics.
Batch is intentionally different: each entry gets a savepoint
A batch cannot share the transaction's failure rule, because then it would simply be a transaction with a different name.
Ilavrita processes each batch entry inside a database savepoint.
The outer request can therefore keep successful entries while undoing the partial work of one failed entry.
In simplified terms:
batch request
entry 1 -> savepoint -> commit entry
entry 2 -> savepoint -> rollback entry
entry 3 -> savepoint -> commit entry
The test TestABatchKeepsTheEntriesThatWorked submits:
- a valid Organization,
- an unsupported Appointment,
- another valid Organization.
The response contains three entries. The first and third report success. The second reports failure. A subsequent search finds both successful Organizations.
That is the central batch promise.
See batch_test.go.
A failed batch entry rolls back its own partial work
Independent failure does not mean "whatever happened before the error stays behind."
Each entry must still behave atomically with respect to itself.
TestAFailedBatchEntryLeavesNothingOfItsOwnBehind uses an Organization that fails validation after processing has begun. The batch keeps the valid neighboring entry but does not keep a partial resource from the failed entry.
That is why the savepoint matters.
Without it, "batch entries are independent" could accidentally mean "failed entries may leave half-completed side effects behind." That would be a much weaker and less useful contract.
Batch failures use normal FHIR error vocabulary
For a failed batch entry, the server renders the refusal into the entry's response with an HTTP-like status and an OperationOutcome.
That means a client does not need one error vocabulary for standalone interactions and another for batch entries.
The batch response can say, entry by entry:
- this one returned
201 Created, - this one failed with a 4xx response and an
OperationOutcome, - this one also returned
201 Created.
FHIR R4 expects a response entry for each request entry, and batch responses explicitly permit mixed success and failure.
Why not always use a transaction?
Because atomicity has a cost and a meaning.
Use a transaction when the entries describe one logical change that must not be partially visible.
A synthetic example:
- create an Organization,
- create another resource that must refer to it,
- require both to exist together.
Use a batch when entries are independent and you would rather keep the successful work than discard all of it because one item was bad.
For example, a client importing unrelated synthetic Organizations may prefer a batch so one malformed resource does not erase the valid imports beside it.
The choice should reflect domain semantics, not convenience.
Ilavrita currently bounds a transaction at 200 entries
At this revision, one transaction may contain at most 200 entries.
That is an implementation limit, not a FHIR R4 limit.
The reason is operational: one transaction holds the process's pooled connection for the duration of the commit boundary. An unbounded Bundle could therefore make every other request wait behind a single oversized transaction.
The test TestATransactionIsBounded constructs 201 entries and expects 400 Bad Request.
A limit like this belongs in documentation because a client can otherwise build a perfectly valid R4 Bundle that this deployment intentionally refuses.
How to reproduce the core claims
A clean verification should pin the same revision this post uses:
git clone https://github.com/Ilavrita/Ilavrita.git
cd Ilavrita
git checkout e261f5e8c976d5017141e83cab1882f73d47b884
make bootstrap
Then run the behavior-focused tests:
go test ./apps/ilavrita -run 'TestATransactionIsAllOrNothing|TestATransactionResolvesItsOwnReferences|TestABatchKeepsTheEntriesThatWorked|TestAFailedBatchEntryLeavesNothingOfItsOwnBehind'
For the transaction entry precondition boundary:
go test ./apps/ilavrita -run 'TestATransactionEntryCreatesOnlyWhatIsNotThere|TestATransactionEntryStatesNoPrecondition'
And for the advertised capability:
go test ./apps/ilavrita -run 'TestATransactionIsDeclared|TestABatchIsDeclared'
Verification note: the current source, docs, and tests were inspected directly through the connected GitHub repository at the pinned dev revision. These shell commands were not executed in this chat runtime, so this post does not claim a fresh local test run.
Documentation drift found during verification
The current README, roadmap, implementation, and tests all agree that transaction and batch Bundles are implemented.
One older subsection inside docs/known-limitations.md still says that batch is refused and describes conditional interactions as absent. That prose conflicts with later sections of the same document as well as the current code and tests.
We excluded that stale subsection from the capability claim instead of averaging contradictory documentation together.
This is exactly why posts carry a source revision and a verification date.
What this does not prove
Passing these tests does not establish production readiness.
It does not prove the absence of security vulnerabilities.
It does not make an Ilavrita deployment compliant with HIPAA, GDPR, EHDS, or another regulatory regime.
It does not mean every FHIR R4 transaction or batch feature is implemented.
It proves a narrower set of things at a specific revision:
- transactions are all-or-nothing for the supported entry interactions,
- batches keep successful independent entries,
- failed batch entries roll back their own partial work,
- transaction-local references are resolved,
- supported conditional-create behavior is tested,
- transaction and batch capabilities are advertised and exercised by the server tests.
That is enough for a useful engineering claim.
It is not permission to make a larger one.
FAQ
What is the difference between a FHIR transaction and a batch?
A transaction is atomic: all supported entries succeed together or the transaction fails as a whole. A batch treats entries independently, so one may fail while others succeed.
Does Ilavrita support both FHIR transaction and batch Bundles?
Yes, at revision e261f5e8c976d5017141e83cab1882f73d47b884, both are implemented and covered by server tests. The implementation is still incomplete relative to the full R4 Bundle surface.
Does a failed batch entry leave partial data behind?
The implementation runs each batch entry inside a savepoint. Tests verify that a failed entry rolls back its own work while successful neighboring entries remain.
Does Ilavrita support urn:uuid: references inside a transaction?
Yes. Tests verify that references to Bundle-local identities are rewritten to the final resource references before storage.
Does Ilavrita support every transaction precondition?
No. ifNoneExist is supported for conditional create. ifMatch, ifNoneMatch, and ifModifiedSince are refused for Bundle transaction entries at this revision.
Evidence
- README at the verified revision
- ROADMAP at the verified revision
- Known limitations at the verified revision
- Transaction implementation
- Transaction tests
- Batch implementation
- Batch tests
- FHIR Bundle model in Ilavrita
- HL7 FHIR R4 Bundle
- HL7 FHIR R4 transaction processing rules
For current project limits beyond Bundles, see the documentation, known limitations, and the roadmap.
