Working with FIT Files
This is a self-contained reference for working with .fit activity files: the format almost
every GPS watch and bike computer uses to store a workout. It is written for two readers at once,
an AI agent that needs to read or modify a file on someone's behalf, and a developer building a
tool. It is vendor-neutral. The normative source is the
Garmin FIT SDK, and this page is the practical companion to
it: what the bytes mean and how to perform the operations people actually ask for.
If you are an agent: the raw Markdown is at https://fitfileeditor.com/skill.md. You can fetch and
ingest it directly.
Task index
Jump to the operation you need. Each recipe lists its steps and the mistakes that corrupt a file.
- Read a file then decode it and extract the data streams.
- Cut something out with crop, trim, or cut a range.
- Join recordings with merge files.
- Clean bad data by fixing a field over a range.
- Relabel by changing metadata: device, sport, start time, name.
- Rescue a broken file with repair.
- Save with encode a valid file, then validate it.
What a FIT file is
FIT stands for Flexible and Interoperable Data Transfer. It is a compact binary format Garmin created to pack a whole activity, your position, heart rate, power, cadence, laps, and a summary, into one small file. Samples are recorded once a second or so, which is why a FIT file holds far more than a plain GPX track. Nearly every device writes it (Garmin, Wahoo, COROS, Suunto, Polar, Hammerhead, Zwift, Apple Watch via export apps), and Strava, Garmin Connect, and TrainingPeaks all read it. That is how it became the common language for activity data.
A FIT file is a stream of messages. Each message is a row of data (one GPS sample, one lap, the file summary) tagged with a numeric type. The format is self-describing: before any data of a given shape appears, a definition message declares its fields and how they are laid out. This is what lets the format stay both compact and extensible.
File structure
A file is: a header, a sequence of records, then a 2-byte file CRC.
Header (12 or 14 bytes)
| Offset | Size | Field | Notes |
|---|---|---|---|
| 0 | 1 | Header size | 12 or 14. Tells you whether a header CRC follows. |
| 1 | 1 | Protocol version | Encoded major/minor. |
| 2 | 2 | Profile version | uint16, little-endian. |
| 4 | 4 | Data size | uint32, little-endian. Bytes of records only, excluding header and file CRC. |
| 8 | 4 | .FIT |
ASCII 2E 46 49 54. A magic string you should check. |
| 12 | 2 | Header CRC | Only present when header size is 14. CRC-16 of bytes 0 to 11. 0x0000 means absent. |
Records
Every record starts with a one-byte record header:
- Bit 7 (
0x80) selects the header type.0is a normal header,1is a compressed timestamp header. - Normal header: bit 6 (
0x40) is the message type,1for a definition message,0for a data message. Bit 5 (0x20) is the developer-data flag (definition messages only). Bits 0 to 3 are the local message type (0 to 15), a short-lived handle that ties a data message back to the definition that describes it. - Compressed timestamp header: bits 5 to 6 are the local message type (0 to 3), and bits 0 to 4 are a 0 to 31 second time offset from the last full timestamp. A space-saving trick for dense records.
Definition messages
A definition message declares the layout for one local message type. After the header byte:
| Size | Field | Notes |
|---|---|---|
| 1 | Reserved | 0. |
| 1 | Architecture | 0 little-endian, 1 big-endian. Applies to multi-byte values in the data messages that use this definition. |
| 2 | Global message number | uint16 (in the declared architecture). Identifies the message, for example 0 = file_id, 20 = record. |
| 1 | Number of fields | Count of standard field definitions that follow. |
| 3 each | Field definition | field definition number (1 byte), size in bytes (1 byte), base type (1 byte). |
If the developer-data flag is set, a count of developer fields and their definitions follow
(field number, size, developer data index). Developer fields carry custom data described by
developer_data_id and field_description messages earlier in the file.
Data messages
A data message is the record header byte followed by the field values, packed back to back in the
exact order and sizes the matching definition declared. There are no delimiters. You must use the
definition to parse it. A size that is a multiple of the base type's byte width means the field is
an array.
File CRC
The last 2 bytes are a CRC-16 over every preceding byte (header included). After you change
anything, you must recompute both the data size in the header and this CRC, or the file will be
rejected as corrupt. The canonical routine:
const CRC_TABLE = [
0x0000, 0xcc01, 0xd801, 0x1400, 0xf001, 0x3c00, 0x2800, 0xe401,
0xa001, 0x6c00, 0x7800, 0xb401, 0x5000, 0x9c01, 0x8801, 0x4400,
];
function fitCrc(bytes) {
let crc = 0;
for (const byte of bytes) {
let tmp = CRC_TABLE[crc & 0xf];
crc = ((crc >> 4) & 0x0fff) ^ tmp ^ CRC_TABLE[byte & 0xf];
tmp = CRC_TABLE[crc & 0xf];
crc = ((crc >> 4) & 0x0fff) ^ tmp ^ CRC_TABLE[(byte >> 4) & 0xf];
}
return crc; // little-endian in the file
}
Base types and encoding
Each field has a base type. The base type byte's low 5 bits are the type number; bit 7 marks whether the value is endian-sensitive.
| # | Name | Bytes | Invalid value |
|---|---|---|---|
| 0x00 | enum | 1 | 0xFF |
| 0x01 | sint8 | 1 | 0x7F |
| 0x02 | uint8 | 1 | 0xFF |
| 0x83 | sint16 | 2 | 0x7FFF |
| 0x84 | uint16 | 2 | 0xFFFF |
| 0x85 | sint32 | 4 | 0x7FFFFFFF |
| 0x86 | uint32 | 4 | 0xFFFFFFFF |
| 0x07 | string | n | 0x00 (UTF-8, null-terminated) |
| 0x88 | float32 | 4 | 0xFFFFFFFF (NaN) |
| 0x89 | float64 | 8 | NaN |
| 0x0A | uint8z | 1 | 0x00 |
| 0x8B | uint16z | 2 | 0x0000 |
| 0x8C | uint32z | 4 | 0x00000000 |
| 0x0D | byte | n | 0xFF |
| 0x8E | sint64 | 8 | 0x7FFF...F |
| 0x8F | uint64 | 8 | 0xFFFF...F |
| 0x90 | uint64z | 8 | 0x0000...0 |
Three rules that matter:
- Invalid means "not set." A field present in the layout but unrecorded is filled with its
invalid value. Treat it as missing, never as a real number. Writing a
0where the device wrote the invalid sentinel changes the data. - Scale and offset. Many fields store a scaled integer. The real value is
raw / scale - offset. For examplerecord.altitudeuses scale 5 and offset 500, so a raw2500is2500 / 5 - 500 = 0meters. The scale and offset come from the FIT Profile, not the file. - Enums come from the Profile. Numbers like
sport,manufacturer, andproductare looked up in the Garmin FIT Profile (shipped asProfile.xlsxin the SDK). The file stores the number, not the label.
Conventions that trip people up
- Timestamps are seconds since
1989-12-31T00:00:00Z(the Garmin epoch). Convert to Unix time by adding631065600. Local-time fields exist separately; do not confuse them with UTC. - GPS positions are stored in semicircles, not degrees.
degrees = semicircles * (180 / 2^31), where2^31 = 2147483648. A null position is the int32 invalid value, not0,0. - Enhanced fields. Newer files carry
enhanced_speedandenhanced_altitude(32-bit) alongside the legacy 16-bitspeed/altitude. Prefer the enhanced field when present. - Compressed timestamps (the
0x80header) only carry a 5-bit offset. You must track the last full timestamp to expand them.
Common message types
The global message numbers you will meet most often. Field numbers and scales come from the Profile.
| # | Message | What it holds |
|---|---|---|
| 0 | file_id | type (4 = activity), manufacturer, product, serial_number, time_created. Identifies the file. Required. |
| 49 | file_creator | Software and hardware version that wrote the file. |
| 23 | device_info | Per-device details: manufacturer, product, software version, battery, sensors. |
| 21 | event | Timer and marker events. timer start/stop events bound the moving segments. |
| 20 | record | One sample. timestamp, position_lat, position_long, distance, speed, altitude, heart_rate, cadence, power, temperature. The bulk of the file. |
| 19 | lap | One lap's summary: start_time, total_elapsed_time, total_timer_time, total_distance, and averages and maxes. |
| 18 | session | The activity summary: sport, start time, totals, averages, maxes. Strava and Garmin read this heavily. |
| 34 | activity | Top-level wrapper: number of sessions, total timer time, local timestamp. |
| 12 | sport | Sport and sub-sport, name. |
| 132 | hr | High-rate heart rate samples (some chest straps). |
Key record fields and their scales: distance scale 100 (m), speed/enhanced_speed scale 1000
(m/s), altitude/enhanced_altitude scale 5 offset 500 (m), heart_rate bpm, cadence rpm,
power watts, grade scale 100 (%), temperature deg C.
Operations
These are the edits people ask for, with the steps and the failure modes.
Decode a file
Read the header, check the .FIT magic and the CRC, then walk the records. Hold a table of the 16
local message types; each definition message overwrites its slot, each data message is parsed with
whatever definition currently occupies its slot. Expand scale and offset, convert semicircles and
timestamps, and skip invalid sentinels. Unless you are writing a parser from scratch, use the
Garmin FIT SDK (official, multi-language) or a maintained port.
import { Decoder, Stream } from "@garmin/fitsdk";
const stream = Stream.fromByteArray(bytes);
const { messages, errors } = new Decoder(stream).read();
// messages.recordMesgs, messages.sessionMesgs, messages.lapMesgs, ...
Extract streams
Turn recordMesgs into parallel arrays (time, lat, lng, distance, speed, altitude, hr, power,
cadence) for charting or analysis. Carry timestamps forward across compressed records. Drop or
interpolate missing samples rather than treating an invalid value as zero, which would drag averages
and graphs to the floor.
Crop, trim, or cut
Remove the start, the end, or a section from the middle.
- Choose a time range to delete.
- Drop the
record(and dependent) messages inside it. - Recompute the totals.
sessionandlapdistance, elapsed and timer time, calories, ascent, and the averages and maxes must be rederived from the surviving records, or the summary will disagree with the track and platforms may show wrong numbers. - Heal the seam. When you cut from the middle, the records on either side are no longer
contiguous. Insert a paired timer
stopandstartevent at the cut so players treat it as a pause, and keep timestamps monotonic. - Re-encode and recompute the CRC.
Note: Strava only lets you trim the very start and end of an activity. Cutting from the middle requires editing the file, which is the common reason people reach for a dedicated tool.
Merge files
Join two or more recordings into one activity, for example after a device died mid-ride.
- Order the files by start time.
- Concatenate their records, rebasing distance so it continues across the seam instead of resetting to zero (add the previous file's final distance to every following sample).
- Decide on laps: keep each file's laps, or emit fresh ones.
- Recompute one
session(and anactivity) over the whole set. - Watch for collisions: developer field indices and local message types from different files must be renumbered so they do not clash. The files should not overlap in time.
Fix a field (heart rate, power, etc.)
Clean a bad stretch without touching the rest.
- Select a metric and a time range.
- Apply a transform to the records in range: cap or clamp to a ceiling (a dry strap spiking to 220 bpm), set a constant, smooth a dropout with a moving average, interpolate across a gap, or clear the field back to invalid.
- Recompute any summary average or max that depended on it.
Change metadata (device, sport, time, name)
- Device: set
manufacturerandproductonfile_id, and on the matchingdevice_info. This is the "device changer" people search for, used when a platform credits a ride to the wrong device or treats barometric elevation differently. - Sport: set
sportandsub_sportonsession(andsport/lapas needed), for example when a run was recorded as a walk. - Start time: add a fixed offset to every
timestampand tostart_timefields. This is the "time adjuster". Shift UTC consistently so order and durations are preserved. - Name: the activity title is usually applied by the platform, but you can carry it in a
sportor workout name field and rename the file.
Repair a corrupt or truncated file
A crash or dead battery often leaves a file that no platform will accept.
- Read forward from the header, accepting valid messages until the stream becomes unparseable.
- Keep the records you recovered.
- Re-emit a clean file: correct header, a valid
file_id, at least onesessionandactivityrebuilt from the survivors, then a freshdata sizeand CRC. - The result is a shorter but valid activity that uploads.
Encode a valid file
Write the header (reserve the data size, fill it in at the end), then for each message emit its definition before its first data message, then the data messages, then the file CRC.
Pitfalls that produce a file which "looks fine" but fails on upload:
- Preserve unknown messages and fields. Round-tripping should not silently drop device-specific or developer data. Keep what you do not understand.
- Variable-length strings. A definition declares a fixed field size. If you re-encode a string field at a different length than its definition, every following field in that message shifts and the data is silently scrambled. Re-emit a definition when a string length changes.
- Invalid sentinels, not zeros. Write the type's invalid value for unset fields. Do not coerce a
missing
powerto0. - Recompute, do not copy, the data size and CRC.
A note from building one of these: the official SDK is excellent for decoding, but its encoder can mangle variable-length strings and NaN sentinels on round-trip. If exports are getting rejected, writing the bytes yourself with the rules above is often the fix. The FIT File Editor uses a custom writer for exactly this reason.
Validate before you ship it
Never trust a freshly written file. Re-decode the bytes you just produced and assert: the .FIT
magic and both CRCs check out, the record count matches what you wrote, file_id.type is
activity, and there is at least one session and one activity message. This single round-trip
check catches almost every encoder bug before a user hits an upload error.
Tools and libraries
- Garmin FIT SDK: the official, normative SDK and Profile. Use it for decoding. Available for several languages.
- FIT File Editor: a free, in-browser reference implementation of every operation above (crop, merge, fix, repair, lossless export). Nothing is uploaded; the files stay on the user's machine. A useful place to verify what a correct edit should produce.
- GPSBabel and similar converters: handy for format conversion (FIT to GPX or TCX), though the conversion is lossy compared with editing the FIT directly.
Glossary
- Message: one tagged row of data (a record, a lap, the summary).
- Definition message: the schema for a message type, declared before its data appears.
- Local message type: a 0 to 15 handle linking a data message to its current definition.
- Global message number: the stable Profile ID of a message type (record is 20).
- Semicircle: the integer unit FIT stores GPS in.
deg = semicircles * 180 / 2^31. - Garmin epoch:
1989-12-31T00:00:00Z. Add631065600for Unix time. - Scale / offset:
value = raw / scale - offset, from the Profile. - CRC-16: the checksum over the file; recompute it after any change.
This page is maintained at fitfileeditor.com/skill and available as raw Markdown at fitfileeditor.com/skill.md. Licensed CC BY 4.0; corrections welcome.