Small scripts for poking at Strava data.
Needs uv.
make run # the TUI launcher
make test # the suite in tests/The launcher builds each form from the script's own --flags, so there is no second list to
keep in sync. Run stays disabled until the required fields are filled, and the exact command
is echoed so you can copy it.
╭──────────────────────╮╭────────────────────────────────────────────────────╮
│ export_runs ││ Export Strava activities as a CSV │
│ find_duplicate_runs ││ │
│ import_gpx ││ --after * 2024-01-01 │
│ merge_activities ││ --before last day to export, YYYY-MM-DD │
│ get_token ││ --type Run │
│ ││ --out write here instead of stdout │
│ │╰────────────────────────────────────────────────────╯
│ │╭────────────────────────────────────────────────────╮
│ ││ $ python3 -m strava_utils.export_runs --after ... │
│ ││ fetched 200 activities... │
╰──────────────────────╯╰────────────────────────────────────────────────────╯
q Quit r Run [ Run ]
get_token and merge_activities prompt on stdin, so the launcher suspends and hands them the
real terminal.
Any script also runs alone, with no virtualenv and nothing installed:
python3 -m strava_utils.export_runs --after 2026-09-01 > runs.csv
python3 -m strava_utils.<name> --helpCreate an app at https://www.strava.com/settings/api. Everything there is cosmetic except
Authorization Callback Domain, which must be exactly localhost (no http://), no port,
no path, or the redirect fails. Strava demands an app icon before it shows the credentials.
python3 -m strava_utils.get_tokenPrompts for Client ID and Secret, opens the consent page, catches the redirect on
http://localhost:8080, and writes .env (chmod 600, gitignored). Every script reads it.
Keep both consent boxes checked: activity:read_all (or private activities go silently
missing) and activity:write (or every upload fails with Authorization Error). Refresh
tokens do not expire, so this is one-time. To skip the refresh flow, put
STRAVA_ACCESS_TOKEN=... (valid ~6h) in .env instead.
Days with more than one run, flagging pairs that look like the same activity imported twice.
python3 -m strava_utils.find_duplicate_runs --after 2024-01-012024-05-12 (2 activities)
07:02 10.03 km 52 min Morning Run https://www.strava.com/activities/111
07:02 10.05 km 52 min Morning Run (imported) https://www.strava.com/activities/222
!! likely duplicate: 111 <-> 222
Flagged when distance and moving time both match within 2%. Genuine doubles are printed but not flagged. Nothing is ever deleted.
| Flag | Meaning |
|---|---|
--type Run |
sport_type filter. --type '' for all. Default Run. |
--after YYYY-MM-DD |
Only activities after this date. |
--json |
Dump the grouped days as JSON instead of the report. |
--no-verify |
Skip the deleted-activity recheck (fewer requests, stale results). |
Strava's activity list keeps returning activities for a while after you delete them, so each candidate day is re-checked one activity at a time and anything already gone is dropped.
A CSV of your runs, ready to paste into a spreadsheet.
python3 -m strava_utils.export_runs --after 2026-09-01 --before 2026-09-10 > runs.csvDate,Day,Distance,Time,Shoe
01/09/26,Tue,5.00,00:21:28,
05/09/26,Sat,15.00,01:16:15,Adidas Adizero Evo SL
Distance in km, Time is moving time, Shoe blank when no gear is assigned.
| Flag | Meaning |
|---|---|
--after YYYY-MM-DD |
First day. Required, inclusive. |
--before YYYY-MM-DD |
Last day, inclusive. Default: today. |
--type Run |
sport_type filter. --type '' for all. Default Run. |
--out runs.csv |
Write to a file instead of stdout. |
Both dates compare against start_date_local, your own clock, so a day means the day you ran,
not a UTC window.
Bulk-import GPX files, one upload at a time, resumable.
python3 -m strava_utils.import_gpx ./tracks --sport-type Run312 file(s) to upload, 0 already done.
[1/312] 2019-03-04-run.gpx: ok https://www.strava.com/activities/111
[2/312] 2019-03-06-run.gpx: duplicate of activity 222
rate limited, sleeping 11.4 min until 14:15 CEST
Directories are searched recursively; only .gpx and .gpx.gz are picked up. Each settled
file (imported, or rejected as a duplicate) has its full path appended to .imported and
is skipped next run; so an interrupted import resumes, and same-named files in different
subfolders never shadow each other. Delete .imported to start over.
| Flag | Meaning |
|---|---|
--sport-type Run |
Run, Ride, Hike, ... Default: whatever Strava reads from the file. |
--description ... |
Applied to every upload. |
--no-wait |
Queue and move on: halves the requests, but duplicates/errors go unseen. |
--state PATH |
Use a different done-list file. |
--dry-run |
Print the files that would be uploaded. |
The GPX itself supplies the name, dates and track.
Merge activities of the same sport into one, in the order you list them.
python3 -m strava_utils.merge_activities 111 222 --dry-run # report and back up only
python3 -m strava_utils.merge_activities 111 222 # ... and publish 111 2026-09-10 18:35 1.25 km (-1.9% vs its GPS track) 6:17 moving Evening Run
222 2026-09-10 18:43 8.92 km (+0.1% vs its GPS track) 40:35 moving Thursday Evening Run
distance 10.17 km
elapsed 47:33 what the merged GPX spans
moving 46:52 Strava recomputes this, so the published activity lands near it
seam 1 1:21 of dead time dropped, 0.02 km jumped
Each source becomes one <trkseg>, re-stamped to start 1s after the previous one ended. That
is the whole trick: Strava takes elapsed time from the first and last GPX timestamp, so keeping
the real clock would bill the dead time between activities to the merge.
Reading the numbers. elapsed is exact by construction. moving is not. Strava recomputes it and the 1s seam reads as a fast jump, so the published activity comes out a few seconds above the sum. % vs its GPS track compares Strava's own distance against the points it gave back; a few tenths is normal, a large drift means the streams came back wrong, so do not publish. A negative seam means those two activities overlap; check the order.
Why it needs you. The merge starts at the same instant as its first source, so Strava
refuses it while that source exists, and DELETE /activities is gone from the API, answering
401 whatever the token's scope, because deletion is partner-only. No script can do it. So the
run pauses: it prints the source URLs, you delete them in the browser, press Enter, and it
re-fetches each to confirm it 404s before retrying. q aborts.
Both the merged GPX and the backups are written before any of that, and the backup folder
holds only the sources, each as its detail JSON plus a GPX on its original timestamps, so
python3 -m strava_utils.import_gpx merge-backup-111/ puts back exactly what you deleted.
It is never cleaned up automatically.
Indoor activities have no GPS stream and cannot be merged. Heart rate, cadence and power are not carried over.
strava_utils/
strava.py shared: .env loading, access_token(), request_json(), rate limits, fetch_activities()
tui.py the launcher
*.py one script each, exposing build_parser() so the launcher can read its flags
tests/ make test
.env and .imported live at the repo root (strava.ROOT), one level above the package.
Every script also carries an offline demo() self-check: python3 -m strava_utils.<name> --demo.
Default limits are 100 requests / 15 min and 1,000 / day. On a 429, request_json() reads
X-RateLimit-Usage and sleeps until the 15-minute window resets, up to 10 waits. If the
daily limit ran out it exits and prints when to retry, re-running import_gpx picks up from
.imported.
An import costs 2 requests per file, so roughly 45 files per 15-minute window and ~450 per
day; --no-wait doubles that at the cost of not seeing failures. A merge costs 2 requests per
source activity plus 2 for the upload.
Strava subscribers can raise their app's limits to 400 / 15 min and 4,000 / day from the app's page. Nothing here needs changing: the backoff reads the actual limits off the response headers.
Nothing here can delete anything; Strava dropped DELETE /activities from the API, so
deletions are always yours to make in the browser.