Skip to content

Extracting Reference Ranges and Flags From a Lab Report

10 min read · updated August 11, 2026

The reference range is the column everyone extracts last and validates never. It is also the column where a layout error produces output that is perfectly well-formed, entirely plausible, and attached to the wrong test.

The range belongs to the result

The instinct is to treat the reference range as a property of the analyte — sodium is 135 to 145, everybody knows that — and to look it up from a table rather than extract it. This is wrong, and it is wrong in a way that gets worse as the dataset grows.

A reference interval is established by the performing laboratory for its own method, instrument and population. Two laboratories measuring the same analyte with different platforms legitimately publish different intervals, and the same laboratory changes its interval when it changes assay. The range that was printed next to the result is the range that result was interpreted against, and it is the only one that licenses the flag on the same line.

The practical consequence: store the range as extracted, on the result, always. Never populate it from a lookup table and never normalise it across laboratories. If you are aggregating results from several sources, the range is part of what makes them comparable or not, and discarding it removes your only evidence about which.

Units carry the same argument. Creatinine reported in milligrams per decilitre and in micromoles per litre differ by a factor of 88.4; glucose in milligrams per decilitre and millimoles per litre by 18.0. A result of 1.0 is normal creatinine in one unit and incompatible with life in the other. The unit is not decoration on the number, and neither is the range printed beside it.

Four columns and one alignment

A chemistry panel prints as a grid with roughly four columns: test name, result, unit, reference range, with a flag column that is sometimes separate and sometimes glued to the result.

TEST                RESULT    UNITS      REFERENCE RANGE
Sodium              139       mmol/L     136-145
Potassium           5.4    H  mmol/L     3.5-5.1
Chloride            103       mmol/L     98-107
CO2                 24        mmol/L     22-29
Glucose             112    H  mg/dL      70-99
BUN                 18        mg/dL      7-20
Creatinine          0.94      mg/dL      0.60-1.30
Calcium             9.2       mg/dL      8.6-10.2

Nothing in that block associates a range with a test except vertical position. There is no key, no repetition of the test name, no delimiter that survives conversion to plain text. The association is purely geometric, which means it is only as good as your reading order — the problem handled in PDF parsing and, when the input is a scan, in the OCR pipeline and scanned fax header noise.

Two layout features break the geometry routinely. A test name long enough to wrap occupies two lines while its result occupies one, so every subsequent row in a naive line-paired reading is off by one. And an analyte with no reference range at all — a qualitative result, a calculated ratio, a send-out with a comment instead of an interval — leaves the fourth column blank, and a parser that pairs the nth non-empty range with the nth test row will shift everything below it. The version of this that spans a page break is column misalignment across pages.

The silent one-row shift

Here is what a one-row shift does to the block above. Suppose the chloride row’s range is dropped and each subsequent range moves up a line:

Chloride      103   mmol/L   22-29        <- CO2's range
CO2           24    mmol/L   70-99        <- glucose's range
Glucose       112   mg/dL    7-20         <- BUN's range
BUN           18    mg/dL    0.60-1.30    <- creatinine's range

Every row still parses. Every range is a well-formed interval of two numbers. No field is null and no type is wrong. But chloride is now flagged high against a bicarbonate range, glucose is catastrophically out of range against a urea range, and BUN sits inside a creatinine range that makes it look profoundly abnormal. The output validates and it is fiction.

Three checks catch this arithmetically, and all three are cheap:

  • Unit agreement. Where the range prints its own units — many do, as 136-145 mmol/L — they must match the result unit on the same row. In the shifted example, glucose in mg/dL against a range whose source row was in mg/dL passes, but chloride in mmol/L against CO2’s mmol/L also passes. This check is necessary and not sufficient, which is why there are three.
  • Flag agreement. If the report prints a flag, the flag and the range must be consistent: an unflagged result must sit inside its extracted range, and a result flagged high must sit above the upper bound. In the shifted block, chloride at 103 is unflagged and sits far above the range 22-29. That is a contradiction inside one row, detectable without any external knowledge, and it is the single most valuable check on the page.
  • Magnitude sanity. A result more than an order of magnitude outside its extracted range, on a row with no critical flag, is far more likely to be a misalignment than a finding — because a laboratory that produced a genuinely extreme result would have flagged it. Route these to review rather than rejecting them, on the thresholds set out in confidence threshold review routing.

The flag-agreement check is worth implementing even if you do not otherwise care about flags, because it converts a geometric problem into an arithmetic one. Geometry you can only verify by looking; arithmetic runs on every row of every document for free.

Partitioned and open-ended ranges

Not every reference range is two numbers with a hyphen, and the variants are common enough that a parser accepting only low-high will drop real data:

  • Open-ended. <150, >60, <=5.7. One bound and a comparator. Store bounds as nullable with explicit inclusivity rather than inventing a zero for the missing side — a lower bound of zero on a test where none was stated is a fabricated value.
  • Qualitative. Negative, Non-reactive, Not detected. There is no interval at all; the expected value is a term, and the result is compared to it by string equality.
  • Partitioned by age or sex. A single row printing two intervals with labels — one for males and one for females, or a paediatric band and an adult band. The report normally applies the correct one already, but the printed cell may contain both, and picking the applicable partition requires patient demographics that you may deliberately not have extracted under minimum necessary. Store the cell verbatim in that case rather than choosing.
  • Therapeutic rather than reference. Drug levels print a therapeutic range and sometimes a separate toxic threshold. These are not reference intervals for a healthy population and should not be stored in the same field as if they were.
  • A footnote instead of a number. A superscript or a letter pointing to a comment at the bottom of the page. If your extraction drops footnote markers, a range of 0.60-1.30 and a range of 0.60-1.30 (a) become indistinguishable, and the note usually says something that changes the interpretation.

A negative dash is the other parsing hazard: an interval printed with an en dash rather than a hyphen, and a range whose lower bound is negative, such as a base excess. Splitting on a dash character breaks both. Match a comparator-and-number grammar rather than splitting.

What to store

One row of output per printed result, carrying the pieces that let somebody reconstruct the page:

{
  "test_name_as_printed": "Potassium",
  "loinc": "2823-3",
  "value": 5.4,
  "value_as_printed": "5.4",
  "unit": "mmol/L",
  "reference_range_as_printed": "3.5-5.1",
  "reference_low": 3.5,
  "reference_high": 5.1,
  "reference_low_inclusive": true,
  "reference_high_inclusive": true,
  "flag_as_printed": "H",
  "checks": { "flag_agrees_with_range": true, "unit_agrees": true }
}

The as_printed fields are not redundancy. They are what makes the row auditable, and in a regulated setting the ability to show what the source document said is worth more than the parsed number. LOINC coding of the test is what makes results comparable across laboratories that print different names for the same analyte, and the reason it works is that LOINC identifies a test by six axes — component, property, time aspect, system, scale and method — so “potassium” in serum and “potassium” in urine are different codes rather than one ambiguous name.

Map to LOINC as a separate, reviewable step against the released table from the Regenstrief Institute, and record which release you mapped against, in the same way an extraction model version audit trail records which model produced a field. Do not ask a model to emit a LOINC code inline: the codes are numeric, the check digit makes an invented one look plausible, and a hallucinated identifier that passes a format check is the worst possible output. The general shape of that argument is in vision hallucination.