Schema Care

Extracting Reference Ranges from Lab Reports

By Schema CarePublished Updated

Extracting reference ranges from lab reports means copying the range string exactly as printed beside each result, then splitting it into a low bound, a high bound, a comparator and a text field. Keep the original string beside the parsed parts, tie it to the same test row as the value, and never swap in a range from elsewhere.

Reference range formats printed on lab reports

A reference range on a lab report is the interval or expected value the performing laboratory prints next to a result. Labs print it in a handful of styles: a low to high pair, a one-sided limit, a list split by sex or age, or a word such as "Negative". Each style needs a different set of fields.

The range belongs to the lab that ran the test. Under the federal CLIA rules, 42 CFR 493.1291 on the test report says pertinent "reference intervals" or "normal" values, as determined by the laboratory performing the tests, must be available to the person who ordered the tests. For data work, that sentence sets a simple policy: store the range the report prints, not a range from a textbook, another lab or a previous report.

The table below shows eight printed range strings from fictional reports and how each one splits into fields. Every value is an example made up for this post.

Printed range (example)LowHighComparatorText
70-997099
3.5 - 5.03.55.0
<200200<
>4040>
<=1.01.0<=
4.5-11.0 x10^3/uL4.511.0
NegativeNegative
See commentSee comment

Keep a ninth column in your own schema for the raw string. A reviewer comparing your record against the page needs to see 3.5 - 5.0 with its spaces, not only the two numbers you parsed from it.

Some labs print the unit inside the range string, as in the sixth row. Strip it into its own field, then check it against the unit printed beside the value. The post on normalizing lab result units with UCUM covers how to record the unit itself.

A few reports move ranges off the row entirely. They print a superscript number beside the result and list the ranges in a footnote block at the bottom of the page, or on a separate reference page at the end of the report. Follow the marker to the footnote and store the range against the row that carries the marker, with the source location pointing at the footnote rather than the row. If two rows share one footnote, both get the same range, and each keeps its own link back to the page.

Range layout also varies by position. Some reports give the range its own column, some print it in brackets after the value, and cumulative reports sometimes print it once in the row header. The parse is the same in every case; only the place you look changes.

How should a reference range like "<200" be stored?

A one-sided reference range such as "<200" has only an upper limit, so it is stored with the high bound set to 200, the low bound left empty and a comparator field holding "<". Leaving the low bound empty is different from setting it to zero, and the comparator keeps the difference between "<" and "<=" that the lab printed.

The HL7 FHIR Observation resource is a useful model even if you never send FHIR. The FHIR R4 Observation reference range definition gives a reference range a low, a high, a type, an applies-to list, an age and a text field, and requires at least a low, a high or text. It says that if the low bound is omitted, it is assumed to be meaningless, and gives "<=2.3" as an example of such a range.

Notice that FHIR's range bounds carry no comparator of their own. If your schema is flat, add one. The FHIR R4 Quantity data type uses a comparator on a value to say whether the actual value is greater or less than the stated value, and the same idea works for a printed bound. Without it, "<200" and "<=200" collapse into the same record, and the record no longer matches the page.

Three habits keep one-sided ranges clean:

  • Never write zero into an empty low bound. Zero is a number the lab did not print.
  • Store the comparator as the lab printed it, including "≤" normalized to "<=" only if you also keep the raw string.
  • Treat a range such as "<200" and a result such as "<5" as separate things. The first is a range; the second is a result reported below a limit, and it belongs in the value fields.

Age and sex specific reference ranges on one lab report

Age and sex specific reference ranges appear when a lab prints several ranges for one test, each labelled with the group it applies to. A fictional example reads "M: 13.5-17.5 F: 12.0-15.5". Capture every printed range as its own entry with the label attached, rather than picking the one you think applies to this patient.

Choosing one range is a judgment the lab did not ask you to make, and it hides what the page said. Store them all:

Entry (example)LowHighApplies to (as printed)
113.517.5M
212.015.5F

The FHIR model has fields for this. Its reference range definition includes an applies-to element, where multiple values are read as an "AND" of the target populations, and an age element expressed as a range. Your own schema can mirror that with an applies-to text field and optional age bounds.

Age bands follow the same pattern. A fictional report might print "0-17 y: 40-150" on one line and "18 y and older: 30-120" on the next. Each line becomes one entry with its age band copied as printed. Keep the age unit; days, months and years all appear on pediatric reports.

Some reports print only the range that matches the patient on file, with no label at all. That is fine. Store the single range and leave the applies-to field empty. Do not invent a label because you know the patient's age or sex from the header; the record should say what the range line said.

Do qualitative lab tests have reference ranges?

Qualitative lab tests often do print a reference range, but it is a word rather than numbers. A fictional urine test might show a result of "Negative" with an expected value of "Negative", or a screening test might print "Not detected". Store that expected value in a text field, with the low and high bounds left empty.

The FHIR definition of the reference range text field uses this case as its example: a reference value of "Negative", or a list or table of normals. That is a good rule for any schema. Text ranges go in text, untouched, and nothing gets coded into a number.

A few text ranges need care:

  • "Negative" and "Neg" are the same word to a reader but not to a string match. Keep the printed form in the raw field and decide separately whether to standardize the text.
  • "Not established" or "No reference range" is a real printed value. Store it as text instead of leaving the field blank, so a reviewer can tell an absent range from a range the lab declined to set.
  • Ordinal scales such as "Negative, Trace, 1+, 2+" may be printed as the full list. Store the whole list as text.

How a qualitative result itself is entered, next to the numeric ones, has its own treatment in the guide to whether qualitative lab results are entered differently from numeric ones. This section only covers the range printed beside them.

Reference range extraction errors: ranges attached to the wrong test row

The most damaging reference range extraction error is a range that lands on the neighboring test row. It happens when a result line wraps, when a comment pushes a row down, or when the range column is unruled and sits closer to the row above. The parsed range looks valid, so nothing flags it unless someone checks the page.

Range drift has a few common causes on fictional reports like the ones in this post:

  • Wrapped test names. A long test name takes two lines, and the range prints on the second line. A row parser that reads line by line attaches it to the next test.
  • Interleaved comments. A note such as "Specimen slightly hemolyzed" sits between rows and shifts every range below it by one.
  • Missing ranges. One test has no printed range, so the parser borrows the next one and every range after it moves up a row.
  • Multi-line ranges. An age or sex split prints on two or three lines, and the extra lines become ranges for the tests that follow.

Two checks catch most drift. First, compare the unit inside a range string, where one is printed, with the unit beside the value on the same row. A range of "4.5-11.0 x10^3/uL" next to a value in mg/dL is almost certainly on the wrong row. Second, count rows: the number of results and the number of ranges should match unless the page shows a blank range cell.

Neither check says anything about the patient. They only test whether the record matches the page. Whether a printed flag agrees with the extracted value and its range is a separate test, described in whether the printed flag agrees with the extracted value and range. The flag itself is a field to copy, and the post on how abnormal flags are printed on lab reports shows each style.

When a check fails, send the row to a person rather than guessing. Schema Care reads lab report reference ranges along with test names, results, units and flags, links each field to its place on the page, and routes the fields below your confidence threshold to a reviewer; the review workflow on the homepage shows those steps. The human review category covers how teams set thresholds and run review queues.

A worked row, start to finish

This fictional row follows one result from page to record. All values are examples.

FieldExample value
Test name (as printed)POTASSIUM
Result4.1
Unit (as printed)mmol/L
Range (raw)3.5 - 5.0
Range low3.5
Range high5.0
Range comparator(empty)
Range text(empty)
SourcePage 1, row 6

The record keeps the raw string, the parsed bounds and the page location together. A reviewer can open page 1, find row 6 and confirm all of it in one look, which is the whole point of extracting the range carefully in the first place.

All posts in Lab Reports