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

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() returns false on 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.

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: HKStatisticsCollectionQuery also supports a statisticsUpdateHandler that 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 sourceRevision or use HKSampleQuery with 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 the AsyncStream. Use the onTermination handler or tie the query lifecycle to a task modifier.

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)

ScenarioRecommendation
Daily step/distance/calorie totalsUse HKStatisticsQuery with .cumulativeSum — handles deduplication automatically.
Heart rate chart over timeUse HKSampleQuery with a limit and sort descriptor — you need individual data points.
Sleep stage breakdownUse HKSampleQuery on sleepAnalysis — category samples carry the stage in their value.
Real-time dashboard updatesUse HKAnchoredObjectQuery with a persisted anchor — avoids full re-fetch.
Weekly or monthly trendsUse HKStatisticsCollectionQuery with daily/weekly intervals — one query, multiple buckets.
Background sync for complicationsRegister enableBackgroundDelivery with .hourly frequency — system wakes your app to refresh.
Simple one-shot read in a widgetAvoid 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 HKStatisticsQuery for cumulative types like steps and calories — it deduplicates across sources automatically.
  • Use HKSampleQuery for discrete types like heart rate where you need individual data points.
  • Sleep analysis returns HKCategorySample values with stage information (core, deep, REM, awake) — plan for source overlap.
  • HKAnchoredObjectQuery enables 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.