Did you know that you need more than 3 clicks to delete one activity from Garmin Connect? Add to this the time needed to load the page after each click (and this page is not a fast one, to be honest).

I have a few thousand activities from my happy 13-year relationship with Garmin. So when doing an update of my daughter’s watch using Garmin Express, it synced all her activities to my account. Garmin’s support basically said “you’re on your own here, buddy”. And I thought: if you’re faithful to me in good times but not in bad times, we’re one step closer to the divorce.

Either way, like it or not, I still needed to delete hundreds of activities from my account. Me being the laziest person on earth, I like to automate every repeatable process, as much as possible.

Garmin Connect’s website lets you look at each activity, and it lets you delete each one manually, a few painful clicks at a time. What it doesn’t offer individuals is an API: Garmin’s official developer program (the Health and Activity APIs) is for approved companies only.

The community solved this years ago. This article is about three things and nothing more: connecting to my own account, finding what I needed to delete, and deleting it - all from Python.

First, the honest disclaimer

Everything below uses garminconnect, an unofficial, open-source library that talks to the same private API that Garmin’s own app and website use. That means:

  • Garmin does not approve, support, or guarantee any of this. It works because Garmin tolerates it, and it can break whenever they change their API (a library update usually fixes it within days).
  • Automated access sits in a gray zone of Garmin’s terms of service, which reserve access to their official interfaces.
  • Use it only with your own account and your own data - you’re automating what you could do by hand in a browser, on data that’s yours.
  • Deletions are permanent. Garmin Connect has no trash bin, no undo.

Comfortable with that? Then it’s genuinely pleasant to use.

Connecting to the account (2FA included)

pip install garminconnect

The login mimics Garmin’s own app, so it handles two-factor authentication: the first time you’ll be asked for the code, and the resulting OAuth tokens are cached in a local folder for about a year.

from pathlib import Path
from garminconnect import Garmin

TOKENS = Path.home() / ".garmintokens"

def first_login(email: str, password: str) -> None:
    garmin = Garmin(email=email, password=password, return_on_mfa=True)
    state, ctx = garmin.login()
    if state == "needs_mfa":
        garmin.resume_login(ctx, input("2FA code: ").strip())
    garmin.client.dump(str(TOKENS))

def login() -> Garmin:
    garmin = Garmin()
    garmin.login(str(TOKENS))    # cached tokens, no password, no 2FA
    return garmin

A few practical notes from my first attempts:

  • Keep credentials in a .env file (git-ignored) rather than in the script.
  • Treat the token folder like a password: whoever has it is you on Garmin Connect - it’s a full session, not a read-only grant. Keep it local, never commit it. Changing your Garmin password invalidates the tokens.
  • If you see 429 - IP rate limited warnings during login, don’t panic: the library tries Garmin’s mobile endpoints first and falls back to the web login, which usually goes through.

Finding what to delete: export everything to CSV

Before deleting anything, I wanted the full picture in front of me. The activity list is paginated, so you just walk it until it runs dry. One extra touch: activities only carry a numeric deviceId, but a separate call maps those IDs to real names (“Forerunner 955”) for the watches currently registered on your account - and that device column is exactly what identifies the intruder activities.

import csv

def fetch_all(garmin):
    acts, start = [], 0
    while True:
        batch = garmin.get_activities(start, 100)
        if not batch:
            return acts
        acts += batch
        start += len(batch)

garmin = login()
devices = {d["deviceId"]: d["productDisplayName"] for d in garmin.get_devices()}

with open("garmin-activities.csv", "w", newline="", encoding="utf-8-sig") as f:
    w = csv.writer(f)
    w.writerow(["id", "start", "name", "type", "event", "km", "sec", "elev", "device"])
    for a in fetch_all(garmin):
        w.writerow([
            a["activityId"],
            a["startTimeLocal"],
            a["activityName"],
            a["activityType"]["typeKey"],          # running, trail_running…
            (a.get("eventType") or {}).get("typeKey", ""),  # race, training…
            round((a.get("distance") or 0) / 1000, 2),
            int(a.get("duration") or 0),
            round(a.get("elevationGain") or 0),
            devices.get(a.get("deviceId"), ""),
        ])

A few thousand activities take under a minute. The utf-8-sig encoding is so Excel opens accented names correctly. The output looks like this (data invented for illustration):

id,start,name,type,event,km,sec,elev,device
90000000001,2026-05-24 09:31:02,Morning Trail,trail_running,race,21.30,8642,760,Forerunner 955
90000000002,2026-05-22 18:05:44,Easy Run,running,training,8.01,2890,45,Forerunner 955
90000000003,2026-05-20 07:58:10,Long Ride,cycling,,64.20,9451,910,Edge 530

Open it in Excel, filter by the device column (in my case, all the foreign activities came from one watch), sanity-check the dates, and copy the id column: that’s your deletion list. As a bonus, this CSV is also a complete backup of your activity history - worth keeping regardless.

Deleting, carefully

Deleting is one call per activity, and I strongly recommend the pattern validate first, delete second, verify third:

import time

ids_to_delete = [90000000004, 90000000005, 90000000006]  # from the CSV

# 1. Show what you're about to destroy
for aid in ids_to_delete:
    a = garmin.get_activity(aid)
    print("will delete:", a["activityName"], a["summaryDTO"]["startTimeLocal"])

input("Permanent and irreversible. Enter to continue, Ctrl+C to abort… ")

# 2. Delete, gently (don't hammer the API)
for aid in ids_to_delete:
    garmin.delete_activity(aid)
    time.sleep(0.4)

# 3. Verify: fetching a deleted activity now raises
for aid in ids_to_delete:
    try:
        garmin.get_activity(aid)
        print(aid, "STILL EXISTS?!")
    except Exception:
        print(aid, "confirmed gone")

Before running this for real, I cross-checked (an easy vlookup) every ID against the CSV - right count, right device, right date range - so a pasted typo couldn’t take out the wrong activity. Thirty seconds of paranoia, well spent: remember, there is no undo.

Total time for hundreds of activities: a couple of minutes to write the AI prompt to create the script. Couple more minutes to for a first test (DEV) to delete one activity and check it works as expected; then a second test (UAT) to delete multiple activities at once and verify again everything looks good. Then the 3rd test (PROD) to delete everything else. The website way, at 3+ slow clicks each, would have been few afternoons I’ll never get back; now it’s a blog post instead.

Closing thought

None of this should have required Python. What I really wish for is that the Garmin Connect activities page had what every other mature SaaS product ships as standard: a richer filter - by device, activity type, date range, any field really - and then bulk actions on the filtered result. Bulk-delete, bulk-edit, bulk-retag etc. Select a few hundred activities from one watch, click delete, confirm once, done.