# HL7v2
```
_ _ _ ___ ____
| | | | | |__ \__ _|___ \
| |_| | | ) \ \ / / __) |
| _ | |__ / / \ V / / __/
|_| |_|____|_/ \_/ |_____|
Pure Elixir HL7 v2.x Toolkit
Schema-driven parsing, building, and MLLP transport.
```
[](https://hex.pm/packages/hl7v2)
[](https://hexdocs.pm/hl7v2)
[](https://github.com/Balneario-de-Cofrentes/hl7v2/blob/main/LICENSE)
Pure Elixir HL7 v2.x toolkit — typed segment structs, programmatic message building,
structural validation, and integrated MLLP transport.
## What You Get
- **Typed segments** — every v2.5.1 segment is an Elixir struct with named fields,
not string maps with integer keys
- **Programmatic message building** — `Message.new/3` + `add_segment/2` with
auto-populated MSH
- **Structural validation** — positional order/group/cardinality checks for
supported message structures, opt-in HL7 table validation
- **Lossless raw mode** — canonical round-trip parsing that preserves everything,
including malformed input
- **Integrated MLLP** — Ranch 2.x listener, GenServer client, TLS/mTLS, telemetry
- **ACK/NAK builder** — `HL7v2.ack/2` with sender/receiver swap
- **Path access** — `get/2`, `fetch/2`, `~h` sigil with compile-time validation
```elixir
# Typed structs with named fields
{:ok, msg} = HL7v2.parse(text, mode: :typed)
pid = Enum.find(msg.segments, &is_struct(&1, HL7v2.Segment.PID))
pid.patient_name #=> [%XPN{family_name: %FN{surname: "Smith"}, given_name: "John"}]
# Build messages programmatically
msg = HL7v2.Message.new("ADT", "A01", sending_application: "PHAOS")
|> HL7v2.Message.add_segment(%HL7v2.Segment.PID{
patient_name: [%XPN{family_name: %FN{surname: "Smith"}, given_name: "John"}]
})
```
## Installation
```elixir
def deps do
[{:hl7v2, "~> 3.0"}]
end
```
> **Upgrading from 2.x?** `send_message/3` now returns `{:error, :protocol_desync}`
> and terminates the client when stale bytes are detected in the MLLP buffer.
> See [CHANGELOG](CHANGELOG.md) for the full breaking change description.
## Quick Start
### Parse
```elixir
# Raw mode — canonical round-trip, minimal overhead
{:ok, raw} = HL7v2.parse(text)
raw.type #=> {"ADT", "A01"}
# Typed mode — segments become structs
{:ok, msg} = HL7v2.parse(text, mode: :typed)
# Access fields naturally
HL7v2.get(msg, "PID-5") #=> %XPN{family_name: %FN{surname: "Smith"}, ...}
HL7v2.get(msg, "PID-3") #=> %CX{id: "12345", identifier_type_code: "MR"}
HL7v2.get(msg, "PID-8") #=> "M"
HL7v2.get(msg, "PID-3[2]") #=> second identifier (repetition)
```
### Build
```elixir
msg =
HL7v2.Message.new("ADT", "A01",
sending_application: "PHAOS",
sending_facility: "HOSP"
)
|> HL7v2.Message.add_segment(%HL7v2.Segment.PID{
set_id: 1,
patient_identifier_list: [
%HL7v2.Type.CX{id: "MRN001", identifier_type_code: "MR"}
],
patient_name: [
%HL7v2.Type.XPN{
family_name: %HL7v2.Type.FN{surname: "Smith"},
given_name: "John"
}
],
administrative_sex: "M"
})
wire = HL7v2.encode(msg)
# => "MSH|^~\\&|PHAOS|HOSP|...\rPID|1||MRN001^^^^MR||Smith^John|||M\r"
```
### Validate
```elixir
{:ok, typed} = HL7v2.parse(text, mode: :typed)
case HL7v2.validate(typed) do
:ok ->
:good
{:error, errors} ->
# [%{level: :error, location: "PID", field: :patient_name,
# message: "Required field is missing"}]
Enum.each(errors, &IO.inspect/1)
end
```
### ACK/NAK
```elixir
# Accept
{ack_msh, msa} = HL7v2.Ack.accept(original_msh)
wire = HL7v2.Ack.encode({ack_msh, msa})
# Reject with error details
{ack_msh, msa, err} = HL7v2.Ack.reject(original_msh,
text: "Unknown patient",
error_code: "204"
)
```
### MLLP Transport
```elixir
# Server
defmodule MyHandler do
@behaviour HL7v2.MLLP.Handler
@impl true
def handle_message(message, _meta) do
{:ok, typed} = HL7v2.parse(message, mode: :typed)
msh = hd(typed.segments)
{ack_msh, msa} = HL7v2.Ack.accept(msh)
{:ok, HL7v2.Ack.encode({ack_msh, msa})}
end
end
{:ok, _} = HL7v2.MLLP.Listener.start_link(port: 2575, handler: MyHandler)
# Client
{:ok, client} = HL7v2.MLLP.Client.start_link(host: "hl7.hospital.local", port: 2575)
{:ok, ack} = HL7v2.MLLP.Client.send_message(client, wire)
# TLS / mTLS
{:ok, _} = HL7v2.MLLP.Listener.start_link(
port: 2576,
handler: MyHandler,
tls: HL7v2.MLLP.TLS.mutual_tls_options(certfile: "cert.pem", keyfile: "key.pem", cacertfile: "ca.pem")
)
```
## Coverage
### Schema Coverage
Every official HL7 v2.5.1 segment, data type, and message structure has a typed
Elixir module. Run `mix hl7v2.coverage --detail` for per-segment field completeness.
```
Segments 152 v2.5.1 + 12 v2.6/v2.7 extensions + generic ZXX
Access/auth: ARV PRT UAC IAR
Materials mgmt: ITM IVT ILT PKG VND STZ SCP SLT
Types 89 official v2.5.1 data types + legacy TN
Structures 186 of 186 official v2.5.1 abstract structures (222 total with aliases)
```
### Validation Coverage
Validation is opt-in (`HL7v2.validate/2`) and layered:
```
Structural positional order/group/cardinality for all 186 official structures
Fields required-field checks, bounded repetition enforcement
Tables 189 HL7 coded-value tables, 255 field bindings (opt-in: validate_tables: true)
Conditional 24 segment-local inter-field rules (see Known Limitations)
```
### Transport
```
MLLP Ranch 2.x listener, GenServer client, TLS/mTLS, telemetry
```
### Conformance Corpus
Real-wire fixtures validated end-to-end (raw parse → typed parse → round-trip →
strict validation with zero warnings):
```
Fixtures 111 wire files covering 101 unique canonical structures
Breadth 101 of 186 official v2.5.1 structures (54.3%)
```
The corpus is computed at **compile time** from the fixture directory and
frozen into `HL7v2.Conformance.Fixtures`. The fixture files themselves
(`test/fixtures/conformance/*.hl7`) ship inside the Hex package. A CI-level
tarball test verifies that the built `.tar` contains exactly the same number
of `.hl7` files as the frozen compile-time list.
> **Freshness caveat.** `@external_resource` only tracks files that existed at
> compile time — adding or removing fixtures from the source tree requires
> recompiling the module before the helper picks them up. The strict-clean
> test suite (`test/hl7v2/conformance/round_trip_test.exs`) enumerates
> fixtures at runtime via `File.ls` and includes a freshness guard that
> delegates to `HL7v2.Conformance.Fixtures.check_freshness/1`, which is
> itself covered by automated stale-case tests.
Fixture filenames, canonical structures, and family prefixes are exposed via:
```elixir
HL7v2.Conformance.Fixtures.coverage()
HL7v2.Conformance.Fixtures.list_fixtures()
HL7v2.Conformance.Fixtures.unique_canonical_structures()
HL7v2.Conformance.Fixtures.families()
```
Canonical resolution uses the same alias fallback as `HL7v2.Validation` — an
`ACK^A01^ACK_A01` fixture correctly reports as covering the registered `ACK`
structure, not the unregistered `ACK_A01`.
## Scope
**HL7 v2.5.1** baseline schema with **version-aware validation** for v2.3 through v2.8.
The parser and encoder round-trip messages at any version in that range, and
`HL7v2.validate/2` extracts MSH-12 (`version_id`) to apply version-specific rules:
- v2.7+ messages exempt deprecated B-fields (PID-13/14, OBR-10/16, ORC-10/12)
- MSH fields 22-25, PID-40, OBR-50, and OBX-20-25 are supported for v2.7+
- PRT (Participation Information, v2.7+) is typed; other new v2.6/v2.7/v2.8
segments (ARV, UAC, etc.) are not yet typed — they parse as ZXX/extra_fields
and round-trip cleanly.
## Known Limitations
**Typed mode canonicalizes wire values.** Trailing empty components are trimmed during
encoding (`Smith^John^^^^^` becomes `Smith^John`). Parse → encode is idempotent but
not identity-preserving against the original wire form.
**Three fields remain raw-typed.** OBX-5 (observation value) is `VARIES` per the spec
and dispatched at runtime via OBX-2 (41 value types supported). QPD-3 (user parameters)
and RDT-1 (column value) are query-specific and cannot be statically typed. These are
the three remaining standard gaps in the typed coverage model.
**Conditional validation is mostly segment-local.** The 24 conditional rules check HL7
inter-field dependencies. Scheduling segments (AIS, AIG, AIL, AIP, RGS) and PV2
transfer rules are trigger-aware: when the message trigger event is available (extracted
from MSH-9), modification triggers (S03-S11) and transfer triggers (A02, A06, A07, etc.)
produce definitive checks instead of heuristic warnings. Without trigger context
(e.g., when calling `conditional_errors/3` directly), the original heuristic fallback
is preserved. Pass `mode: :strict` for error-level enforcement.
- Every v2.5.1 segment and data type has a typed Elixir module
- Raw mode is lossless after successful MSH/separator detection
- Typed mode preserves values it cannot parse (invalid dates, malformed
numbers) in `original` fields for round-trip fidelity
- Extra fields beyond declared definitions are preserved in `extra_fields`
- Escape sequences are preserved literally in typed fields — call
`HL7v2.Escape.decode/2` when you need decoded text
Run `mix hl7v2.coverage` for detailed per-segment field completeness.
## Handling Unknown Segments
Real-world HL7 is messy. Messages arrive with vendor-specific Z-segments, obsolete
segments from older versions, and segments your system doesn't care about. The library
handles all of them without crashing or losing data:
```elixir
{:ok, msg} = HL7v2.parse(text, mode: :typed)
# Known segments → typed structs with named fields
%HL7v2.Segment.PID{patient_name: [%XPN{...}], ...}
# Z-segments → ZXX struct preserving segment ID and all raw fields
%HL7v2.Segment.ZXX{segment_id: "ZPD", raw_fields: ["custom", "data"]}
# Unknown segments from other versions → raw tuples, lossless
{"XYZ", ["1", "DATA001", ...]}
```
All three forms encode back to valid HL7 wire format. The typed API (`get/2`, `fetch/2`,
`~h` sigil) works across all forms — typed segments return struct fields with component
and repetition selection, raw tuples return whole fields by position (component/repetition
selectors are not applied to raw tuples).
This means you can parse any HL7 message from any source, work with the segments you
understand, and forward the rest unchanged. No schema registration required.
## Documentation
Full API docs: [hexdocs.pm/hl7v2](https://hexdocs.pm/hl7v2)
Guides:
- [Getting Started](guides/getting-started.md) — parsing, building, ACK, MLLP
- [Conformance Profiles](guides/conformance-profiles.md) — organization-specific validation
- [IHE Profile Pack](guides/ihe-profiles.md) — 22 pre-built IHE profiles (PAM, PIX, PDQ, LTW, RAD-SWF)
- [Migration Guide](guides/migration.md) — moving from `elixir_hl7`, HAPI v2, or HL7apy
## Part of the Balneario Healthcare Toolkit
Three pure-Elixir libraries covering the core protocol surface of healthcare IT. Zero NIFs. Built for production.
| Library | Domain | Standards | |
|---------|--------|-----------|---|
| **dicom** | Medical imaging data | PS3.5 / 6 / 10 / 15 / 16 / 18 | [Hex](https://hex.pm/packages/dicom) · [Docs](https://hexdocs.pm/dicom) · [GitHub](https://github.com/Balneario-de-Cofrentes/dicom) |
| **dimse** | DICOM networking | PS3.7 / 8 / 15 | [Hex](https://hex.pm/packages/dimse) · [Docs](https://hexdocs.pm/dimse) · [GitHub](https://github.com/Balneario-de-Cofrentes/dimse) |
| **hl7v2** | Clinical messaging | HL7 v2.5.1 | [Hex](https://hex.pm/packages/hl7v2) · [Docs](https://hexdocs.pm/hl7v2) · [GitHub](https://github.com/Balneario-de-Cofrentes/hl7v2) |
[`dicom`](https://github.com/Balneario-de-Cofrentes/dicom) parses and writes DICOM files. [`dimse`](https://github.com/Balneario-de-Cofrentes/dimse) moves them over the network via DIMSE-C/N services. `hl7v2` handles the clinical messages (ADT, ORM, ORU) that trigger and contextualize imaging workflows.
Together they give Elixir the same healthcare protocol coverage that Java has with dcm4che + HAPI, or C++ with DCMTK — on the BEAM.
## How hl7v2 Compares
The HL7 v2 open-source landscape, as of April 2026. Entries for other libraries
are based on their current official docs and READMEs — corrections welcome via
PR.
| Library | Lang | Core model | Validation | Version scope | MLLP/TLS | Best fit |
|---------|------|-----------|-----------|--------------|----------|----------|
| **hl7v2** (this) | Elixir | Typed segment structs + raw round-trip | Structural + field + conditional (24 rules, trigger-aware) + optional tables + version-aware + **conformance profiles** | v2.5.1 baseline with **version-aware validation for v2.3–v2.8** (B-field exemptions, v2.7+ field additions) | Built-in Ranch 2.x listener/client + TLS/mTLS | BEAM-native typed parsing, builder, validation, and transport in one package |
| [HAPI HL7v2](https://hapifhir.github.io/hapi-hl7v2/) | Java | Object-oriented message model | Validator + conformance profiles + TestPanel | Full v2.1–v2.8.1 schema trees | LLP/MLLP server/client; TLS-capable | Most complete mature OSS option, especially for Java shops |
| [nHapi](https://github.com/nHapiNET/nHapi) | .NET | HAPI-style HL7 object model | Core encoding validation; richer helpers via NHapiTools | v2.1–v2.8.1 | Via NHapiTools (separate) | Best .NET analogue to HAPI |
| [HL7apy](https://hl7apy.readthedocs.io/) | Python | Structured messages/groups/segments/fields | STRICT/TOLERANT + message-profile validation | Through v2.8.2 | `to_mllp()` + `hl7apy.mllp` server | Python with real schema-aware validation + profiles |
| [elixir_hl7](https://hex.pm/packages/elixir_hl7) + [mllp](https://hex.pm/packages/mllp) | Elixir | Delimiter/text-oriented with path access | Minimal by design | Tolerant v2.x text handling | Via separate `mllp` package | Pragmatic Elixir option for tolerant parsing and simple routing |
| [medparse](https://pkg.go.dev/github.com/medparse/medparse) | Go | Message/segment/field hierarchy + terser paths | Message validation API | General v2.x, not strongly versioned | MLLP framing helpers | Lean, fast Go parsing/manipulation |
| [nodehl7](https://github.com/Loksly/nodehl7) | Node.js/TS | Parsed message object with typed segments | Limited | General v2.x | Built-in MLLP server/client + TLS | Node environments needing parser + transport quickly |
**Quick read**
- **Broadest and most mature OSS HL7 v2 platform**: HAPI
- **Closest .NET equivalent to HAPI**: nHapi
- **Python with real schema-aware validation**: HL7apy
- **Elixir, tolerant parsing and simple routing**: elixir_hl7 + mllp
- **Elixir with typed structs, builder, validation, transport in one package**: hl7v2
- **Lean Go parser with ACK helpers**: medparse
**Where hl7v2 fits**
hl7v2 is differentiated if you want *typed HL7 v2 on the BEAM with integrated
transport and version-aware validation*. It is not yet a HAPI replacement in
absolute schema breadth — HAPI still wins on per-version schema trees and
ecosystem maturity; HL7apy still wins on Python-side conformance profile
support. On the BEAM, hl7v2 is the only package with typed structs, builder,
structural + conditional + version-aware validation, and MLLP/TLS in one place.
## License
MIT — see [LICENSE](LICENSE).