Normalizing wearable data across providers
Updated July 27, 2026
A user replaces their iPhone with a Pixel. Your HRV chart steps onto a different level on the day they switched and never comes back. Nothing errored. Nothing was dropped, nothing was duplicated, no schema check failed. You took two numbers that both arrived in a field called hrv and drew them on one line — and Apple's HealthKit stores HRV as SDNN while Android's Health Connect stores RMSSD. Same word, different measurement, no conversion factor exists.
Normalization gets described as a mapping problem: rename the fields, fix the units, ship it. It is four problems stacked on top of each other, and only the top two are mappings. The bottom one has no mechanical solution at all — the only correct response is to stop merging and start recording what you actually have.
The four layers of divergence
| Layer | What diverges | How hard | What it costs when you get it wrong |
|---|---|---|---|
| 1. Units | kcal vs kJ, metres vs feet, seconds vs milliseconds | Trivial — a lookup table | A loud, obvious, quickly-reported bug |
| 2. Field names | steps vs step_count vs StepsRecord | Tedious but mechanical — one adapter per provider | A missing field. Also loud |
| 3. Semantics | The same name meaning a different thing: where a sleep session starts, whether a workout includes rest, what a "day" is | No mechanical solution; you must decide and write it down | Numbers that are individually plausible and collectively incoherent |
| 4. Measurement definition | The same name being a different measurement — SDNN vs RMSSD | No solution. They do not convert | Silently invalid analysis. Nothing ever looks broken |
Layer 1 is the only one most teams plan for, and it is the least of the problems.
Layer 1 — units are trivial, but a silent unit change is not
A conversion table is an afternoon's work. What is not trivial is a provider quietly changing units on you, because a kcal-to-kJ swap keeps the field name, keeps the type, keeps the nullability, and passes every schema validation you have. It shows up as a roughly 4.184x shift in the daily median for every user of one provider, inside one day. Our judgement: track per-provider, per-metric distribution statistics daily and alert on step changes in the median, because that is the only layer at which this class of change is visible at all.
Layer 2 — field names are mechanical, but source names are not
Mapping StepsRecord to your steps is grunt work and nothing more. The trap sits one column over, in the field you use to identify who wrote the record.
Apple exposes the writing app as HKSource, with bundleIdentifier as the stable machine key, and Apple documents that HKSourceRevision is assigned by HealthKit at save time rather than by the writer — so the app version on a sample is trustworthy in a way the writer-supplied HKDevice is not. Health Connect exposes metadata.dataOrigin.packageName, which Google documents as automatically added on creation, and a device that is manually supplied.
So far, so mappable — until you try to enumerate the values. Google documents that from the June 2026 update, steps tracked natively by Health Connect carry a per-device, per-app Synthetic Package Name instead of the package name android that historical data retains, which makes it precisely the kind of value a server-side source-mapping table cannot hold: not enumerable in advance, different per app and per device. So the normalization constraint is that source identity gets resolved on the device and shipped up as a label, and the consequences for ranking one source above another are worked out in identifying which app wrote a sample. The type-name half of this mapping is catalogued in our HealthKit and Health Connect type reference; the source-name half is not a table anyone can publish.
Layer 3 — the same field name meaning different things
Here is a documented case with three answers. What is a "day"?
- Apple. A daily total comes from
HKStatisticsCollectionQuery, which Apple documents as partitioning samples using ananchorDateplusintervalComponents, with each interval the same length and no gaps between adjacent intervals, tiling outward from an absolute anchor instant. That is a fixed-length window, not a calendar day. - Health Connect, option A.
aggregateGroupByDurationslices by aDuration— fixed elapsed time. A 24-hourDurationis 24 hours of wall clock. - Health Connect, option B.
aggregateGroupByPeriodslices by aPeriod— a calendar unit. Google documents that this one requires aLocalDateTime-basedTimeRangeFilter.between(...), and that passingInstantvalues instead returns anIllegalStateException.
Three definitions of "daily steps" before a byte reaches your server, and they disagree for any user who travels or lives through a DST transition. Pick one, store which one produced each row, and never mix them in a table. The full treatment of that decision is in timezones and day boundaries.
A second documented case: what is a "total"?
- Apple documents that statistics queries automatically merge data from all of your data sources before performing the calculations, and an Apple Frameworks Engineer has stated on the developer forums that with a plain
HKSampleQueryyou must select between overlapping samples yourself and are unlikely to match HealthKit's merge algorithm correctly. Apple does not publish that algorithm. - Google documents that only the Activity and Sleep data types are deduped by Health Connect, that the dedupe is a property of the Aggregate API rather than of the stored records, and that for other types the aggregated result combines all data from all apps that wrote it. The priority list driving the dedupe is one only end users can alter.
So for one user, one day, one device you can produce a raw-sample sum, a platform-merged figure, and — on Android — a platform-merged figure that changes when the user reorders apps in Health Connect settings, with no change event to tell you it happened. Those are three different measurements sharing one name. Put the read path on the row. This is the seam where normalization and deduplication stop being separate problems, and it is why the source-name question above is not a side issue.
The third class in this layer is the one nobody can answer for you. Where does a sleep session start — lights out, first detected sleep stage, or the device's own onset heuristic? Does a workout's duration include the rest between sets, or only the working intervals? Is "distance" GPS-derived or stride-derived? Our research found no documented cross-provider definitions for any of these, and most providers do not publish theirs. The engineering move is not to go find the true answer. It is to stop pretending one shared answer exists: give every provider-and-metric pair an explicit measurement name, write down in prose what you believe it means, date that belief, and make changing your mind cheap.
Layer 4 — the same name being a different measurement
This is the one that ends analyses.
HealthKit's HRV type is HKQuantityTypeIdentifier.heartRateVariabilitySDNN and it stores SDNN. Health Connect's is HeartRateVariabilityRmssdRecord and it stores RMSSD. Both are in milliseconds. Both are computed from the same underlying interbeat intervals. Both are called "HRV" by their platform, by every aggregator, and by your product manager. They are two different statistics and they are not interconvertible — there is no constant k for which RMSSD equals k times SDNN. We cover the source-by-source picture in HRV APIs.
The same metric has a second definition axis: the measurement window. Most consumer platforms compute HRV over a nocturnal window; some apps use a guided morning spot reading instead. Two RMSSD values taken from different windows are also not the same measurement, even though the statistic matches.
The shape generalises. HealthKit distinguishes activeEnergyBurned from basalEnergyBurned; a cloud provider's field called "calories" may be either, or their sum, and you should verify rather than assume. The HRV case is the one we can state as fact; treat the others as the same class of hazard requiring per-provider verification against a live data dictionary.
What makes layer 4 dangerous is its failure signature: no error, no null, no type mismatch, no anomaly in any individual record. It is only visible in aggregate, and only if someone happens to look at a cohort whose provider mix changed. A single normalized hrv column does not surface this problem. It is this problem.
A canonical record that does not lie
A schema earns the word "canonical" only if it can represent what you do not know. Three consequences:
- The load-bearing column is the measurement, not the metric. A metric family (
hrv) is a UI grouping. A measurement (hrv_sdnn) is the unit of comparability. They are not the same column and one cannot substitute for the other. - Provenance is not metadata. Source app, app version, device, recording method and read path are part of what the number means, not decoration hanging off it.
- Every provenance field that can be absent needs an explicit unknown — not a default, not
false, not a backfilled guess presented as an observation.
An illustrative sketch, not production code:
CREATE TABLE health_sample (
user_id uuid NOT NULL,
provider text NOT NULL, -- 'apple_health' | 'health_connect' | 'oura' | ...
provider_record_id text, -- HealthKit uuid, Health Connect metadata.id
client_record_id text, -- your own key, when you authored the write
metric_family text NOT NULL, -- 'hrv' -- for grouping in the UI
measurement text NOT NULL, -- 'hrv_sdnn' -- for any arithmetic at all
value numeric NOT NULL,
unit text NOT NULL, -- canonical unit for THIS measurement
start_instant timestamptz NOT NULL,
end_instant timestamptz,
zone_offset_sec integer,
zone_offset_src text NOT NULL, -- 'provided' | 'platform_default' | 'unknown'
local_date date NOT NULL, -- civil date, computed at ingest
source_app text, -- bundle id / package name, resolved on device
source_app_version text,
device_manufacturer text,
device_model text,
recording_method text NOT NULL, -- 'automatic' | 'active' | 'manual' | 'unknown'
read_path text NOT NULL, -- 'raw_sample' | 'platform_aggregate'
adapter_version int NOT NULL,
raw_payload_id uuid NOT NULL, -- points at the untouched provider payload
source_modified_at timestamptz,
ingested_at timestamptz NOT NULL
);
Four of those columns are doing work that a conventional schema leaves out.
zone_offset_src. Google documents that all data written to Health Connect must specify zone offset so that readers can represent it in civil time, and that on Android 14 and higher, if the zone offset is not available, Health Connect sets it from the device's system default time zone. That value is present, non-null, and a guess. Apple's HKMetadataKeyTimeZone is optional and writer-supplied, and Apple only recommends it for sleep samples. A schema with a non-null offset and no provenance for that offset reports an inference as an observation, which is the precise thing a canonical schema must not do.
read_path. Because of the merge behaviour above, a platform aggregate and a raw sum are different measurements of the same day. Two rows that disagree are not necessarily a bug; two rows that disagree and cannot be told apart are.
recording_method. Its own section below.
adapter_version and raw_payload_id. Your assignment of a measurement value is a belief about what a provider meant, and beliefs get corrected. Keep the untouched payload and the adapter version that produced each row so a correction is a replay rather than a loss — and it would be a loss, because you usually cannot go back and re-read. Google documents that by default apps can read Health Connect data for up to 30 days prior to when any permission was first granted, that reading further back needs PERMISSION_READ_HEALTH_DATA_HISTORY, and that deleting and reinstalling your app resets that window to 30 days before the new grant. Apple documents a limited-authorization mode in which you cannot distinguish full access from denied access, only limited access. Your archive of raw payloads is frequently the only copy of that history you will ever be permitted to hold.
Alongside it, a small registry that makes the measurement column mean something:
CREATE TABLE measurement_def (
measurement text PRIMARY KEY, -- 'hrv_sdnn'
metric_family text NOT NULL, -- 'hrv'
canonical_unit text NOT NULL, -- 'ms'
window text NOT NULL, -- 'nocturnal' | 'spot' | 'unspecified'
definition_note text NOT NULL, -- prose: what you believe this measures
reviewed_on date NOT NULL -- when a human last checked that belief
);
And one predicate that the rest of the system obeys — two rows may share a chart line, a sum, or a baseline only when this tuple matches:
(measurement, unit, window, recording_class)
The rule that makes it stick is social, not technical: the read API never returns a metric_family without its measurement, and any GROUP BY metric_family that does not also group by measurement fails code review. Deliberately mixing definitions is a product decision someone can make; doing it by accident is what a single normalized column guarantees.
Recording method deserves its own branch
Two platform keys carry it — Health Connect's recordingMethod and HealthKit's HKMetadataKeyWasUserEntered — and neither is reliable enough to read as a boolean, which is why your column needs unknown as a real third value rather than folding to false. The states, their caveats and the branch they select between are worked out in dedupe for measurements, merge for intentions; what matters at the adapter boundary is only that the flag survives it.
Propagate it all the way into storage and branch on it, for four reasons:
- A manual entry is a claim, not a measurement. A typed bodyweight or a logged meal is real data about what the user believes and should be kept, but it did not come off a sensor and should not be averaged with things that did.
- Plausibility envelopes are differently shaped. In our judgement a 100,000-step day from a device is a pipeline bug worth paging someone about, while the same figure on a manual entry is usually an extra zero — and the right response there is to ask the user to confirm the entry, not to alert your on-call. Same value, same metric, different incident.
- Scoring and coaching should be able to exclude manual entries entirely, which is only possible if the flag survived the adapter boundary, and it cannot be reconstructed once it has been dropped.
- Actively recorded is a third thing, not a synonym for automatic. It is device-measured but user-initiated, which means the session boundary reflects a human intention. Collapsing it into "automatic" throws away the most reliable statement of intent in the record.
Manual entries also overlap device data differently. A user who hand-logs a run their watch already recorded has not sent you a duplicate delivery; they have given you a second honest account of one event, and the resolution is a priority decision rather than an idempotency key.
What we would actually build
- Two tables, always. An immutable archive of raw provider payloads, and a canonical table derived from it by a pure, versioned adapter function. Normalization must be non-destructive because it is the step most likely to be wrong.
measurementas the only key any arithmetic touches.metric_familyexists for menus and nothing else.- Provenance columns
NOT NULLwith explicitunknown. Nullable provenance decays into "we forgot" within a quarter. - A reviewed measurement registry with dates on it. Definitions drift. An undated definition is a claim nobody owns.
- No cross-definition merging anywhere below the presentation layer, and where the product genuinely needs a single HRV line, label it in the UI and record that choice in the read model rather than baking it into storage.
Where an aggregator helps, and where it hides what you needed
Layers 1 and 2 are exactly what a health data aggregator is for, and buying is often the right call. The unit tables and field maps are not the expensive part — the maintenance is, because every provider changes something eventually and none of that work differentiates your product. Terra, Rook, Sahha, Vital and others sell you that maintenance. Most teams should take the deal; see health data aggregator APIs and what is a health data aggregator.
What normalization can hide is layers 3 and 4, and for a structural reason: a normalized schema's job is to make providers look alike, which is the same operation as erasing the ways they differ. Three questions with right answers, worth asking before you commit:
- Does a record carry its source measurement definition, or just a field named
hrv? If one field can be fed by an SDNN source and an RMSSD source with no discriminator on the record, that field cannot support a trend line, no matter how clean the schema looks. - Does every record carry recording method and device? If manual entries arrive indistinguishable from sensor readings, you cannot build the branch in the previous section at all.
- Can you get the untouched provider payload as well as the normalized view? This is the question that decides whether a mapping mistake is a replay or a permanent hole in your data.
One more thing to hold in view: an aggregator's normalized daily total is somebody else's merge rule, unobservable to you and free to change between their releases. That is the same class of problem as a platform aggregate whose priority list only the user controls — not a reason to avoid aggregators, but a reason to store which system produced a number.
We would build the layer ourselves when the distinction is the product — a recovery or readiness score whose validity depends on a single HRV definition, or a research or regulated use that needs per-source provenance the vendor drops — or when there are only one or two providers, so the maintenance argument does not apply. Otherwise buy, and spend the reclaimed time on layers 3 and 4, which no vendor can decide for you because they are product decisions wearing engineering clothes.
The honest limits
This design does not recover provenance the provider never sent. HealthKit's HKDevice is optional and writer-supplied; Health Connect records written before recording method became mandatory may not carry one; pre-Android-14 records may carry no zone offset at all. The schema lets you record that you do not know, which is better than a confident wrong value and is not the same as knowing.
It also does not tell you which of two measurements is correct — only that they are different. And it costs you a wide, denormalized row and a second copy of every payload, which is a real bill for a benefit that only shows up the day a definition turns out to have been wrong.
Finally: our documented positions here are Apple's and Google's. For cloud providers, the measurement definitions behind a field name are usually undocumented and change without appearing in any changelog you subscribe to. Re-verify them against each vendor's live data dictionary, and put the date you checked in the registry.
If you take one thing: a column named hrv that can hold both SDNN and RMSSD is not a normalized schema. It is an unnormalized schema with the evidence removed.
Frequently asked questions
- Can I convert SDNN into RMSSD so HRV lines up across platforms?
- No. They are two different statistics computed from the same interbeat intervals, and there is no constant that converts one into the other. Apple HealthKit stores SDNN; Android Health Connect stores RMSSD. Keep them as separate measurements with separate names, chart them separately, and never let a baseline or trend span both.
- What provenance columns does a canonical health record actually need?
- Beyond the value and unit: the measurement definition (not just the metric family), the source app and its version, the device manufacturer and model, the recording method, the read path that produced the number, the zone offset plus where that offset came from, and a pointer to the untouched provider payload with the adapter version that parsed it. Every one of those that can be absent needs an explicit unknown value rather than a default.
- Should manually entered health data be stored in the same table as device samples?
- Same table is fine; same treatment is not. Health Connect models recording method as a required field with separate constants for automatically recorded, actively recorded and manual entry, and HealthKit has a user-entered flag that the writing app sets. Carry that flag into storage and branch on it: manual entries need a differently shaped plausibility envelope, should be excludable from scoring and baselines, and an implausible manual value should prompt the user to confirm rather than page your on-call.
- Why is a source package name or bundle identifier not a stable way to identify a wearable source?
- Google documents that from the June 2026 update, steps tracked natively by Health Connect are attributed to a Synthetic Package Name that is device-specific and scoped per application, must not be hardcoded, and is resolved at runtime. Step data recorded before that keeps the package name android. So one physical phone appears under two different source identifiers across the cutover, and the values cannot be enumerated in a server-side mapping table.
- Does buying an aggregator remove the normalization problem?
- It removes the tedious half and often that is the right trade, because maintaining unit tables and field maps across many providers is real work that does not differentiate your product. It does not remove the semantic and measurement-definition layers, and it can hide them: check whether each record carries its source measurement definition, its recording method and device, and whether you can also retrieve the untouched provider payload. If a single field can be fed by both an SDNN and an RMSSD source with no discriminator, that field cannot support a trend line.
Keep reading
Independent comparison, last reviewed July 27, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.
← All architecture · by AIFitnessAPI