My Track Has Impossible Speeds or Teleports
On this page
Problem statement
A derived speed column contains 630 km/h on a walking dataset. A track jumps 200 m sideways and back. A journey's distance is twice what the odometer says.
These have four distinct causes, and they need different fixes. Guessing wrongly makes it worse — deleting "outliers" that are really a broken identity column silently removes the fastest genuine travel.
Diagnosing first matters even when the data is clean. Measured on 46 real GPS tracks from Brighton (Example 4):
p50 1.23 m/s 4.4 km/h
p99 12.03 m/s 43.3 km/h
p99.9 24.58 m/s 88.5 km/h
max 47.87 m/s 172.3 km/h
None of 55,269 intervals exceeded 200 km/h, or the 56 m/s used below as the limit for anything on land. The fast tail here is travel, not error — and a speed filter tuned for a walking dataset would delete it (Example 3).
Quick answer
Diagnose before filtering:
import numpy as np
def diagnose_speeds(df, id_col="track_id"):
g = df.sort_values([id_col, "time"]).groupby(id_col)
dt = g["time"].diff().dt.total_seconds()
dist = np.hypot(g["x"].diff(), g["y"].diff())
speed = dist / dt.replace(0, np.nan)
print(f" zero intervals {int((dt == 0).sum()):6,} -> infinite speed")
print(f" negative intervals {int((dt < 0).sum()):6,} -> unsorted or naive tz")
print(f" big jump, small dt "
f"{int(((dist > 500) & (dt < 10)).sum()):6,} -> position spike")
print(f" big jump, big dt "
f"{int(((dist > 500) & (dt > 300)).sum()):6,} -> data gap, not a spike")
print(f" speed > 56 m/s {int((speed > 56).sum()):6,}")
return speed
speed = diagnose_speeds(df)
zero intervals 1 -> infinite speed
negative intervals 0 -> unsorted or naive tz
big jump, small dt 0 -> position spike
big jump, big dt 3 -> data gap, not a spike
speed > 56 m/s 0
Each line points at a different fix.
Zero intervals, wrong identity, spikes, gaps, unsorted times
1. Cause one: a zero time interval
Two fixes in the same second give dist / 0, which is inf, or 0/0, which is NaN with a warning.
This is common in merged or re-exported files. The Brighton download had no exact duplicate rows, and one zero interval remained after loading.
df = df.drop_duplicates(subset=["track_id", "time", "x", "y"])
df["dist"] = np.hypot(df.groupby("track_id")["x"].diff(), df.groupby("track_id")["y"].diff())
df["speed"] = df["dist"] / df["dt"].replace(0, np.nan)
2. Cause two: the track identity is wrong
If consecutive rows belong to different journeys, the "speed" between them is the distance between two unrelated places divided by whatever time separates them.
The signature is that impossible speeds cluster at group boundaries rather than being scattered. The check:
first_rows = df.groupby("track_id").head(1).index
fast = df.index[df["speed"] > 56]
if len(fast):
print(f"{fast.isin(first_rows).mean():.0%} of impossible speeds are at a track boundary")
else:
print("no impossible speeds to place")
no impossible speeds to place
If that number is high, the fix is the identity column, not a filter. In the raw Brighton download, 4 of 54 segments had more than 10% zero intervals (Example 2) — here each was one receiver logging several fixes a second, not merged traces, but only checking the distance between the same-second points tells you that.
3. Cause three: a genuine position spike
One badly displaced fix, usually from multipath or a poor satellite geometry. It produces a high speed into the point and a high speed out of it, and an enormous implied acceleration.
spike = (speed > 56) & (speed.shift(-1) > 56)
Remove the point, not its neighbours, and iterate — one spike can hide another.
4. Cause four: a data gap
A long interval with a large displacement is not an error at all. The receiver was off; the movement happened.
gaps over 300 s: 16 of 55,269 intervals (0.03%)
The largest gap in the tracks measured in GPS sampling rates was 61,691 seconds — over seventeen hours. The average speed across it is meaningless, and the fix is to split the track rather than to filter the value.
5. Cause five, the invisible one: unsorted or timezone-naive timestamps
Negative intervals mean the rows are not in time order. Two causes: the table was never sorted, or naive local timestamps crossed a daylight-saving boundary and an hour of data sorts into the wrong place.
df["time"] = pd.to_datetime(df["time"], utc=True)
df = df.sort_values(["track_id", "time"])
A diagnostic for teleports and an identity check
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. The setup loads them the way How to load GPS tracks in Python does — one track per <trkseg>, exact duplicates dropped, elements with more than 10% repeated timestamps set aside, tracks under ten points dropped — and projects them to UTM zone 30N, adding x, y and the interval dt in seconds. The API serves the newest uploads first, so the setup reads the pages as saved on 9 October 2026. Nothing on this page prints a location or a username.
import glob
import io
import os
import xml.etree.ElementTree as ET
import zipfile
import geopandas as gpd
import numpy as np
import pandas as pd
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")
NS = {"g": "http://www.topografix.com/GPX/1/0"}
rows = []
for n, path in enumerate(sorted(glob.glob("traces/*.gpx"))):
for t, trk in enumerate(ET.parse(path).getroot().findall("g:trk", NS)):
for s, seg in enumerate(trk.findall("g:trkseg", NS)):
rows += [(f"p{n}:{t}-{s}", float(p.get("lat")), float(p.get("lon")),
p.findtext("g:time", namespaces=NS)) for p in seg.findall("g:trkpt", NS)]
raw = pd.DataFrame(rows, columns=["track_id", "lat", "lon", "time"]).dropna()
raw["time"] = pd.to_datetime(raw["time"], utc=True, format="ISO8601")
raw = raw.drop_duplicates(["track_id", "time", "lat", "lon"]).sort_values(["track_id", "time"], kind="stable")
zero = raw.groupby("track_id")["time"].apply(lambda s: (s.diff().dt.total_seconds() == 0).mean())
size = raw.groupby("track_id").size()
keep = zero.index[(zero <= 0.1) & (size >= 10)]
df = gpd.GeoDataFrame(raw[raw.track_id.isin(keep)], crs=4326,
geometry=gpd.points_from_xy(raw.lon, raw.lat)[raw.track_id.isin(keep).to_numpy()])
df = df.to_crs(32630).reset_index(drop=True)
df["x"], df["y"] = df.geometry.x, df.geometry.y
df["dt"] = df.groupby("track_id")["time"].diff().dt.total_seconds()
print(f"{df.track_id.nunique()} tracks, {len(df):,} points")
46 tracks, 55,316 points
Example 1 — a diagnostic that names the cause
import numpy as np
import pandas as pd
def explain_impossible(df, id_col="track_id", max_speed_ms=56.0):
"""Classify each impossible speed rather than deleting it."""
df = df.sort_values([id_col, "time"]).reset_index(drop=True).copy()
g = df.groupby(id_col)
df["dt"] = g["time"].diff().dt.total_seconds()
df["dist"] = np.hypot(g["x"].diff(), g["y"].diff())
df["speed"] = df["dist"] / df["dt"].replace(0, np.nan)
bad = df["speed"] > max_speed_ms
df["cause"] = pd.NA
df.loc[df["dt"] == 0, "cause"] = "zero interval"
df.loc[df["dt"] < 0, "cause"] = "unsorted or naive timezone"
df.loc[bad & (df["dt"] > 300), "cause"] = "data gap"
df.loc[bad & (df["dt"] <= 300) &
(df["speed"].shift(-1) > max_speed_ms), "cause"] = "position spike"
df.loc[bad & df["cause"].isna(), "cause"] = "unexplained"
counts = df["cause"].value_counts(dropna=True)
for cause, count in counts.items():
print(f" {cause:28} {count:6,}")
if "unexplained" in counts:
print(" -> check the identity column: unexplained cases cluster "
"at track boundaries when identity is wrong")
return df
explained = explain_impossible(df)
zero interval 1
Example 2 — checking whether identity is the real problem
import numpy as np
def identity_check(df, id_col="track_id", max_speed_ms=56.0):
"""Do the impossible speeds sit at group boundaries?"""
df = df.sort_values([id_col, "time"]).reset_index(drop=True)
g = df.groupby(id_col)
dt = g["time"].diff().dt.total_seconds()
speed = np.hypot(g["x"].diff(), g["y"].diff()) / dt.replace(0, np.nan)
zero_frac = g["time"].apply(
lambda s: float((s.sort_values().diff().dt.total_seconds() == 0).mean()))
suspect = zero_frac[zero_frac > 0.1]
print(f" {int((speed > max_speed_ms).sum()):,} impossible speeds")
print(f" {len(suspect)} of {len(zero_frac)} tracks have >10% zero intervals")
if len(suspect):
print(" -> check those tracks before filtering speed: merged traces, "
"or several fixes a second with whole-second timestamps")
return suspect
# every <trkseg> in the download, before the setup set any aside
every = gpd.GeoDataFrame(raw, geometry=gpd.points_from_xy(raw.lon, raw.lat), crs=4326).to_crs(32630)
every["x"], every["y"] = every.geometry.x, every.geometry.y
suspect = identity_check(every)
0 impossible speeds
4 of 54 tracks have >10% zero intervals
-> check those tracks before filtering speed: merged traces, or several fixes a second with whole-second timestamps
Example 3 — a filter whose cost you can see
import numpy as np
def filter_with_cost(df, id_col="track_id", thresholds=(28, 56, 100)):
"""Apply each threshold and report what it removes."""
g = df.groupby(id_col)
dt = g["time"].diff().dt.total_seconds()
dist = np.hypot(g["x"].diff(), g["y"].diff())
speed = dist / dt.replace(0, np.nan)
base_km = dist.sum() / 1000
print(f" unfiltered {base_km:.3f} km over {len(df):,} points")
for t in thresholds:
keep = (speed <= t) | speed.isna()
sub = df[keep]
gg = sub.groupby(id_col)
km = np.hypot(gg["x"].diff(), gg["y"].diff()).sum() / 1000
print(f" <= {t:3d} m/s: -{int((~keep).sum()):4,} points, "
f"{km:8.3f} km ({km / base_km - 1:+.2%})")
filter_with_cost(df)
unfiltered 163.173 km over 55,316 points
<= 28 m/s: - 21 points, 163.142 km (-0.02%)
<= 56 m/s: - 0 points, 163.173 km (+0.00%)
<= 100 m/s: - 0 points, 163.173 km (+0.00%)
On these tracks the 56 and 100 m/s limits removed nothing, and the 28 m/s limit removed 21 fixes — every one of them genuine fast travel, since nothing here is impossible. A loose filter can also increase measured distance, because removing one of a spike's two points leaves a longer straight segment. Either result is a sign the threshold is not removing what you think.
Example 4 — the speed distribution quoted at the top
v = speed.replace([np.inf, -np.inf], np.nan).dropna()
for label, q in (("p50", 50), ("p99", 99), ("p99.9", 99.9)):
print(f"{label:6} {np.percentile(v, q):7.2f} m/s {np.percentile(v, q) * 3.6:7.1f} km/h")
print(f"{'max':6} {v.max():7.2f} m/s {v.max() * 3.6:7.1f} km/h")
print(f"over 200 km/h: {int((v * 3.6 > 200).sum())} of {len(v):,} intervals; "
f"gaps over 300 s: {int((df.dt > 300).sum())} ({(df.dt > 300).mean():.2%})")
p50 1.23 m/s 4.4 km/h
p99 12.03 m/s 43.3 km/h
p99.9 24.58 m/s 88.5 km/h
max 47.87 m/s 172.3 km/h
over 200 km/h: 0 of 55,269 intervals; gaps over 300 s: 16 (0.03%)
Two populations, not one long tail
Why the extreme tail is a different phenomenon
The distribution measured above is not a bell curve with a long tail. It is two populations: real movement up to about 20 m/s, and errors from 60 m/s upward, with very little between.
That separation is what makes a threshold workable. If the two populations overlapped, no threshold could separate them and you would need external information — a road network, a mode classifier — instead.
Plot a histogram of log speed before choosing a threshold. A clear valley means a clean cut exists; a smooth decline means it does not.
Why filtering by speed alone is not enough
Speed is a property of a pair of points. When one point is bad, two speeds are wrong, and a speed filter cannot tell which point caused it.
Acceleration resolves this: the displaced point has an enormous implied acceleration because the speed reverses across it. A vehicle genuinely reaching 30 m/s does so over several seconds, with acceleration under 5 m/s².
Why a broken identity column looks like noise
With ten traces merged into one element, consecutive rows are simultaneous positions of ten different people. Every derived speed is the distance between two of them divided by a fraction of a second.
The result is a dense population of impossible speeds — not a few outliers. A speed filter applied to that data removes most of the rows and leaves a track that is still nonsense, because the remaining rows are still from different journeys.
The first diagnostic is the fraction of zero intervals — ten merged traces give about 90%. It is not enough on its own: in the Brighton download four segments showed exactly that 90%, and the points sharing each second were 1–3 m apart, one receiver logging ten fixes a second. Merged traces put those points hundreds of metres or kilometres apart.
Why removing points can lengthen the track
Deleting a point joins its neighbours directly. If the point was on the inside of a curve, the straight join is shorter; if the point was a detour, removing it shortens the track; but if you remove a point adjacent to a spike rather than the spike itself, the remaining straight line to the spike can be longer than before.
The Brighton tracks had no spikes, so their cost table shows no such row — but on data that has them, a filter that increases distance is removing the wrong points.
Negative intervals, group boundaries and filters that add distance
- Drop duplicates before deriving speed, or zero intervals give infinities.
- Negative intervals mean unsorted rows or naive timezones.
- Impossible speeds clustered at group boundaries are an identity problem, not an outlier problem.
- A long gap is not a spike. Split the track instead of filtering the value.
- Use acceleration to identify which point is bad.
- Iterate: removing one spike can reveal another.
- A filter that increases total distance is removing the wrong points.
- Check the histogram for a valley before trusting any threshold.
Internal links
- How to clean a GPS track: outliers, duplicates and jumps — the full cleaning sequence
- GPS error explained: why your track wanders — where spikes come from
- How to load GPS tracks into Python as trajectories — fixing the identity column
- Trajectories explained — why identity matters so much
- Sampling rate and gaps explained — handling gaps rather than filtering them
- My time column sorts or joins wrongly — negative intervals
- How to calculate speed, distance and direction along a track — computing the columns correctly
- How to map-match a GPS track to a street network — when the positions are wrong but plausible
FAQ
Why does my GPS track show impossible speeds?
Four common causes: a zero time interval from duplicate rows, a wrong track identity so consecutive rows are different journeys, a single displaced position, or a long data gap. Diagnose before filtering.
How do I tell a spike from a gap?
A spike has a short interval and a large displacement, with high speed both into and out of the point. A gap has a long interval, and only one high speed.
What speed threshold should I use?
One justified by the mode of transport. On 46 real Brighton tracks a 56 m/s (200 km/h) cap removed nothing, and a 28 m/s cap removed 21 fixes of real vehicle travel.
Why do I have infinite speeds?
Duplicate rows or two fixes in the same second give a zero time interval. Drop exact duplicates and replace zero intervals with NaN.
Why are my speeds negative?
Negative time differences, from unsorted rows or from naive local timestamps crossing a daylight-saving boundary. Parse with utc=True and sort by track and time.
My whole file is full of impossible speeds — what now?
Almost certainly a broken identity column. Check what fraction of intervals are zero; around 90% means roughly ten traces merged into one group.
Should I delete the bad points?
Flag them first, measure what each threshold costs in points and distance, and delete once at the end. A filter that increases total distance is removing the wrong points.