HealthKit: Reading Health Data — Steps, Heart Rate, and Sleep
Health and fitness apps consistently rank among the most downloaded categories on the App Store, yet HealthKit’s query
system trips up even experienced developers. Between HKSampleQuery, HKStatisticsQuery, and HKAnchoredObjectQuery,
choosing the wrong tool for the job means either missing data or hammering the Health Store with redundant reads.
This post covers the full read-side of HealthKit: authorization, querying steps, heart rate, and sleep data, plus incremental updates with anchored queries. We won’t cover writing workouts or the mental health APIs — those are in HealthKit: Workouts.
Contents
- The Problem
- Setting Up HealthKit Authorization
- Reading Step Count with HKStatisticsQuery
- Reading Heart Rate with HKSampleQuery
- Reading Sleep Data
- Incremental Updates with HKAnchoredObjectQuery
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem
Imagine you are building a fitness dashboard for Pixar’s Monster Energy Tracker — a wellness app where Sulley tracks his
daily scare-floor performance metrics: step count, heart rate during scares, and sleep recovery between shifts. Your
first instinct might be to reach for HKSampleQuery for everything, but that approach quickly falls apart.
// Naive approach — fetching ALL step samples for today
let stepType = HKQuantityType(.stepCount)
let predicate = HKQuery.predicateForSamples(
withStart: Calendar.current.startOfDay(for: Date()),
end: Date(),
options: .strictStartDate
)
let query = HKSampleQuery(
sampleType: stepType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: nil
) { _, samples, error in
// You get individual samples from every source:
// Apple Watch, iPhone, third-party apps...
// Now you have to deduplicate and sum manually.
let totalSteps = samples?
.compactMap { $0 as? HKQuantitySample }
.reduce(0.0) { $0 + $1.quantity.doubleValue(for: .count()) }
print("Total steps: \(totalSteps ?? 0)")
}
The problem is threefold. First, step count data comes from multiple sources — Apple Watch, iPhone, and third-party
pedometers — and HKSampleQuery returns raw overlapping samples without deduplication. Second, you are pulling the
entire dataset every time the view appears instead of fetching only what changed since the last read. Third, the
completion-handler pattern does not compose well with SwiftUI’s @Observable architecture.
HealthKit provides purpose-built query types that solve each of these problems. Let’s use them.
Setting Up HealthKit Authorization
Before reading any data, you need to configure your project and request user authorization. HealthKit requires explicit opt-in for every data type you want to read.
Project Configuration
Add the HealthKit capability in your target’s Signing & Capabilities tab, then add these keys to your Info.plist:
<key>NSHealthShareUsageDescription</key>
<string>Monster Energy Tracker reads your steps, heart rate, and sleep data to display your daily scare-floor performance.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>Monster Energy Tracker records workout sessions after scare-floor shifts.</string>
Warning: If you omit the usage description strings, the authorization prompt will not appear and your app will silently fail to access health data. Apple rejects apps that request health data without clear explanations.
Authorization Flow
Wrap authorization in a dedicated service. The key insight is that
requestAuthorization(toShare:read:)
only prompts the user once per data type — subsequent calls return immediately.
import HealthKit
@Observable
final class HealthService {
let store = HKHealthStore()
private let readTypes: Set<HKObjectType> = [
HKQuantityType(.stepCount),
HKQuantityType(.heartRate),
HKCategoryType(.sleepAnalysis)
]
var isAuthorized = false
func requestAuthorization() async throws {
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthServiceError.healthDataUnavailable
}
try await store.requestAuthorization(
toShare: [], // Read-only for now
read: readTypes
)
isAuthorized = true
}
}
enum HealthServiceError: LocalizedError {
case healthDataUnavailable
case queryFailed(Error)
var errorDescription: String? {
switch self {
case .healthDataUnavailable:
return "Health data is not available on this device."
case .queryFailed(let underlying):
return "Health query failed: \(underlying.localizedDescription)"
}
}
}
Note:
HKHealthStore.isHealthDataAvailable()returnsfalseon iPad (prior to iPadOS 17) and in certain enterprise configurations. Always check before attempting authorization.
One important caveat: HealthKit does not tell you whether the user granted or denied read access for a specific type.
This is a deliberate privacy design —
authorizationStatus(for:)
only distinguishes between .notDetermined, .sharingAuthorized, and .sharingDenied for write types. For read
types, you will simply get empty results if the user denied access. Design your UI to handle empty states gracefully
rather than trying to detect denial.
Reading Step Count with HKStatisticsQuery
For cumulative quantity types like step count,
HKStatisticsQuery is the right tool. It
handles deduplication across sources and computes sums, averages, min, and max values automatically.
extension HealthService {
/// Returns Sulley's total step count for a given day,
/// deduplicated across all sources.
func fetchStepCount(for date: Date) async throws -> Double {
let stepType = HKQuantityType(.stepCount)
let startOfDay = Calendar.current.startOfDay(for: date)
let endOfDay = Calendar.current.date(
byAdding: .day, value: 1, to: startOfDay
)!
let predicate = HKQuery.predicateForSamples(
withStart: startOfDay,
end: endOfDay,
options: .strictStartDate
)
return try await withCheckedThrowingContinuation { continuation in
let query = HKStatisticsQuery(
quantityType: stepType,
quantitySamplePredicate: predicate,
options: .cumulativeSum
) { _, statistics, error in
if let error {
continuation.resume(throwing: HealthServiceError.queryFailed(error))
return
}
let sum = statistics?
.sumQuantity()?
.doubleValue(for: .count()) ?? 0.0
continuation.resume(returning: sum)
}
self.store.execute(query)
}
}
}
The .cumulativeSum option tells HealthKit to merge overlapping samples from different sources. If Sulley’s Apple Watch
recorded 3,000 steps between 9:00 and 10:00 AM, and his iPhone recorded 2,800 steps for the same window, HealthKit takes
the higher value rather than double-counting. This is the same deduplication logic the Health app uses.
Statistics Collection for Weekly Trends
When you need daily totals across a range — say, Sulley’s step trend for the past week — use
HKStatisticsCollectionQuery:
extension HealthService {
/// Returns daily step totals for the past 7 days.
func fetchWeeklySteps() async throws -> [(date: Date, steps: Double)] {
let stepType = HKQuantityType(.stepCount)
let calendar = Calendar.current
let endDate = Date()
let startDate = calendar.date(byAdding: .day, value: -6, to: calendar.startOfDay(for: endDate))!
let daily = DateComponents(day: 1)
let predicate = HKQuery.predicateForSamples(
withStart: startDate, end: endDate, options: .strictStartDate
)
return try await withCheckedThrowingContinuation { continuation in
let query = HKStatisticsCollectionQuery(
quantityType: stepType,
quantitySamplePredicate: predicate,
options: .cumulativeSum,
anchorDate: startDate,
intervalComponents: daily
)
query.initialResultsHandler = { _, collection, error in
if let error {
continuation.resume(throwing: HealthServiceError.queryFailed(error))
return
}
var results: [(date: Date, steps: Double)] = []
collection?.enumerateStatistics(from: startDate, to: endDate) { statistics, _ in
let steps = statistics.sumQuantity()?.doubleValue(for: .count()) ?? 0.0
results.append((date: statistics.startDate, steps: steps))
}
continuation.resume(returning: results)
}
self.store.execute(query)
}
}
}
Tip:
HKStatisticsCollectionQueryalso supports astatisticsUpdateHandlerthat fires whenever new data arrives. Set it if you want real-time updates — but for most dashboard views, fetching on appear is sufficient.
Reading Heart Rate with HKSampleQuery
Heart rate is a discrete quantity type — each sample is an independent measurement, not a cumulative total.
HKStatisticsQuery can compute averages and ranges, but if you need the actual data points (for charting BPM over time
during a scare), use HKSampleQuery.
extension HealthService {
/// Fetches the most recent heart rate samples for Sulley's
/// scare-floor shift, sorted by date descending.
func fetchHeartRateSamples(
from startDate: Date,
to endDate: Date,
limit: Int = 100
) async throws -> [HeartRateSample] {
let heartRateType = HKQuantityType(.heartRate)
let predicate = HKQuery.predicateForSamples(
withStart: startDate, end: endDate, options: .strictStartDate
)
let sortDescriptor = NSSortDescriptor(
key: HKSampleSortIdentifierStartDate,
ascending: false
)
return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: heartRateType,
predicate: predicate,
limit: limit,
sortDescriptors: [sortDescriptor]
) { _, samples, error in
if let error {
continuation.resume(throwing: HealthServiceError.queryFailed(error))
return
}
let heartRates = (samples as? [HKQuantitySample])?.map { sample in
HeartRateSample(
bpm: sample.quantity.doubleValue(
for: HKUnit.count().unitDivided(by: .minute())
),
date: sample.startDate,
source: sample.sourceRevision.source.name
)
} ?? []
continuation.resume(returning: heartRates)
}
self.store.execute(query)
}
}
}
struct HeartRateSample: Identifiable {
let id = UUID()
let bpm: Double
let date: Date
let source: String
}
Notice the heart rate unit: HKUnit.count().unitDivided(by: .minute()), which represents beats per minute. HealthKit
stores all quantities in base units and converts at read time. Using the wrong unit will throw a runtime exception — not
a compile-time error.
Apple Docs:
HKUnit— HealthKit
Reading Sleep Data
Sleep analysis uses
HKCategoryType(.sleepAnalysis),
which returns category samples rather than quantity samples. Each sample has a value property that maps to
HKCategoryValueSleepAnalysis,
distinguishing between in-bed, asleep (core, deep, REM), and awake periods.
extension HealthService {
/// Fetches last night's sleep stages for Sulley's recovery report.
func fetchSleepAnalysis(for date: Date) async throws -> SleepReport {
let sleepType = HKCategoryType(.sleepAnalysis)
let calendar = Calendar.current
// Look from 6 PM yesterday to noon today
let evening = calendar.date(
bySettingHour: 18, minute: 0, second: 0,
of: calendar.date(byAdding: .day, value: -1, to: date)!
)!
let noon = calendar.date(
bySettingHour: 12, minute: 0, second: 0, of: date
)!
let predicate = HKQuery.predicateForSamples(
withStart: evening, end: noon, options: .strictStartDate
)
let sortDescriptor = NSSortDescriptor(
key: HKSampleSortIdentifierStartDate,
ascending: true
)
return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: sleepType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: [sortDescriptor]
) { _, samples, error in
if let error {
continuation.resume(throwing: HealthServiceError.queryFailed(error))
return
}
let categorySamples = samples as? [HKCategorySample] ?? []
let report = self.buildSleepReport(from: categorySamples)
continuation.resume(returning: report)
}
self.store.execute(query)
}
}
private func buildSleepReport(from samples: [HKCategorySample]) -> SleepReport {
var inBed: TimeInterval = 0
var asleepCore: TimeInterval = 0
var asleepDeep: TimeInterval = 0
var asleepREM: TimeInterval = 0
var awake: TimeInterval = 0
for sample in samples {
let duration = sample.endDate.timeIntervalSince(sample.startDate)
guard let value = HKCategoryValueSleepAnalysis(rawValue: sample.value) else {
continue
}
switch value {
case .inBed:
inBed += duration
case .asleepCore:
asleepCore += duration
case .asleepDeep:
asleepDeep += duration
case .asleepREM:
asleepREM += duration
case .awake:
awake += duration
default:
break // Handle future cases gracefully
}
}
return SleepReport(
inBed: inBed,
asleepCore: asleepCore,
asleepDeep: asleepDeep,
asleepREM: asleepREM,
awake: awake
)
}
}
struct SleepReport {
let inBed: TimeInterval
let asleepCore: TimeInterval
let asleepDeep: TimeInterval
let asleepREM: TimeInterval
let awake: TimeInterval
var totalAsleep: TimeInterval {
asleepCore + asleepDeep + asleepREM
}
var sleepEfficiency: Double {
guard inBed > 0 else { return 0 }
return totalAsleep / inBed
}
}
Warning: Sleep data from different sources can overlap. Apple Watch, iPhone, and third-party sleep trackers may all record sessions for the same night. Unlike step count, HealthKit does not automatically deduplicate sleep samples. If you need a single consolidated view, filter by
sourceRevisionor useHKSampleQuerywith a source predicate to pick a preferred source.
Incremental Updates with HKAnchoredObjectQuery
The queries above fetch data on demand, but what if Sulley’s dashboard needs to update in real time as new samples
arrive? Polling is wasteful.
HKAnchoredObjectQuery solves this by
maintaining an anchor — a cursor into the Health Store — and delivering only new or deleted samples since the last
check.
extension HealthService {
/// Starts observing step count changes and calls the handler
/// with new samples as they arrive.
func observeStepUpdates(
handler: @escaping ([HKQuantitySample]) -> Void
) -> HKAnchoredObjectQuery {
let stepType = HKQuantityType(.stepCount)
let predicate = HKQuery.predicateForSamples(
withStart: Calendar.current.startOfDay(for: Date()),
end: nil,
options: .strictStartDate
)
let query = HKAnchoredObjectQuery(
type: stepType,
predicate: predicate,
anchor: nil, // Start from the beginning; persist anchor for incremental updates
limit: HKObjectQueryNoLimit
) { _, addedSamples, _, newAnchor, error in
guard error == nil else { return }
let quantitySamples = addedSamples as? [HKQuantitySample] ?? []
handler(quantitySamples)
// Persist newAnchor to UserDefaults or a local store
// for incremental fetches on next launch
}
// The update handler fires for every subsequent change
query.updateHandler = { _, addedSamples, deletedSamples, newAnchor, error in
guard error == nil else { return }
let quantitySamples = addedSamples as? [HKQuantitySample] ?? []
handler(quantitySamples)
}
store.execute(query)
return query
}
}
The anchor is an opaque HKQueryAnchor object. Persist it between launches (it conforms to NSSecureCoding) to avoid
re-fetching the entire dataset on every app start. This is critical for apps that process large volumes of health data —
Mike Wazowski’s nightly sleep analysis should not re-download six months of samples every morning.
Combining with AsyncStream
For SwiftUI integration, wrap the anchored query in an AsyncStream:
extension HealthService {
/// An AsyncStream of step count updates for reactive SwiftUI binding.
func stepCountStream() -> AsyncStream<Double> {
AsyncStream { continuation in
var runningTotal = 0.0
let query = observeStepUpdates { newSamples in
let newSteps = newSamples.reduce(0.0) {
$0 + $1.quantity.doubleValue(for: .count())
}
runningTotal += newSteps
continuation.yield(runningTotal)
}
continuation.onTermination = { @Sendable _ in
self.store.stop(query)
}
}
}
}
Tip: Remember to call
store.stop(query)when the view disappears. Long-running queries hold references and continue consuming resources even if you discard theAsyncStream. Use theonTerminationhandler or tie the query lifecycle to ataskmodifier.
Performance Considerations
HealthKit queries run on a background serial queue managed by HKHealthStore. A few things to keep in mind:
Query limits matter. Setting limit: HKObjectQueryNoLimit on an HKSampleQuery for a type like heart rate can
return tens of thousands of samples. If you only need the latest 100, set the limit. The Health Store optimizes fetch
paths for bounded queries.
Statistics queries are cheaper than manual aggregation. HKStatisticsQuery and HKStatisticsCollectionQuery
perform deduplication and aggregation inside the Health Store’s SQLite database rather than loading raw samples into
your process. For cumulative types (steps, distance, active energy), always prefer statistics queries.
Background delivery has limits. You can register for
enableBackgroundDelivery(for:frequency:)
to wake your app when new data arrives, but the system coalesces notifications at .hourly, .daily, or .immediate
granularity. .immediate burns battery and Apple may throttle it. Use .hourly unless you have a compelling reason for
tighter latency.
Anchor persistence reduces startup cost. An anchored query without a persisted anchor replays the entire dataset on
first execution. For an app tracking months of data, this means loading thousands of samples just to discover nothing
changed. Encode the HKQueryAnchor to Data via NSKeyedArchiver and store it in UserDefaults or a local file.
Apple Docs:
HKStatisticsQuery— HealthKit
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| Daily step/distance/calorie totals | Use HKStatisticsQuery with .cumulativeSum — handles deduplication automatically. |
| Heart rate chart over time | Use HKSampleQuery with a limit and sort descriptor — you need individual data points. |
| Sleep stage breakdown | Use HKSampleQuery on sleepAnalysis — category samples carry the stage in their value. |
| Real-time dashboard updates | Use HKAnchoredObjectQuery with a persisted anchor — avoids full re-fetch. |
| Weekly or monthly trends | Use HKStatisticsCollectionQuery with daily/weekly intervals — one query, multiple buckets. |
| Background sync for complications | Register enableBackgroundDelivery with .hourly frequency — system wakes your app to refresh. |
| Simple one-shot read in a widget | Avoid HealthKit in widgets directly. Read data in your main app and share via App Groups. |
One scenario where HealthKit is explicitly the wrong choice: if you only need the current step count and do not need
historical data or integration with the Health app,
CMPedometer from Core Motion is simpler and does
not require user authorization for step data.
Summary
- Use
HKStatisticsQueryfor cumulative types like steps and calories — it deduplicates across sources automatically. - Use
HKSampleQueryfor discrete types like heart rate where you need individual data points. - Sleep analysis returns
HKCategorySamplevalues with stage information (core, deep, REM, awake) — plan for source overlap. HKAnchoredObjectQueryenables incremental updates; persist the anchor between launches to avoid replaying the full dataset.- HealthKit never reveals whether the user denied read access — design for empty states, not error states.
Ready to write workout data and explore the mental health APIs? Head to HealthKit: Workouts for the write side of the framework, or jump to Build a Health and Fitness Tracker to put it all together in a complete app.