Batch resource validation
Available in Aidbox starting from version 2607.
Breaking change. $batch-validate replaces the previous batch-validation API, which is removed. The aidbox.validation/batch-validation, aidbox.validation/batch-validation-result, aidbox.validation/clear-batch-validation, and aidbox.validation/resources-batch-validation-task RPCs no longer exist, and a run no longer produces BatchValidationRun / BatchValidationError resources. Results now live in the aggregated aidbox_batch_validation schema (see How results are stored). Migrate to the $batch-validate operation described below.
Overview
Batch validation checks resources already in the database against the active FHIR schemas and an optional set of profiles. Use it when you loaded data with validation off, or when you publish a new profile version and want to know how many existing resources are non-compliant and why.
$batch-validate runs against one resource type. Aidbox hash-partitions it into a fixed number of tasks (number-of-chunks, default 12), each task validating its mod(hash(id), N) slice, and aggregates the results into a compact, offender-indexed form (see How results are stored).
It works two ways, chosen by the Prefer header:
| Trigger | Response | |
|---|---|---|
| Synchronous (default) | POST …/$batch-validate | blocks, returns a Parameters summary |
| Asynchronous | same + Prefer: respond-async | 202 + Content-Location; poll for the result |
Both paths produce the same result under a task-id and persist it the same way, so you drill into a synchronous run the same as an asynchronous one.
Synchronous validation blocks the request until it finishes. That suits a type scoped by a narrow _since/_until window; for a large type, use Prefer: respond-async. The synchronous path runs the N tasks on a local pool sized by scheduler-executors (BOX_SCHEDULER_EXECUTORS, default 4); the async path schedules them on the task scheduler, which spreads them across nodes.
Each task scans the window once for its hash slice, so total scan work grows with the task count. More chunks means more parallelism (the async path spreads them across nodes) but more scans; fewer chunks means fewer scans but less parallelism. The default of 12 balances the two. Raise number-of-chunks to parallelize a large type further.
PostgreSQL connections. Running tasks each use their own PostgreSQL connections, so a run with many parallel tasks (a high number-of-chunks together with a high scheduler-executors) raises connection use. Make sure PostgreSQL max_connections (and any external pooler) has the headroom, or tasks will fail to acquire a connection.
Start a validation
POST /fhir/<type>/$batch-validate with a FHIR Parameters body:
POST /fhir/Observation/$batch-validate
content-type: application/json
resourceType: Parameters
parameter:
# required: only resources whose meta.lastUpdated >= _since
- {name: _since, valueInstant: '2025-06-02T00:00:00Z'}
# optional upper bound (exclusive)
- {name: _until, valueInstant: '2025-06-09T00:00:00Z'}
# validate against these profiles (conjunctive, see Profiles)
- {name: profile, valueCanonical: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-observation-lab'}
# tuning (optional): number of parallel tasks (default 12)
- {name: number-of-chunks, valuePositiveInt: 24}
# validator options (optional): override the box's validation settings for this run
- {name: disable-terminology-validation, valueBoolean: true}
| Parameter | Type | Meaning |
|---|---|---|
_since (required) | instant | Only resources whose meta.lastUpdated >= _since (inclusive). Required so that a run declares a window instead of scanning a whole type (see Filtering by date). |
_until | instant | Upper bound: meta.lastUpdated < _until (exclusive). |
profile (repeatable) | canonical | Validate every resource against these profile URLs (in addition to its base schema), conjunctively (see Profiles). |
number-of-chunks (default 12) | positiveInt | Number of hash-partitioned tasks the run is split into. More parallelizes a large type (across nodes when async) at the cost of more scans; each task streams its slice, so heap stays bounded regardless. No fixed maximum: a sync run streams the tasks through a bounded thread pool (heap stays proportional to the executor count, not the task count), and an async run writes one scheduler row per task, so a very large value costs task rows and scans, not memory. |
disable-terminology-validation | boolean | Skip coded-binding / terminology checks. |
disable-primitive-validation | boolean | Skip primitive type & format checks. |
disable-slicing-validation | boolean | Skip slice validation. |
disable-constraint-validation | boolean | Blanket switch for FHIRPath invariants: true skips all of them; false checks all (see Validator options). |
disable-constraint (repeatable) | string | Skip specific invariants by key (e.g. us-core-8). |
strict-profile-resolution | boolean | Treat an unresolved profile / meta.profile canonical as an error instead of skipping it. |
strict-extension-resolution | boolean | Treat an unresolved extension as an error. |
The body must be a valid Parameters resource. Each parameter must use the exact value[x] type above (profile as valueCanonical, _since/_until as valueInstant, number-of-chunks as valuePositiveInt, the disable-*/strict-* flags as valueBoolean, disable-constraint as valueString). Aidbox rejects an unknown parameter, a wrong value type, or a missing _since with 422 and an OperationOutcome that names the offending parameter.
Validator options
The disable-* and strict-* parameters tune what the validator checks, for this run only. Each is three-state: omit it to keep the box's configured setting (see FHIR Schema Validator), or pass it to override that setting (true/false). Use them to trade completeness for speed on a huge type (e.g. skip the terminology and slicing passes for a structural-only sweep), or to tighten a run beyond the box defaults (e.g. strict-profile-resolution to surface resources whose declared profiles don't resolve, which otherwise read as compliant).
Constraints (FHIRPath invariants) have two controls that compose:
disable-constraint-validationis the blanket switch:trueskips every invariant,falsechecks every invariant (including any the box normally mutes).disable-constraintnames specific invariants to skip (repeat it per key).- When both are given, the blanket wins:
trueskips everything (the list is moot); withfalse, only the listed keys are skipped and all others are checked. With the blanket omitted, the listed keys are skipped on top of the box's defaults.
Indexing the hash partition
Each task validates its mod(abs(hashtextextended(id, 0)), N) slice of the type, where N is number-of-chunks. N stays fixed for a run, so the partition predicate is a constant expression that a PostgreSQL expression index can cover. Without such an index, every task scans the whole table to find its slice, so a run with N tasks costs N full scans (the scan cost the number-of-chunks note above describes). With a matching index, each task reads only its slice through the index.
The gain grows as the slice shrinks, so an index matters most for a large type validated with a high number-of-chunks. Build it on the resource's storage table (named after the lowercased resource type) with the same modulus as the number-of-chunks the run passes:
-- Observation, validated in 10000 chunks
CREATE INDEX CONCURRENTLY observation_batch_validate_10000
ON observation (mod(abs(hashtextextended(id, 0)), 10000));
Then run with the matching number-of-chunks:
POST /fhir/Observation/$batch-validate
resourceType: Parameters
parameter:
- {name: _since, valueInstant: '1970-01-01T00:00:00Z'}
- {name: number-of-chunks, valuePositiveInt: 10000}
The index modulus must equal number-of-chunks. A different value is a different expression, so PostgreSQL skips the index and the run falls back to per-task scans. Check the plan with EXPLAIN on one task's query before relying on the index.
CREATE INDEX CONCURRENTLY builds the index without blocking writes to the table. The index costs disk and slows writes, so drop it after a one-off sweep: DROP INDEX observation_batch_validate_10000.
Synchronous response
A Parameters resource holds the task-id, the headline counts, a link to the offending resources, and one issue per distinct error (each with its own filtered drill-down link).
status: 200
resourceType: Parameters
parameter:
- {name: task-id, valueString: '<task-id>'}
- {name: validated, valueUnsignedInt: 1804646} # resources validated
- {name: valid, valueUnsignedInt: 1317494} # resources with no issues
- {name: invalid, valueUnsignedInt: 487152} # distinct resources with ≥1 issue
- {name: bytes, valueDecimal: 5242880000} # total bytes of resource JSON processed
- {name: invalid-resources, valueUrl: '/fhir/$batch-validate/<task-id>/invalid-resources'}
- name: issue
part:
- {name: id, valueString: '5b4b07e4…'} # issue-id (for drill-down)
- {name: invalid-resources, valueUrl: '/fhir/$batch-validate/<task-id>/invalid-resources?_issue=5b4b07e4…'}
- {name: code, valueCode: invalid-slice-cardinality}
- {name: expression, valueString: category} # the element
- {name: profile, valueString: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-observation-lab'}
- {name: count, valueUnsignedInt: 486018} # distinct offending resources
- {name: constraint, valueString: us-core-8} # for invariants
- {name: diagnostics, valueString: '…human-readable message…'}
bytesis the total size of the resource JSON the run processed, adecimalbecause the total overflowsunsignedIntat scale.countis the number of distinct offending resources for the issue, derived from the offender index.- For invariant issues, the
constraintpart carries the constraint key. Thediagnosticspart is a generated summary keyed off the issuecode(for an invariant,<expression>: constraint <key> is not satisfied). It is not the validator's original message text, which is not stored (see How results are stored). - Other issue kinds add a type-specific part:
slice(the slice name) for slice issues,binding(the value-set URL) for terminology-binding issues, andunknown-profile(the unresolved canonical) for an unresolvedprofile/meta.profile. A part with no value is omitted. - Each
issuecarries its owninvalid-resourceslink, pre-filtered to that issue. - The summary lists at most 10,000 distinct issues, worst first. If a run produces more, it lists the worst 10,000 and adds
issues-total(the true count) andissues-truncated: true, so a truncated list is not mistaken for the whole. - On failure the response is an
OperationOutcome.
Asynchronous response
status: 202 Accepted
Content-Location: /fhir/$batch-validate/<task-id>
The endpoints below are system-level, keyed by task-id alone (no resource type in the path).
Poll the Content-Location:
GET /fhir/$batch-validate/<task-id>
- In progress →
202 Acceptedwith anX-Progressheader (percent of tasks completed, e.g.45%). - Complete →
200with the sameParameterssummary as the synchronous response. - Cancelled →
200, an informationalOperationOutcome(issue codeinformational). - Failed →
200with the partialParameterssummary from the tasks that completed, plus astatus: failedparameter; if no task completed, a200OperationOutcome. - Unknown task →
404.
Drill into the offending resources
The summary tells you which issues occur and how many resources hit each. To get the offending resources, each linked to the version that was validated and carrying its full OperationOutcome, call invalid-resources:
GET /fhir/$batch-validate/<task-id>/invalid-resources
?_issue=<issue-id>&_issue=<issue-id2>&_count=50&_page=1&_fullurl-only=false
| Query parameter | Meaning |
|---|---|
_issue (repeatable) | Restrict to offenders of these issue(s). Omit for all offending resources. |
_count / _page | Page size (default 50, max 1000) and 1-based page number. |
_fullurl-only (default false) | When true, return only the fullUrl of each offender (omit the body and outcome). |
The response is a Parameters report rather than a Bundle (see Response format): a total, flat paging links, and one repeated resource parameter per offending resource.
resourceType: Parameters
parameter:
- {name: total, valueUnsignedInt: 1114}
# paging: flat links. first/previous appear past page 1; next/last before the last page
- {name: self, valueUrl: '/fhir/$batch-validate/<task-id>/invalid-resources?_count=50&_page=1'}
- {name: next, valueUrl: '/fhir/$batch-validate/<task-id>/invalid-resources?_count=50&_page=2'}
- {name: last, valueUrl: '/fhir/$batch-validate/<task-id>/invalid-resources?_count=50&_page=23'}
- name: resource
part:
- {name: fullUrl, valueUrl: '/Observation/<id>/_history/<version>'}
- name: resource # omitted when _fullurl-only=true
resource: {resourceType: Observation, meta: {versionId: '<version>'}, …}
- name: outcome # omitted when _fullurl-only=true
resource:
resourceType: OperationOutcome
issue:
- {severity: fatal, code: invalid, expression: [Observation.category],
diagnostics: '…', details: {coding: [{code: invalid-slice-cardinality}]}}
- Each
resourceparameter is one distinct offending resource: its versionedfullUrl, theresourcebody (read from history at the validated version), and itsoutcome, anOperationOutcomelisting every issue that resource has (its full issue set, even when_issuenarrows which resources come back). _fullurl-only=truedrops theresourceandoutcomeparts and keeps thefullUrl.- Paging is flat:
selfalways;firstandpreviousonce past page 1;nextandlastwhile before the last page. - An unknown
_issueon a known task returns an empty report (total: 0). An unknown task returns404.
The fullUrl is version-specific (/_history/<version>), so a vread resolves to the resource version that was validated rather than the current one. (If that version was pruned, the body is absent; the fullUrl and outcome remain.)
Cancel
DELETE /fhir/$batch-validate/<task-id>
Responds 202 Accepted. Cancellation removes the run's pending tasks and marks it cancelled (a later poll reports cancelled). Aidbox does not interrupt a task already running, and keeps partial results.
Profiles
Pass profile (as valueCanonical) to validate every resource against those URLs (via meta.profile), say to check the impact of a new profile version on existing data. The profiles must be loaded (an installed IG package, or a StructureDefinition you created).
Multiple profiles are conjunctive (AND). A resource is compliant only if it conforms to every listed profile, and issues are the union of violations across them (same as FHIR $validate).
For OR ("valid against US Core 6 or 7"), run one validation per profile and intersect the offender sets: a resource is OR-invalid only if it appears in every run's offenders.
Aidbox skips an unresolvable profile URL when strict profile resolution is off, so a typo yields a "compliant" report that is wrong. Make sure the profile (and its package) is installed and the URL matches.
Filtering by date
_since and _until are FHIR instants that filter on meta.lastUpdated, following the bulk-export _since/_until semantics:
parameter:
- {name: _since, valueInstant: '2025-06-02T00:00:00Z'} # lastUpdated in [2025-06-02, 2025-06-09)
- {name: _until, valueInstant: '2025-06-09T00:00:00Z'}
_sinceis an inclusive lower bound (lastUpdated >= _since);_untilis an exclusive upper bound (lastUpdated < _until)._sinceis required: every run declares a window, so no call scans a whole (possibly huge) type by accident. To validate everything, pass an epoch_since(1970-01-01T00:00:00Z).- A narrow window keeps a synchronous run small. Date filters compose with
profile.
How results are stored
Aidbox stores results in an aggregated, compact form, so validating 100 GB of non-conformant data does not add 100 GB to the database. The aidbox_batch_validation schema holds:
| Table | Holds |
|---|---|
issue | one row per distinct error (no per-resource rows) |
invalid_resource | a tiny (issue_id, resource_id, version_id) row per offending resource: ids and versions only |
chunk_stat | one row per task, written once when the task finishes: its validated/invalid/bytes tallies |
The aggregation key is profile, resource_type, index-normalized path (identifier[2].system → identifier.system), code, constraint_key, and (where they apply) the slice name, binding value set, and unresolved profile canonical. All occurrences that share these collapse into one issue; the issue's count is the number of offender rows (distinct resources).
Aidbox does not store the invalid resource bodies, their OperationOutcomes, or the validator's original message text. The drill-down re-reads the body from history at the validated version and reconstructs the OperationOutcome from the stored machine fields (code, expression, constraint, and the type-specific parts), synthesizing the diagnostics message from the code.
Both synchronous and asynchronous runs persist these tables under the run's task-id, so you poll and drill into either until you cancel it.
Response format
The invalid-resources response is a Parameters resource rather than a Bundle. A searchset or collection Bundle cannot carry a total, version-specific links, a per-offender OperationOutcome, and the invalid resource bodies together while remaining FHIR-valid: Bundle invariants prohibit a version-specific fullUrl, permit total and entry.response only on certain Bundle types, and require each embedded resource to be valid in its own right, which the invalid bodies are not. A Parameters resource is subject to none of these constraints: it preserves the version-specific drill-down links and embeds each OperationOutcome beside the resource it describes. The invalid resource bodies are the report's content: the data under review.
Terminology
Coded-binding and slice validation may call the configured terminology server. Bindings that resolve locally (local code systems, or a hybrid engine with local content) validate offline and surface invalid codes as ordinary terminology-binding-error issues. But if validation needs the configured terminology server and it is unreachable or errors, the validator cannot complete and the whole run fails: a synchronous call returns 422 (OperationOutcome, "Batch validation failed…"), an asynchronous run reports failed. Point fhir.terminology.service-base-url at a reachable server (a local or hybrid engine works best) so coded validation is accurate and does not fail the run.