GPS Error Explained: Why Your Track Wanders
On this page
Problem statement
A GPS track of someone standing still is not a point. It is a cloud a few metres across, drifting slowly. A track along a straight road is not straight. And the derived speeds can contain values nothing on that road could reach.
Measured on 46 real GPS tracks uploaded to OpenStreetMap around Brighton, with a median speed of 4.4 km/h β walking pace (Example 4):
p50 1.23 m/s 4.4 km/h
p90 4.81 m/s 17.3 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
Nothing in that table is impossible, and a 200 km/h cap removes nothing from these tracks. The error is still there; it is just smaller than the movement. It shows when the receiver stops: in 33 stretches of two minutes or more that never left a 15 m circle, the fixes scattered a median 2.2 m around their centre, 8.1 m at the 95th percentile, and traced a median 34.0 m of path while going nowhere (Example 5). Telling error from movement is the whole of GPS data cleaning.
Quick answer
Position error divided by a short interval produces a large speed error:
# a rough noise floor for derived speed
position_error_m = 5.0 # typical consumer horizontal error
interval_s = 1.0
speed_noise = position_error_m * (2 ** 0.5) / interval_s
print(f"~{speed_noise:.1f} m/s of speed noise from position error alone")
~7.1 m/s of speed noise from position error alone
Seven metres per second is 25 km/h. At one-second sampling, that noise floor sits above the actual speed of a pedestrian β which is why high-frequency pedestrian tracks look erratic and low-frequency ones look smooth.
Error sources, where they bite, and physics as a filter
1. Know the error sources and which ones you can fix
- Multipath β the signal bounces off a building before reaching the receiver, so the computed range is too long. This is the dominant urban error and it is biased, not random: reflections make you appear further from the satellite, and in a street canyon they push consistently in one direction.
- Satellite geometry (DOP) β satellites clustered in one part of the sky give a poorly conditioned solution. Recorded as HDOP or PDOP where the format supports it.
- Atmospheric delay β ionospheric and tropospheric refraction, largely corrected by modern receivers and by differential methods.
- Receiver noise β the irreducible floor, a metre or two.
- Cold start and reacquisition β the first fixes after losing lock are much worse than the steady state.
Only the last two behave like random noise. The rest are correlated in time and space, which is why averaging does not remove them.
2. Expect error to be worst where the analysis is hardest
Multipath is worst among tall buildings. Signal loss is worst in tunnels and under canopy. Reacquisition error follows every gap.
So error concentrates in dense urban areas, exactly where you want to know which street someone used. The measured error is not spread evenly over the track; it is concentrated at the interesting parts.
3. Separate error from movement using physics
On the Brighton tracks, with a median of 4.4 km/h, no interval implies more than 200 km/h: on recent, cleaned uploads the fast tail is travel. Filtering by a plausible maximum speed is still the first line of defence, and what it costs can be measured (Example 4):
speed <= 200 km/h: removed 0 points, length 163.17 km (+0.00%)
speed <= 100 km/h: removed 21 points, length 163.14 km (-0.02%)
speed <= 50 km/h: removed 442 points, length 159.09 km (-2.50%)
speed <= 29 km/h: removed 1452 points, length 157.29 km (-3.61%)
A generous threshold removes nothing here. A tight one removes real movement: a 50 km/h cap deletes 442 fixes and 2.50% of the distance, a 29 km/h cap 3.61% β this dataset contains genuine vehicle travel, so those caps delete data rather than noise.
4. Prefer a threshold you can justify
Set the cap from the mode of transport, not from the histogram. A pedestrian study can cap at 10 m/s; a mixed-mode dataset cannot, because the same file contains walking and driving.
Where modes are mixed, filter on implausibility β a speed nothing achieves, like 200 km/h on urban streets β and handle mode separation as its own step.
5. Understand what smoothing does and does not fix
A moving average or Kalman filter reduces uncorrelated noise. Multipath is correlated over seconds to minutes, so smoothing reduces its visible jitter while leaving its bias in place.
The result is a smooth track that is confidently in the wrong street. For street-level accuracy, map matching against a road network is the tool that actually helps β see How to map-match a GPS track to a street network.
Speed and spike filters with the recorded quality fields
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 β an error-aware speed filter
import numpy as np
def flag_implausible(df, id_col="track_id", max_speed_ms=56.0,
max_accel_ms2=10.0):
"""Flag rather than delete, so the decision stays visible."""
df = df.sort_values([id_col, "time"]).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)
df["accel"] = df.groupby(id_col)["speed"].diff() / df["dt"].replace(0, np.nan)
df["flag_speed"] = df["speed"] > max_speed_ms
df["flag_accel"] = df["accel"].abs() > max_accel_ms2
df["flag_zero_dt"] = df["dt"] == 0
df["flag"] = df[["flag_speed", "flag_accel", "flag_zero_dt"]].any(axis=1)
for name in ("flag_speed", "flag_accel", "flag_zero_dt"):
print(f" {name:14} {int(df[name].sum()):6,} "
f"({df[name].mean():6.3%})")
print(f" {'any flag':14} {int(df['flag'].sum()):6,} "
f"({df['flag'].mean():6.3%})")
return df
flagged = flag_implausible(df[["track_id", "time", "x", "y"]])
flag_speed 0 (0.000%)
flag_accel 62 (0.112%)
flag_zero_dt 1 (0.002%)
any flag 63 (0.114%)
On the Brighton tracks no interval breaks 56 m/s, but 62 break the acceleration limit. Acceleration catches something speed alone misses: a single displaced point produces one very high speed into it and one very high speed out of it, so the acceleration between them is enormous. A genuine acceleration to 30 m/s takes several seconds.
Example 2 β a spike filter that removes the point, not the track
import numpy as np
def remove_spikes(df, id_col="track_id", max_speed_ms=56.0, max_passes=5):
"""Iteratively drop points whose removal makes the track plausible."""
df = df.sort_values([id_col, "time"]).reset_index(drop=True).copy()
for step in range(max_passes):
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)
bad = speed > max_speed_ms
if not bad.any():
print(f" converged after {step} pass(es)")
break
# a spike shows as a high speed in AND out; drop the middle point
next_bad = bad.shift(-1, fill_value=False)
spike = bad & next_bad
drop = spike if spike.any() else bad
print(f" pass {step + 1}: dropping {int(drop.sum()):,} points")
df = df[~drop].reset_index(drop=True)
return df
despiked = remove_spikes(df[["track_id", "time", "x", "y"]])
print(f" {len(df) - len(despiked):,} points removed")
converged after 0 pass(es)
0 points removed
On the Brighton tracks it has nothing to do, because no interval exceeds 56 m/s. Iterating matters on data that has spikes: removing one spike changes the speeds of its neighbours, which can reveal a second spike that was hidden by the first.
Example 3 β using the recorded quality fields
import numpy as np
def quality_filter(df, hdop_max=5.0, min_sats=4, fix_types=("3d",)):
"""If the format recorded quality, use it rather than inferring it."""
mask = np.ones(len(df), bool)
report = {}
if "hdop" in df:
ok = df["hdop"] <= hdop_max
report["hdop"] = float((~ok).mean()); mask &= ok.fillna(True)
if "sat" in df:
ok = df["sat"] >= min_sats
report["satellites"] = float((~ok).mean()); mask &= ok.fillna(True)
if "fix" in df:
ok = df["fix"].isin(fix_types)
report["fix_type"] = float((~ok).mean()); mask &= ok.fillna(True)
if not report:
print(" no quality fields in this file β falling back to physics")
for key, share in report.items():
print(f" {key:12} would remove {share:6.2%}")
print(f" keeping {mask.mean():.2%} of points")
return df[mask]
kept = quality_filter(df)
no quality fields in this file β falling back to physics
keeping 100.00% of points
GPX supports hdop, sat and fix elements, and most exporters omit them. The OpenStreetMap trackpoints API serves position and time only, so the Brighton tracks fall back to physics. When they are present they are better evidence than any physical filter, because they describe the solution rather than its consequences.
Example 4 β the speed distribution, and what a cap costs
g = df.groupby("track_id")
step = np.hypot(g["x"].diff(), g["y"].diff())
v = (step / df["dt"].replace(0, np.nan)).dropna()
print(f"{len(v):,} intervals, median interval {df.dt.median():.0f} s")
for label, q in (("p50", 50), ("p90", 90), ("p99", 99), ("p99.9", 99.9), ("max", 100)):
x = np.percentile(v, q)
print(f"{label:6} {x:7.2f} m/s {x * 3.6:7.1f} km/h")
base = step.sum() / 1000
for kmh in (200, 100, 50, 29):
keep = ~((step / df["dt"].replace(0, np.nan)) * 3.6 > kmh)
sub = df[keep]
gg = sub.groupby("track_id")
km = np.hypot(gg["x"].diff(), gg["y"].diff()).sum() / 1000
print(f"speed <= {kmh:3} km/h: removed {int((~keep).sum()):3} points, "
f"length {km:6.2f} km ({km / base - 1:+.2%})")
55,269 intervals, median interval 1 s
p50 1.23 m/s 4.4 km/h
p90 4.81 m/s 17.3 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
speed <= 200 km/h: removed 0 points, length 163.17 km (+0.00%)
speed <= 100 km/h: removed 21 points, length 163.14 km (-0.02%)
speed <= 50 km/h: removed 442 points, length 159.09 km (-2.50%)
speed <= 29 km/h: removed 1452 points, length 157.29 km (-3.61%)
Example 5 β what standing still looks like
def stays(df, radius=15.0, min_seconds=120.0):
"""Runs of at least min_seconds that never leave radius metres of where they began."""
rows = []
for track, t in df.groupby("track_id"):
x, y, tm = t.x.to_numpy(), t.y.to_numpy(), t.time.to_numpy()
i = 0
while i < len(t):
j = i
while j + 1 < len(t) and np.hypot(x[j + 1] - x[i], y[j + 1] - y[i]) <= radius:
j += 1
seconds = (tm[j] - tm[i]) / np.timedelta64(1, "s")
if seconds >= min_seconds and j - i >= 10:
sx, sy = x[i:j + 1], y[i:j + 1]
r = np.hypot(sx - sx.mean(), sy - sy.mean())
rows.append({"seconds": seconds, "points": j - i + 1,
"radius_p50": np.median(r), "radius_p95": np.percentile(r, 95),
"path_m": np.hypot(np.diff(sx), np.diff(sy)).sum()})
i = j + 1
else:
i += 1
return pd.DataFrame(rows)
s = stays(df)
print(f"{len(s)} stays of 2 minutes or more within 15 m")
print(s.median().round(1).to_string())
print(f"phantom distance inside stays: {s.path_m.sum() / 1000:.2f} km "
f"of {step.sum() / 1000:.2f} km in all ({s.path_m.sum() / step.sum():.1%})")
typical = s.loc[((s.radius_p50 - s.radius_p50.median()).abs()
+ (s.radius_p95 - s.radius_p95.median()).abs() / 4).idxmin()]
print(f"the stay drawn below: {typical.points:.0f} fixes over {typical.seconds:.0f} s, "
f"radius {typical.radius_p50:.1f} m median / {typical.radius_p95:.1f} m p95, path {typical.path_m:.0f} m")
33 stays of 2 minutes or more within 15 m
seconds 177.0
points 128.0
radius_p50 2.2
radius_p95 8.1
path_m 34.0
phantom distance inside stays: 1.37 km of 163.17 km in all (0.8%)
the stay drawn below: 163 fixes over 252 s, radius 2.2 m median / 7.8 m p95, path 112 m
How position error becomes speed error
Why position error becomes large speed error
Derived speed is the distance between two noisy positions divided by the time between them. Each position carries an independent error of roughly Ο, so their difference carries about Οβ2, and dividing by a short interval multiplies it.
At Ο = 5 m and a 1-second interval that is about 7 m/s β 25 km/h of noise on a measurement whose true value might be 1.4 m/s.
Two practical consequences. Derived speed at high sampling rates is dominated by noise, so smooth it or use the receiver's Doppler speed. And the noise floor scales as 1/interval, which is why coarse logs give cleaner-looking speeds β they are averaging over more real movement.
Why multipath is worse than noise
Random noise averages out. Multipath does not: a reflection always makes the measured range longer than the true one, and in a street canyon the same buildings reflect the same way for as long as you are there.
The result is a track that is smoothly, confidently and consistently displaced β often onto the parallel street. No amount of filtering fixes it, because the data contains no information about the true position.
This is why map matching exists. It brings in a road network as external evidence and asks which sequence of roads best explains the observations, which can recover a displacement that is systematically wrong.
Why standing still produces the worst-looking data
When a receiver is stationary, all of the apparent movement is error. The track becomes a wandering blob a few metres across, generating a stream of small, meaningless displacements and headings.
Two consequences. Stationary periods inflate total distance β in Example 5, 33 stays added 1.37 km of phantom path, 0.8% of everything the tracks recorded. And they produce meaningless headings, which corrupt any turn or direction analysis.
Detecting and collapsing stops before computing distance is therefore not a refinement; it is a correctness fix. See How to split a track into trips and stops in Python.
Why the accuracy figure on the box is optimistic
Quoted accuracies are typically 50th-percentile values under open-sky conditions with a good satellite constellation. Real deployments are the tail: urban canyons, in a pocket, under a windscreen, after a cold start.
Design filters for the tail. A receiver standing still in Brighton scattered a median 2.2 m, but 8.1 m at the 95th percentile β the tail is where the work is.
Multipath bias, phantom distance and altitude
- Speed noise scales as 1/interval. High-rate logs look worse and are not.
- Multipath is biased, not random, so smoothing cannot remove it.
- Stationary periods generate phantom distance. Detect stops before summing.
- Reacquisition after a gap gives the worst fixes. Consider dropping the first few.
- Flag rather than delete where you can, so the choice is auditable.
- Filter on acceleration as well as speed to catch single displaced points.
- Use
hdop,satandfixif the file has them. - Altitude error is roughly twice horizontal error β never derive gradient from raw GPS altitude.
Internal links
- Trajectories explained: what makes a track different from points β where derived speed comes from
- Sampling rate and gaps explained β why the interval sets the noise floor
- How to clean a GPS track: outliers, duplicates and jumps β the full cleaning sequence
- My track has impossible speeds or teleports β diagnosing the extreme values
- How to map-match a GPS track to a street network β the only fix for multipath bias
- How to split a track into trips and stops in Python β removing phantom distance
- How to calculate speed, distance and direction along a track β computing them robustly
- Coordinate precision and floating point in GIS explained β a different kind of position error
FAQ
Why does my GPS track wander when I am standing still?
Because all apparent movement is error. Receiver noise and multipath produce a drifting cloud a few metres across, which accumulates into phantom distance if you sum it.
Why do I see impossible speeds in walking data?
Position error divided by a short interval. With 5 m error and 1-second sampling the noise floor for derived speed is about 7 m/s β above walking pace.
What speed threshold should I filter at?
One justified by the mode of transport, and generous. On the Brighton tracks, capping at 200 km/h removed nothing; capping at 29 km/h removed 1,452 points and 3.61% of the distance, all of it real movement.
Does smoothing fix GPS error?
Only the random part. Multipath is correlated and biased, so smoothing produces a smooth track in the wrong street. Map matching is the fix for that.
Why is my track in the wrong street?
Multipath in an urban canyon. Reflected signals lengthen the measured range consistently, displacing the solution β often onto a parallel road.
Should I use the altitude from GPS?
Not for gradients. Vertical error is roughly twice horizontal error, so derived slopes are dominated by noise. Sample a DEM at the track positions instead.
What are HDOP and PDOP?
Dilution of precision: how well the satellite geometry conditions the position solution. Lower is better; above about 5 the fix is weak.