How to Load GPS Tracks into Python as Trajectories
On this page
Problem statement
Loading a GPX file is three lines. Loading it correctly means answering four questions the file does not answer for you:
- Which points belong to one journey?
- Are the timestamps timezone-aware and monotonic?
- What CRS should the coordinates be in before anything is measured?
- Which rows are duplicates, and which are genuinely two fixes in one second?
Question one is the one that bites, and the obvious test for it misleads. Downloading 60,000 public trackpoints for central Brighton from the OpenStreetMap API produced 54 <trkseg> elements. In four of them about 90% of rows share a timestamp with the row before, which looks exactly like several journeys merged into one element. It was not: the points that share a second are 1β3 m apart, so each is one device logging several fixes a second with its timestamps cut to the second. Treat it as merged traces and you throw away good data; treat real merged traces as one and they teleport between journeys. Only the distance between the same-second points tells the two apart.
Quick answer
import numpy as np
import pandas as pd
import geopandas as gpd
import xml.etree.ElementTree as ET
NS = {"g": "http://www.topografix.com/GPX/1/1"}
def read_gpx(path):
"""Every trkpt with its track and segment index preserved."""
root = ET.parse(path).getroot()
ns = NS if root.tag.endswith("}gpx") and "1/1" in root.tag else \
{"g": "http://www.topografix.com/GPX/1/0"}
rows = []
for ti, trk in enumerate(root.findall("g:trk", ns)):
name = trk.findtext("g:name", default="", namespaces=ns)
for si, seg in enumerate(trk.findall("g:trkseg", ns)):
for pt in seg.findall("g:trkpt", ns):
time = pt.findtext("g:time", namespaces=ns)
rows.append({
"track": ti, "segment": si, "name": name,
"lat": float(pt.get("lat")), "lon": float(pt.get("lon")),
"ele": float(pt.findtext("g:ele", default="nan", namespaces=ns)),
"time": time,
"hdop": pt.findtext("g:hdop", namespaces=ns),
})
df = pd.DataFrame(rows)
df["time"] = pd.to_datetime(df["time"], utc=True, format="ISO8601")
df["track_id"] = df["track"].astype(str) + "-" + df["segment"].astype(str)
return df
import glob
# track numbers restart in every file, so prefix the file before combining
df = pd.concat([read_gpx(path).assign(track_id=lambda d, n=n: f"p{n}:" + d["track_id"])
for n, path in enumerate(sorted(glob.glob("traces/*.gpx")))], ignore_index=True)
print(f"{len(df):,} points in {df.track_id.nunique()} <trkseg> elements; "
f"{df.time.notna().mean():.1%} with a timestamp")
60,000 points in 54 <trkseg> elements; 100.0% with a timestamp
Namespace, timezone, identity, projection, derived columns
1. Parse without assuming the namespace
GPX exists in versions 1.0 and 1.1 with different namespace URIs. A hard-coded namespace silently returns zero points for the other version β findall returns an empty list rather than raising.
Detect the namespace from the root element, or strip namespaces entirely before searching.
2. Make the timestamps timezone-aware immediately
df["time"] = pd.to_datetime(df["time"], utc=True, format="ISO8601")
GPX timestamps are UTC by specification, but exporters write local times, offsets and occasionally nothing at all. utc=True normalises what is there; format="ISO8601" stops pandas inferring a format per row, which is slow and occasionally inconsistent.
Points with no timestamp are not trajectory points. Keep them if you need the geometry, but exclude them from anything temporal.
3. Validate the identity column rather than trusting it
def check_identity(df, id_col="track_id"):
"""Flag elements with many repeated timestamps, and say which kind they are."""
for key, sub in df.groupby(id_col):
sub = sub.sort_values("time", kind="stable")
dt = sub["time"].diff().dt.total_seconds()
zero = float((dt == 0).mean())
if zero <= 0.1:
continue
# how far apart are the points that share a second?
step_m = np.hypot(sub["lon"].diff() * 111_320 * np.cos(np.radians(sub["lat"])),
sub["lat"].diff() * 110_574)[dt == 0]
kind = ("several fixes a second, timestamps cut to the second"
if step_m.median() < 50 else "different traces sharing timestamps")
print(f" {key}: {zero:.1%} of rows share a timestamp, "
f"median {step_m.median():,.0f} m between them β {kind}")
check_identity(df)
p6:5-0: 90.0% of rows share a timestamp, median 1 m between them β several fixes a second, timestamps cut to the second
p7:0-0: 89.8% of rows share a timestamp, median 3 m between them β several fixes a second, timestamps cut to the second
p7:1-0: 89.8% of rows share a timestamp, median 2 m between them β several fixes a second, timestamps cut to the second
p7:2-0: 89.9% of rows share a timestamp, median 3 m between them β several fixes a second, timestamps cut to the second
A large fraction of zero intervals means one of two things, and the distance between the points that share a second decides which. A few metres apart is one receiver logging faster than its timestamps can show β here, about ten fixes a second. Hundreds of metres apart is several traces sharing an element. In this download all four flagged elements were the first kind; load_tracks below sets them aside anyway until you have decided, keeping 46 tracks and 55,316 points of the 60,000.
4. Project before deriving anything
gdf = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df.lon, df.lat), crs=4326)
gdf = gdf.to_crs(gdf.estimate_utm_crs())
gdf["x"], gdf["y"] = gdf.geometry.x, gdf.geometry.y
estimate_utm_crs() picks the UTM zone containing the data's centroid. For tracks spanning several zones, choose an equidistant projection centred on the data instead.
5. Derive the between-row columns once, in the right order
gdf = gdf.sort_values(["track_id", "time"]).reset_index(drop=True)
g = gdf.groupby("track_id")
gdf["dt"] = g["time"].diff().dt.total_seconds()
gdf["dist"] = np.hypot(g["x"].diff(), g["y"].diff())
gdf["speed"] = gdf["dist"] / gdf["dt"].replace(0, np.nan)
Sort by identity and time, group before diffing, and guard the zero interval. Each of those three prevents a specific, silent, wrong answer.
A reporting GPX loader and tracks rebuilt without identity
The data used on this page
Public GPS traces from the OpenStreetMap trackpoints API for a box over central Brighton and Hove, β0.20 to β0.10Β° E, 50.81 to 50.86Β° N: the first 12 pages of 5,000 points, 60,000 points in all. The API serves the newest uploads first, so the setup reads the pages as saved on 9 October 2026. Traces uploaded as "identifiable" carry their owner's trace URL; "trackable" ones carry timestamps but no owner. The API interleaves traces that were uploaded without an owner, which is the problem this page is about. Nothing here prints a location or a username.
import io
import os
import zipfile
import requests
# The OSM trackpoints API for this box, pages 0-11, as saved on 9 October 2026. The API serves the
# newest uploads first, so a live download changes whenever someone uploads a trace here. Live:
# requests.get("https://api.openstreetmap.org/api/0.6/trackpoints",
# params={"bbox": "-0.20,50.81,-0.10,50.86", "page": page})
if not os.path.exists("traces/page_11.gpx"):
reply = requests.get("https://data.spatialworkflow.io/osm-gps-traces-brighton-2026-10-09-v2.zip",
timeout=120)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("traces")
print(len(os.listdir("traces")), "pages")
12 pages
Example 1 β a loader that reports what it found
import numpy as np
import pandas as pd
import geopandas as gpd
def load_tracks(df, id_col="track_id", min_points=10, crs=None):
"""Validated, projected trajectories plus a per-track summary."""
df = df.dropna(subset=["time", "lat", "lon"]).copy()
df["time"] = pd.to_datetime(df["time"], utc=True)
before = len(df)
df = df.drop_duplicates(subset=[id_col, "time", "lat", "lon"])
if len(df) < before:
print(f" dropped {before - len(df):,} exact duplicate rows "
f"({1 - len(df) / before:.1%})")
gdf = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df.lon, df.lat),
crs=4326)
gdf = gdf.to_crs(crs or gdf.estimate_utm_crs())
gdf["x"], gdf["y"] = gdf.geometry.x, gdf.geometry.y
gdf = gdf.sort_values([id_col, "time"]).reset_index(drop=True)
g = gdf.groupby(id_col)
gdf["dt"] = g["time"].diff().dt.total_seconds()
gdf["dist"] = np.hypot(g["x"].diff(), g["y"].diff())
gdf["speed"] = gdf["dist"] / gdf["dt"].replace(0, np.nan)
summary = g.agg(points=("time", "size"), start=("time", "min"),
end=("time", "max"))
summary["duration_s"] = (summary["end"] - summary["start"]).dt.total_seconds()
summary["length_km"] = g["dist"].sum() / 1000
summary["median_dt_s"] = g["dt"].median()
summary["zero_dt_frac"] = g["dt"].apply(lambda s: float((s == 0).mean()))
suspect = summary[summary["zero_dt_frac"] > 0.1]
if len(suspect):
print(f" {len(suspect)} of {len(summary)} tracks have over 10% zero intervals "
f"β set aside until check_identity says what they are")
keep = summary[(summary["points"] >= min_points) &
(summary["zero_dt_frac"] <= 0.1)]
print(f" keeping {len(keep)} tracks, {int(keep['points'].sum()):,} points, "
f"{keep['length_km'].sum():.1f} km") # 0.1 km: same-second fixes arrive in no fixed order
return gdf[gdf[id_col].isin(keep.index)], summary
tracks, summary = load_tracks(df)
4 of 54 tracks have over 10% zero intervals β set aside until check_identity says what they are
keeping 46 tracks, 55,316 points, 163.2 km
Example 2 β reading other formats onto the same shape
import pandas as pd
import geopandas as gpd
def load_any(path, id_col=None, time_col=None, lon=None, lat=None):
"""CSV, GeoPackage, Parquet or GPX into one trajectory table."""
suffix = path.rsplit(".", 1)[-1].lower()
if suffix in ("csv", "txt"):
df = pd.read_csv(path)
elif suffix == "gpx":
return read_gpx(path)
elif suffix in ("parquet", "pq"):
df = pd.read_parquet(path)
else:
gdf = gpd.read_file(path)
gdf["lon"], gdf["lat"] = gdf.geometry.x, gdf.geometry.y
df = pd.DataFrame(gdf.drop(columns="geometry"))
rename = {}
if time_col: rename[time_col] = "time"
if lon: rename[lon] = "lon"
if lat: rename[lat] = "lat"
if id_col: rename[id_col] = "track_id"
df = df.rename(columns=rename)
missing = {"time", "lon", "lat"} - set(df.columns)
if missing:
raise ValueError(f"missing columns {sorted(missing)}; "
f"available: {sorted(df.columns)[:12]}")
if "track_id" not in df:
print(" no identity column β treating the whole file as one track")
df["track_id"] = "all"
return df
Normalising every source to track_id, time, lon, lat early means the rest of the pipeline has one shape to handle. The alternative β special cases threaded through every function β is how trajectory code becomes unmaintainable.
Example 3 β reconstructing tracks when the file has no identity
import numpy as np
def infer_tracks(df, max_gap_s=600, max_speed_ms=56.0):
"""Build track identity from continuity when the file provides none."""
df = df.sort_values("time").reset_index(drop=True).copy()
dt = df["time"].diff().dt.total_seconds()
dist = np.hypot(df["x"].diff(), df["y"].diff())
speed = dist / dt.replace(0, np.nan)
new_track = (dt > max_gap_s) | (speed > max_speed_ms) | dt.isna()
df["track_id"] = new_track.cumsum()
sizes = df.groupby("track_id").size()
print(f" inferred {len(sizes)} tracks; "
f"{int((sizes < 10).sum())} have fewer than 10 points")
return df
# one interleaved element, with its identity thrown away
suspect = summary["zero_dt_frac"].idxmax()
print(f" {suspect}: {summary.loc[suspect, 'points']:,} points, "
f"{summary.loc[suspect, 'zero_dt_frac']:.1%} zero intervals")
merged = gpd.GeoDataFrame(df[df.track_id == suspect].copy(), crs=4326,
geometry=gpd.points_from_xy(df.lon, df.lat)[(df.track_id == suspect).to_numpy()])
merged = merged.to_crs(merged.estimate_utm_crs())
merged["x"], merged["y"] = merged.geometry.x, merged.geometry.y
rebuilt = infer_tracks(merged.drop(columns="track_id").dropna(subset=["time"]))
p6:5-0: 1,268 points, 90.0% zero intervals
inferred 1 tracks; 0 have fewer than 10 points
This works when a single device logged continuously. On the most repetitive element it found one track, which is right here β the element is one device β but it would say the same about ten genuinely merged traces, because a zero interval gives no speed to split on. For merged traces no continuity rule helps; the points genuinely interleave in time.
Why GPX structure cannot be trusted
Why the file's own structure is not trustworthy
GPX has <trk>, <trkseg> and <trkpt>, and the specification says a <trkseg> is a contiguous run of points. Producers interpret that loosely:
- Some emit one segment per file regardless of gaps.
- Some emit a new segment on every GPS loss, so one journey becomes twenty.
- Some loggers record several fixes a second but write whole-second timestamps, so most intervals are zero.
The validation in step 3 costs a few lines and tells these apart.
Why duplicate rows are common and worth removing
This 60,000-point download held two, both inside one track. The harder kind sits across tracks: one recording uploaded twice, where each copy has its own id and a check keyed on the id never sees it. How to clean a GPS track counts both ways and drops on (time, lat, lon) alone. Exact duplicates come from overlapping API pages, from recordings uploaded twice, from devices writing a point twice, and from files concatenated more than once.
They matter because a duplicate produces a zero interval and therefore an infinite derived speed, and because they double-weight one location in any density or clustering analysis.
Why timezone-naive timestamps break silently
Two tracks recorded either side of a daylight-saving change, stored as naive local times, sort into the wrong order for an hour. diff() produces negative intervals, speeds go negative, and nothing raises.
Parsing with utc=True at load time makes this impossible. If the source really is local time with no offset, attach the zone explicitly with tz_localize and let it raise on the ambiguous hour rather than guessing.
Why to keep points rather than lines
Converting to a LineString per track is convenient and lossy: a line has no time, so speed, stops and duration cannot be recovered from it.
Keep points as the primary representation. Build lines for display, and keep them as a derived product that can be rebuilt after any filtering step.
Namespaces, UTC parsing, duplicates and elevation
- Detect the GPX namespace. Version 1.0 and 1.1 differ, and a wrong namespace returns zero points silently.
- Parse timestamps with
utc=Trueandformat="ISO8601". - Validate the identity column by checking the fraction of zero intervals β and the distance between the points that share a timestamp.
- Drop exact duplicates on
(id, time, lat, lon). - Project before measuring.
estimate_utm_crs()is a good default for local data. groupbybeforediff, always.- Keep
hdop,satandfixwhere the file has them; they beat inferred quality. - GPS elevation is poor. Sample a DEM instead for anything about gradient.
Internal links
- Trajectories explained: what makes a track different from points β why identity and order matter
- How to clean a GPS track: outliers, duplicates and jumps β the next step
- GPS error explained: why your track wanders β what the quality fields describe
- Sampling rate and gaps explained β reading the interval distribution
- Stops, trips and segmentation explained β recovering journey structure
- My time column sorts or joins wrongly β the timezone failure in detail
- How to clean messy CSV coordinates into a reliable GeoDataFrame β the same problem for point CSVs
- How to calculate speed, distance and direction along a track β the derived columns
FAQ
How do I read a GPX file in Python?
Parse it with xml.etree.ElementTree, detecting the namespace from the root element, and collect trkpt elements with their track and segment indices. Then parse timestamps with utc=True and project.
Can I trust each trkseg to be one track?
Not without checking. Of 54 segments from the OpenStreetMap trace API, four had most of their intervals at zero β but in all four the points sharing a second were metres apart, one device logging several fixes a second. Validate by checking what fraction of intervals are zero, and then how far apart those points are.
Why do I get infinite speeds after loading?
Duplicate rows or two fixes in the same second give a zero interval. Drop exact duplicates and replace zero intervals with NaN before dividing.
What CRS should GPS tracks be in?
WGS 84 for storage, a local projected CRS for any measurement. estimate_utm_crs() picks a sensible zone for local data.
How do I handle points with no timestamp?
Keep them for geometry if you need it, but exclude them from anything temporal. A point without a time is not a trajectory point.
Should I convert tracks to LineStrings on load?
No. A LineString discards the time dimension. Keep points as the primary representation and build lines for display.
What if my file has no track identifier?
Infer one from continuity β a new track wherever the time gap or implied speed is impossible. That works for a single device logging over time, but cannot separate simultaneous traces.