Skip to content

How to Interpret Your Own CGM Data

Oak
Glowing amber pods along a misty river brighten in a wave, echoed by pulsing bands on wading long-necked animals.

By the end of this you will have a single tidy dataframe of glucose readings on a uniform 5-minute grid, a set of summary metrics (mean, SD, CV, time in range, GMI) computed by your own code rather than a vendor app, an ambulatory glucose profile you drew yourself, and a per-meal table with peak, delta, time to peak, time back to baseline, and 2-hour incremental AUC. You need: at least 14 days of sensor wear with good capture, the raw CSV export from your sensor app (not a PDF report), a meal log with timestamps accurate to about five minutes, and Python with pandas, numpy, and matplotlib. A phone photo of each meal with the EXIF timestamp works fine as a log.

1. Get the raw export, not the report

The vendor PDF is a summary of a summary. Export the CSV.

FreeStyle Libre: in the LibreView web app, Account Settings → Download Glucose Data. You get a CSV whose first line is device metadata, so the real header is on line 2. Columns include Device Timestamp, Record Type, Historic Glucose mg/dL, and Scan Glucose mg/dL. Record Type 0 is the 15-minute historic value the sensor stored, 1 is an on-demand scan value, 5 and 6 are notes and carb/insulin entries. The Libre system logs a reading every 15 minutes and retains roughly 8 hours in sensor memory, so gaps appear whenever you went longer than that without scanning on the first-generation flash device1.

Dexcom: Clarity → Export. The rows you want have Event Type == "EGV", with Timestamp (YYYY-MM-DDThh:mm:ss) and Glucose Value (mg/dL) at 5-minute cadence. Out-of-range readings appear as the literal strings Low and High rather than numbers.

Two traps in both formats: timestamps are local wall-clock with no timezone or DST marker, and the unit column depends on your account locale (mg/dL vs mmol/L). Decide on one unit now. This guide uses mg/dL; multiply by 0.0555 for mmol/L.

import pandas as pd, numpy as np

def load_libre(path):
    df = pd.read_csv(path, skiprows=1)
    df = df[df["Record Type"].isin([0, 1])].copy()
    df["glucose"] = df["Historic Glucose mg/dL"].fillna(df["Scan Glucose mg/dL"])
    df["ts"] = pd.to_datetime(df["Device Timestamp"], format="mixed", dayfirst=False)
    return df[["ts", "glucose"]].dropna().sort_values("ts")

def load_dexcom(path):
    df = pd.read_csv(path)
    df = df[df["Event Type"] == "EGV"].copy()
    g = df["Glucose Value (mg/dL)"].replace({"Low": 39, "High": 401})
    df["glucose"] = pd.to_numeric(g, errors="coerce")
    df["ts"] = pd.to_datetime(df["Timestamp (YYYY-MM-DDThh:mm:ss)"])
    return df[["ts", "glucose"]].dropna().sort_values("ts")

2. Resample onto a uniform grid and mark the gaps

Every metric below assumes evenly spaced samples. Interpolating across a six-hour gap invents data, so interpolate short gaps only and leave long ones as NaN.

def to_grid(df, freq="5min", max_gap_min=30):
    s = df.set_index("ts")["glucose"].sort_index()
    s = s[~s.index.duplicated(keep="first")]
    grid = s.resample(freq).mean()
    limit = max_gap_min // int(freq.replace("min", ""))
    return grid.interpolate(method="time", limit=limit, limit_area="inside")

For Libre’s 15-minute historic data, upsampling to 5 minutes is linear interpolation between real points. That is fine for percentile plots and AUC, and misleading if you want true rate-of-change. Keep the native cadence in a second series if you care about slopes.

3. Check sufficiency before you compute anything

The single most common way to get a wrong answer is to compute a 14-day mean over 60% capture, where the missing hours are systematically the ones you were asleep on the sensor or away from the reader. The working standard for an ambulatory glucose profile is 14 days with at least 70% of possible readings present, and the report is unreliable below that2.

def capture(grid):
    return grid.notna().mean(), grid.notna().resample("1D").mean()

overall, by_day = capture(g5)
print(f"{overall:.1%} overall")
print(by_day[by_day < 0.7])   # days to consider dropping

Also check capture by hour of day. If your 02:00–05:00 bin has 40% capture and your 13:00 bin has 99%, your overnight percentiles are built on a biased subsample.

4. Compute the summary metrics yourself

def summary(x):
    x = x.dropna()
    mean, sd = x.mean(), x.std(ddof=1)
    out = {
        "n": len(x),
        "mean": mean,
        "sd": sd,
        "cv_pct": 100 * sd / mean,
        "gmi_pct": 3.31 + 0.02392 * mean,
        "tir_70_180": 100 * x.between(70, 180).mean(),
        "tir_70_140": 100 * x.between(70, 140).mean(),
        "tar_180": 100 * (x > 180).mean(),
        "tar_250": 100 * (x > 250).mean(),
        "tbr_70":  100 * (x < 70).mean(),
        "tbr_54":  100 * (x < 54).mean(),
    }
    return pd.Series(out).round(2)

gmi_pct is the glucose management indicator, a linear mapping from mean sensor glucose to the HbA1c you would expect on average from a population with that mean3. It is a restatement of your mean glucose, nothing more. It carries no information the mean did not already carry.

On the question people ask most: is a GMI of 6.2% good? GMI 6.2% corresponds to a mean sensor glucose of about 121 mg/dL. In someone managing type 1 or type 2 diabetes, that mean sits below the usual glycemic target and the more informative question is what the time below range and CV look like alongside it, since the same mean can come from a flat trace or from a sawtooth with hypoglycemia. In someone without a diabetes diagnosis, a 14-day mean of 121 mg/dL is above the typical range seen in metabolically healthy adults and is a reason to have fasting glucose and HbA1c measured in a lab. CGM is not a diagnostic test for diabetes or prediabetes, and neither GMI nor any number in this guide substitutes for a clinician’s assessment.

GMI and laboratory HbA1c frequently disagree, in either direction, and the gap is not noise in the sensor. HbA1c depends on red blood cell lifespan and glycation rate, which vary between people, so two individuals with identical mean glucose can have HbA1c values that differ meaningfully3. Use them as two separate measurements of two different things, not as a check on each other.

The consensus targets used clinically for people with diabetes are time in 70–180 mg/dL above 70%, time below 70 under 4%, time below 54 under 1%, and CV at or below 36%3. For someone without diabetes those thresholds are uninformative, because you will be at 99–100% in range. Use the tighter 70–140 mg/dL window and CV instead.

5. Draw the ambulatory glucose profile

The AGP collapses all days onto a single 24-hour axis and plots percentiles: median, 25th–75th band, 10th–90th band. It is the highest-yield single view because it separates the shape of your day from day-to-day variance4.

import matplotlib.pyplot as plt

df = g5.to_frame("glucose")
df["tod"] = df.index.hour * 60 + df.index.minute
p = df.groupby("tod")["glucose"].quantile([0.10, 0.25, 0.50, 0.75, 0.90]).unstack()
p = p.rolling(5, center=True, min_periods=1).mean()   # smooth 25-min window

h = p.index / 60
fig, ax = plt.subplots(figsize=(11, 4))
ax.fill_between(h, p[0.10], p[0.90], alpha=0.15)
ax.fill_between(h, p[0.25], p[0.75], alpha=0.35)
ax.plot(h, p[0.50], lw=2)
ax.axhspan(70, 140, alpha=0.08)
ax.set_xlim(0, 24); ax.set_ylim(40, 250)
ax.set_xlabel("hour of day"); ax.set_ylabel("mg/dL")

Read it in this order24:

  1. Overnight median from 00:00 to 06:00. This is your closest thing to a repeated fasting measurement. A flat, low band here is the strongest signal in the plot.
  2. Width of the 10th–90th band. Wide bands mean your days are not alike, which usually means behavior (meal timing, exercise, alcohol) rather than physiology. Narrow the behavior before you interpret the physiology.
  3. Height and timing of the post-meal humps in the median line.
  4. The early-morning rise. A median that climbs from roughly 03:00 to wake time is the dawn pattern.

What a metabolically healthy non-diabetic AGP tends to look like: an overnight median in the 80s to mid-90s with a narrow band, post-meal humps that peak under about 140 mg/dL within 45 to 60 minutes and return to baseline by 2 hours, a 10th–90th band a few tens of mg/dL wide, and CV well under 36%. What blunted insulin sensitivity tends to look like: higher and later peaks, a return to baseline that stretches past 2 to 3 hours, a higher and less flat overnight floor, and a larger area under the post-meal curve for the same meal5. These are patterns, not diagnoses. Peak height alone is a poor discriminator, since a fast-absorbing meal in a highly insulin-sensitive person can spike sharply and clear in 60 minutes.

6. Extract per-meal responses

This is where CGM stops being a wellness dashboard and becomes an experiment. Define a meal by its start timestamp, take the baseline as the mean of the 15 minutes before, and measure the curve.

def meal_response(grid, t0, window_min=180, baseline_min=15):
    t0 = pd.Timestamp(t0)
    base = grid.loc[t0 - pd.Timedelta(minutes=baseline_min): t0].mean()
    seg = grid.loc[t0: t0 + pd.Timedelta(minutes=window_min)].dropna()
    if seg.empty or np.isnan(base):
        return None
    mins = (seg.index - t0).total_seconds() / 60
    delta = seg.values - base
    peak_i = int(np.argmax(seg.values))
    above = np.clip(delta, 0, None)
    iauc_120 = np.trapz(above[mins <= 120], mins[mins <= 120])  # mg/dL*min
    back = mins[(mins > mins[peak_i]) & (seg.values <= base + 10)]
    return {
        "baseline": base,
        "peak": seg.values[peak_i],
        "delta_peak": delta[peak_i],
        "t_peak_min": mins[peak_i],
        "iauc_120": iauc_120,
        "t_return_min": back[0] if len(back) else np.nan,
    }

Use incremental AUC above baseline, not total AUC, when comparing meals. Total AUC is dominated by your fasting level and will rank a high-baseline day above a high-response meal. Report iauc_120 in mg/dL·min and treat differences below roughly 20% between two single meals as inside the noise.

The noise is real and larger than most people assume. Run the same meal on three separate mornings, same time, same preparation, same prior-night sleep if you can manage it. In our experience the spread across repeats of an identical meal is wide enough that a single A/B comparison of two meals tells you very little. If two meals differ by less than the within-meal repeat spread, you have not measured anything. Prior exercise, sleep duration, and time of day all shift the response to the same food5.

7. Quantify the overnight and dawn windows

night = df.between_time("00:00", "06:00")["glucose"]
print(night.groupby(night.index.date).agg(["mean", "std", "min"]))

# dawn slope: 03:00 -> 07:00 regression per day, mg/dL per hour
def dawn_slope(day):
    seg = day.between_time("03:00", "07:00").dropna()
    if len(seg) < 24: return np.nan
    x = (seg.index - seg.index[0]).total_seconds() / 3600
    return np.polyfit(x, seg.values, 1)[0]

slopes = df["glucose"].groupby(df.index.date).apply(
    lambda s: dawn_slope(s.to_frame("g").set_index(pd.DatetimeIndex(s.index))["g"]))

A positive dawn slope of a few mg/dL per hour is common and physiological. What matters for your own tracking is whether it changes over months and whether the 00:00–06:00 mean drifts.

8. Feed it to an agent, carefully

Large language models are now decent at narrating an AGP and terrible at knowing what they do not know. Retrieval-augmented setups grounded in guideline text perform better on CGM counseling tasks than ungrounded generation, and evaluation of these systems is still an open research question6. If you hand your data to an agent, hand it the computed metrics table, the percentile matrix, and the per-meal table rather than 4,000 raw rows, and give it the capture rate so it can refuse to over-interpret a thin dataset. Ask it for pattern descriptions and hypotheses to test. Do not ask it for a diagnosis or for anything touching medication.

Common problems

Compression lows. Lying on the sensor reduces local interstitial perfusion and produces a smooth 40–60 mg/dL dip lasting 30 to 90 minutes, almost always overnight, with a quick recovery on rolling over. These inflate time below range. Flag overnight excursions below 65 mg/dL that are not preceded by any descent and check them against sleep position before counting them.

Day 1 and last-day drift. Sensors are frequently least accurate in the first 12 to 24 hours after insertion. A defensible default is to drop the first 12 hours of each sensor from summary statistics, and to note that comparing sensor A’s last day to sensor B’s first day will show a step change that is instrumentation, not you.

Sensor error is large relative to non-diabetic signal. In healthy adults with narrow glucose ranges, factory-calibrated sensors show measurable bias and their relative error is worst exactly where your data lives, in and near the normal range7. A 10 mg/dL difference between two meals is within sensor error. A 40 mg/dL difference is probably real. Wearing two sensors simultaneously for a week is the only way to see your own device noise directly, and the disagreement between them is often eye-opening.

Interstitial lag. Sensor glucose trails blood glucose by roughly 5 to 10 minutes, plus additional delay from the device’s internal smoothing. This shifts your measured time-to-peak later and blunts sharp peaks. Trend arrows encode rate of change and are useful in real time, but they are estimates from a lagged signal and should be read as direction, not as a prediction of where you will be in 15 minutes8.

Exercise artifacts. During and after intense exercise, sensor readings can diverge from blood glucose because of changes in skin perfusion, temperature, and sweat, and the glucose excursions athletes see are often a normal response to fuel demand rather than a sign of dysfunction9. Tag your exercise windows and analyze them separately instead of letting them widen your AGP bands.

Clock drift and DST. Libre and Dexcom timestamps are device-local. A daylight saving transition or a flight will fold two different clock times onto the same AGP bin. Normalize to a single timezone, or drop the travel days.

Reading a single day as a trend. One day of CGM is an anecdote. Structured interpretation frameworks exist precisely because ad hoc reading of individual traces leads to overconfident conclusions, and pattern identification should run over a defined multi-week window with documented capture10. If a metric moves, confirm it over a second 14-day block before acting.

Chasing the flattest possible line. Optimizing every meal for minimal excursion can push you toward a diet you will not sustain and tells you nothing about long-term health outcomes, which CGM has not been shown to predict in people without diabetes. Use the data to find your largest and most repeatable excursions, and take persistent elevations, unexplained lows, or any GMI above about 6.0% in an undiagnosed person to a clinician for laboratory confirmation.

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. Alyson Blum. Freestyle Libre Glucose Monitoring System. Clinical Diabetes, 2018. https://doi.org/10.2337/cd17-0130 ↩

  2. Peter Hammond. Interpreting the ambulatory glucose profile. British Journal of Diabetes, 2016. https://doi.org/10.15277/bjd.2016.072 ↩ ↩2

  3. Thomas W. Martens, Gregg D. Simonson, Richard M. Bergenstal. Using continuous glucose monitoring data in daily clinical practice. Cleveland Clinic Journal of Medicine, 2024. https://doi.org/10.3949/ccjm.91a.23090 ↩ ↩2 ↩3

  4. Fraser W Gibb, Peter Jennings, Lalantha Leelarathna, et al. AGP in daily clinical practice: a guide for use with the FreeStyle Libre flash glucose monitoring system. British Journal of Diabetes, 2020. https://doi.org/10.15277/bjd.2020.240 ↩ ↩2

  5. Jitendra D. Belani. Interpreting Continuous Glucose Monitoring Through Metabolic Physiology: A Framework for Precision Nutrition in Type 2 Diabetes. Nutrients, 2026. https://doi.org/10.3390/nu18152578 ↩ ↩2

  6. Zhijun Guo, Alvina Lai, Emmanouil Korakas, et al. Retrieval-Augmented Large Language Model Counseling for Continuous Glucose Monitoring in Diabetes: Source-Masked Multirater Comparative Evaluation. Journal of Medical Internet Research, 2026. https://doi.org/10.2196/98519 ↩

  7. Eva Fellinger, Tom Brandt, Justin Creutzburg, et al. Analytical Performance of the FreeStyle Libre 2 Glucose Sensor in Healthy Male Adults. Sensors, 2024. https://doi.org/10.3390/s24175769 ↩

  8. Yogish C Kudva, Andrew J Ahmann, Richard M Bergenstal, et al. Approach to Using Trend Arrows in the FreeStyle Libre Flash Glucose Monitoring Systems in Adults. Journal of the Endocrine Society, 2018. https://doi.org/10.1210/js.2018-00294 ↩

  9. Mikael Flockhart, Filip J. Larsen. Continuous Glucose Monitoring in Endurance Athletes: Interpretation and Relevance of Measurements for Improving Performance and Health. Sports Medicine, 2023. https://doi.org/10.1007/s40279-023-01910-4 ↩

  10. S. Borot, P.Y. Benhamou, C. Atlan, et al. Practical implementation, education and interpretation guidelines for continuous glucose monitoring: A French position statement. Diabetes & Metabolism, 2018. https://doi.org/10.1016/j.diabet.2017.10.009 ↩