Fit File Editor

For AI agents and developers: view the raw Markdown·

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.

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. 0 is a normal header, 1 is a compressed timestamp header.
  • Normal header: bit 6 (0x40) is the message type, 1 for a definition message, 0 for 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 0 where 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 example record.altitude uses scale 5 and offset 500, so a raw 2500 is 2500 / 5 - 500 = 0 meters. The scale and offset come from the FIT Profile, not the file.
  • Enums come from the Profile. Numbers like sport, manufacturer, and product are looked up in the Garmin FIT Profile (shipped as Profile.xlsx in 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 adding 631065600. Local-time fields exist separately; do not confuse them with UTC.
  • GPS positions are stored in semicircles, not degrees. degrees = semicircles * (180 / 2^31), where 2^31 = 2147483648. A null position is the int32 invalid value, not 0,0.
  • Enhanced fields. Newer files carry enhanced_speed and enhanced_altitude (32-bit) alongside the legacy 16-bit speed/altitude. Prefer the enhanced field when present.
  • Compressed timestamps (the 0x80 header) 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.

  1. Choose a time range to delete.
  2. Drop the record (and dependent) messages inside it.
  3. Recompute the totals. session and lap distance, 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.
  4. Heal the seam. When you cut from the middle, the records on either side are no longer contiguous. Insert a paired timer stop and start event at the cut so players treat it as a pause, and keep timestamps monotonic.
  5. 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.

  1. Order the files by start time.
  2. 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).
  3. Decide on laps: keep each file's laps, or emit fresh ones.
  4. Recompute one session (and an activity) over the whole set.
  5. 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.

  1. Select a metric and a time range.
  2. 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.
  3. Recompute any summary average or max that depended on it.

Change metadata (device, sport, time, name)

  • Device: set manufacturer and product on file_id, and on the matching device_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 sport and sub_sport on session (and sport/lap as needed), for example when a run was recorded as a walk.
  • Start time: add a fixed offset to every timestamp and to start_time fields. 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 sport or 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.

  1. Read forward from the header, accepting valid messages until the stream becomes unparseable.
  2. Keep the records you recovered.
  3. Re-emit a clean file: correct header, a valid file_id, at least one session and activity rebuilt from the survivors, then a fresh data size and CRC.
  4. 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 power to 0.
  • 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. Add 631065600 for 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.