Why Your Apple Health Export Has Silent Gaps (and the Days That Never Get Recomputed)

A silent gap is a day your export either never wrote, or wrote once and never corrected. No error appears: the file simply carries fewer days than Apple Health does, and any agent reading it treats the short file as the whole truth.

By MetricBridge · Updated 1 October 2026 · ~6 min read

Quick answer. Most incremental exporters advance a cursor by the sample's own date: read everything with a start date after the last run. Apple Health keeps a separate added-to-store time that is not a filterable sample property, so a sample backfilled today for three weeks ago sits behind the cursor and is never seen again. The fix is to read store changes with an anchored query, or to re-read a bounded trailing window and overwrite it, not to filter on dates.

What a silent gap actually looks like

You open your dashboard and see normal days, then one that reads low, flat or empty. Apple Health has the data; the export does not. The expensive version is the one that looks fine: a partial day written while it was still in progress and never revisited, so the number is present but wrong. No exception, no log line, nobody notices until an agent answers a question off it. The only detector is comparing what you have against what should be there.

Why backfilled samples never get recomputed

Every HealthKit sample carries a start date and an end date. The moment it was added to the store is a different thing, and Apple does not expose that as a sample property you can put in a predicate. That distinction is the whole bug. Here is the design that fails, and it is the obvious one:

// The trap: a cursor keyed on the sample's own dates.
let lastExport = defaults.object(forKey: "lastExportDate") as? Date

let predicate = HKQuery.predicateForSamples(
    withStart: lastExport,      // "everything newer than last time"
    end: nil,
    options: .strictStartDate)

// A walk saved TODAY for a run three weeks ago carries
// a start date three weeks ago, so it is already behind
// the cursor. This predicate can never match it.

Who actually backfills? A third-party app that was offline syncs on reconnect. A wearable pushes a week of workouts after the phone unlocks. A restore or a new device re-imports a stretch of the past. A health record source imports results with historical dates. In every case the sample's date is old and the arrival is new, and a date cursor moves past it permanently.

The daily summary dies with it. If you compute each calendar day once and cache it, a day written three weeks ago was already computed and is never recomputed. The gap is not in the samples you fetched, it is in the aggregate you decided was finished.

The three ways gaps appear

  • Backfill. Late writes with old dates. A forward-only cursor never reaches them.
  • Frozen partials. A day written while it was still in progress and never revisited. Today's 09:00 step total becomes tomorrow's "yesterday", forever.
  • Deletions and re-buckets. Delete a sample and a push-only export keeps it. Move timezone and the same samples bucket onto different local days, leaving ghost keys. Neither raises an error.

The property Apple Health does not give you

HealthKit exposes when a sample happened, who wrote it, and which device produced it. It does not expose a modified-on timestamp you can filter a query by, so there is no WHERE modified > lastRun to write.

What Apple does provide is a query whose anchor tracks store changes instead of dates. HKAnchoredObjectQuery starts with a nil anchor and a persistent one after that, and its documentation is explicit that the returned samples are those saved after the anchor, not those whose dates fall in a range. A backfill with an old internal date is still a new arrival, so an anchored query sees it, and it reports deletions too.

The other correct design abandons the cursor: re-read a bounded window of recent days every run and overwrite them. No anchor state to persist, and it catches backfills and deletions inside the window.

What a correct incremental design actually looks like

This is the shape MetricBridge uses. The numbers are worth stating plainly.

  • Re-read, do not append. The daily run re-reads a trailing window and overwrites it. Default is 2 days, widening to 7 once after a detected timezone change, because a move can shift a sample onto an adjacent day key and leave ghost days needing an authoritative overwrite.
  • Walk history in bounded windows, newest-first. An interruption then keeps the most recent stretch. 30-day windows for dense metrics (heart rate, steps, energy, distance), 180 days for sparse ones. An unbounded read is killed by the OS: ten years of heart rate is millions of objects.
  • Aggregate inside HealthKit. Each day's value uses HealthKit's own statistics aggregation, which applies the style Apple defines per type and merges sources. Summing raw samples instead double-counts every step the iPhone and the Watch both recorded.
  • Snap windows to local midnight. A walk stepping back from "now" splits a day into two partials carrying the same date, and last-write-wins lets the second replace the first. Every seam loses half a day.
  • Persist resume state per window. HealthKit is unreadable while the device is locked, so a multi-year walk will be interrupted. A resume more than 3 days stale restarts from today rather than leaving a hole behind it.

None of this is exotic. It is the difference between a cursor that assumes history is append-only and a writer that assumes any day can still change.

How to check your own export for gaps

If your export is read by the health-export-mcp server, three of its 14 read-only tools already answer the question. Call get_mcp_status first:

{
  "ok": true,
  "source": "file ~/Library/Mobile Documents/iCloud~ai~healthexport~app/Documents/.health-cache.json",
  "metricCount": 190,
  "workoutCount": 412,
  "lastDataDate": "2026-09-30",
  "intraday": { "present": true, "lastWrite": "2026-09-30T06:00:11.402Z" }
}

If lastDataDate is not today, the export is not running and every answer your agent gives is reasoning over old numbers.

For a quieter gap, ask a question deeper than your file. Call get_trends for a window longer than your history:

{
  "metric": "hrv",
  "windowDays": 30,
  "recentDays": 30,
  "priorDays": 11,
  "daysAvailable": 41,
  "windowSatisfied": false,
  "coverage": { "firstDate": "2026-08-21", "lastDate": "2026-09-30", "days": 41 }
}

windowSatisfied: false means one side of the comparison is shorter than the other. The change percent then switches to per-day rates and the raw change is omitted, because totals over different spans are not comparable. get_health_metrics returns the same coverage block for every metric, so you can compare days present against days expected across the whole file.

Why that field exists at all. An earlier revision of this server relaxed the check to "both sides are non-empty", which let a 365-day request against 400 days of history report satisfied while it was actually comparing 365 days to 35. A gap detector that can miss a gap is worse than none, because it answers confidently over a short file.

What a gap costs an agent

A hole is not only a missing chart. An agent asked "is this trending up" over a file with a hidden short side will answer with a direction and a percentage and never know the comparison is weighted wrong. So every data tool returns coverage, not values alone. The real question about an exporter is not "does it run on a schedule" but "what does it do when a day changes after it was written".

190 metrics as JSON, with coverage on every answer

A trailing re-read instead of a date cursor, bounded-history backfill, on-device Ask with provenance cards, and a Weekly Brief. One purchase covers it all: $24.99 one-time, no subscription.

Frequently asked questions

What is a silent gap in an Apple Health export?

A day that is in Apple Health but missing, or permanently wrong, in the exported file. Nothing errors, and every tool reading the short file treats it as complete.

Why does my export miss days that are clearly in Apple Health?

An exporter that advances a cursor by the sample's own start date never sees a backfilled sample, because its date is already behind the cursor. Apple Health's separate added-to-store time is not a filterable sample property.

Does Apple Health tell an app when a sample was added or changed?

It tells an anchored query. HKAnchoredObjectQuery tracks store changes, not date ranges, so a backfilled sample with an old internal date still comes back. HealthKit exposes no filterable modified-on timestamp.

Can I repair gaps without regenerating the whole export?

Yes, if the writer re-reads a bounded trailing window every run and merges idempotently. A fixed window catches late writes and deletions in that span, and a bounded history walk fills anything older.

How do I check whether my own export has gaps?

In health-export-mcp, get_mcp_status reports lastDataDate and metricCount, get_health_metrics returns a coverage block (firstDate, lastDate, days), and get_trends returns windowSatisfied, false when the file lacks enough history for your window.

MetricBridge · Apple Health → your AI agent, privately. · Home · Privacy · Terms · Support