Reading a Medical Style HL7 CDA Document Safely With lxml
Extract the patient id and measurements from a synthetic CDA clinical document with a namespace map, handle nullFlavor and disable entity resolution.

Clinical documents are among the most security-sensitive XML you will parse and among the most irregular. HL7's Clinical Document Architecture (CDA) wraps a patient's data in a deeply nested, namespaced document where the meaning of an entry lives in codes, not in element names. Reading one safely means two separate disciplines: interpret the structure correctly, and make the parser refuse to do anything dangerous with a document you did not write. This post does both on a synthetic document, with no real patient data anywhere.
The structure that matters
A CDA document has a header (patient, author, document type) and a body made of sections, each holding entries. An entry that records a measurement is an observation, with a code that says what was measured and a value that carries the result and a unit:
<ClinicalDocument xmlns="urn:hl7-org:v3" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<templateId root="2.16.840.1.113883.10.20.22.1.1"/>
<title>Synthetic Visit Summary</title>
<recordTarget><patientRole><id extension="TEST-0001" root="2.16.840.1.113883.19.5"/></patientRole></recordTarget>
<component><structuredBody><component><section>
<title>Vital Signs</title>
<entry><observation>
<code code="8480-6" codeSystem="2.16.840.1.113883.6.1" displayName="Systolic blood pressure"/>
<value xsi:type="PQ" value="128" unit="mm[Hg]"/>
</observation></entry>
...
<entry><observation>
<code code="8310-5" codeSystem="2.16.840.1.113883.6.1" displayName="Body temperature"/>
<value xsi:type="PQ" nullFlavor="NI"/>
</observation></entry>
</section></component></structuredBody></component>
</ClinicalDocument>Three features distinguish it from the XML of most data feeds. Everything is in the urn:hl7-org:v3 namespace. Values are attributes (value, unit) on empty elements, not text. And a missing value is not an absent element: the value is present with a nullFlavor attribute saying why there is no value (NI no information, UNK unknown, NA not applicable, MSK masked).
Extracting observations by code
Map the namespace once, find the observations, and key on the code, not on position:
from lxml import etree
ns = {"h": "urn:hl7-org:v3", "xsi": "http://www.w3.org/2001/XMLSchema-instance"}
parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False, huge_tree=False)
r = etree.fromstring(cda, parser)
print("patient id:", r.find("h:recordTarget/h:patientRole/h:id", ns).get("extension"))
for o in r.iterfind(".//h:observation", ns):
c, v = o.find("h:code", ns), o.find("h:value", ns)
print(f"{c.get('displayName'):28s} {c.get('code'):8s} value={v.get('value')!s:5s} unit={v.get('unit')} nullFlavor={v.get('nullFlavor')}")patient id: TEST-0001
Systolic blood pressure 8480-6 value=128 unit=mm[Hg] nullFlavor=None
Diastolic blood pressure 8462-4 value=82 unit=mm[Hg] nullFlavor=None
Body temperature 8310-5 value=None unit=None nullFlavor=NIEach observation is identified by code, here a LOINC code, and the display name is a convenience that must not be relied on, because it is free text. Store code and codeSystem together: a code is meaningful only inside its system. The body temperature has no value and a nullFlavor of NI. Load that as a null in your table and keep the flavor in a second column. Treating it as zero, or leaving the row out, loses the difference between "not measured" and "measured as zero", a difference that matters clinically.
The unit is in UCUM notation (mm[Hg]), a standard unit syntax. Do not strip the brackets, and convert units only through a table you trust. A reader that finds Cel in one document and [degF] in another must convert, and the earlier lesson about silent unit changes in sensor data applies here with more at stake (extracting time series from XML sensor files).
Do not rely on the order of entries or on the nesting depth. Different producers organize sections differently, so a path such as section[2]/entry[1] works for one vendor's documents and fails on the next. Select by code with a predicate: .//h:observation[h:code/@code='8480-6'].
Make the parser refuse dangerous input
A document from outside your organization is untrusted input, and XML has features that attack the parser. The best known is the external entity: a DOCTYPE that declares an entity pointing at a local file or a URL, which the parser then substitutes into the document. Here is a test document, with an entity that points at a file on the machine:
<?xml version="1.0"?>
<!DOCTYPE d [<!ENTITY x SYSTEM "file:///etc/hostname">]>
<d xmlns="urn:hl7-org:v3"><title>&x;</title></d>Parsed with entity resolution enabled and with the safe options:
default parser : '4cc1864fc1ac\n'
safe parser : ''
defusedxml.lxml : EntitiesForbidden EntitiesForbidden(name='x', system_id=None, public_id=None)With resolve_entities=True the title element contained the contents of /etc/hostname, the container's name. In a real attack it would be a credentials file or an internal URL, and the content would flow into whatever you do with the title. With resolve_entities=False the entity stays unresolved and the title is empty. The defusedxml wrapper goes further and raises EntitiesForbidden when it sees an entity declaration at all; the lxml helper in that package is deprecated (it printed a deprecation warning), so prefer configuring the lxml parser directly, and use defusedxml for the standard-library parsers (safe XML parsing in Python with defusedxml). Real clinical documents do not need a DOCTYPE, so rejecting any document that has one is reasonable.
The settings used: resolve_entities=False, no_network=True (no fetching of external resources), load_dtd=False (no external DTD loading) and huge_tree=False (keep lxml's limits on size and depth in place, which protect against entity-expansion attacks). Combine them with a limit on the size of the upload before parsing (limiting XML size and depth for uploads).
Privacy and handling
Everything above can be done with synthetic data, and it should be. Real documents carry personal health information, regulated in most places (HIPAA in the US, GDPR in the EU, local laws elsewhere). A few practical rules follow:
Develop and test with synthetic or de-identified documents. Many standards bodies publish sample documents for exactly this reason.
Do not log document contents or parse errors that quote them. A parse error that includes a line of the document can carry a name.
Do not send documents to third-party online formatters or validators.
Keep the parsed table under the same access control as the source.
Record which document each row came from, so a deletion request can be honored.
Related reading
This post belongs to XML problems worked from real data. The attack background is in XML external entity attacks, and the remedies for size and nesting in limiting XML size and depth for uploads. The standard is published by HL7 International, codes come from LOINC, and the parser options from the lxml parsing documentation. The OWASP XXE prevention cheat sheet lists the settings for many languages.
When a clinical document needs more than a script
You must validate conformance. CDA documents are validated against schema and Schematron rules for their template. Use the validators the standard provides.
You exchange documents with other institutions. The workflow involves consent, audit and secure transport that no parsing script covers.
You interpret values clinically. Reference ranges, units and flags are the job of clinical software that has been reviewed for the purpose. A script that extracts numbers is not a decision support system.
The document is a scan or a PDF inside XML. Embedded binary content needs its own handling (embedding binary data in XML).


