An hourly HealthKit query returned all zeros, and the Health app had the data

Calorie Burndown put up a screen saying it couldn't read my Health data. Health could read it fine. Opening the Health app on the same phone, in the same minute, showed a resting energy average of 2,052 calories a day for the week the app claimed to know nothing about. Every day of it populated.

That combination took four rounds to explain, and three of them produced a confident wrong answer first. The cause was a single predicate option that is correct on the query it was written for and catastrophic on the query it got carried onto.

ELI5: what's basal energy?

The calories your body burns just existing — breathing, keeping warm, running a heart. On Apple's platforms it's usually written to Health by an Apple Watch, continuously, all day, and it's much larger than the part you burn by moving around. An app that tells you how much you have left to eat is mostly reporting this number.

Three wrong answers, each killed by the next measurement

"Health access was revoked." It fits the symptom perfectly, and a revoked grant does make reads fail rather than come back empty. Killed by a device capture: across 452,455 lines, 43 resting-energy collection queries were executed and 43 completed. Not one query error. Not one healthd error. The subsystem was answering every time.

"The store genuinely has no resting energy." A legitimate state — plenty of people have no Apple Watch and so no basal samples at all, and the app has a designed answer for exactly that. Its own logging said this was the case. Killed by looking at the Health app, the one instrument in this story that sits outside the system under test.

"The read permission for that one type is off." Consistent with how HealthKit behaves: a read against an unauthorized type returns an empty result rather than an error, so an app cannot tell whether you said no. Killed by running a plain sum over the same seven days, which returned 15,050 calories — roughly 2,150 a day, which is not the Health app's figure to the calorie but is very plainly not zero.

So: the queries ran, the data existed, and permission was fine. Three rounds of asking the app what it could see, when the thing that reframed everything was a screenshot of the data itself.

The probe that actually worked

The fourth attempt stopped asking "what does the app read?" and asked "what does this read return under four combinations of one variable?" — same day, same quantity type, same device, one run:

probe day = 2026-09-23 05:00:00 +0000

plain sum,         .strictStartDate -> 2138
plain sum,         no options       -> 2206
hourly collection, .strictStartDate -> total=0    intervals=25
hourly collection, no options       -> total=2206

A single reading cannot isolate a variable no matter how carefully it is taken. Four differing in one thing name the mechanism in one run: .strictStartDate costs the plain query 68 calories and zeroes the collection query completely.

ELI5: what's a statistics collection query?

HealthKit gives you two shapes of totaling query. One adds up everything in a range and hands back a single number. The other chops the range into repeating intervals — every hour, every day — and hands back a number per interval, so you can draw a chart. They take the same arguments, which is what makes it easy to move code from one to the other without thinking about it.

What "strict" is strict about

Apple documents the option in one sentence. From HKQueryOptions.strictStartDate:

The sample’s start time must fall within the target time period.

The load-bearing word is target, and on a plain sum its meaning is unambiguous: one target, the size of the whole range. The cost is the samples that start before the range does, and the probe day has exactly one — it begins at 04:59:15, before the 05:00 start, and carries 67 calories. Loose sum 2206, strict sum 2138. The option costs that sample, to within a calorie, and nothing else.

On a collection query, the documentation never says what target means. If it means the intervals — twenty-five in the probe day, an hour apart — the same rule should cost one sample per boundary, because basal energy does not arrive in tidy hour-shaped pieces. Here are the first few samples, against a day starting at 05:00:00:

04:59:15 -> 05:53:33   67 cal    <- starts before the day does
05:53:33 -> 05:53:54    0
05:53:54 -> 06:13:01   23
06:13:01 -> 06:13:42    1

Long, continuous, and largely indifferent to where the clock says an hour ends. But read those starts against their buckets: 05:53:33 and 05:53:54 both start inside the 05:00 hour, and 06:13:01 starts inside the 06:00 hour. A rule about start times admits every one of them, each into the interval it starts in. The only sample with nowhere to go is the 04:59:15 one — the same sample the plain query loses.

So that reading predicts an hourly total near 2139, the strict plain sum. The measured total was zero, across all twenty-five intervals. Boundary exclusion is real — its price is the paragraph above — but it does not account for what the collection query did, and from outside HealthKit I can't tell you what does.

The fix is measurable even so: drop the option and HealthKit apportions a straddling sample across the intervals it overlaps, which is what a cumulative energy total wants in the first place. The loose hourly run returns 2206, matching the plain loose sum.

ELI5: what's a HealthKit sample?

One recorded observation, with a start time and an end time. Some are instants — a meal you logged, a weight reading. Others cover a stretch of time, and a stretch can be long: a single resting-energy sample here runs the best part of an hour. A sample that begins at 4:59 and ends at 5:53 belongs to two different hours, and something has to decide how to split it.

Why it survived, and why nothing caught it

The option was correct when it was written. A few weeks earlier I'd changed that read from a plain daily sum to an hourly one, to fix a real defect: a watch worn four hours shouldn't have its basal rate divided by twenty-four. That change was right. The predicate came along unchanged.

The failure is silent by construction, which is the part that cost the time. An all-zero bucket array is indistinguishable from a legitimate state: a user with no Apple Watch really does have no basal energy. So every layer above read "no resting data" and reported it honestly, all the way up to a screen telling me to go check a Health permission that was working perfectly.

This is the same shape as the other way a HealthKit read lies to you: there, a failed query and an empty day were the same object, and coercing one to zero published a confident false number. Here the query succeeded. Nothing failed at all. The answer was simply the wrong answer, and it was a plausible one.

What I can't tell you

Two things. First, whether this is new. I only ever ran this query shape on iOS 27 — the hourly read landed while the phone was already on the beta, and the measurements above were taken on iOS 27.0 (24A437) on an iPhone 17. I have no iOS 26 data point, so I cannot separate "the option was always wrong here" from "something changed underneath it."

There is reason to think something did. Two Apple Developer Forums threads describe the same family of symptom on iOS 27 — 838450, where a collection query returns no quantity for buckets a plain statistics query reads fine, and 842754, where historical step counts go missing. Both posters use .strictStartDate. Neither has a solution, and on the second an Apple engineer says the behavior "does sound like a bug" and asks for a feedback report. A reply landed on the first while I was writing this, reporting that choosing "limited access" in Health clears it and full access does not — which I cannot account for either, and which is a second one-variable comparison somebody should run.

Cutting the other way, the oldest report I found predates iOS 27 by a year and describes this option on exactly this query shape: 786576, June 2025, hourly intervals over basalEnergyBurned with .strictStartDate, where the first interval reads low and the low interval moves when the window moves. That is a partial loss at the leading boundary, which is what the documented rule predicts and what my plain query does. So the option was already costing people samples here before iOS 27 — evidence for "always wrong," and at the same time the clearest evidence I have that an empty day is a different animal.

The gap above cuts the same way: if the documented rule only accounts for one sample per boundary, an empty bucket array is more than this option should be able to do. That argues for something being wrong underneath, not against it.

So, put no more strongly than it deserves: .strictStartDate is the variable that flips this on my device, the loose read is correct, and removing it is defensible on its own terms whatever Apple concludes. What I am not claiming is an explanation for the size of the failure. Nobody in those threads appears to have run the one-variable comparison — the 2025 one gets as far as someone suggesting the predicate options are worth a look — and it takes one build.

Don't generalize the fix without measuring it

The obvious follow-on is to strip .strictStartDate everywhere. I measured that instead of assuming it, and the answer was no. For the types still reaching a figure through a plain sum — dietary and active energy — the delta between strict and loose was exactly zero, on a single day and on a seven-day window. Meals are instants, and active energy samples don't straddle the boundaries that matter here. Only basal moved, and basal no longer uses that path.

Moving live figures for a measured zero is churn, so the option stays on the single-interval queries — with the measurement written down next to it, so the question is settled rather than reopened.

What I'd tell you to check first

If a HealthKit collection query is handing you empty buckets and the Health app disagrees, run the same read four ways before you theorize about anything:

// Same type, same day, same device, one run.
let variants: [(String, HKQueryOptions)] = [("strict", .strictStartDate), ("loose", [])]

for (label, options) in variants {
    print("plain ", label, await plainSum(type, day, options))
    print("hourly", label, await hourlyTotals(type, day, options))
}

If the plain row is right and the hourly row is empty, it is the predicate, not the store and not your permissions.

The transferable half is bigger than one option. When you change a read's shape — one interval to many, single to collection, one range to many — re-derive its predicate rather than carrying it. Query options are assertions about interval boundaries, and changing the intervals changes what they assert. Nothing in the compiler, the tests or the type system has an opinion about that, and the result of getting it wrong is not an error. It's a number that looks like a fact about the user.

And when your instrument and the ground truth disagree, go get the ground truth from outside the system under test. Three rounds here were spent asking the app what it could see. One screenshot of the Health app reframed all of them.