| name | healthkit-queries |
| description | Deep expertise for HealthKit query construction, optimization, and debugging. Use when implementing new HealthKit features, debugging data accuracy issues, or optimizing query performance. |
HealthKit Queries - Deep Dive
Expert guidance for constructing HealthKit queries with focus on accuracy, performance, and edge case handling.
When to Use This Skill
- Implementing new HealthKit data queries
- Debugging "why is my data off by X?" issues
- Optimizing query performance for large datasets
- Handling day/hour boundary edge cases
- Implementing background delivery and observer queries
- Dealing with timezone/DST issues in HealthKit data
Core Query Types
HKSampleQuery - Direct Sample Access
When to use: Simple queries, custom aggregation logic, widget contexts where you need full control
Pattern:
let predicate = HKQuery.predicateForSamples(
withStart: startDate,
end: endDate,
options: .strictStartDate
)
let samples = try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: quantityType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: nil
) { _, samples, error in
if let error = error {
continuation.resume(throwing: error)
return
}
continuation.resume(returning: samples as? [HKQuantitySample] ?? [])
}
healthStore.execute(query)
}
var hourlyTotals: [Date: Double] = [:]
for sample in samples {
let hourStart = calendar.dateInterval(of: .hour, for: sample.startDate)?.start
?? sample.startDate
let calories = sample.quantity.doubleValue(for: .kilocalorie())
hourlyTotals[hourStart, default: 0] += calories
}
Pros:
- Full control over aggregation logic
- Works in widget extensions (no long-running queries)
- Can implement custom grouping (by hour, by source, etc.)
- Straightforward error handling
Cons:
- Manual aggregation required
- More verbose than statistics queries
- Need to handle empty intervals yourself
Real example from HealthTrends:
private func fetchHourlyData(from startDate: Date, to endDate: Date, type: HKQuantityType) async throws -> [HourlyEnergyData] {
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
var hourlyTotals: [Date: Double] = [:]
let samples = try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(sampleType: type, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
if let error = error {
continuation.resume(throwing: error)
return
}
continuation.resume(returning: samples as? [HKQuantitySample] ?? [])
}
healthStore.execute(query)
}
for sample in samples {
let hourStart = calendar.dateInterval(of: .hour, for: sample.startDate)?.start ?? sample.startDate
let calories = sample.quantity.doubleValue(for: .kilocalorie())
hourlyTotals[hourStart, default: 0] += calories
}
return hourlyTotals.map { HourlyEnergyData(hour: $0.key, calories: $0.value) }
.sorted { $0.hour < $1.hour }
}
HKStatisticsCollectionQuery - Built-in Time Series Aggregation
When to use: App context (not widgets), standard time intervals, built-in aggregation
Pattern:
let calendar = Calendar.current
let anchorDate = calendar.startOfDay(for: Date())
let interval = DateComponents(hour: 1)
let query = HKStatisticsCollectionQuery(
quantityType: activeEnergyType,
quantitySamplePredicate: nil,
options: .cumulativeSum,
anchorDate: anchorDate,
intervalComponents: interval
)
query.initialResultsHandler = { query, results, error in
guard let results = results else {
if let error = error {
print("Query failed: \(error)")
}
return
}
results.enumerateStatistics(from: startDate, to: endDate) { statistics, stop in
if let sum = statistics.sumQuantity() {
let calories = sum.doubleValue(for: .kilocalorie())
print("\(statistics.startDate): \(calories) cal")
}
}
}
healthStore.execute(query)
Pros:
- HealthKit handles aggregation (correct, efficient)
- Natural time interval alignment
- Handles empty intervals gracefully (returns nil quantity)
- Can act as long-running query with
statisticsUpdateHandler
Cons:
- More complex setup (anchor dates, intervals)
- Less flexible for custom aggregation
- Not suitable for widgets (long-running queries)
Choosing Statistics Options:
options: .cumulativeSum
options: [.discreteAverage, .discreteMin, .discreteMax]
options: [.cumulativeSum, .separateBySource]
Understanding Anchor Dates:
The anchor date defines when each interval starts. For 1-hour intervals:
- Anchor at 3:00 AM → intervals start at 3 AM, 4 AM, 5 AM...
- Anchor at midnight → intervals start at 12 AM, 1 AM, 2 AM...
The exact date doesn't matter - only the time component. These are equivalent:
2020-01-01 03:00
2025-11-26 03:00
Both produce intervals starting at 3 AM daily.
Authorization & Privacy
Requesting Authorization
func requestAuthorization() async throws {
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthKitError.notAvailable
}
let activeEnergyType = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!
let activitySummaryType = HKObjectType.activitySummaryType()
let typesToRead: Set<HKObjectType> = [activeEnergyType, activitySummaryType]
let typesToWrite: Set<HKSampleType> = [activeEnergyType]
try await healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead)
isAuthorized = true
}
Privacy Protection:
- HealthKit never tells you if read permission was denied
- Apps only see data they wrote themselves (if denied)
- Check authorization status before writes:
let status = healthStore.authorizationStatus(for: activeEnergyType)
switch status {
case .notDetermined:
try await requestAuthorization()
case .sharingDenied:
throw HealthKitError.authorizationDenied
case .sharingAuthorized:
try await healthStore.save(samples)
@unknown default:
break
}
Guest User Mode (Vision Pro)
do {
try await healthStore.save(sample)
} catch let error as HKError where error.code == .errorNotPermissibleForGuestUserMode {
print("Cannot save in Guest User mode")
}
Date & Time Edge Cases
Midnight Boundaries
Critical insight: HealthKit samples can span midnight. Always consider boundary conditions.
let today = calendar.startOfDay(for: Date())
let predicate = HKQuery.predicateForSamples(withStart: today, end: Date(), options: .strictStartDate)
let predicate = HKQuery.predicateForSamples(
withStart: startOfDay,
end: Date(),
options: .strictStartDate
)
let predicate = HKQuery.predicateForSamples(
withStart: startOfDay,
end: Date(),
options: []
)
Handling midnight transitions in widgets:
let midnight = calendar.nextDate(after: currentDate, matching: DateComponents(hour: 0), matchingPolicy: .nextTime)
if midnight < next15MinUpdate {
let midnightEntry = EnergyWidgetEntry(
date: midnight,
todayTotal: 0,
averageAtCurrentHour: 0,
projectedTotal: currentEntry.projectedTotal,
moveGoal: currentEntry.moveGoal,
todayHourlyData: [HourlyEnergyData(hour: midnight, calories: 0)],
averageHourlyData: currentEntry.averageHourlyData
)
let reloadTime = calendar.date(byAdding: .minute, value: 1, to: midnight)!
return Timeline(entries: [currentEntry, midnightEntry], policy: .after(reloadTime))
}
DST & Timezone Handling
Always use Calendar.current for date arithmetic:
let hourStart = calendar.dateInterval(of: .hour, for: date)?.start
let hourStart = Date(timeIntervalSince1970: (date.timeIntervalSince1970 / 3600).rounded(.down) * 3600)
DST Edge Case - Hour 2 AM doesn't exist on spring forward:
let march12_2023 = Date()
for hour in 0..<24 {
if let hourDate = calendar.date(byAdding: .hour, value: hour, to: startOfDay) {
}
}
Cumulative Data Across Days
Pattern for averaging cumulative patterns across multiple days:
private func fetchCumulativeAverageHourlyPattern(from startDate: Date, to endDate: Date, type: HKQuantityType) async throws -> [HourlyEnergyData] {
let samples =
var dailyCumulativeData: [Date: [Int: Double]] = [:]
for sample in samples {
let dayStart = calendar.startOfDay(for: sample.startDate)
let hour = calendar.component(.hour, from: sample.startDate)
let calories = sample.quantity.doubleValue(for: .kilocalorie())
if dailyCumulativeData[dayStart] == nil {
dailyCumulativeData[dayStart] = [:]
}
dailyCumulativeData[dayStart]![hour, default: 0] += calories
}
var dailyCumulative: [Date: [Int: Double]] = [:]
for (dayStart, hourlyData) in dailyCumulativeData {
var runningTotal: Double = 0
var cumulativeByHour: [Int: Double] = [:]
for hour in 0..<24 {
runningTotal += hourlyData[hour] ?? 0
cumulativeByHour[hour] = runningTotal
}
dailyCumulative[dayStart] = cumulativeByHour
}
var averageCumulativeByHour: [Int: Double] = [:]
for hour in 0..<24 {
var totalForHour: Double = 0
var count = 0
for (_, cumulativeByHour) in dailyCumulative {
if let cumulativeAtHour = cumulativeByHour[hour], cumulativeAtHour > 0 {
totalForHour += cumulativeAtHour
count += 1
}
}
averageCumulativeByHour[hour] = count > 0 ? totalForHour / Double(count) : 0
}
let startOfToday = calendar.startOfDay(for: Date())
var hourlyData: [HourlyEnergyData] = []
hourlyData.append(HourlyEnergyData(hour: startOfToday, calories: 0))
for hour in 0..<24 {
let timestamp = calendar.date(byAdding: .hour, value: hour + 1, to: startOfToday)!
let avgCumulative = averageCumulativeByHour[hour] ?? 0
hourlyData.append(HourlyEnergyData(hour: timestamp, calories: avgCumulative))
}
return hourlyData
}
Why this matters:
- Averaging NON-cumulative hourly data gives you "average hourly burn"
- Averaging CUMULATIVE hourly data gives you "typical progress throughout day"
- HealthTrends needs the latter for "are you ahead or behind?" comparisons
Observer Queries & Background Delivery
Setting Up Observer Queries
func enableBackgroundDelivery() async throws {
try await healthStore.enableBackgroundDelivery(
for: activeEnergyType,
frequency: .hourly
)
}
let observerQuery = HKObserverQuery(
sampleType: activeEnergyType,
predicate: nil
) { query, completionHandler, error in
guard error == nil else {
print("Observer query failed: \(error!)")
completionHandler()
return
}
Task {
try? await fetchEnergyData()
WidgetCenter.shared.reloadAllTimelines()
completionHandler()
}
}
healthStore.execute(observerQuery)
Critical:
- Call
completionHandler() even on errors
- Set up observer queries in
application(_:didFinishLaunchingWithOptions:)
- Background delivery wakes your app, runs observer handler, then suspends
Disabling Background Delivery
func disableBackgroundDelivery() async throws {
try await healthStore.disableBackgroundDelivery(for: activeEnergyType)
}
Performance Optimization
Caching Strategies
Pattern from HealthTrends: Cache slow queries (average data), refresh fast queries (today)
let todayData = try await healthKit.fetchTodayHourlyTotals()
if cacheManager.shouldRefresh() {
let (total, hourlyData) = try await healthKit.fetchAverageData()
let cache = AverageDataCache(
averageHourlyPattern: hourlyData,
projectedTotal: total,
cachedAt: Date(),
cacheVersion: 1
)
try? cacheManager.save(cache)
} else {
let cache = cacheManager.load()
}
Why this works:
- Today's total changes every few minutes → must be fresh
- 30-day average changes minimally → safe to cache
- Widgets have strict time limits (~30 seconds)
Predicate Optimization
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate),
HKQuery.predicateForObjects(from: [HKSource.default()])
])
let samples = try await fetchAllSamples()
let filtered = samples.filter { $0.sourceRevision.source == HKSource.default() }
Batch Queries for Multiple Metrics
async let activeEnergy = fetchActiveEnergy()
async let heartRate = fetchHeartRate()
async let steps = fetchSteps()
let (energy, hr, stepCount) = try await (activeEnergy, heartRate, steps)
let energy = try await fetchActiveEnergy()
let hr = try await fetchHeartRate()
let steps = try await fetchSteps()
Debugging Common Issues
"My data is off by one hour"
Cause: Confusing hour START vs hour END timestamps
HourlyEnergyData(hour: sevenAM, calories: 250)
HourlyEnergyData(hour: eightAM, calories: 250)
let timestamp = calendar.date(byAdding: .hour, value: 1, to: data.hour)!
cumulativeData.append(HourlyEnergyData(hour: timestamp, calories: runningTotal))
"Widget shows stale data after midnight"
Cause: Cached data from previous day
let calendar = Calendar.current
let cachedEntry = loadCachedEntry(forDate: date)
if let lastDataPoint = cachedEntry.todayHourlyData.last,
!calendar.isDate(lastDataPoint.hour, inSameDayAs: date) {
return EnergyWidgetEntry(
date: date,
todayTotal: 0,
todayHourlyData: [HourlyEnergyData(hour: calendar.startOfDay(for: date), calories: 0)],
)
}
"Query returns no data but I know there are samples"
Debug steps:
- Check authorization:
let status = healthStore.authorizationStatus(for: quantityType)
print("Auth status: \(status)")
- Verify date range:
print("Query range: \(startDate) to \(endDate)")
print("Sample dates: \(samples.map { $0.startDate })")
- Check predicate options:
let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate)
- Inspect sample sources:
for sample in samples {
print("\(sample.startDate): \(sample.quantity) from \(sample.sourceRevision.source.name)")
}
"Average calculation seems wrong at late hours (11 PM - midnight)"
Cause: Incomplete days, zero-value filtering
for (_, cumulativeByHour) in dailyCumulative {
if let cumulativeAtHour = cumulativeByHour[hour], cumulativeAtHour > 0 {
totalForHour += cumulativeAtHour
count += 1
}
}
averageCumulativeByHour[hour] = count > 0 ? totalForHour / Double(count) : 0
See: HealthKitManager.swift:420-427
"Widget fails to load with database inaccessible error"
Cause: Device locked, HealthKit protected
do {
let samples = try await healthStore.execute(query)
} catch let error as HKError where error.code == .errorDatabaseInaccessible {
return loadCachedEntry()
}
Activity Summary Queries
For fetching Move goals:
func fetchMoveGoal() async throws -> Double {
let calendar = Calendar.current
let now = Date()
var dateComponents = calendar.dateComponents([.year, .month, .day], from: now)
dateComponents.calendar = calendar
let predicate = HKQuery.predicateForActivitySummary(with: dateComponents)
let activitySummary = try await withCheckedThrowingContinuation { continuation in
let query = HKActivitySummaryQuery(predicate: predicate) { _, summaries, error in
if let error = error {
continuation.resume(throwing: error)
return
}
continuation.resume(returning: summaries?.first)
}
healthStore.execute(query)
}
if let summary = activitySummary {
let goal = summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie())
return goal > 0 ? goal : cachedGoal
}
return cachedGoal
}
Widget-Specific Patterns
Timeline Generation with Midnight Handling
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
Task {
let currentEntry = await loadFreshEntry()
var entries = [currentEntry]
let calendar = Calendar.current
let next15Min = calendar.date(byAdding: .minute, value: 15, to: Date())!
let midnight = calendar.nextDate(after: Date(), matching: DateComponents(hour: 0), matchingPolicy: .nextTime)!
if midnight < next15Min {
let midnightEntry = createMidnightEntry()
entries.append(midnightEntry)
let reloadTime = calendar.date(byAdding: .minute, value: 1, to: midnight)!
completion(Timeline(entries: entries, policy: .after(reloadTime)))
} else {
completion(Timeline(entries: entries, policy: .after(next15Min)))
}
}
}
Fallback Strategy for Failed Queries
do {
todayData = try await healthKit.fetchTodayHourlyTotals()
} catch {
let cachedEntry = loadCachedEntry()
if isFromPreviousDay(cachedEntry) {
return EnergyWidgetEntry()
}
return cachedEntry
}
Testing Strategies
Preview Data Generation
static func generateSampleTodayData() -> [HourlyEnergyData] {
let calendar = Calendar.current
let now = Date()
let startOfDay = calendar.startOfDay(for: now)
let currentHour = calendar.component(.hour, from: now)
var data: [HourlyEnergyData] = []
var cumulative: Double = 0
data.append(HourlyEnergyData(hour: startOfDay, calories: 0))
for hour in 0..<currentHour {
let calories = generateRealisticCalories(for: hour)
cumulative += calories
let timestamp = calendar.date(byAdding: .hour, value: hour + 1, to: startOfDay)!
data.append(HourlyEnergyData(hour: timestamp, calories: cumulative))
}
return data
}
private static func generateRealisticCalories(for hour: Int) -> Double {
switch hour {
case 0..<6: return Double.random(in: 5...15)
case 7: return Double.random(in: 150...250)
case 9..<12: return Double.random(in: 25...50)
case 12..<14: return Double.random(in: 30...60)
default: return Double.random(in: 20...40)
}
}
Testing with Sample Data
func generateSampleData() async throws {
let calendar = Calendar.current
let now = Date()
var samples: [HKQuantitySample] = []
for dayOffset in 0..<60 {
guard let dayStart = calendar.date(byAdding: .day, value: -dayOffset, to: calendar.startOfDay(for: now)) else {
continue
}
let maxHour = dayOffset == 0 ? calendar.component(.hour, from: now) : 23
for hour in 0...maxHour {
guard let hourStart = calendar.date(byAdding: .hour, value: hour, to: dayStart),
hourStart <= now else {
continue
}
let calories = generateRealisticCalories(for: hour)
let quantity = HKQuantity(unit: .kilocalorie(), doubleValue: calories)
let sample = HKQuantitySample(
type: activeEnergyType,
quantity: quantity,
start: hourStart,
end: calendar.date(byAdding: .hour, value: 1, to: hourStart)!
)
samples.append(sample)
}
}
try await healthStore.save(samples)
}
Best Practices Summary
- Always use
Calendar.current for date arithmetic (handles DST/timezones)
- Use
.strictStartDate for daily queries (samples that START in range)
- Cache slow queries (30-day averages), refresh fast queries (today)
- Handle midnight boundaries explicitly in widgets (create zero-state entries)
- Filter zero values when averaging to avoid skewing results
- Check cached data dates before using (might be from previous day)
- Use
async let for parallel queries (activeEnergy, heartRate, steps)
- Call completion handlers in observer queries, even on errors
- Validate date ranges when debugging (print start/end, sample dates)
- Use HKSampleQuery in widgets (not long-running statistics queries)
References
See Also
- swiftui-advanced skill: For SwiftUI performance optimization, state management patterns, scene phase handling
- swift-charts-advanced skill: For visualizing HealthKit data with Swift Charts, including data preparation and chart composition