Skip to content
Skip article header Engineering

FIT File Ingestion

What a fitness platform has to build to accept device uploads and hand them back: a decoder that reads definition messages before data, a timestamp path that knows the FIT epoch from a relative value, an integrity check the specification owner makes the case for skipping on activity files, an identity key that survives a re-upload and an export path that is honest about what TCX and GPX drop.

20 min read 43 views
A bike computer and a sports watch on a workbench beside a laptop showing an uploaded activity.
Skip key takeaways

FIT file ingestion begins the moment a watch or a bike computer hands your platform a binary blob and walks away. Your service then has to decide whether it is a FIT file at all, decode it without a schema, normalize the values, store it so a re-upload does not become a second ride and hand an equivalent file back. The specification owner states the purpose of the format in the FIT protocol chapter of Garmin's developer documentation: "The Flexible and Interoperable Data Transfer (FIT) protocol is a format designed specifically for the storing and sharing of data that originates from sport, fitness and health devices." What no page describes is what to do when the file arrives half written, which is where most of the engineering sits.

In short: a FIT file is self-describing rather than self-validating. A definition message has to be read before the data messages it governs mean anything, so the parser is a state machine and not a struct cast. The message table covers almost everything a training platform shows. Timestamps count from a 1989 epoch and are not always absolute. The validation pipeline has three checks, and the specification owner makes the case for skipping the strictest of them on activity files, because truncated uploads are ordinary traffic rather than an exception.

What a FIT upload actually is

FIT is a binary container published by Garmin, which also publishes the software development kits that read and write it. The documentation names Garmin International as the maintainer of the global message profile and routes additions to it through Garmin rather than through any external standards body. Two properties matter more than the layout. Several files may be chained into one stream, each with its own header and checksum, so a decoder stopping at the first checksum discards whatever followed. And the declared type does not pin the message list, as the file types guide states: "The FIT Profile defines a list of common file types but does not explicitly define which messages are to be used with each file type." A parser reads what is there, recognizes what it knows and keeps the rest.

The file structure a decoder must follow

Everything starts at the header, the first place a fixed offset becomes a bug. It runs to at least 12 bytes and carries a protocol version number, a profile version number, the data section size and a data type signature, the ASCII characters .FIT in bytes 8 to 11. Those two version numbers are separate counters and neither is the version of the software development kit doing the reading. On which header to emit, the protocol chapter is unambiguous: "The 12 byte header is considered legacy, using the 14 byte header is preferred."

Reading its length is a rule rather than a convention. "The header size should always be decoded before attempting to interpret a FIT file, Garmin International, Inc. may extend the header as necessary", says the same chapter. A decoder that assumes 14 bytes works until the day it does not, and the failure is silent because misaligned bytes still parse as something.

Inside the data section every record opens with one byte saying whether a definition message, a normal data message or a compressed timestamp data message follows. A compressed timestamp header moves part of the timestamp into that byte, which restricts it to local message types 0 to 3, and the five low bits holding the offset wrap: "The 5-bit time offset rolls over every 32 seconds; hence, it is necessary that any two consecutive compressed timestamp records be measured less than 32 seconds apart", states the protocol chapter. Lose the previous full timestamp and a decoder does not error. It produces times wrong by a multiple of 32 seconds.

Definition messages and data messages

Records come in two kinds, as the protocol chapter puts it: "The record content is either a definition message that is used to specify upcoming data, or a data message that contains a series of data-filled fields (Figure 2)." Definitions carry no measurements, only the shape of the ones that follow.

A definition binds a local identifier to a global message number and fixes the byte layout of everything using it, field by field, with a size and a base type. Ordering is absolute: "A data message must always be specified by a definition message before it can be used in a FIT file", says the protocol chapter, and one arriving without a definition causes a decode error rather than a best-effort read. Parsing means carrying a live table of definitions, keyed by local message type, for the whole pass.

Values arrive as integers and are frequently not the numbers you want. Scale and offset are declared in the profile, and the arithmetic is one sentence in the protocol chapter: "When specified, the binary quantity is divided by the scale factor and then the offset is subtracted, yielding a floating point quantity." Reverse those two operations and every scaled value in your database is wrong by a constant, the kind of defect a user finds rather than a test.

Local message types are reusable within one file, which traps a decoder that caches. A definition can be replaced mid-file, and the protocol chapter states what happens to a reader that misses the replacement: "If data message formats are recorded without the new definition message, unpredictable results will occur and may cause the decoder to fail." Treat every definition message as invalidating whatever was cached under that identifier.

Unknown content, by contrast, is normal traffic. Devices implement different profile versions, so a reader meets messages it has never seen: "When this occurs, the FIT file is maintained in its entirety and any unrecognized messages are simply ignored by the decoder without interrupting the operation of the receiving device, or causing any errors", per the protocol chapter.

Developer data fields are the extension point a platform will actually meet in production. Their metadata travels in two special messages that must appear before the data referencing them, and being self-describing makes them tractable: "These field definitions are included in the message definitions of the messages that they are used with, allowing for the custom fields to be decoded without the need for any prior knowledge of the Developer Data Fields", explains the developer data recipe. The protocol chapter warns that decoders consuming developer data should not trust it to be logged correctly, so range-check it on the way in.

One version trap here belongs to the protocol counter, not to any library release. These fields were a breaking change, encoders default to the older protocol, and the same recipe names the consequence: "However, when using the v1.0 protocol, any messages containing Developer Data Fields will not be written to the file." Not truncated, not flagged. Whole messages disappear and the encoder still writes a structurally complete file, which is why an export path that loses a field is usually an encoder version choice rather than a decoder defect.

The messages a platform needs

A training platform reads far fewer message types than a FIT file can contain. Below is the working set, and its columns are not the same kind of claim. The first two are sourced from the FIT documentation cited here and the validation column is sourced where the documentation states a rule, otherwise ours. The failure column lists engineering consequences Pharos Production designs against rather than incidents observed in the field. One fact first, because it explains why the raw file is worth keeping: "Session messages define almost 150 fields that can provide information related to the activity", per the activity file guide.

Message Fields the platform needs (sourced) Validation (sourced where stated, else ours) Failure the message's absence or misuse produces (ours)
file_id, the identity of the file Type, manufacturer, product, serial number, time created and file number Present once and first in the file; type 4 marks an activity and type 5 a workout A re-upload carries an identical identity block, so a service keying on upload time creates a second activity
activity, the file-level wrapper Local timestamp, the only route to the recording's UTC offset Sourced: post-processing should not depend on it, because it is written last Missing on a truncated transfer, so the activity lands with the wrong local day
session, the summary of one leg Start time, total elapsed time, total timer time and timestamp, plus roughly 150 optional fields Sourced: a summary spans a time range, and messages belong to it by timestamp containment Absent on a truncated file; a multisport upload carries one per leg, so a parser taking the first reports a swim as the whole race
lap, the splits inside a session Start time, total elapsed time, total timer time and timestamp Sourced: at least one per session, sequential and non-overlapping, lap totals summing to the session totals The cheapest integrity check available, and a gap surfaces months later as a mismatched total
record, the sample stream Timestamp plus at least one measured value: position, speed, distance or heart rate Sourced: one-second resolution is the ceiling and devices may record more slowly Smart recording emits samples at irregular intervals, so any rate computed on an assumed sampling interval is quietly wrong
event, the markers The notable points of a recording, timer starts and stops among them Not required by the format but a stated best practice to include Ignoring timer events turns a coffee stop into a slow ride, because the gap looks like lost reception
device_info, the provenance The creating device plus the accessories and sensors used Not required by the format but a stated best practice to include Without it a sensor-specific correction cannot be scoped, so one bad power meter becomes a full-corpus reprocess
workout and workout_step, the plan Step duration and target intensity, repeat blocks and required equipment Sourced: a workout file must carry an identity and a workout message; uniqueness rests on type, manufacturer, product and serial number A device re-sends the same plan under the same four values, so keying on the name accumulates near-duplicates
developer_data_id and field_description, the extensions A 16-byte application identity and one description per custom field Sourced: both must be written before the data that references them Encoding with the older protocol drops every message carrying a developer field, and the encoder still writes a structurally complete file

All of it hangs off the identity message, which the file types guide places first by design: "The File Id message should be the first message in the file." Route on its declared type, not on what messages you find, because several file types share message types. A multisport file is the case that decides a data model, because one file carries several sessions and a platform has to choose whether a race is one activity or several.

Timestamps units and the FIT epoch

FIT counts time from its own epoch, and the safest way to hold that is as a type rather than as a date. The date and time recipe defines it precisely: "The FIT Profile defines the date_time type as an uint32 that represents the number of seconds since midnight on December 31, 1989 UTC". Write that instant in UTC and only in UTC, because rendering it in a local zone moves it to the previous day and a converter written from that rendering is wrong forever. Converting to a Unix timestamp is a single offset of 631065600 seconds, which belongs in a named constant rather than inline.

Not every date_time value is an absolute time, which is the trap. "If a date_time value is less than 0x10000000, then the value represents a relative number of seconds", warns the date and time recipe, and that constant is the declared minimum of the type in the FIT profile. Apply the epoch offset unconditionally and a relative duration becomes a date somewhere between 1990 and 1998, which then sorts to the top of every descending activity list. Test for the threshold before converting, always.

The file stores UTC, with a local timestamp beside it and no time zone anywhere. Recovering the offset is arithmetic: "When both a date_time value and corresponding local_date_time value are provided, the time zone offset for the FIT file can be calculated", per the date and time recipe, and in an activity file that local timestamp rides on the activity message. Our practice is to persist the offset as signed seconds beside the UTC instant, since a fixed offset cannot be turned back into a zone name.

The validation pipeline

FIT file ingestion validation is three checks, and the published software development kits express them as three steps rather than one. The FIT Python SDK readme lists a header check, a size check that the total file length equals the header plus the data plus the checksum and a checksum recomputation, then states the conjunction: "A file must pass all three of these tests to be considered a valid FIT file." Run the first synchronously at the edge, where it rejects a renamed photograph or a zero-byte upload before any decode work is scheduled.

Check three is where judgment enters, and the specification owner states a rule for it rather than leaving it to taste. "It is a best practice to use the CheckIntegrity() method when decoding file types where any corrupt or missing data invalidates the entire file", says the integrity recipe, naming workout, user profile and device settings files among them. Those are instructions, so one wrong byte makes the rest untrustworthy.

For activity files, which is what an ingestion path sees most of, the same page makes the case for skipping it: "There are use cases where it might not be critical that the file passes the integrity check or where calling CheckIntegrity() is not efficient", per the integrity recipe, which also notes that "Calling CheckIntegrity() also means that the full contents of the file will be read twice". A specification owner arguing against its own strictest check is unusual enough to build the pipeline around.

So our pipeline has four stages rather than three. Identify the file cheaply at the edge. Branch on the declared type, checking integrity strictly for instruction files and skipping it for activities. Decode with error collection on and persist the error list, because a decode producing most of a ride and two errors is a different event from one producing nothing. Then validate semantically against your own model, reconciling lap totals against session totals. That last stage is ours, and it is the one that catches defects a checksum cannot see.

Corrupt and truncated uploads

Truncated activity files are not an edge case but a structural consequence of how devices write, and the decoding recipe explains why: "Most devices encode Activity files in real time using the summary last message sequence, resulting in the Session and Activity messages being written to the file at the end of the activity recording." Anything interrupting the recording or the transfer removes exactly the messages that describe the whole.

Such a file fails its checksum by construction, and the specification owner is direct about what that does and does not mean. "This does not mean that the contents of the entire file are corrupt, and it may be acceptable, or even desirable, to recover as much data as possible from the file", states the integrity recipe, adding the product argument that users would rather see part of their activity data than none of it. Skipping the check is what lets the file decode up to the point where it is corrupt.

Repairing a missing summary is documented rather than invented. "If there are no Session messages but there are Record messages, a Session message can be created based on the timestamps of the first and last Record messages", says the decoding recipe. Our addition is a flag: a synthesized summary is stored as synthesized, because the platform's totals and the watch's totals will differ and somebody will ask which is which. Our data model carries three terminal states for that reason: decoded clean, decoded partial with a stop point and an error list, rejected with the raw bytes retained.

Ownership of a failed decode is our rule rather than a documented one. A failed decode creates a durable record carrying the raw bytes, the error list, the decoder version and the athlete it belongs to, queryable by support without an engineer. In our experience failures cluster by device and by firmware, so the next question is how many uploads share that product.

What proves the path works is a short acceptance set run against real device files. A truncated activity lands as partial rather than rejected. The same file uploaded twice produces one activity. Lap totals reconcile with session totals or the mismatch is flagged, and a relative timestamp value never becomes a 1990s date.

Deduplication on re-upload

A coach reviewing a list of uploaded training activities on a laptop for duplicate entries.

The FIT documentation states a uniqueness rule for workout files and none for activity uploads, so this section is Pharos Production practice and should be read as such. The format supplies only raw material. An identity message may carry, in the words of the protocol chapter, "file type, manufacturer, product, serial number, time created and file number depending on the FIT file type". The activity file guide adds that manufacturer identifiers are assigned by Garmin and that each manufacturer defines its own product identifiers.

For one file type the documentation does state a uniqueness rule. "Since a device may contain multiple workout files it is important that the combination of type, manufacturer, product, and serial number is unique", says the workout file guide. That four-part key is stated for workout files, not for activities, so borrowing it here is a design decision rather than a reading of the specification.

Our key for activity uploads is the manufacturer, the product, the device serial number and the creation timestamp, hashed together, with a second key over the raw bytes catching byte-identical resubmissions before any decode work is queued. Those four values are written into file_id by the creating device rather than derived at upload, which is exactly what upload time, file name and file size are not. Devices reporting no serial number collapse a fleet onto one key, so a null serial degrades the key to the athlete's account plus the creation timestamp.

What to store beside the raw file

Keep the raw upload unmodified and derive every normalized record from it. Our retention policy is to keep it for the life of the account, because decoders improve and profile coverage widens. A platform that kept only its own normalized rows cannot go back, so keeping the bytes turns a decoder fix into a reprocessing job. The one case where derived rows may outlive the file they came from is an erasure request that deletes the raw upload, which is Pharos Production practice rather than a legal reading.

What gets normalized alongside it is a storage question rather than an analytics one. The sample stream is the irreducible part: as the activity file guide describes it, "Record messages are where the moment-by-moment GPS coordinate, speed, distance, heart rate" and the other instantaneous values are stored. Keep every sample with its own timestamp rather than resampling to a fixed grid, because resampling is lossy and cannot be undone.

Two timing facts decide whether anything computed from those samples can be trusted. The decoding recipe notes that devices "will use Timer events to indicate when the recording of data has been paused and then restarted", independent of the recording rate, and that smart recording writes a sample only when a value changes significantly. So the interval between two samples is not a constant and a gap is not necessarily a gap. Persist the timer events and the actual interval, and any figure computed per unit time can be recomputed correctly afterwards.

Record which decoder version produced a given set of rows, and a reprocessing campaign becomes a query. Our IoT development guide covers the same retain-the-raw-payload pattern for device pipelines that stream rather than upload.

Exporting back out to TCX and GPX

Export is a retention question before it is a feature, and the cheapest correct answer is to hand back the original file. Where a platform writes a FIT file of its own, output cannot be a pure forward stream, and the encoding recipe explains why: "The stream must be created with both read and write access so that the data size and CRC can be updated once all the messages have been written to the file."

The two common interchange formats lose different things, and knowing which is which stops an export feature from becoming a support problem. TopoGrafix, which authors GPX, describes it on its GPX page as "GPX (the GPS Exchange Format) is a light-weight XML data format for the interchange of GPS data" between applications and online services, and adds that "The GPX 1.1 schema was released on August 9, 2004".

The GPX 1.1 waypoint type, which backs track points and route points alike, declares elevation, time, naming and link elements, a fix type, a satellite count, the dilution-of-precision values and an extensions element. No heart rate, no cadence, no power and no lap object at all. That extensions element is defined while its contents are not: "You can add your own elements to the extensions section of the GPX document", states the GPX 1.1 schema documentation, so an importer that does not know your extension will skip your heart rate stream silently.

TCX sits between the two. The Garmin Training Center Database v2 schema, cited here by title rather than quoted, declares a track point carrying time, position, altitude, distance, a heart rate value, one cadence value, a sensor state and an extensions element, and a lap object carrying total time, distance, maximum speed, calories, average and maximum heart rate, intensity, cadence, a trigger method and the track. So TCX natively carries heart rate and laps where GPX has neither, but it still has no native power and no left-right balance, so against a FIT session message the gap is structural.

Our export rule follows. Offer the original FIT file as the lossless option and label it as such, offer TCX where the consumer needs laps and heart rate and offer GPX only where the consumer wants a route. Never present the three as equivalent in a download menu: a user who exports GPX expecting power data will blame your platform.

How Pharos Production helps

A FIT file ingestion path is a handful of components that each have to be right, and any one of them getting it wrong stays invisible until an athlete finds it.

Our fitness software development practice designs that layer as a whole rather than as a parser bolted to an upload endpoint: the ingestion queue and its terminal states, the reprocessing path a decoder fix turns into a routine job, the device provenance that makes a later correction scopeable and an export that states what it drops. Those designs are what this page describes. The wearable integration side of the same work is covered in our fitness app development guide.

Sources: the protocol chapter, the Activity and Workout file guides and the decoding, encoding, date and time, developer data and integrity recipes of Garmin's FIT documentation; the FIT Python SDK readme; the Training Center Database v2 schema by title; the TopoGrafix GPX pages. Read on 17 September 2026. Engineering guidance, not legal advice.

FAQ

Last updated:

Quick answers to common questions about custom software development, pricing, process and technology.

  • Copy link Copies a direct link to this answer to your clipboard.

    Use the published SDK for the binary layer and write your own everything else. The parts genuinely hard to reimplement are the profile dictionary, the base type handling and the scale and offset arithmetic, and the first of the three moves every time the profile does.

    What the SDK cannot give you is the layer your product actually needs: the semantic validation, the identity key, the terminal states of your ingestion queue and the reprocessing path. Teams that write their own binary reader usually do it for a runtime the SDK does not target, which is a platform constraint rather than a preference.

  • Copy link Copies a direct link to this answer to your clipboard.

    Three cases. If your platform only ever renders a route on a map, most of the storage design here is overhead and a positions table is enough.

    If uploads arrive through a partner API that has already decoded them, you are integrating a data contract rather than a binary format and the validation questions move to that contract. And if you process workout, course or settings files rather than activities, the integrity rule inverts: those are instructions, a corrupt byte invalidates them and the strict check the activity path skips is the correct default there.

  • Copy link Copies a direct link to this answer to your clipboard.

    Route on the declared type in the identity message rather than on message content, because several file types share message types and a truncated file loses the ones that would identify it. Course, segment, monitoring and settings files exist alongside activities and are named in the file type guide.

    Decide explicitly which ones your platform accepts and reject the rest at the edge with a clear message, because a settings file quietly stored as an activity is worse than a rejection. Our practice is to accept activity files on the athlete upload path and to route every other type to its own endpoint.

  • Copy link Copies a direct link to this answer to your clipboard.

    Our practice is one activity per file with a child record per session, because the file is the unit the device produced and the unit a re-upload arrives as. A triathlon file carries a session per leg, and where the device wrote transitions as their own sessions we keep them as legs rather than folding them into the ones on either side, so the child totals still sum to the parent.

    The product decides how that is rendered, one race or three efforts, but the identity key stays on the file. Keying on a leg makes every re-upload a partial duplicate.

  • Copy link Copies a direct link to this answer to your clipboard.

    From the retained raw files, in a job that writes new rows rather than mutating the old ones. Select by decoder version and by the symptom, run the new decoder, then diff the derived values before promoting anything.

    The awkward part is not the compute, it is the user-visible change: totals that shift under an athlete who already saw them need a visible note rather than a silent correction. Keeping the original derived rows until the diff is reviewed is what makes a bad reprocessing run reversible instead of a second incident.

  • Copy link Copies a direct link to this answer to your clipboard.

    Keep reading past the first checksum. The protocol allows complete files to be chained into one stream, each with its own header, records and checksum, so a decoder that stops at the first one silently discards the rest of the upload.

    Our practice is to decode each file in the chain separately and to compute the identity key per file rather than per upload, so each decoded file becomes its own activity and one chained upload can produce several. Trailing bytes that do not open a valid header are recorded with the upload rather than discarded.

I work with startup founders who need a dedicated software development team but don’t want to gamble on hiring, random outsourcing, or opaque delivery.
Most founders face the same problem sooner or later.
Early technical and team decisions lock the product into tech debt, slow delivery, missed milestones and constant re-hiring. By the time this becomes visible, fixing it is already expensive.

As a CTO and software architect, I help founders design, build and run dedicated development teams that work as a true extension of the startup. Not as a black-box vendor.

My focus is on complex products where mistakes are costly:

  • Web3 and blockchain platforms
  • FinTech and regulated products
  • High-load startup systems
  • MVP → scale transitions

We don’t do body-shopping.
We don’t sell generic outsourcing.

Instead, we help founders:

  • build the right team structure from day one
  • keep technical ownership and transparency
  • scale delivery without losing control
  • avoid vendor lock-in and hidden risks

Teams are aligned with the product roadmap, business goals and long-term architecture. Not just short-term velocity.

Dmytro Nasyrov, Founder and CTO at Pharos Production
Dmytro Nasyrov Founder & CTO Let's work together!

Your business results matter

Achieve them with minimized risk through our bespoke innovation capabilities

Your contact details
Please enter your name
Please enter a valid email address
Please enter your message
* required

We typically reply within 4 hours

What happens next?

  1. Contact us

    Contact us today to discuss your project. We're ready to review your request promptly and guide you on the best next steps for collaboration

    Same day
  2. NDA

    We're committed to keeping your information confidential, so we'll sign a Non-Disclosure Agreement

    1 day
  3. Plan the Goals

    After we chat about your goals and needs, we'll craft a comprehensive proposal detailing the project scope, team, timeline and budget

    3-5 days
  4. Finalize the Details

    Let's connect on Google Meet to go through the proposal and confirm all the details together!

    1-2 days
  5. Sign the Contract

    As soon as the contract is signed, our dedicated team will jump into action on your project!

    Same day