A lab report parser takes the text of a lab report, from OCR or a PDF text layer, and finds the fields in it: patient, dates, and each result's test name, value, unit, range and flag. Parsers come in three kinds, templates, rules and models, and the right one depends on how many lab layouts you receive.
What a lab report parser does that OCR does not
A lab report parser turns text into labeled fields, while optical character recognition (OCR) only turns an image into text. OCR can tell you that "104" sits at a certain spot on page 1. The parser decides that "104" is the glucose result, that "mg/dL" is its unit and that "70-99" is its range.
The line between the two jobs is easiest to see in what each one outputs:
| Job | Input | Output | Typical failure |
|---|---|---|---|
| Reading (OCR or text layer) | Page image or PDF | Words, positions, confidence | A character is misread |
| Parsing | Words and positions | Named fields per report and per row | A correct word lands in the wrong field |
Parsing has three jobs of its own. First, it finds the regions of the page: header, results table, comments, footer. Second, it splits the table into rows and columns, which is hard when a lab prints without ruling lines or wraps a long test name onto two lines. Third, it assigns each piece of text to a field and checks that the row is complete.
Column finding is the part that breaks most often. Many lab tables have no lines between columns, so the parser has to infer column edges from where the words sit. The post on finding column boundaries in an unruled lab table covers the methods.
The parser's target is a fixed set of fields. Defining that set before writing any parser saves rework, and the post on a JSON schema for lab results, field by field gives one to start from.
Template, rule or model: which lab report parser type fits your volume?
The three lab report parser types differ in how they know where fields are. A template stores fixed positions for one layout. A rule parser uses patterns and nearby labels that work across similar layouts. A model learns from labeled examples and handles layouts it has not seen. Few layouts favor templates; many layouts favor models.
| Template parser | Rule parser | Model parser | |
|---|---|---|---|
| How it finds a field | Fixed box on a known layout | Patterns such as "a number, then a unit, then a range" and labels such as "Collected:" | Learned from labeled pages |
| Setup per new layout | Draw a new template | Adjust or add rules | Often none; add examples if it struggles |
| Upkeep when a layout changes | Template breaks; redraw | Some rules break; patch | Usually tolerant; check output |
| Explains its choices | Yes: the box | Yes: the rule that fired | Partly: needs a confidence score and a source link |
| Good fit | One or two labs, stable layouts | A handful of labs with similar tables | Many labs, faxes, portal printouts |
| Main risk | Silent misreads after a layout change | Rule sprawl and conflicts | Confident errors on odd pages |
Most production systems mix them. A model finds the regions and rows, rules check each row's shape, and a template handles one high-volume lab whose layout never changes. Whatever the mix, every field should come out with a confidence score and a link to its place on the page, so a person can check the uncertain ones.
Volume is only half the question. Count distinct layouts, not reports. A clinic receiving thousands of reports from two reference labs has a different problem from one receiving a few hundred reports from forty labs, hospitals and patient portals.
Specialized reports change the answer too. Pathology reports are mostly narrative text with some structured sections, so a row parser does not fit them at all. The guide to pathology report extraction covers what to capture from those reports.
Lab report parsing in Python with pdfplumber and regular expressions
Lab report parsing in Python can start with two tools: pdfplumber to read the text of a digital PDF, and a regular expression to split each result line into fields. This works on clean, text-based reports from one lab. It is a sketch for learning, not a production parser.
The pdfplumber project README describes extract_text as collating "all of the page's character objects into a single string" and notes that the library works best on machine-generated PDFs and does not do OCR. So this sketch applies to digital PDFs, not scans.
The sample line is fictional: Glucose 104 H mg/dL 70-99.
import re
import pdfplumber
ROW = re.compile(r"""
(?P<test>[A-Za-z][A-Za-z0-9 ,()/-]*?) \s+ # test name: the shortest run of words before the value
(?P<value>[<>]?\d+(?:\.\d+)?) \s+ # value, with an optional < or > sign
(?:(?P<flag>HH|LL|H|L|A) \s+)? # optional flag, recorded as printed
(?P<unit>[^\s\d]\S*) \s+ # unit: any token that does not start with a digit
(?P<range>\d+(?:\.\d+)?\s*-\s*\d+(?:\.\d+)?) # range printed as low-high
""", re.VERBOSE)
def parse_row(line):
match = ROW.fullmatch(line.strip())
if match is None:
return None # not a row this pattern understands: send it to a person, do not guess
return match.groupdict()
print(parse_row("Glucose 104 H mg/dL 70-99"))
# {'test': 'Glucose', 'value': '104', 'flag': 'H', 'unit': 'mg/dL', 'range': '70-99'}
with pdfplumber.open("example-lab-report.pdf") as pdf:
for page_number, page in enumerate(pdf.pages, start=1):
for line in (page.extract_text() or "").splitlines():
row = parse_row(line)
if row:
print(page_number, row)
How the regular expression reads the row
The pattern uses named groups and verbose mode. The Python re module documentation explains that a named group is "accessible via the symbolic group name," and that the verbose flag lets you "visually separate logical sections of the pattern and add comments." Reading the pattern one line at a time:
- test starts with a letter and takes the fewest words it can. The lazy
*?makes it stop at the first point where the rest of the pattern fits, so "Hemoglobin A1c 5.4 % 4.0-5.6" still yields the test name "Hemoglobin A1c", because "A1c" is not a valid value. - value is digits with an optional decimal part and an optional
<or>in front. Keeping the sign inside the value means "<0.5" is never stored as 0.5. - flag is optional and limited to a short list of letters. Anything else is not treated as a flag.
- unit is any token that does not start with a digit, so "mg/dL", "mmol/L" and "%" all fit.
- range is a low number, a hyphen and a high number.
fullmatchrequires the whole line to fit. The docs describe it as matching only "if the whole string matches." A line that only partly fits returns nothing instead of a half-filled row.
What the sketch does not handle
The sketch misses a lot, on purpose. It rejects ranges printed as "<200" or "Negative", text results, units with spaces, test names that wrap onto a second line, and rows whose columns come out of extract_text in a different order. Returning None for those lines is the correct behavior: a parser should say it does not know rather than fill fields with a guess. A real parser also keeps the page and position of each value, which extract_words provides as bounding boxes.
Why does a lab report parser break when a lab changes its layout?
A lab report parser breaks after a layout change because it depends on where text sits or how a line is shaped. When a lab adds a column, moves the flag, or changes its software, positions shift and patterns stop matching. Templates break the hardest, because they encode positions directly.
Typical changes that break parsers:
- A new column. The lab adds a "Previous result" column. A template now reads the previous value as the current one. A rule expecting "value, unit, range" fails, which is the better outcome because it is visible.
- A moved flag. The flag moves from its own column to a suffix on the value, such as "104H". A pattern that expects the value and the flag as separate tokens returns nothing for that line.
- New fonts or spacing. Word positions shift by a few points. A template's boxes now cut through values.
- Merged reports. A lab starts sending cumulative reports with several dates as columns. Every row now has several values.
The cost is not the fix itself, which may take an hour. The cost is the period before anyone notices. A template that keeps producing values after a layout change produces wrong values that look right. Teams that run templates need a check that fails loudly: compare the count of parsed rows with the count of lines that look like results, and alert when a lab's output suddenly changes shape.
Budget for this upkeep when you compare options. Each template or rule set is code that someone owns, and the number of layouts grows every time a clinic adds a referral partner.
Catching a layout change before it reaches your records
A parser that breaks loudly is cheap to run. A parser that breaks quietly is expensive. Three habits turn quiet failures into loud ones.
First, keep a small set of fixture reports for each lab layout: fictional or de-identified pages with the expected output written down by a person. Run the parser against them on every change to its code, and treat any difference as a failure until someone explains it.
Second, watch each lab's output over time. Track the share of lines per report that the parser could not place, the average number of rows per report, and the share of rows missing a unit. A sudden jump in any of them for one lab usually means that lab changed something.
Third, route the first reports from any new or changed layout to a person, even when every field scores high. A model or rule set can be confident about a layout it has never seen, and the first few reports are where that shows.
Lab report parser output: what a buyer should ask to see
The best way to judge a lab report parser is to look at its output for your own reports, field by field. Ask a vendor or your own team to run ten to twenty fictional or de-identified reports and show you the raw output, not a dashboard.
What to look for in that output:
- Every field, not just the easy ones. Collection date, report status, comments and corrections, not only test names and values.
- Values exactly as printed. "<0.5" stays "<0.5", "Negative" stays "Negative", units keep their original spelling. A normalized copy can sit next to the printed one.
- A source link on every field. Page number and position, so a reviewer can jump to the spot.
- A confidence score on every field. Not one score per document.
- Honest gaps. Lines it could not parse should appear as unparsed, not vanish.
- Stable output across layouts. The same field names for every lab, so downstream code does not change per lab.
- No interpretation. The output should carry the flag the lab printed. It should not decide on its own whether a result is high, low or normal.
Points 3 and 4 are where Schema Care sits: it returns each lab result field with a confidence score and a pointer to its place on the original page, and routes the ones below your team's threshold to a reviewer. The list of documents and fields on the homepage shows what it reads from lab reports. For more on schemas, outputs and integration topics in general, see the developer integration category.