How to Resample and Interpolate a Track to a Fixed Interval
On this page
Problem statement
Two datasets logged at different rates cannot be compared. The finer one reports longer journeys for identical driving, because straight lines between fixes cut fewer corners.
Measured on 46 real tracks from Brighton, downsampled from their native rate of about one fix a second (Example 4):
native 163.17 km
1 s 163.17 km -0.0%
2 s 162.32 km -0.5%
5 s 160.65 km -1.5%
10 s 158.27 km -3.0%
30 s 151.38 km -7.2%
60 s 142.92 km -12.4%
120 s 133.11 km -18.4%
Resampling is how you make them comparable β and the direction matters. Downsampling to a common interval works; upsampling to recover detail does not.
Quick answer
Downsample by time, not by row count:
import numpy as np
def downsample(sub, step_s):
"""Keep the first fix, then the next fix at least step_s later."""
elapsed = (sub["time"] - sub["time"].iloc[0]).dt.total_seconds().values
keep = [0]
for i in range(1, len(elapsed)):
if elapsed[i] - elapsed[keep[-1]] >= step_s:
keep.append(i)
return sub.iloc[keep]
Taking every nth row instead gives a variable interval whenever the source rate varies, which it always does β the tracks measured here had a median interval of 1 s and a maximum of 61,691 s.
What you get β from The data used on this page
46 tracks, 55,316 points
Pick a direction, resample by time, never across a gap
1. Decide which direction you need
- Downsample to compare datasets, to reduce volume, or to match a model's time step. It loses detail, and you can quantify exactly how much.
- Upsample to align two datasets in time, or to feed a routine that requires a regular series. It does not recover detail.
Both are legitimate. Confusing them is not.
2. Downsample by elapsed time
The greedy rule above β keep a fix if it is at least step after the last kept one β produces intervals of at least step and never resamples across a gap in a misleading way.
The alternative, df.resample("30s").first(), produces a regular index with NaN in every empty bin. That is useful when you want a strictly regular series and want the gaps visible, and unhelpful when you want a smaller set of real fixes.
3. Never resample across a gap
The longest gap in these tracks, 61,691 seconds, resampled to 10 seconds would produce 6,169 interpolated points along a straight line that nobody travelled.
df["leg"] = (df["dt"] > gap_s).groupby(df["track_id"]).cumsum()
Split into legs first, resample within each, and leave the gaps as gaps.
4. If upsampling, interpolate positions β and mark them
resampled = (sub.set_index("time")[["x", "y"]]
.resample(f"{step}s").mean()
.interpolate(method="time"))
resampled["interpolated"] = sub.set_index("time")["x"] \
.resample(f"{step}s").count().eq(0).values
The interpolated flag is the important line. An interpolated position is a model output, and any statistic computed over the series should be able to exclude it or weight it down.
5. Report the interval with every derived number
Distance, mean speed and time-in-motion are all interval-dependent. State the interval the way you state the units.
Downsampling, flagged upsampling and matching two datasets
The data used on this page
Public GPS traces from the OpenStreetMap trackpoints API 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 β downsampling with the cost reported
import numpy as np
import pandas as pd
def downsample_tracks(df, step_s, id_col="track_id", gap_s=None):
"""Downsample every track to a minimum interval, respecting gaps."""
if gap_s:
g = df.groupby(id_col)
dt = g["time"].diff().dt.total_seconds()
df = df.copy()
df["leg"] = (dt > gap_s).groupby(df[id_col]).cumsum()
group_cols = [id_col, "leg"]
else:
group_cols = [id_col]
kept = []
for _, sub in df.sort_values(group_cols + ["time"]).groupby(group_cols):
elapsed = (sub["time"] - sub["time"].iloc[0]).dt.total_seconds().values
keep = [0]
for i in range(1, len(elapsed)):
if elapsed[i] - elapsed[keep[-1]] >= step_s:
keep.append(i)
kept.append(sub.iloc[keep])
out = pd.concat(kept, ignore_index=True)
def length(frame):
gg = frame.groupby(group_cols)
return float(np.hypot(gg["x"].diff(), gg["y"].diff()).sum())
before, after = length(df), length(out)
print(f" {len(df):,} -> {len(out):,} points ({len(out) / len(df):.1%})")
print(f" {before / 1000:.2f} km -> {after / 1000:.2f} km "
f"({after / before - 1:+.1%})")
return out
every_10s = downsample_tracks(df, 10)
55,316 -> 7,055 points (12.8%)
163.17 km -> 158.27 km (-3.0%)
Printing both the point reduction and the distance change makes the trade-off explicit at the moment you make it.
Example 2 β upsampling to a regular series, with flags
import numpy as np
import pandas as pd
def upsample_tracks(df, step_s, id_col="track_id", gap_s=300,
method="time"):
"""Regular time series per leg, with interpolated rows marked."""
g = df.groupby(id_col)
df = df.copy()
df["leg"] = (g["time"].diff().dt.total_seconds() > gap_s) \
.groupby(df[id_col]).cumsum()
out = []
for keys, sub in df.groupby([id_col, "leg"]):
sub = sub.sort_values("time").set_index("time")
regular = sub[["x", "y"]].resample(f"{int(step_s)}s").mean()
observed = sub[["x"]].resample(f"{int(step_s)}s").count()["x"] > 0
regular = regular.interpolate(method=method, limit_area="inside")
regular["interpolated"] = ~observed.reindex(regular.index,
fill_value=False).values
regular[id_col], regular["leg"] = keys
out.append(regular.reset_index())
result = pd.concat(out, ignore_index=True)
print(f" {len(df):,} observed -> {len(result):,} rows, "
f"{result['interpolated'].mean():.1%} interpolated")
return result
regular = upsample_tracks(every_10s, 1)
g = regular.dropna(subset=["x"]).groupby(["track_id", "leg"])
print(f" path length: {np.hypot(g['x'].diff(), g['y'].diff()).sum() / 1000:.2f} km")
7,055 observed -> 82,511 rows, 91.4% interpolated
path length: 147.57 km
limit_area="inside" stops the interpolation extending past the last real fix in a leg, which would extrapolate the journey beyond where it was observed.
Example 3 β matching two datasets to a common interval
import numpy as np
def harmonise(datasets, id_col="track_id"):
"""Downsample every dataset to the coarsest median interval present."""
intervals = {}
for name, df in datasets.items():
dt = df.groupby(id_col)["time"].diff().dt.total_seconds().dropna()
intervals[name] = float(dt.median())
print(f" {name:16} median interval {intervals[name]:6.1f}s, "
f"{len(df):,} points")
target = max(intervals.values())
print(f" harmonising everything to {target:.0f}s")
return {name: downsample_tracks(df, target, id_col=id_col)
for name, df in datasets.items()}
# the same journeys as if one fleet logged every second and another every 10 s
same = harmonise({"1 s logger": df, "10 s logger": every_10s})
1 s logger median interval 1.0s, 55,316 points
10 s logger median interval 10.0s, 7,055 points
harmonising everything to 10s
55,316 -> 7,055 points (12.8%)
163.17 km -> 158.27 km (-3.0%)
7,055 -> 7,055 points (100.0%)
158.27 km -> 158.27 km (+0.0%)
Choosing the coarsest is the only safe direction. Harmonising upward would upsample the coarse dataset, which changes its row count without changing its measured distance and produces two series that look comparable and are not.
Example 4 β the distance table at the top of this page
def path_km(frame, cols=("track_id",)):
g = frame.groupby(list(cols))
return float(np.hypot(g["x"].diff(), g["y"].diff()).sum()) / 1000
native = path_km(df)
print(f"native {native:7.2f} km")
for step in (1, 2, 5, 10, 30, 60, 120):
thinned = pd.concat([downsample(sub, step) for _, sub in df.groupby("track_id")])
km = path_km(thinned)
print(f"{step:4d} s {km:7.2f} km {km / native - 1:+7.1%}")
dt = df["dt"].dropna()
print(f"\nintervals: median {dt.median():.0f} s, max {dt.max():,.0f} s; "
f"the longest gap resampled to 10 s would be {int(dt.max() // 10):,} invented points")
native 163.17 km
1 s 163.17 km -0.0%
2 s 162.32 km -0.5%
5 s 160.65 km -1.5%
10 s 158.27 km -3.0%
30 s 151.38 km -7.2%
60 s 142.92 km -12.4%
120 s 133.11 km -18.4%
intervals: median 1 s, max 61,691 s; the longest gap resampled to 10 s would be 6,169 invented points
Downsampling shortens, upsampling adds rows
Why downsampling always shortens the track
Between two kept fixes, the path is assumed straight, so every deviation shorter than the new interval is discarded. Length therefore falls monotonically and never rises.
The measured curve steepens as the interval grows: 0.5% lost at 2 seconds, 3.0% at 10, 18.4% at 120. How fast depends on how sinuous the movement is β a motorway drive loses very little, a walk through a park loses a lot.
Why upsampling adds rows and not length
Interpolating between two fixes places new points on the straight line already assumed between them. The path length along a straight line does not change when you add points to it.
So the 10-second log upsampled back to one row a second (Example 2) grows from 7,055 rows to 82,511 β 91.4% of them interpolated β and measures 147.57 km: less than the 158.27 km it had, because splitting at gaps over five minutes removes the straight lines across them, and nowhere near the native 163.17. The rows increased nearly twelvefold and no lost path came back.
The legitimate uses of upsampling are alignment β putting two datasets on the same time base for joining or comparison β and satisfying a downstream tool that requires regular sampling. Neither is about recovering detail.
Why linear interpolation is usually enough
More sophisticated interpolators exist β cubic splines, Kalman smoothers, map-matched paths β and each imports assumptions.
A spline through GPS points overshoots at sharp turns, producing positions outside the corridor the vehicle occupied. A Kalman smoother assumes a motion model that may not fit. Linear interpolation assumes only that the object went roughly from A to B, which is the weakest and safest assumption.
If you need better than linear, the honest improvement is map matching, which brings in external information about where movement is possible.
Why the interval belongs in the metadata
A resampled dataset looks exactly like the original: same columns, same types, same CRS. Nothing in a minute-resampled copy records that its distances run 12.4% short.
Write the interval into the file's metadata or a sidecar, and into the column name if you can β length_km_at_10s is ugly and unambiguous.
limit_area, harmonising downward and splines
- Downsample by elapsed time, not by taking every nth row.
- Split at gaps before resampling. Resampling across a 50-minute gap manufactures a journey.
- Mark interpolated rows so downstream statistics can exclude them.
limit_area="inside"stops interpolation extrapolating past the last fix.- Harmonise downward, to the coarsest interval present.
- Upsampling does not change measured length. It cannot.
- Avoid splines for positions; they overshoot at turns.
- Record the interval with every distance and speed you report.
Internal links
- Sampling rate and gaps explained β how much detail each interval keeps
- Trajectories explained β why length depends on the ruler
- How to calculate speed, distance and direction along a track β the numbers that shift
- How to clean a GPS track: outliers, duplicates and jumps β clean before resampling
- Stops, trips and segmentation explained β segment before resampling
- How to map-match a GPS track to a street network β the principled alternative to interpolation
- How to animate movement over time in Python β where a regular series is genuinely required
- How to load GPS tracks into Python as trajectories β the input to all of this
- M values explained: geometry that carries a measure β time as an M ordinate on a track
FAQ
How do I resample a GPS track to a fixed interval?
Group by track, then keep the first fix and each subsequent fix at least the target interval after the last kept one. Split at long gaps first.
Does resampling change my track distance?
Downsampling does, always downward: measured length fell from 163.17 km to 142.92 km between 1-second and 60-second sampling on real tracks. Upsampling does not change it at all.
Can I upsample to recover detail?
No. Interpolation adds points along the straight line already assumed between fixes, so it adds rows without adding path.
Should I use df.resample() or a greedy filter?
resample for a strictly regular index with visible gaps; the greedy filter when you want a smaller set of real observations at a minimum interval.
How do I compare two datasets logged at different rates?
Downsample both to the coarser median interval before computing anything. Never harmonise upward.
What interpolation method should I use?
Linear. Splines overshoot at turns and place positions outside the corridor the object occupied. If you need better, use map matching.
Should I resample before or after cleaning?
After. Cleaning uses the full-rate data to find spikes, and resampling first can drop the very fix that identifies an error.