How to Import a FIT File to HealthKit

One of the things that motivated me to develop apps in the Health and Fitness space was seeing my Apple Health workouts section disappointingly empty after recording a bike ride. Whether I used the Strava app on my Apple Watch or tracked it with my Garmin Edge 130 Plus, my workouts simply didn’t show up properly in the Fitness app. It quickly became clear that some companies choose not to send full workout data to Apple Health, likely to keep users within their ecosystem. But I also wondered if there was a technical reason – maybe converting and saving a workout to HealthKit was just too complex? I was determined to get this done, so I set out to learn how to import my rides into HealthKit, even though it was a bit tricky figuring out how to format the data.

Here’s a screenshot showing a comparison of two workouts from the Fitness app. On the left, you’ll see a workout recorded with the Strava app on my Apple Watch. On the right, you’ll see a workout recorded with a Wahoo ELEMNT Bolt cycling computer and the FIT file imported with my own app, Ride Import.

In this tutorial, I’ll guide you through importing workouts recorded by non-Apple devices, specifically cycling workouts saved as FIT files from devices like Garmin, Wahoo Fitness, and Coros. I’ll show you how to format and save the data to Apple Health so that it displays correctly in the Fitness app or other third-party apps. We’ll cover key concepts like cumulative vs. discrete samples, handling moving time, and setting data samples for accurate stats. While I’m focusing on cycling for simplicity, you can easily apply these concepts to other activities with some adjustments.

Getting Started

Before importing the data, we need to understand what a FIT file is and how it stores data.

A FIT (Flexible and Interoperable Data Transfer) file is a binary file format commonly used to store fitness and workout data, such as GPS coordinates, heart rate, speed, distance, elevation, and other activity-related metrics. Developed by Garmin, it’s widely used by fitness devices, smartwatches, cycling computers, and fitness tracking software due to its efficient storage and compatibility across different platforms.

Here’s a screenshot of the content inside a FIT file using a macOS app called FitFileExplorer.

FIT files are like spreadsheets that store data in different sections. We’ll only be looking at a few of them.

  1. Session — includes general information about the workout, including start time, sport, and total metrics

  2. Events — includes events triggered by pause, resume, etc.

  3. Records — includes all detailed samples for any sensor connected to a device including gps, distance, heart rate, calories, etc. Popular sports watches and cycling computer can record multiple events per second. The sample shown above includes more than 6,000 records for a 20 mile bike ride.

Reading the FIT File

To read the FIT files, I’m using a Swift dependency called FitDataProtocol developed by Kevin A. Hoogheem.

If you’re running your own Xcode project, you can install it using Swift Package Manager:

dependencies: [
    .package(url: "https://github.com/FitnessKit/FitDataProtocol", from: "2.1.4")
]

Workout Data Encoder

Below you can see a service called FitFileProcessor that parses the data from a FIT file and extracts the sections that we need. As you can see, there’s only one session object and multiple objects for events and records. Opening the FIT file itself is beyond the scope of this tutorial, but you can download the source code for a demo app below to see how it’s done.

import Foundation
import FitDataProtocol

enum FitFileProcessorError: Error {
    case missingSession
}

struct WorkoutData {
    let session: SessionMessage
    let events: [EventMessage]
    let records: [RecordMessage]
}

final class FitFileProcessor {    
    lazy var decoder = FitFileDecoder(crcCheckingStrategy: .throws)
    
    func decode(data: Data) throws -> WorkoutData {
        var session: SessionMessage?
        var events: [EventMessage] = []
        var records: [RecordMessage] = []

        try decoder.decode(
            data: data,
            messages: FitFileDecoder.defaultMessages
        ) { message in
            if let sessionMessage = message as? SessionMessage {
                session = sessionMessage
            }
            
            if let event = message as? EventMessage {
                events.append(event)
            }
            
            if let message = message as? RecordMessage {
                records.append(message)
            }
        }
        
        guard let session else {
            throw GenericError("missing session")
        }
        
        return WorkoutData(session: session, events: events, records: records)
    }
    
}

Saving to HealthKit

Setup Workout Importer

I’m using another service to save data to HealthKit. First we start creating a class WorkoutImporter where we’ll be adding the logic later.

NOTE: Ideally, the service should be an Actor instead of a Class, but it looks that the metadata dictionary doesn’t want to play nice with concurrency and throws a compiler error.

import Foundation
import HealthKit
import CoreLocation
import FitDataProtocol
import AntMessageProtocol

enum WorkoutImporterError: Error {
    case invalidData
    case missingSession
    case activityNotSupported
    case saveFailed
}

final class WorkoutImporter {
    private let healthStore: HKHealthStore
    
    private var session: SessionMessage = SessionMessage()
    private var events: [EventMessage] = []
    private var records: [RecordMessage] = []
    
    init(healthStore: HealthStore = .shared) {
        self.healthStore = healthStore.defaultStore
    }
}

extension WorkoutImporter {
    
    func process(data: WorkoutData) async throws -> UUID {
        self.session = data.session
        self.events = data.events
        self.records = data.records
        return try await saveToHealth()
    }
    
}

The basic logic for saving a workout to HealthKit is as follows.

  1. Create a HKWorkoutConfiguration and set the activity type and indoor properties. We need to create an activityType() and isIndoor(subSport:) methods to extract the values from the FIT file session.

  2. Create an HKWorkoutBuilder object

  3. Call the beginCollection(at:) method on workout builder

  4. Generate and add metadata

  5. Generate and add events

  6. Generate and add samples for distance, energy, heart rate, cadence and power; these samples may depend on the activity type

  7. Call the endCollection(at:) method on workout builder

  8. Save and finish the workout

  9. Generate and save a route, if available

extension WorkoutImporter {
    
    func saveToHealth() async throws -> UUID {
        // all workouts must have a start time and duration
        guard let startDate = session.startTime?.recordDate, let elapsedTime = session.totalElapsedTime else {
            throw WorkoutImporterError.invalidData
        }
        
        // Map the Sport property in the FIT file to HKWorkoutActivityType
        // Or throw an error if an activity type is not supported
        guard let activityType = activityType() else {
            throw WorkoutImporterError.activityNotSupported
        }
        
        // A workout is indoor if the subSport is spinning, indoor cycling or virtual activity (i.e. Zwift)
        let subSport = session.subSport
        let isIndoor = isIndoor(subSport: subSport)
        
        // 1. Create Configuration
        let configuration = HKWorkoutConfiguration()
        configuration.activityType = activityType
        configuration.locationType = isIndoor  ? .indoor : .outdoor
        
        // 2. Create Builder
        let builder = HKWorkoutBuilder(healthStore: healthStore, configuration: configuration, device: .local())
        
        // 3. Begin Collecting Data
        try await builder.beginCollection(at: startDate)
        
        // 4. Add Metadata
        let metadata = try self.metadata(from: session)
        try await builder.addMetadata(metadata)
        
        // 5. Add Events
        // The app will crash if you try to add empty values to the builder
        let events = generateWorkoutEvents()
        if !events.isEmpty {
            try await builder.addWorkoutEvents(events)
        }
        
        // 6. Add Samples
        let samples = generateWorkoutSamples()
        if !samples.isEmpty {
            try await builder.addSamples(samples)
        }
        
        // 7. End Collecting Data
        // FIT files don't include an end data so we need to calculate it from the elapsed time
        let elapsedTimeInSeconds = elapsedTime.converted(to: .seconds).value
        let endDate = startDate.addingTimeInterval(elapsedTimeInSeconds)
        try await builder.endCollection(at: endDate)
        
        // 8. Finish the Workout
        guard let workout = try await builder.finishWorkout() else {
            throw WorkoutImporterError.saveFailed
        }
        
        // 9. Generate and save a route if needed
        let locations = generateLocations()
        
        // Route processing is ignored if the activity is virtual or indoor
        // It is also ignored if there are no locations available in the FIT file
        let shouldProcessRoute = !((subSport == .virtualActivity || isIndoor) || locations.isEmpty)
        if shouldProcessRoute {
            let routeBuilder = HKWorkoutRouteBuilder(healthStore: healthStore, device: .local())
            try await routeBuilder.insertRouteData(locations)
            try await routeBuilder.finishRoute(with: workout, metadata: nil)
        }
        
        return workout.uuid
    }

    func activityType() -> HKWorkoutActivityType? {
        // I"m only supporting cycling to keep things simple,
        // but you should map supported activities here.
        // You may need to include additional metadata or samples.
        
        switch session.sport {
        case .cycling:
            return .cycling
        default:
            return nil
        }
    }
    
    func isIndoor(subSport: SubSport?) -> Bool {
        guard let subSport else { return false }
        return [.indoorCycling, .spin, .virtualActivity].contains(subSport)
    }

}

Generate Metadata

When it comes to cycling, the only info you really need is the indoor flag, average speed, and elevation. You can also add some extra details like the weather, time zone, and a special code to keep track of your workouts in your own database. But other activities might need more info. Check out the extra metadata keys on Apple’s website for more details.

extension WorkoutImporter {

    func metadata(from session: SessionMessage) throws -> [String: Any] {
        var dictionary: [String: Any] = [:]
        dictionary[HKMetadataKeyIndoorWorkout] = isIndoor(subSport: session.subSport)
        
        if let avgSpeed = session.averageSpeed {
            let conversion = avgSpeed.converted(to: .metersPerSecond)
            let quantity = HKQuantity(unit: HKUnit.meter().unitDivided(by: .second()), doubleValue: conversion.value)
            dictionary[HKMetadataKeyAverageSpeed] = quantity
        }
        
        if let totalAscent = session.totalAscent {
            let conversion = totalAscent.converted(to: .meters)
            let quantity = HKQuantity(unit: HKUnit.meter(), doubleValue: conversion.value)
            dictionary[HKMetadataKeyElevationAscended] = quantity
        }
        
        return dictionary
    }

}

Generate Events

Generating events correctly is super important because it helps us figure out your average speed and moving time. For now, we only need to worry about pause and resume events. Just to reiterate, there’s no way to manually set moving time or average speed in HealthKit. The Fitness app calculates them automatically. As an app developer, you’ll also need to calculate the average speed manually in your apps by dividing the total distance by the moving time.

extension WorkoutImporter {

    func generateWorkoutEvents() -> [HKWorkoutEvent] {
        var workoutEvents: [HKWorkoutEvent] = []
        
        // We only care about start and stop events in the FIT file to map them to their corresponding
        // resume or pause events in HealthKit
        for event in events {
            guard let timestamp = event.timeStamp?.recordDate else { continue }
            
            let dateInterval = DateInterval(start: timestamp, end: timestamp)
            switch event.eventType {
            case .start:
                workoutEvents.append(.init(type: .resume, dateInterval: dateInterval, metadata: nil))
            case .stop, .stopAll:
                workoutEvents.append(.init(type: .pause, dateInterval: dateInterval, metadata: nil))
            default:
                break
            }
        }

        // cleaning the first and last events to make sure they are valid
        // the app will crash if the events are not in order following sequences
        // of pause, resume, pause, resume, etc...
        
        // the first event cannot be resume
        if let first = workoutEvents.first, first.type == .resume {
            workoutEvents = Array(workoutEvents.dropFirst())
        }
        
        // the last event cannot be a pause
        if let last = workoutEvents.last, last.type == .pause {
            workoutEvents = Array(workoutEvents.dropLast())
        }
        
        return workoutEvents
    }

}

Generate Samples

This is probably the most challenging part of converting data from a FIT file to a workout. The format is quite standard and used by most players in the industry. HealthKit prefers data in a specific way, so we need to convert it. HealthKit has two types of data samples: cumulative and discrete.

  1. Cumulative samples increase during the workout and each value depends on the previous. Some examples may include distance or energy.

  2. Discrete samples are independent values such as heart rate. Some examples may include heart rate, cadence, and power.

Here’s a basic overview of distance, heart rate, and cycling cadence. The process should be similar for the other metrics. You can download the complete demo source code below to see the full implementation.

Cumulative Example

extension WorkoutImporter {

    func distanceSample(start: RecordMessage, end: RecordMessage) -> HKCumulativeQuantitySample? {
        guard let startMeasurement = start.distance,
              let endMeasurement = end.distance else { return nil }
        guard let startDate = start.timeStamp?.recordDate,
              let endDate = end.timeStamp?.recordDate else { return nil }

        let startValue = startMeasurement.converted(to: .meters).value
        let endValue = endMeasurement.converted(to: .meters).value

        // FIT files include the total cummulative value in each record
        // but samples in HealthKit expects fractional values as they increase
        let value = endValue - startValue
        guard value > 0 else { return nil }

        let quantity = HKQuantity(unit: .meter(), doubleValue: value)
        return HKCumulativeQuantitySample(type: .distanceCycling(), quantity: quantity, start: startDate, end: endDate)
    }

}

Discrete Example

Here are two examples, one for heart rate and one for cycling cadence. Cycling cadence is a bit different because it’s calculated using two things from the FIT file: cadence and fractional cadence.

extension WorkoutImporter {

    func heartRateSample(record: RecordMessage) -> HKDiscreteQuantitySample? {
        guard let date = record.timeStamp?.recordDate else { return nil }
        guard let measurement = record.heartRate else { return nil }
        
        let value = measurement.value
        guard value > 0 else { return nil }
                
        let quantity = HKQuantity(unit: HKUnit.count().unitDivided(by: HKUnit.minute()), doubleValue: value)
        return .init(type: .heartRate(), quantity: quantity, start: date, end: date)
    }
    
    func cadenceSample(record: RecordMessage) -> HKDiscreteQuantitySample? {
        guard let date = record.timeStamp?.recordDate else { return nil }
        guard let measurement = record.cadence else { return nil }
        guard measurement.value > 0 else { return nil }
        
        // NOTE ABOUNT CADENCE:
        // cadence samples use two properties in FIT files due to number constraints
        // the total cadence is the sum of the cadence and fractional cadence properties in the FIT file
        let fractionalCadence = record.fractionalCadence ?? .init(value: 0, unit: .revolutionsPerMinute)
        let value = measurement.value + fractionalCadence.value
                
        let quantity = HKQuantity(unit: HKUnit.count().unitDivided(by: HKUnit.minute()), doubleValue: value)
        return .init(type: .cyclingCadence(), quantity: quantity, start: date, end: date)
    }

}

Generating All Samples

To generate all the samples, we simply iterate through the FIT file, taking two records at a time and saving them individually. For cumulative metrics, we need two values to calculate the delta between them, making them compatible with HealthKit.

I’ve omitted some of the methods for brevity. You can download the full demo source code linked below to see the complete implementation.

extension WorkoutImporter {

    func generateWorkoutSamples() -> [HKSample] {
        var distance: [HKSample] = []
        var energy: [HKSample] = []
        var heartRate: [HKSample] = []
        var cadence: [HKSample] = []
        var power: [HKSample] = []
        
        for (record, nextRecord) in zip(records, records.dropFirst()) {
            if let value = distanceSample(start: record, end: nextRecord) {
                distance.append(value)
            }
            
            if let value = energySample(start: record, end: nextRecord) {
                energy.append(value)
            }
            
            if let value = heartRateSample(record: record) {
                heartRate.append(value)
            }
            
            if let value = cadenceSample(record: record) {
                cadence.append(value)
            }
            
            if let value = powerSample(record: record) {
                power.append(value)
            }
        }
        
        return distance + energy + heartRate + cadence + power
    }

}

Generate the Route

FIT files have a unique way of storing GPS data, so we need to convert it to a CLLocation. Why CLLocation instead of just CLLocationCoordinate2D? Well, HealthKit saves extra info when you save a workout route. You can check out the elevation chart in workout details when using the Fitness app or access the elevation data directly in your app.

extension WorkoutImporter {

    func generateLocations() -> [CLLocation] {
        // NOTE: Including location metadata is important!
        // The metadata is used when showing the elevation chart in Workout Details in the Fitness app.
        
        records.compactMap { (record) -> CLLocation? in
            guard let position = record.position else { return nil }
            guard let latitude = position.latitude, let longitude = position.longitude else {
                return nil
            }
            
            // FIT files have their own data structure for location data
            // We need to convert to CLLocationCoordinate2D and CLLocation
            let newLatitude = latitude.converted(to: .degrees).value
            let newLongitude = longitude.converted(to: .degrees).value
            let coordinate = CLLocationCoordinate2D(latitude: newLatitude, longitude: newLongitude)
            
            guard CLLocationCoordinate2DIsValid(coordinate) else { return nil }
            guard let timestamp = record.timeStamp?.recordDate else { return nil }
            
            let accuracy = record.gpsAccuracy?.converted(to: .meters).value ?? 0
            let altitude = record.altitude?.converted(to: .meters).value ?? 0
            let speed = record.speed?.converted(to: .metersPerSecond).value ?? 0
            
            return CLLocation(
                coordinate: coordinate,
                altitude: altitude,
                horizontalAccuracy: accuracy,
                verticalAccuracy: accuracy,
                course: -1,
                speed: speed,
                timestamp: timestamp
            )
        }
    }

}

Where to Go From Here?

You can download the source code for the sample app on GitHub. The code is organized for reusability, making it a solid foundation for your own projects. No FIT file? No problem—the demo app includes several sample files, so you can start experimenting right away.

This code has been thoroughly tested in production over the last four years, recording more than 15,000 cycling miles to HealthKit.

If you plan to support additional activity types, be sure to review the HealthKit documentation for further insights.

At a future date, I’ll be posting about how to fetch workouts and create an infinite feed of workouts that also show the route. Stay tuned!

Next
Next

The Apple Watch Rings Don’t Work for Endurance Athletes