Sampling Rate and Gaps Explained: How Often Is Often Enough
On this page
Problem statement
The sampling interval of a GPS log is usually chosen by whatever the device defaulted to, and it silently determines what your analysis can and cannot say.
Measured on 46 real GPS tracks from Brighton, logged at a median of one fix a second and thinned to coarser intervals (Example 3):
native 163.2 km
5 s 160.6 km -1.5%
10 s 158.3 km -3.0%
30 s 151.4 km -7.2%
60 s 142.9 km -12.4%
120 s 133.1 km -18.4%
Same journeys, same receivers, same code. Nearly a fifth of the distance disappears between 1-second and 2-minute logging.
The interval also is not one number. Across those tracks the median gap was 1 s, the 95th percentile 3 s and the maximum 61,691 s β over seventeen hours with no fix at all.
Quick answer
Report the interval distribution, not the nominal rate:
import numpy as np
dt = df.groupby("track_id")["time"].diff().dt.total_seconds().dropna()
print(f"median {dt.median():.0f}s p95 {dt.quantile(0.95):.0f}s "
f"p99 {dt.quantile(0.99):.0f}s max {dt.max():,.0f}s")
for threshold in (5, 30, 60, 300):
print(f" gaps over {threshold:4d}s: {int((dt > threshold).sum()):5,} "
f"({(dt > threshold).mean():6.2%})")
median 1s p95 3s p99 11s max 61,691s
gaps over 5s: 1,389 ( 2.51%)
gaps over 30s: 113 ( 0.20%)
gaps over 60s: 64 ( 0.12%)
gaps over 300s: 16 ( 0.03%)
The interval your question needs against the one you have
1. Work out what interval your question needs
Different questions have different requirements, and they differ by orders of magnitude:
| question | interval needed | why |
|---|---|---|
| where did this vehicle go | 30β60 s | route topology survives coarse sampling |
| how far did it travel | 1β5 s | distance is sampling-dependent |
| where did it stop | 5β30 s | a 2-minute stop needs several fixes inside it |
| how fast at a point | 1 s, or the receiver's own speed | derived speed needs a short baseline |
| did it exceed a speed limit | 1 s, plus Doppler speed | averages hide brief excesses |
| turn-by-turn behaviour | 1 s | turns take a few seconds |
"Where did it go" is remarkably robust; "how far did it go" is not.
2. Measure the interval you actually have
Nominal rates are aspirations. A device set to 1 Hz loses lock in tunnels, throttles on low battery, and may drop to event-based logging when stationary.
The distribution above has a median of 1 s and a maximum of 61,691 s. Both matter: the median sets the resolution, the tail sets where the track is fiction. Here the 16 gaps over five minutes are 0.03% of the intervals and 51% of the elapsed time (Example 1).
3. Treat long gaps as breaks, not as long straight lines
A 61,691-second gap interpolated as a straight line at constant speed is a fabrication. Where the receiver was off, the honest representation is a break.
GAP_SECONDS = 120
df["new_leg"] = (df["dt"] > GAP_SECONDS) | df["dt"].isna()
df["leg"] = df.groupby("track_id")["new_leg"].cumsum()
Splitting at gaps turns one impossible line into two honest ones with a hole between them.
4. Resample to a common interval before comparing datasets
Two fleets, two loggers, two mileage figures for the same driving. The only fix is to downsample both to the coarser interval and compare like with like. See How to resample and interpolate a track to a fixed interval.
5. Do not upsample to fix a coarse log
Interpolating a 60-second log to 1 Hz produces sixty points a minute, of which one is a measurement. On these tracks it turned 1,341 fixes into 168,895 points, and the distance stayed at 142.92 km rather than rising back to 163.17 km, because interpolation on a straight line adds points without adding path.
Upsampling is useful for aligning two datasets in time. It does not recover information.
Interval reports, gap splits and an upsampling test
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 interval report with the tail made visible
import numpy as np
import pandas as pd
def interval_report(df, id_col="track_id", time_col="time",
thresholds=(2, 5, 10, 30, 60, 300, 900)):
"""The nominal rate, the real distribution, and where the holes are."""
dt = df.sort_values([id_col, time_col]).groupby(id_col)[time_col] \
.diff().dt.total_seconds().dropna()
print(f" {len(dt):,} intervals across {df[id_col].nunique()} tracks")
for q in (5, 25, 50, 75, 95, 99):
print(f" p{q:<3} {np.percentile(dt, q):9.1f} s")
print(f" max {dt.max():9.1f} s")
total = dt.sum()
for t in thresholds:
over = dt[dt > t]
print(f" over {t:5d}s: {len(over):5,} gaps "
f"({len(over) / len(dt):6.2%} of intervals, "
f"{over.sum() / total:6.2%} of elapsed time)")
return dt
dt = interval_report(df)
55,270 intervals across 46 tracks
p5 1.0 s
p25 1.0 s
p50 1.0 s
p75 1.0 s
p95 3.0 s
p99 11.0 s
max 61691.0 s
over 2s: 3,408 gaps ( 6.17% of intervals, 68.00% of elapsed time)
over 5s: 1,389 gaps ( 2.51% of intervals, 63.38% of elapsed time)
over 10s: 559 gaps ( 1.01% of intervals, 60.12% of elapsed time)
over 30s: 113 gaps ( 0.20% of intervals, 55.46% of elapsed time)
over 60s: 64 gaps ( 0.12% of intervals, 54.23% of elapsed time)
over 300s: 16 gaps ( 0.03% of intervals, 51.39% of elapsed time)
over 900s: 6 gaps ( 0.01% of intervals, 48.00% of elapsed time)
The second percentage is the one people miss. A handful of long gaps can be a negligible fraction of the intervals and most of the elapsed time β meaning the track covers far less of the journey than its point count suggests.
Example 2 β splitting a track at its gaps
import numpy as np
def split_on_gaps(df, id_col="track_id", time_col="time",
max_gap_s=120, max_jump_m=None):
"""One leg per continuously observed stretch."""
df = df.sort_values([id_col, time_col]).copy()
g = df.groupby(id_col)
dt = g[time_col].diff().dt.total_seconds()
breaks = dt > max_gap_s
if max_jump_m is not None:
jump = np.hypot(g["x"].diff(), g["y"].diff())
breaks |= jump > max_jump_m
breaks |= dt.isna() # first row of each track
df["leg"] = breaks.groupby(df[id_col]).cumsum()
df["leg_id"] = df[id_col].astype(str) + "-" + df["leg"].astype(str)
legs = df.groupby("leg_id").size()
print(f" {df[id_col].nunique()} tracks -> {len(legs)} legs")
print(f" legs with fewer than 3 points: {int((legs < 3).sum())}")
return df
legs = split_on_gaps(df, max_gap_s=120, max_jump_m=500)
46 tracks -> 75 legs
legs with fewer than 3 points: 3
Splitting on a distance jump as well as a time gap catches the other failure: a short interval with an impossible displacement, which is a position error rather than a pause.
Example 3 β what interval does your question tolerate?
import numpy as np
def question_sensitivity(gdf, metric, id_col="track_id", time_col="time",
steps=(1, 5, 10, 30, 60, 120)):
"""Recompute your actual metric at several intervals and see if it moves."""
base = metric(gdf)
print(f" native: {base:.4g}")
for step in steps:
keep = []
for _, sub in gdf.groupby(id_col):
elapsed = (sub[time_col] - sub[time_col].iloc[0]) \
.dt.total_seconds().values
picked = [0]
for i in range(1, len(elapsed)):
if elapsed[i] - elapsed[picked[-1]] >= step:
picked.append(i)
keep.append(sub.iloc[picked])
value = metric(pd.concat(keep))
print(f" {step:4d}s: {value:.4g} ({value / base - 1:+.1%})")
def path_km(g):
return sum(np.hypot(np.diff(t.x), np.diff(t.y)).sum() for _, t in g.groupby("track_id")) / 1000
def displacement_km(g):
return sum(np.hypot(t.x.iloc[-1] - t.x.iloc[0], t.y.iloc[-1] - t.y.iloc[0])
for _, t in g.groupby("track_id")) / 1000
print("path length, km")
question_sensitivity(df, path_km, steps=(5, 10, 30, 60, 120))
print("start-to-end displacement, km")
question_sensitivity(df, displacement_km, steps=(5, 10, 30, 60, 120))
path length, km
native: 163.2
5s: 160.6 (-1.5%)
10s: 158.3 (-3.0%)
30s: 151.4 (-7.2%)
60s: 142.9 (-12.4%)
120s: 133.1 (-18.4%)
start-to-end displacement, km
native: 56.52
5s: 56.31 (-0.4%)
10s: 56.03 (-0.9%)
30s: 55.52 (-1.8%)
60s: 54.47 (-3.6%)
120s: 52.82 (-6.5%)
Do this with your metric rather than with distance. Some are far more robust than others: start-to-end displacement moved by 1.8% or less up to 30-second sampling, while path length lost 7.2%. At 120 s even displacement fell 6.5%, partly because keeping one fix every two minutes drops up to two minutes from the end of every track.
Example 4 β upsampling does not bring the distance back
def thin(g, step):
out = []
for _, sub in g.groupby("track_id"):
elapsed = (sub.time - sub.time.iloc[0]).dt.total_seconds().to_numpy()
picked = [0]
for i in range(1, len(elapsed)):
if elapsed[i] - elapsed[picked[-1]] >= step:
picked.append(i)
out.append(sub.iloc[picked])
return pd.concat(out)
def upsample(g, step=1):
out = []
for key, sub in g.groupby("track_id"):
t = (sub.time - sub.time.iloc[0]).dt.total_seconds().to_numpy()
grid = np.arange(0, t[-1] + step, step)
out.append(pd.DataFrame({"track_id": key, "x": np.interp(grid, t, sub.x),
"y": np.interp(grid, t, sub.y)}))
return pd.concat(out)
coarse = thin(df, 60)
print(f"native {path_km(df):.2f} km; every 60 s {path_km(coarse):.2f} km; "
f"that log interpolated to 1 s {path_km(upsample(coarse)):.2f} km "
f"({len(upsample(coarse)):,} points from {len(coarse):,})")
native 163.17 km; every 60 s 142.92 km; that log interpolated to 1 s 142.92 km (168,895 points from 1,341)
Why coarser sampling shortens the path
Why coarser sampling always shortens the path
Between two fixes the software assumes a straight line. Any deviation from straight between them is lost, and deviations exist at every scale β lane changes, gentle curves, the wobble of a walking gait.
So measured length falls monotonically as the interval grows, and never rises. It is the coastline paradox with time as the ruler: 163.2 km measured at 1 s becomes 133.1 km at 120 s.
There is no "true" length independent of the ruler. There is only length-at-an-interval, which is why the interval belongs beside the number.
Why the gap distribution is more useful than the rate
A device advertised as "1 Hz" produces intervals of 1 s most of the time and much longer occasionally, and the occasional ones concentrate exactly where the environment is difficult β urban canyons, tunnels, dense canopy.
That correlation matters. The gaps are not random missing data; they are systematically located in the places where routing, mode detection and stop detection are hardest. An analysis that ignores them is biased, not merely noisy.
Why the receiver's own speed beats derived speed
Many receivers compute speed from Doppler shift on the carrier signal, which is an independent measurement rather than a difference of two noisy positions.
Derived speed inherits the error of both endpoints, divided by the interval. With 5 m of position error and a 1-second interval, that is roughly 7 m/s of noise β which is why the highest-frequency logs contain the most implausible speeds.
If your data has a speed column from the device, prefer it, and use derived speed only as a cross-check.
Why upsampling does not help
Interpolating between fixes adds rows along the straight line already assumed. Distance along a straight line does not change when you add points to it, so the measured length is unchanged.
Upsampling is genuinely useful for one thing: putting two datasets on a common time base so they can be compared or joined. It is not a way to recover a path that was never sampled.
Medians, percentiles and devices that change rate
- Report the interval with every distance. It is part of the measurement.
- Use the median and the 95th percentile, not the mean β one long gap dominates the mean.
- Split at long gaps rather than interpolating across them.
- A gap of zero is also a problem: two fixes in the same second give infinite derived speed.
- Gaps cluster in hard environments, so missing data is not missing at random.
- Downsample to compare, never upsample.
- Devices change rate mid-log on low battery or when stationary; check for regime changes.
- Displacement is robust, path length is not. Choose the metric that survives your sampling.
Internal links
- Trajectories explained: what makes a track different from points β the sampling-rate effect in context
- How to resample and interpolate a track to a fixed interval β the implementation
- GPS error explained: why your track wanders β the other source of bad speeds
- How to clean a GPS track: outliers, duplicates and jumps β handling the tail
- Stops, trips and segmentation explained β why stop detection needs a minimum rate
- How to calculate speed, distance and direction along a track β the derived columns
- My track has impossible speeds or teleports β the zero-interval case
- How to load GPS tracks into Python as trajectories β where to compute the report
FAQ
What GPS sampling rate should I use?
It depends on the question. Route topology survives 30β60 s; distance needs 1β5 s; stop detection needs several fixes inside the shortest stop you care about.
Why does my track distance change with the sampling rate?
Because straight lines between fixes cut every deviation shorter than the interval. Measured length fell 21.5% between 1-second and 2-minute sampling on real traces.
Can I recover detail by interpolating to a higher rate?
No. Interpolation adds points along the straight line already assumed, so the measured length does not change. It is useful only for aligning datasets in time.
What should I do about long gaps?
Split the track into legs at gaps above a threshold. Drawing a straight line across a 50-minute gap asserts a journey that was never recorded.
Is a mean interval useful?
Not really. One long gap dominates it. Report the median, the 95th percentile and the maximum.
Why does high-frequency GPS show more impossible speeds?
Because derived speed is a position difference divided by a short interval, so position noise is amplified. Use the receiver's Doppler speed if it is available.
How do I compare two datasets logged at different rates?
Downsample both to the coarser interval before computing anything, and state the interval alongside every distance.