Small scripts for poking at Strava data. Standard library only (no pip install).
Go to https://www.strava.com/settings/api:
| Field | What to put | Why |
|---|---|---|
| Application Name | anything, e.g. strutils |
cosmetic, can't include "strava" |
| Category | anything, e.g. Data Importer |
cosmetic |
| Club | leave empty | cosmetic |
| Website | anything valid, e.g. your GitHub URL | cosmetic, but must parse as a URL |
| Application Description | leave empty | cosmetic |
| Authorization Callback Domain | localhost |
must be exactly this: no http://, no port, no path, or the redirect fails |
Strava then demands an app icon before showing the credentials. Upload any small square image. Client ID and Client Secret (click "Show") appear after that.
python3 get_token.pyPrompts for Client ID and Secret, opens the consent page, catches the redirect on
http://localhost:8080, and writes .env (chmod 600, gitignored). The other scripts read it
automatically.
Keep both boxes checked on the consent screen: "View data about your private activities"
(activity:read_all, without it private activities are silently missing) and "Upload your
activities" (activity:write, without it every import_gpx.py upload fails with
Authorization Error). The helper warns if either was not granted.
Refresh tokens do not expire, so this is one-time. To skip the refresh flow instead, put
STRAVA_ACCESS_TOKEN=... (valid ~6h) in .env or the environment.
Every day with more than one run, flagging pairs that look like the same activity imported twice.
python3 find_duplicate_runs.py --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
1 multi-activity days, 1 likely duplicate pairs.
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. Pass --type '' for all types. 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). |
--demo |
Run the built-in self-check. |
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.
Bulk-import GPX files, one upload at a time.
python3 import_gpx.py ./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
[3/312] 2019-03-09-run.gpx: ok https://www.strava.com/activities/333
Directories are searched recursively, so a tree like tracks/2019/…/track.gpx works as-is; 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 on the next run, so an interrupted import
resumes where it stopped 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. |
--demo |
Offline self-check of the multipart encoder and rate-limit math. |
The GPX itself supplies the name, dates and track.
Merge activities of the same sport into one, in the order you list them.
python3 merge_activities.py 111 222 --dry-run # report and back up, upload nothing
python3 merge_activities.py 111 222 # ... and publish the mergeMerging 2 activities, in the order given:
111 2026-09-10 18:35 1.25 km (-1.9% vs its GPS track) 6:17 moving 6:49 elapsed Evening Run
222 2026-09-10 18:43 8.92 km (+0.1% vs its GPS track) 40:35 moving 40:43 elapsed Thursday Evening Run
distance 10.17 km
elapsed 47:33 what the merged GPX spans
moving 46:52 Strava recomputes this from the merged track, so the published
activity lands near this, not exactly on it
seam 1 1:21 of dead time dropped, 0.02 km jumped
merged GPX merged-111.gpx
backups merge-backup-111/ (4 files, restore with `python3 import_gpx.py merge-backup-111/`)
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.
- elapsed is exact — the merged GPX spans it by construction.
- moving is not. Strava recomputes it from the merged track, and the 1s seam reads as a fast jump, so the published activity comes out a few seconds above the sum. (Real example: 46:52 predicted, 47:33 elapsed, 47:20 published.)
- % 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 — do not publish.
- A negative seam means those two activities overlap: check the order you passed.
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 Unauthorized whatever
the token's scope, because deletion is a partner-only permission. No script can do it.
So the run pauses: it prints the source URLs, you delete them in the browser (··· → Delete), press
Enter, and it re-fetches each one to confirm it 404s before retrying the upload. q aborts.
Both files 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 import_gpx.py merge-backup-111/ puts back exactly what you deleted, and nothing else. 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 — the merged activity is a GPS track only. --demo runs the offline self-check.
Not a script. Shared plumbing the others import: .env loading, access_token() (refresh flow),
request_json(), rate_limit_reset(), fetch_activities(), and the API / TOKEN_URL /
ENV_FILE constants.
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 (aligned to :00 :15 :30 :45),
up to 10 waits. If the daily limit is what ran out, it exits and prints the local datetime to
retry after — re-running import_gpx.py then picks up from .imported.
An import costs 2 requests per file (upload + one status poll, more if Strava is slow to process),
so roughly 45 files per 15-minute window and ~450 per day. 300+ files means a few sleeps but
one run; --no-wait doubles the throughput at the cost of not seeing failures.
Strava subscribers (premium) can raise their app's limits to 400 requests / 15 min and 4,000 / day — request the increase from the app's page at https://www.strava.com/settings/api (or via https://developers.strava.com/docs/rate-limits/). That takes the same import to ~200 files per window, i.e. one pass with a single sleep. Nothing in these scripts needs changing: the backoff reads the actual limits off the response headers.
A merge costs 2 requests per source activity (detail + streams) plus 2 for the upload.
Nothing here can delete anything: Strava dropped DELETE /activities from the API, so deletions
are always yours to make in the browser. merge_activities.py backs the activities up first and
waits for you.