To turn a PDF lab report to structured data, read its text, split it into one report record and one row per result, and validate both against a schema. Each row keeps the test name, value, unit, range and flag exactly as printed, plus the dates and a link to its page, so the data can load anywhere.
What should structured data from a PDF lab report look like?
Structured data from a PDF lab report works best as both: one report record holding the facts printed once (patient, lab, dates, status) and one result row per test that points back to its report. Nested records keep a report whole for exchange. Flat rows suit spreadsheets and databases. Most systems store the rows and can rebuild the nesting.
The two shapes hold the same facts:
- Nested. One JSON object per report, with a
resultsarray inside it. This is the natural shape for sending one report between systems, and it mirrors the page: header first, rows below. - Flat. One row per result, with the report's key fields repeated on every row or stored once in a reports table and joined by a report ID. This is the shape you sort, filter and chart.
The HL7 FHIR Observation resource is a good outside reference for the per-result shape, even if you never use FHIR. The HL7 FHIR R4 Observation resource page gives each observation a status, a code for what was observed, an effective time described as "the time or time-period the observed value is asserted as being true", an issued time when "this version of the observation was made available," a value that can be a quantity, a coded concept or a string, an interpretation, notes, a specimen reference and one or more reference ranges.
Three design choices hold across both shapes. Keep the printed text of every value next to any parsed or normalized version. Keep a source pointer (document, page, position) on every row. And give every report a stable ID so rows extracted twice can be recognized as the same rows.
Pathology reports do not fit the row model well, because most of their content is narrative. The guide to pathology report extraction covers the fields for those reports.
A JSON schema for lab results, field by field
A JSON schema for lab results lists each field, its type, whether it is required, and what a valid value looks like, so every extracted result can be checked before it is loaded. The schema below covers one result row with the report fields it needs. It describes data shape only and makes no judgment about any value.
JSON Schema's object reference explains the three keywords this uses: properties, where "each key is the name of a property and each value is a schema used to validate that property," required, which lists properties that must be present, and additionalProperties, which can be set to false to reject fields the schema does not name.
| Field | Type | Required | Example (fictional) |
|---|---|---|---|
| report_id | string | Yes | RPT-2026-10-7731 |
| patient_name | string | Yes | Taylor Brooks (fictional) |
| patient_dob | string, date | Yes | 1990-02-11 |
| performing_lab | string | Yes | Example Reference Laboratory |
| accession_number | string | No | A26-0442190 |
| collected_at | string, date-time | Yes | 2026-10-07T07:30:00 |
| reported_at | string, date-time | Yes | 2026-10-07T14:12:00 |
| report_status | string, one of preliminary, final, corrected, amended | Yes | final |
| panel_name | string | No | Lipid panel |
| test_name_printed | string | Yes | Cholesterol, total |
| test_code | string | No | (mapped later) |
| value_text | string | Yes | 182 |
| value_number | number or null | No | 182 |
| comparator | string, one of <, <=, >, >= | No | (none) |
| unit_printed | string | No | mg/dL |
| unit_ucum | string | No | mg/dL |
| reference_range_text | string | No | <200 |
| flag_printed | string | No | (none) |
| comment_text | string | No | (none) |
| source_page | integer | Yes | 1 |
| source_box | array of 4 numbers | No | [72, 402, 455, 12] |
A few fields need a word of explanation. value_text is required and always holds the result as printed, which is how text results such as "Negative" fit the same schema as numbers. unit_ucum holds a coded form of the unit from the UCUM specification, which describes itself as a code system for "all units of measures being contemporarily used in international science"; the printed unit stays in its own field. The report_status values follow the FHIR observation status codes, which define corrected as modified after being final to fix an error in the test result.
The reference range is kept as text because labs print it in many forms. The post on extracting reference ranges from lab reports shows how to split that text into low and high values where it makes sense.
One result that conforms
Every value here is invented for the example:
{
"report_id": "RPT-2026-10-7731",
"patient_name": "Taylor Brooks",
"patient_dob": "1990-02-11",
"performing_lab": "Example Reference Laboratory",
"accession_number": "A26-0442190",
"collected_at": "2026-10-07T07:30:00",
"reported_at": "2026-10-07T14:12:00",
"report_status": "final",
"panel_name": "Lipid panel",
"test_name_printed": "Cholesterol, total",
"value_text": "182",
"value_number": 182,
"unit_printed": "mg/dL",
"unit_ucum": "mg/dL",
"reference_range_text": "<200",
"source_page": 1,
"source_box": [72, 402, 455, 12]
}
Are patient portal lab PDFs easier to convert than lab-issued PDFs?
Patient portal lab PDFs are often easier to read, because many are saved straight from a web page and carry a clean text layer. They are not always easier to convert into complete records, because portal layouts can leave out fields the lab's own report prints, such as the lab's address, the collection time or the comments.
Federal rules allow both routes to the patient. The CLIA test report rule at 42 CFR 493.1291(l) on the eCFR lets a laboratory, on the request of a patient or the patient's personal representative, give access to completed test reports that can be identified as belonging to that patient. So a clinic can receive the same result as a lab-issued PDF from the lab, and as a portal printout from the patient.
What to expect from portal PDFs:
- Text layer, mostly. A page saved as PDF from a browser usually holds real text. A page printed on paper and then scanned does not. Check every file. The side-by-side table of scanned and digital lab report extraction lists what changes between the two.
- Web layout, not report layout. Results may sit in cards or expandable sections instead of a table. Collapsed sections print as headings with nothing under them.
- Missing fields. A portal view may show the result date but not separate collection and report times. Leave the missing field empty and flag it. Do not copy one date into the other.
- Extra content. Navigation menus, banners and page URLs print in the margins. A parser must ignore them.
- Many results per file. Portal exports often list results from several dates and orders in one file, so a single PDF can produce several reports.
The safe default is to treat portal PDFs as a separate source type, with their own parser rules and a higher share of review, until you have seen enough of them.
Building a lab results table from many PDFs without duplicate rows
Building a lab results table from many PDFs without duplicate rows needs a key that identifies the same result across files. Use the patient, the performing lab, the accession number when printed, the test name and the collection time. Two rows with the same key are one result, and the newer report version wins.
The same result arrives more than once for ordinary reasons. The lab faxes a preliminary report and then the final one. A corrected report replaces a final one. The patient brings a portal printout of results you already received from the lab. A cumulative report repeats last month's values in a column next to this month's. Turning those date columns into one row per result is covered in the post on pivoting cumulative reports into one row per result.
A merge procedure that holds up:
- Extract each file on its own. Give every file a document ID and every report a report ID. Never merge during extraction.
- Build the result key.
patient_id + performing_lab + (accession_number or collected_at) + test_name_printed. Use the collection time, not the report time, because a correction changes the report time. - Group rows by key. One group per real result.
- Pick the current version. Prefer corrected over final over preliminary, then the latest
reported_at. Keep the others as history linked to the current row. - Flag conflicts. Two final versions of the same key with different values, and no correction notice, go to a person.
- Keep every source. The current row should list every document it was seen in, so a reviewer can open any of them.
A small fictional example:
| Source file | Status | Collected | Reported | Test | Value | Kept as |
|---|---|---|---|---|---|---|
| fax-0412.pdf | preliminary | 2026-10-07 07:30 | 2026-10-07 11:05 | Cholesterol, total | 182 | history |
| fax-0419.pdf | final | 2026-10-07 07:30 | 2026-10-07 14:12 | Cholesterol, total | 182 | current |
| portal-export.pdf | (not printed) | 2026-10-07 | (not printed) | Total cholesterol | 182 | source added to current row |
The portal row shows why test names need care. "Total cholesterol" and "Cholesterol, total" are the same test to a person but different strings to a computer. Match on a mapped test code where you have one, and send unmatched names to review rather than creating a second row.
Lab PDF conversion mistakes: dropped units, merged rows and lost footnotes
Lab PDF conversion mistakes are the facts that go missing when a printed report becomes a flat table: units dropped from rows, two rows merged into one, footnotes and comments left behind, and signs or statuses that do not survive. Each mistake produces data that looks complete but no longer matches the page.
Dropped units. A lab prints the unit once in a column header, or only on the first row of a panel. A converter that reads row by row leaves the other rows with no unit. Carry header units down to each row and record that the unit came from the header.
Merged rows. A long test name wraps onto a second line, and the converter reads the second line as a new row, or joins two short rows into one. Check that every row has exactly one value, and compare the row count with the count of values in the value column.
Lost footnotes. A symbol such as an asterisk or a superscript number next to a value points to a note at the bottom of the page. Flat tables usually lose the link. Store the marker in the row and the footnote text in a comment field linked by that marker.
Lost signs and statuses. "<5" becomes 5, and "CORRECTED" printed once at the top of the report never reaches the rows. Keep value_text as printed, and copy the report status onto every row it applies to.
Dates collapsed into one. A converter keeps only the printed date at the top of the page, which may be the print date. Keep collection and report times as separate fields.
Silent normalization. Converting units or rounding values during conversion changes what the lab reported. Normalize into separate fields and leave the printed values alone.
Catching these needs a person looking at the page next to the data. Schema Care is designed for that check: each lab result it extracts carries a confidence score and a link to the spot on the page, and fields below your chosen threshold wait for a reviewer, as the threshold demo on the homepage shows with a fictional document. For schemas, formats and integration topics in general, see the developer integration category.