Sitemap

HKQuantitySample: 7 Gotchas Apple’s Documentation Doesn’t Spell Out

6 min readJul 21, 2026

--

Writing one water sample to HealthKit is three lines. Keeping it correct, editable, and de-duplicated across two stores is where the framework stops helping you.

Press enter or click to view image in full size
The life of one HKQuantitySample in AquaTrack: created with an explicit unit, stamped with your own UUID, saved — then found only by metadata and deleted, never edited.

HKQuantitySample looks like the easiest type in HealthKit. You make one, you call save, you're done. That impression lasts exactly until you need to change a value, delete the right sample, or stop your own writes from coming back as duplicates.

I hit all seven of these building AquaTrack, a hydration tracker that writes every logged drink to Apple Health as a dietaryWater sample and reads body mass and workouts back. None of them are in the headline docs. Here they are, with the code I actually shipped.

1. Samples are immutable. “Editing” means delete-and-reinsert

There is no sample.value = newValue. Once an HKQuantitySample is saved, it is frozen. If a user corrects a logged drink from 300 ml to 250 ml, you don't update the sample — you delete the old one and write a new one.

Which raises the obvious question: delete which sample? You no longer have the object you saved, and you can’t trust an in-memory reference across launches. The answer is to find it by a key you control:

let predicate = HKQuery.predicateForObjects(
withMetadataKey: Metadata.externalUUID,
operatorType: .equalTo,
value: externalUUID.uuidString
)
for type in [HKQuantityType(.dietaryWater), HKQuantityType(.dietaryCaffeine)] {
let samples = try await fetchSamples(type: type, predicate: predicate, limit: 10)
for sample in samples {
try await healthStore.delete(sample)
}
}

Immutability isn’t a limitation to fight — it’s the contract. Plan your write path around being able to find samples again, because you will need to.

2. Every quantity needs an explicit HKUnit — and the wrong one is a runtime trap

HKQuantity has no default unit. You state the unit at construction, and you state it again when you read the value back. Water goes in as milliliters:

let quantity = HKQuantity(unit: .literUnit(with: .milli), doubleValue: hydrationCreditMl)

Caffeine is stored as grams with a milli prefix (so the number is milligrams). Body mass comes back in kilograms:

let kg = sample.quantity.doubleValue(for: .gramUnit(with: .kilo))

The trap: passing a unit that isn’t compatible with the quantity type doesn’t fail to compile. It traps at runtime. Ask a dietaryWater sample for its value in kilograms and the app crashes on the spot. The compiler can't help you here, so the units have to be deliberate and tested, not guessed.

3. Read authorization is deliberately opaque

This is the one that surprises everyone. You can ask HealthKit whether you’re allowed to write a type. You cannot ask whether you’re allowed to read one. By design, authorizationStatus(for:) never reveals a granted read — Apple hides it so an app can't infer that a user has, say, no cardiac data by noticing a read was denied.

Get Pavel Turchenko’s stories in your inbox

Join Medium for free to get updates from this writer.

So this guard is doing less than it looks like it’s doing:

guard healthStore.authorizationStatus(for: HKQuantityType(.bodyMass)) != .notDetermined else {
return nil
}

All it tells you is whether you’ve asked yet. Whether the read will actually return data is something you only discover by running the query and seeing what comes back — an empty result might mean “denied” or might mean “no data.” Design your read paths to treat “nothing” as a normal, non-exceptional outcome.

4. Authorization is async, per-type, and split into two sets

There’s no single “use HealthKit” permission. You request a set of types to share (write) and a separate set to read, and the call is async:

let typesToShare: Set<HKSampleType> = [
HKQuantityType(.dietaryWater),
HKQuantityType(.dietaryCaffeine),
]
let typesToRead: Set<HKObjectType> = [
HKQuantityType(.dietaryWater),
HKQuantityType(.bodyMass),
HKQuantityType(.appleExerciseTime),
HKWorkoutType.workoutType(),
]
try await healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead)

Note dietaryWater is in both sets — you write it and read it back, and each direction is authorized independently. (Also note the modern HKQuantityType(.dietaryWater) initializer; the old HKObjectType.quantityType(forIdentifier:)! force-unwrap dance is no longer necessary on current iOS.)

5. You can’t keep a handle on a sample — so stamp it with your own ID

Gotchas 1 and 3 converge on one habit: attach your domain identifier to every sample you write, in metadata, under HKMetadataKeyExternalUUID:

metadata: [
HKMetadataKeyExternalUUID: externalUUID.uuidString,
HKMetadataKeyWasUserEntered: true,
"com.posterumsoft.aquatrack.rawVolumeMl": rawVolumeMl,
"com.posterumsoft.aquatrack.beverageName": beverageName,
]

That external UUID is the join key between your database and HealthKit. It’s how delete finds the sample (gotcha 1), and it’s the first thing you check when samples come back from a query so you don’t re-import your own writes. Custom keys, by the way, must be reverse-DNS namespaced — com.yourcompany.app.key — or HealthKit rejects them.

6. HealthKit stores one number per sample — so model the mapping on purpose

A dietaryWater sample is a single scalar. But "300 ml of milk" isn't 300 ml of hydration — under the Beverage Hydration Index, milk hydrates better than water, coffee slightly less. AquaTrack resolves this by deciding, explicitly, which number is canonical in HealthKit: the hydration-equivalent volume goes into the dietaryWater quantity, and the raw poured volume is preserved in custom metadata.

HKQuantitySample(
type: HKQuantityType(.dietaryWater),
quantity: HKQuantity(unit: .literUnit(with: .milli), doubleValue: hydrationCreditMl),
start: loggedAt, end: loggedAt,
metadata: [ /* rawVolumeMl preserved here */ ]
)

The framework won’t make this decision for you. If your domain value and HealthKit’s single number aren’t the same thing, pick which one HealthKit gets and keep the other in metadata — don’t let the two quietly diverge.

7. You can only delete what you wrote — so track provenance

healthStore.delete(_:) only succeeds on samples your app authored. You cannot delete another app's or the Watch's samples, which is correct but easy to forget when a "clear everything" feature silently no-ops on foreign data.

The same HKSource machinery solves the duplicate problem. When samples arrive from a query, the bundle identifier tells you whether you wrote them:

if sample.sourceRevision.source.bundleIdentifier == ourBundleId {
// our own write echoing back — skip it
}

Provenance is the difference between a clean two-way sync and an app that slowly fills Apple Health with copies of its own data.

What this means in practice

One. Write samples you can find again. Immutability plus opaque references means your future self needs a key. The HKMetadataKeyExternalUUID stamp is cheap insurance that pays for itself the first time a user edits or deletes a log.

Two. Make units and provenance explicit, every time. The compiler checks neither. A wrong unit is a crash; a missing provenance check is a duplicate. Both are avoidable with a few deliberate lines.

Three. Treat “no data” as normal. Because read grants are invisible, a robust HealthKit integration never assumes a read succeeded — it degrades gracefully when a query comes back empty.

What this doesn’t mean

This isn’t a knock on HealthKit. Most of these “gotchas” are deliberate, defensible API design — immutability protects data integrity, read opacity protects privacy. They’re surprising, not wrong.

Units aren’t always a crash. Compatible-but-different units (milliliters vs. liters) convert cleanly; only genuinely incompatible dimensions trap. The discipline is the same either way.

Provenance checks aren’t a full dedup strategy. Bundle id catches your own echoes, but metadata can be stripped on round-trips, so production sync needs more than one tier. That’s a separate article.

What I built

AquaTrack is the hydration tracker these patterns came out of. Every drink is logged to SwiftData first, then mirrored to Apple Health as a dietaryWater sample — stamped, unit-checked, and de-duplicated — while body mass and workouts are read back to adjust the daily goal. The HealthKit layer is invisible when it works, which is the whole point.

Press enter or click to view image in full size
AquaTrack — Today screen. Each logged drink becomes one immutable dietaryWater sample, stamped with an external UUID so it can be found and corrected later.
AquaTrack — methodology. The hydration-credit value written to HealthKit comes from the peer-reviewed Beverage Hydration Index, with the raw poured volume kept in metadata.

AquaTrack is live on the App Store. → aquatrack.club

References — Apple developer documentation: HKQuantitySample, HKQuantity, HKUnit, HKHealthStore.

--

--

Pavel Turchenko
Pavel Turchenko

Written by Pavel Turchenko

Founder @PosterumSoft. Building AquaTrack — science-first hydration on iOS. Built on the Beverage Hydration Index. Live on App Store. Dnipro 🇺🇦