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.

Derived-speed noise from a 5 metre position error at three sampling intervals. derived-speed noise from 5 m position errorintervalnoise flooragainst a walking pace1 s7.1 m/s25 km/h β€” above it3 s2.4 m/stwice a 1.2 m/s walk10 s0.7 m/swell below itAt 1 Hz the noise floor sits above a pedestrian actual speed spatialworkflow.io
The same position error becomes a different amount of speed error depending only on the interval.

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.

Random receiver noise scattering around the true position against multipath bias displacing the track consistently to one side of a street. two errors, one of them removable by smoothingreceiver noisesmoothing helpsrandom, averages outmultipathsmoothly in the wrong streetbiased, does notOnly external information β€” a road network β€” recovers a systematic displacement spatialworkflow.io
Smoothing removes the scatter and keeps the bias. Only external information β€” a road network β€” removes the bias.

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
One real stationary GPS stay from the Brighton tracks: 163 fixes over 252 seconds, plotted relative to their centre. a stay: 163 fixes over 252 s, never leaving a 15 m circle10 mmedian distance from the centre2.2 m (solid circle)95th percentile7.8 m (dashed circle)path traced between fixes112 m, inside a 15 m circlerelative positions only; the centreis the mean of the fixes spatialworkflow.io
One of the 33 stays found in Example 5, chosen as the one closest to their median scatter.

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.

Speed thresholds removing few points and little distance at generous settings, and real vehicle travel at tight ones. what each speed cap removes from 163.17 km (46 Brighton tracks)≀ 200 km/h0 points, 0.00%≀ 100 km/h21 points, βˆ’0.02%≀ 50 km/h442 points, βˆ’2.50%≀ 29 km/h1,452 points, βˆ’3.61%02%4%Cleaning too hard is not the safe option β€” the tight cap deletes real driving spatialworkflow.io
A cap above every real speed costs nothing; a cap below them deletes real movement.

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, sat and fix if the file has them.
  • Altitude error is roughly twice horizontal error β€” never derive gradient from raw GPS altitude.

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.