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.

Four causes of impossible GPS speeds: zero interval, wrong track identity, position spike and data gap, each with its own fix. four causes, one symptomdiagnosticcausedt == 0duplicate rows — drop themclustered at group boundariesthe identity column is wrongfast in and fast outa single displaced positiona long dt with a large jumpa data gap — split, do not filterdt < 0unsorted rows or naive timezonesFiltering by speed treats only one of these spatialworkflow.io
The same symptom, four causes. Filtering by speed treats only one of them.

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 position spike showing a high speed into and out of one point with an enormous implied acceleration, against a data gap with a single high speed. the pattern tells you whichdthigh speedsaccelerationposition spike!short✕two, in and out✕enormousdata gaplong!one✓unremarkableAcceleration identifies which point is wrong; speed alone cannot spatialworkflow.io
A spike is fast in and fast out. A gap is fast once. The pattern tells you which.

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
Speed percentiles of 46 Brighton GPS tracks against the 56 m/s limit for anything on land. the fast tail is travel, not errormedian1.23 m/s99th percentile12.03 m/s99.9th percentile24.58 m/sfastest interval47.87 m/s — under the limit028 m/s56 m/sNothing reaches 56 m/s; a 28 m/s filter would delete 21 real fixes spatialworkflow.io
Diagnose first: on clean data the only thing a speed filter removes is real travel.

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.

A speed filter that removes the wrong point increasing the measured track length. a filter that increases total distance is removing the wrong pointsremove the spike✓neighbours joined directly✓length falls✓the intended effect✓its impossible speed is goneremove its neighbour✕the spike survives✕a longer straight line to it✕length rises✕a sign the filter hit the wrong pointCheck the distance change of every filter on data that has spikes spatialworkflow.io
A filter that increases total distance has removed the wrong points. It is a free correctness check.

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.

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.