Skip to content

How to Read Your Own Lab Work Like a Data Problem

Oak
A tall glass laboratory instrument holding a stack of glowing amber fluid cartridges, each a slightly different shade, in a dark clean lab.

By the end of this you will have a single tidy table of every lab result you have ever had, keyed by LOINC code and UCUM unit, with a per-analyte estimate of how much a value has to move before the move means anything. You need a patient portal account at each lab or health system that has drawn your blood (Quest, Labcorp, Epic MyChart, Athena), Python 3.11 with pandas, requests, and matplotlib, and about two hours. Familiarity with REST APIs helps. No clinical background required, but the last step of interpretation, deciding whether a number means something about your health, belongs with a clinician, and this guide will not do it for you.

1. Get the data as data, not PDFs

Almost every US health system runs an Epic, Cerner, or Athena patient portal with a FHIR R4 endpoint behind it, because the 21st Century Cures Act information-blocking rules require patient API access. That endpoint is what you want. The PDF is a rendering.

The practical path is to register a SMART on FHIR public client (PKCE, no client secret) with the vendor’s developer program, then authorize against your own portal login. Once you have an access token:

BASE="https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4"
curl -s -H "Authorization: Bearer $TOKEN" \
     -H "Accept: application/fhir+json" \
     "$BASE/Observation?patient=$PATIENT_ID&category=laboratory&date=ge2015-01-01&_count=200" \
  > page1.json

Pagination is in Bundle.link with relation == "next". Follow it until there is no next link. Results come back as Observation resources; a panel like a CMP arrives either as one Observation with hasMember references or as a DiagnosticReport with a result array pointing at individual Observations. Handle both.

jq -r '.entry[].resource
  | select(.valueQuantity != null)
  | [ (.code.coding[] | select(.system=="http://loinc.org") | .code),
      .code.text,
      .effectiveDateTime,
      .valueQuantity.value,
      .valueQuantity.code,
      (.referenceRange[0].low.value // ""),
      (.referenceRange[0].high.value // "") ]
  | @tsv' page*.json > labs.tsv

If a lab has no FHIR endpoint, request the results under HIPAA in electronic form. Quest and Labcorp will hand over CSV or HL7 v2.5 ORU messages. HL7 v2 is pipe-delimited and the values live in OBX-5, units in OBX-6, LOINC in OBX-3, and the abnormal flag in OBX-8. Parse with hl7apy rather than regex.

Last resort is OCR on PDFs. Expect to hand-check every row. Decimal points and units are exactly where OCR fails, and a misread 0.9 as 9.0 for creatinine will poison every downstream calculation.

2. Normalize to one long table keyed by LOINC and UCUM

Two hazards make lab data messier than it looks: the same analyte appears under different LOINC codes depending on specimen and method, and units differ between labs.

Creatinine in serum is 2160-0. Creatinine in a 24-hour urine collection is a different code entirely. Hemoglobin A1c has separate codes for the IFCC (mmol/mol) and NGSP (%) scales. Vitamin D is usually 62292-8 (25-hydroxy D3+D2). Build an explicit allowlist of the codes you care about rather than joining on display text, because display text drifts between labs (“Glucose, Ser”, “GLUCOSE”, “Glucose Bld-mCnc”).

Unit conversion is where most personal lab pipelines silently break. Cholesterol in mg/dL versus mmol/L differs by a factor of 38.67. Glucose differs by 18.016. Creatinine differs by 88.4. Keep a conversion table keyed by (LOINC, from_unit, to_unit) and fail loudly on anything not in it:

CONV = {
    ("2093-3", "mmol/L", "mg/dL"): 38.67,   # total cholesterol
    ("2345-7", "mmol/L", "mg/dL"): 18.016,  # glucose
    ("2160-0", "umol/L", "mg/dL"): 1/88.4,  # creatinine
}

def to_canonical(loinc, value, unit, target):
    if unit == target:
        return value
    try:
        return value * CONV[(loinc, unit, target)]
    except KeyError:
        raise ValueError(f"no conversion for {loinc}: {unit} -> {target}")

Store one row per (loinc, datetime, value, unit, lab_id, method, ref_low, ref_high). Keep lab_id and the reported reference range. You will need both in step 7.

3. Standardize the draw so the numbers are comparable

Your own measurement protocol contributes more variance than you would guess, and unlike assay noise you control it. Pre-analytical factors are the largest single source of error in clinical laboratory results, and they are the ones nobody writes down 1.

Things that move numbers before the sample reaches the analyzer:

  • Posture. Standing for 15 minutes shifts water out of the vascular space and raises protein-bound analytes (albumin, total protein, calcium, lipids, most hormones) by roughly 5 to 10 percent versus supine. Pick one posture and keep it.
  • Tourniquet time. Over about a minute of stasis, potassium, lactate, and protein-bound analytes rise. Fist clenching raises potassium further.
  • Fasting state. Triglycerides are the most food-sensitive common analyte. Glucose and insulin obviously. If you compare a fasting draw to a non-fasting draw and call the difference a trend, you have measured breakfast.
  • Time of day. Cortisol, testosterone, iron, and TSH all have real diurnal amplitude. Testosterone and cortisol peak in the morning. Draw at a fixed hour.
  • Recent hard exercise. Creatine kinase, AST, ALT, and creatinine all rise after strenuous training and can stay elevated for days.
  • Hydration. Modest dehydration concentrates everything measured per volume.

On the questions people ask about the draw itself: the useful answers are mechanical. Hold firm pressure on the site for several minutes so you do not bruise, keep the arm straight rather than bent, avoid heavy lifting with that arm for a few hours, eat if you fasted, and drink water. If you feel faint, sit or lie down until it passes. None of that changes the result, since the sample is already in the tube. What changes the result is everything in the hours before.

After collection, sample handling matters too. Glucose in a plain tube falls by roughly 5 to 7 percent per hour at room temperature from red cell glycolysis, which is why fluoride-oxalate tubes exist. Hemolysis raises potassium, LDH, and AST because those are concentrated inside red cells. Many biomarkers tolerate delayed processing and a freeze-thaw cycle much better than you would expect, and several tolerate it much worse, so the specific analyte matters more than a general rule 2. If you ship samples to a direct-to-consumer lab, ask for the stability data for the specific analyte and the specific transit conditions.

Write your protocol down as a checklist and follow it every time: same lab, same hour, same fasting state, same posture, same hydration, no hard training in the prior 48 hours.

4. Understand what a reference interval is

The bracketed range next to your result is usually the central 95 percent of results from a reference population the lab selected, after excluding people with known disease. Three consequences follow, and they explain most of the anxiety produced by lab reports.

First, by construction, 1 in 20 results from a healthy person falls outside the range. A comprehensive metabolic panel has 14 analytes. A CBC with differential adds a dozen more. If you run 20 independent tests, the probability that at least one flags is 1 - 0.95**20, about 64 percent. Flagging is the expected outcome of a large panel, not evidence of anything.

Second, the interval describes a population, not you. The relevant quantity is the index of individuality: the ratio of within-person variation to between-person variation. When within-person variation is small relative to between-person variation (index below about 0.6), you can sit comfortably inside the population range while being far from your own set point, and the population range is close to useless for you. Serum sodium, calcium, and creatinine behave this way. Their within-person coefficients of variation are only a few percent while the population spread is wide.

Third, a result near a decision threshold carries less information than the crisp printed number suggests, because both the assay and your biology have noise. High-sensitivity cardiac troponin assays make this explicit: the interpretation depends on the 99th percentile upper reference limit and on the change between serial draws, not a single value against a fixed cut-off 3.

The panel most people get is a complete blood count, a comprehensive metabolic panel, a lipid panel, hemoglobin A1c, and a thyroid test (TSH). That is roughly 30 numbers covering red and white cells and platelets, electrolytes and kidney function, liver enzymes and protein, glucose and lipids, and thyroid axis. Which of those matter for you is a question for a clinician who knows your history, not something to read off the chart.

5. Compute your own reference change value

This is the single most useful calculation you can do with your own results, and clinical labs rarely report it.

Two independent noise sources sit between your physiology and the printed value. Analytical variation, CVA, is the imprecision of the assay, and the lab can tell you the value from their internal QC. Within-person biological variation, CVI, is the random fluctuation of your own set point over time, and published estimates live in the EFLM Biological Variation Database. The smallest change between two results that exceeds noise at 95 percent confidence, two-sided, is:

import math

def rcv(cv_a, cv_i, z=1.96):
    """Reference change value, percent."""
    return math.sqrt(2) * z * math.sqrt(cv_a**2 + cv_i**2)

Plug in realistic numbers and the picture changes. For serum creatinine, with CVI around 5 percent and CVA around 3 percent, the RCV is roughly 16 percent. Your creatinine going from 0.95 to 1.05 mg/dL is noise. For ALT, where CVI is far larger (on the order of 20 percent), you need a change of over 50 percent before you have evidence of anything. For sodium, with CVI under 1 percent, a change of 4 mmol/L is large. Look up the actual CVI for your analyte rather than trusting these round numbers, and ask your lab for their measured CVA on the specific platform.

For analytes with skewed distributions (triglycerides, CRP, ferritin, most hormones), compute the RCV on log-transformed values instead, which gives asymmetric up and down thresholds. Understanding how a given assay’s imprecision, bias, and reportable range shape the meaning of a change is the difference between tracking a biomarker and tracking its measurement error 1.

Attach the RCV to your table and render it in every plot as a band around the prior value. Most apparent trends in personal lab dashboards disappear once you do.

6. Build a personal baseline and plot it

With three or more results under a standardized protocol, estimate your own set point and spread:

import pandas as pd, numpy as np

df = pd.read_csv("labs_canonical.csv", parse_dates=["datetime"])

def baseline(g, min_n=3):
    if len(g) < min_n:
        return None
    x = np.log(g["value"]) if g["skewed"].iloc[0] else g["value"]
    mu, sd = x.mean(), x.std(ddof=1)
    return pd.Series({"n": len(g), "mean": mu, "sd": sd,
                      "cv_obs": sd / abs(mu) * 100})

bl = df.groupby("loinc").apply(baseline)

cv_obs is your total observed variation, which combines CVA, CVI, and any pre-analytical sloppiness. If cv_obs is much larger than sqrt(cv_a**2 + cv_i**2) from the literature, your protocol is leaking variance. Go back to step 3 before you interpret anything.

Plot each analyte as time on x, value on y, with three horizontal references: your personal mean, your personal mean ± 2 SD, and the lab’s population reference interval as a shaded band. Mark every point where the lab or method changed with a vertical line. Do not connect points across a lab change with a line.

If you have genotype data alongside the labs, polygenic scores for common traits associate with specific laboratory measurements in exactly the way you would expect from the underlying biology, which is a useful sanity check on whether a persistent offset from the population mean is constitutional rather than acquired 4. A lifelong slightly-high bilirubin in someone with the relevant UGT1A1 genotype is a different object than a bilirubin that started rising last year.

7. Handle assay and lab changes

Switching labs, or the same lab switching platforms, introduces a step change that looks exactly like a biological trend. Vitamin D, ferritin, thyroid hormones, and most immunoassays are the worst offenders because they are standardized against different calibrators.

You cannot correct this retroactively without paired samples. What you can do:

  • Record the performing lab and the method string from the FHIR Observation.method or the report footer on every row.
  • Segment your series at each change and estimate the offset from overlapping draws if you have them (split one draw across both labs, which some labs will accommodate).
  • If you have enough paired points, fit Passing-Bablok or Deming regression rather than ordinary least squares, since both axes carry measurement error.

For an analyte you plan to track for years, pick one lab and stay there. The cost of a slightly worse assay you understand is lower than the cost of a series with an unknown discontinuity in it.

8. Decide what a change means, with help

A result that clears your RCV tells you something moved. It does not tell you what. The interpretive step depends on pre-test probability, on the specificity of the marker, and on your clinical context, and the sequence matters: define the question first, then pick the marker, then set the threshold. Autoimmune serology is the clearest illustration, where ordering broad antibody panels without a specific clinical question generates false positives at a rate that swamps the true ones 5.

This is where you stop and bring a clinician in. Bring the table, the plot, the RCV, and the protocol notes. A physician looking at six years of standardized measurements with noise bands is working with better input than one looking at a single flagged value on a page. The analysis is yours. The interpretation is a joint exercise, and any decision about diagnosis or treatment is theirs.

Common problems

The FHIR endpoint returns only two years of data. Many portals limit the default date range. Set date=ge2010-01-01 explicitly and check whether the health system archived older results in a separate legacy system. You may need a records request for anything pre-migration.

Observations come back with valueString instead of valueQuantity. Common for qualitative results (“Negative”, “<0.01”) and for anything the lab reports with a censoring operator. Parse the operator separately into a censored column and treat left-censored values as bounds, not as the numeric limit.

Units are missing or use non-UCUM codes. valueQuantity.unit is display text and can be anything. valueQuantity.code with system == "http://unitsofmeasure.org" is the machine-readable one. When only display text exists, map it manually and log the mapping.

Two LOINC codes for what you thought was one analyte. Usually a specimen or method difference (serum versus plasma, direct versus calculated LDL). Keep them as separate series. Calculated LDL by Friedewald is unreliable when triglycerides exceed about 400 mg/dL and diverges from direct LDL at low values.

Everything trends in the same direction at once. Total protein, albumin, calcium, hemoglobin, and lipids all moving together across two draws usually indicates a hydration or posture difference, not five simultaneous biological changes. Check the protocol first.

A single dramatic outlier. Before modeling it, check for hemolysis flags, a note about difficult draw, and whether the sample sat over a weekend. One specimen handled badly produces results that look clinically alarming and mean nothing.

RCV bands look implausibly wide. They often are wide, and that is the finding. For analytes with large within-person variation, a single pair of measurements cannot detect anything short of a large change. The fix is more measurements under a tighter protocol, not a narrower band.

Oak builds longitudinal molecular profiles of individuals: whole-genome sequencing, RNA sequencing, proteomics, blood biomarkers, and continuous glucose data, integrated into one model of you. Build your profile.

Footnotes

  1. Robert H. Christenson, Show‐Hong Duh. Methodological and Analytic Considerations for Blood Biomarkers. Progress in Cardiovascular Diseases, 2012. https://doi.org/10.1016/j.pcad.2012.05.001 ↩ ↩2

  2. Paymon G. Rezaii, Gerald A. Grant, Michael Zeineh, et al. Stability of Blood Biomarkers of Traumatic Brain Injury. Journal of Neurotrauma, 2019. https://doi.org/10.1089/neu.2018.6053 ↩

  3. Ioan Ţilea, Andreea Varga, Răzvan Constantin Şerban. Past, Present, and Future of Blood Biomarkers for the Diagnosis of Acute Myocardial Infarction—Promises and Challenges. Diagnostics, 2021. https://doi.org/10.3390/diagnostics11050881 ↩

  4. Jessica Dennis, Julia Sealock, Péter Straub, et al. Clinical laboratory test-wide association scan of polygenic scores identifies biomarkers of complex disease. Genome Medicine, 2021. https://doi.org/10.1186/s13073-020-00820-8 ↩

  5. Mirjam Kolev, Michael P. Horn, Nasser Semmo, et al. Rational development and application of biomarkers in the field of autoimmunity: A conceptual framework guiding clinicians and researchers. Journal of Translational Autoimmunity, 2022. https://doi.org/10.1016/j.jtauto.2022.100151 ↩