Turn the raw HealthKit database from an iPhone backup into a clean, queryable SQLite file you can browse with Datasette — no slow on-device "Export All Health Data" step required.
This is the missing companion to Simon Willison's
healthkit-to-sqlite: that tool ingests the
Health app's export.zip (the "Export All Health Data" file). This one reads the raw
healthdb_secure.sqlite directly — the store Apple keeps on the device and syncs via iCloud —
which you get from an iPhone backup. The raw store is richer and avoids the slow, sometimes-failing
on-device XML export entirely.
healthkit-to-sqlite |
this tool | |
|---|---|---|
| Input | Health app export.zip |
raw healthdb_secure.sqlite from a backup |
| Get the input | on-device export (slow, can fail/time out) | extract once from an encrypted backup |
| Fidelity | re-serialized XML | the original store Apple actually keeps |
| Output | SQLite for Datasette | SQLite for Datasette |
iPhone backup ──> healthdb_secure.sqlite ──[this tool]──> health.db ──> datasette
(source of truth, read-only) (derived, disposable lens)
Why bother
The raw store is faithful but unfriendly: integer type enums with no string table, Apple-absolute
timestamps, per-type canonical units, and values split across quantity / original_quantity
columns. This tool resolves all of that into one readable database:
- Readable type names — the
data_typeinteger enum is mapped to names likeHeartRate,StepCount,SleepAnalysis(mapping cross-checked against christophhagen/HealthDB and verified against each DB's own row counts). - ISO-8601 timestamps — Apple-absolute seconds (since 2001-01-01) converted to UTC; daily rollups bucket on a timezone offset you pass in.
- Normalized units — pulse rates normalized to bpm (HealthKit stores some types as count/s and others as count/min), distances in metres, energy in kcal.
- Daily rollups —
daily_steps,daily_distance_km,daily_active_energy,daily_heart_rate,daily_sleep_hours. - Provenance — each sample keeps its source device and recorded timezone.
The raw DB is opened read-only and immutable — it is never modified. health.db is fully
regenerable: delete it and re-run any time.
Getting the raw database
- Make an encrypted local backup of the iPhone (Finder/iTunes, or
idevicebackup2). Encryption is required — health data is only included in encrypted backups. - Extract
HealthDomain/Health/healthdb_secure.sqlitefrom the backup (e.g. with iMazing, or a backup-decryption library such asiphone_backup_decrypt).
Usage
Requires uv (the script declares its own deps — none beyond
the stdlib — via PEP 723):
./healthkit_from_backup_to_sqlite.py healthdb_secure.sqlite health.db 7
# ^raw (read-only) ^out ^UTC offset for daily rollupsAll three args are optional (defaults: ./healthdb_secure.sqlite, ./health.db, +0/UTC).
Then explore:
What you get
The importer prints a summary as it builds (≈4 s for ~10 years of data). Illustrative run:
built health.db (220 MB)
row counts:
quantity_samples 1,300,000
category_samples 28,000
workouts 420
date range: ('2016-09-13 21:21:48', '2026-06-12 03:59:14')
top quantity types:
ActiveEnergyBurned 460,000
HeartRate 220,000
BasalEnergyBurned 170,000
StepCount 148,000
DistanceWalkingRunning 138,000
…and then the readable, query-ready database. For example, a year-over-year resting heart rate trend is one line of SQL:
SELECT strftime('%Y', start_utc) AS yr, round(avg(value)) AS resting_bpm FROM quantity_samples WHERE type = 'RestingHeartRate' GROUP BY yr;
yr resting_bpm
2019 68
2020 71
... ...
No enum decoding, no Apple-epoch math, no unit juggling — that's all done.
Schema
Tables
| Table | What |
|---|---|
quantity_samples |
numeric samples — type, start_utc, end_utc, value, unit, canonical_value, source, tz |
category_samples |
enumerated samples (sleep, stand hours, mindful, symptoms) with decoded value_label |
workouts |
one row per workout — activity, start_utc, end_utc, duration_min, distance_km, source, tz |
data_types, workout_types, category_value_labels |
enum lookups |
Views — samples (unified quantity+category), daily_steps, daily_distance_km,
daily_active_energy, daily_heart_rate, daily_sleep_hours.
Example queries
SELECT * FROM daily_heart_rate ORDER BY day DESC LIMIT 30; SELECT activity, count(*), round(avg(distance_km),2) FROM workouts GROUP BY 1 ORDER BY 2 DESC; SELECT strftime('%Y',start_utc) yr, round(avg(value)) avg_bpm FROM quantity_samples WHERE type='HeartRate' GROUP BY 1;
Notes / gotchas
- Energy is stored labelled
calin the raw DB but the values are kcal (an Apple labelling quirk); the rollups call it kcal. Unknown_<n>types are enum values not in the map (Apple adds types with new iOS releases) — they still import, just without a friendly name. PRs welcome to extend the map.- HealthKit has no single fixed timezone. Each sample keeps its own recorded
tz; the daily-rollup views need one offset to bucket by, which is the third CLI argument. - Apple Watch metrics (heart rate, resting HR, energy, sleep) only exist for periods the watch was worn — gaps are real, not a bug.
Related
- dogsheep/healthkit-to-sqlite — the export.xml-based sibling
- christophhagen/HealthDB — Swift reader for the same raw store; source of the type-enum mapping
- Datasette — browse the resulting
health.db
License
MIT






















