My Watch Died Mid-Hike: Merging Two FIT Files with Python
Four and a half hours into a long hike my watch ran out of battery, so the descent got recorded with the Strava app on my phone. Strava cannot merge two activities into one, and the web tools that can want my GPS data uploaded to somebody else’s server. FIT is a documented binary format with a usable Python parser, so I stitched the two recordings together myself.
Two files, two very different recorders
Both devices export FIT. The parser is fit-tool:
$ python -m venv .venv && source .venv/bin/activate
$ pip install fit-tool
Before writing any merge logic I wrote a small inspect_fit.py that walks
every message in a file, counts message types, and reports which record
fields actually carry data. Know your inputs first; the two files turned out
to differ in almost every way that matters.
The morning file, from the watch:
$ python inspect_fit.py Morning_Hike.fit
=== Morning_Hike.fit ===
file_id: {'type': 4, 'manufacturer': 123, 'product': 209, 'product_name': 'Polar Vantage M', ...snip...}
records: 14189, sessions: 1, laps: 12
record time range: 2026-06-13T08:39:39+00:00 -> 2026-06-13T13:03:38+00:00 (15839 s)
record fields populated (non-null count / total records):
timestamp: 14189/14189
position_lat: 14171/14189
position_long: 14171/14189
altitude: 14189/14189
heart_rate: 14189/14189
cadence: 14189/14189
speed: 14189/14189
distance: 14189/14189
temperature: 14189/14189
...snip...
session[0]:
sport: 17
total_timer_time: 14189.0
total_distance: 11521.0
total_calories: 1835
avg_heart_rate: 110
max_heart_rate: 155
total_ascent: 829
...snip...
One record per second of timer time, with the full sensor stream: heart rate, cadence, temperature, barometric altitude. 11.5 km and 829 m of ascent before the battery gave up. Sport 17 is hiking in the FIT profile.
The afternoon file, from the Strava iPhone app:
$ python inspect_fit.py Afternoon_Hike.fit
=== Afternoon_Hike.fit ===
file_id: {'type': 4, 'manufacturer': 265, 'product': 101, 'product_name': None, ...snip...}
records: 7214, sessions: 1, laps: 2
record time range: 2026-06-13T13:19:39+00:00 -> 2026-06-13T14:21:47+00:00 (3728 s)
record fields populated (non-null count / total records):
timestamp: 7214/7214
position_lat: 3606/7214
position_long: 3606/7214
enhanced_altitude: 3606/7214
speed: 3606/7214
distance: 3607/7214
gps_accuracy: 3606/7214
Manufacturer 265 is Strava. The app writes roughly two record messages per
second but populates GPS fields on only every other one, uses
enhanced_altitude where the watch used altitude, and carries no heart
rate, cadence or calories at all. It also ships developer fields
(FieldDescriptionMessage, DeveloperDataIdMessage) that the watch file
does not have. Between the last watch record and the first phone record sits
a 16 minute gap: the time it took to notice the dead watch and get the phone
recording.
fit-tool also logs a few complaints while parsing the watch file:
Field id: 193 is not defined for message session:18. Skipping this field
Record 14231, ...: size (36) != defined size (37). Some fields were not read correctly.
Vendors are allowed to put fields into their files that are not part of the published FIT profile; the library skips them and everything I need survives.
FIT in five minutes
A FIT activity file is a header, a stream of typed messages, and a CRC. The message types that matter here:
file_idanddevice_info: what recorded this and whenrecord: one row of samples (timestamp, position, distance, HR, …)event: timer start/stop markers, so consumers know recording statelap: summary of one lap (auto-lap every km on the watch)session: summary of the whole activity; this is what the Strava activity page displays as totalsactivity: the closing message, wraps one or more sessions
Two properties drive the whole merge design. Record distance is
cumulative from the start of the activity, so the phone segment restarts at
zero and must be shifted. And uploaders trust the session message for
totals, so a merged file needs a session that summarizes both parts.
Merge rules
The tool ended up around 360 lines. The decisions:
file_id,device_infoand the closingactivitycome from the watch file; the merged activity claims to be one Polar recording.- Watch records and laps are copied verbatim.
- Phone records get
distanceshifted by the watch’s final distance, so the cumulative counter keeps climbing instead of resetting. - The gap is bracketed with timer events, stop at the last watch record and start at the first phone record, so Strava treats it as a pause rather than missing data.
- One synthesized
sessionmessage with recomputed totals replaces the two originals.
The body assembly reads exactly like that list:
builder = FitFileBuilder(auto_define=True, min_string_size=50)
builder.add(make_event(morning_start_ts, Event.TIMER, EventType.START))
for r in morning_records:
builder.add(r)
for l in morning_laps:
builder.add(l)
builder.add(make_event(morning_end_ts, Event.TIMER, EventType.STOP_ALL))
builder.add(make_event(afternoon_start_ts, Event.TIMER, EventType.START))
for r in afternoon_records:
builder.add(r)
...snip...
One thing to know about laps: lap total_distance is the length of that lap alone, not
a cumulative counter, so laps are copied without any shifting. Only the
per-record distance field gets the offset.
Invalid does not mean absent
The first merged file had a distance graph that jumped to just under 43000 km. The cause is the most useful thing I learned about FIT internals.
FIT encodes missing values in-band: an unset uint32 field is stored as
0xFFFFFFFF, uint16 as 0xFFFF, and so on. On top of that, numeric fields
have a scale and offset, so distance is stored in centimeters and divided by
100 on read. fit-tool applies the scale to the sentinel too and hands you
4294967295 / 100 = 42949672.95 as if it were a real distance in meters.
Add a distance offset to that and write it back out, and your hike detours
through the upper atmosphere.
So every copied field goes through a sentinel filter, with the sentinel computed per field from its storage type, scale and offset:
INVALID_SENTINELS = {
"distance": 4294967295 / 100,
"altitude": 65535 / 5 - 500,
"enhanced_altitude": 4294967295 / 5 - 500,
"heart_rate": 255,
"temperature": 127,
# position fields are sint32 semicircles, converted via *180/2^31
"position_lat": 2147483647 * 180 / 2 ** 31,
...snip...
}
EPS = 1e-3
def _real(field, value):
if value is None:
return None
sentinel = INVALID_SENTINELS.get(field)
if sentinel is not None and abs(value - sentinel) < EPS:
return None
return value
A value that survives _real() is copied to the fresh record; a sentinel is
simply left unset, which writes the correct invalid marker back out. This is
what keeps the half-empty phone records (GPS on every other message) from
polluting the output with fake zeros or fake maximums.
Recomputing the session
The synthesized session gets its totals from three places. Elapsed time is wall clock from first to last record; timer time is the sum of the two source timers; distance is the last shifted record. Average and maximum heart rate come from re-scanning all records, which naturally covers only the watch segment since the phone has no HR. Ascent, descent and calories are summed from the two source sessions, treating a missing value as zero but leaving the field unset when neither source has it.
The result
$ python merge_fit.py Morning_Hike.fit Afternoon_Hike.fit -o merged-hike.fit
wrote merged-hike.fit
records: 21403 (morning 14189 + afternoon 7214)
laps: 14
total_distance: 15176.4 m
total_timer_time: 17917 s
total_elapsed_time: 20528 s
distance offset applied to afternoon: +11520.0 m
The numbers add up: 11.5 km from the watch plus 3.7 km from the phone, and the 2611 second difference between elapsed and timer time is the 27 minutes of breaks the watch had already recorded plus the 16 minute gap between the two files. Running the inspector over the output confirms one session, 14 laps, and a monotonic distance stream.
Strava accepted the upload on the first try and shows one hike: heart rate
and temperature for the first 11.5 km, a pause where the watch died, GPS all
the way through. One blemish remains: the phone data contains a GPS speed
spike, so max_speed in the merged session is nonsense. Strava derives its
pace charts from timestamps and positions rather than the session field, so
I left it.
Takeaways
- Inspect before you merge. The two files disagreed on sampling rate, altitude field, sensor coverage and developer fields, and every one of those differences shaped the tool.
- FIT invalid sentinels pass through scale and offset like real values. Filter them per field or they become real values in your output.
- Record
distanceis cumulative, laptotal_distanceis not. Shift one, copy the other. - Bracket recording gaps with timer stop/start events and uploaders will render them as pauses instead of interpolating across them.
Resources
- fit-tool on PyPI
- Garmin FIT protocol documentation
- FIT activity file structure
- FIT SDK overview
- GOTOES FIT file tools - the hosted alternative if you are less picky about where your GPS tracks go