---
name: working-with-fit-files
title: Working with FIT Files
description: How to read, edit, repair, and write Garmin FIT activity files. The file structure, base types and encodings, message types, the CRC, and the common operations, with the gotchas that break real files.
version: 1.0.0
updated: 2026-06-18
canonical: https://fitfileeditor.com/skill
raw: https://fitfileeditor.com/skill.md
license: CC-BY-4.0
audience: [ai-agents, developers]
---

# 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](https://developer.garmin.com/fit/), 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](#decode-a-file) and [extract the data streams](#extract-streams).
- **Cut something out** with [crop, trim, or cut a range](#crop-trim-or-cut).
- **Join recordings** with [merge files](#merge-files).
- **Clean bad data** by [fixing a field](#fix-a-field-heart-rate-power-etc) over a range.
- **Relabel** by [changing metadata](#change-metadata-device-sport-time-name): device, sport, start time, name.
- **Rescue a broken file** with [repair](#repair-a-corrupt-or-truncated-file).
- **Save** with [encode a valid file](#encode-a-valid-file), then [validate](#validate-before-you-ship-it) 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. `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:

```js
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](https://developer.garmin.com/fit/) (official, multi-language) or a maintained port.

```js
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](https://fitfileeditor.com/) 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](https://developer.garmin.com/fit/)**: the official, normative SDK and Profile.
  Use it for decoding. Available for several languages.
- **[FIT File Editor](https://fitfileeditor.com/)**: 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](https://fitfileeditor.com/skill) and available
as raw Markdown at [fitfileeditor.com/skill.md](https://fitfileeditor.com/skill.md). Licensed
CC BY 4.0; corrections welcome.
