Linear Referencing Explained: Routes, Measures and Events
On this page
Problem statement
Open a highway asset inventory and the locations are not coordinates. A bridge is at 05-SLO-101-49.64. A speed limit runs from postmile 12.4 to 18.9. A crash is at milepost 233.7. None of these is a latitude and longitude, and converting them to one throws away the thing that makes them useful.
Linear referencing is the practice of locating things by position along a route rather than by position on the earth. It is how every road, railway, pipeline and river network of any size is actually managed β and it has its own arithmetic, its own failure modes, and its own reason for existing that is not "the data is old".
Quick answer
A location is a route plus a measure. Given both, the coordinate is a lookup:
import numpy as np
import geopandas as gpd, pandas as pd
from shapely.geometry import LineString, Point
from lrs import routes, point_at_measure # the module in the setup cell below
# The California State Highway Network: geometry carrying a postmile at every vertex.
shn = routes("County='SLO' AND Route=101 AND AlignCode='Right'")
print(f"{len(shn)} route segments, {shn.bPM.min():.3f} to {shn.ePM.max():.3f} postmiles")
target = 49.64 # 05-SLO-101-49.64, a bridge
hit = shn[(shn.bPM <= target) & (shn.ePM >= target)].iloc[0]
here = point_at_measure(hit.geometry, hit.m, target)
print(f"postmile {target} on {hit.pm_route} is at "
f"{gpd.GeoSeries([here], crs=3310).to_crs(4326).iloc[0].wkt}")
print(f" that segment runs {hit.bPM:.3f}β{hit.ePM:.3f} and is "
f"{hit.geometry.length / 1609.344:.3f} miles of geometry")
9 route segments, 0.000 to 69.322 postmiles
postmile 49.64 on SLO-101-Right is at POINT (-120.7078578626468 35.53016690596774)
that segment runs 49.477β67.456 and is 17.988 miles of geometry
The route is not "US 101". It is US 101 in San Luis Obispo county, with no postmile prefix, on the right-hand alignment β because postmiles restart at every county line, and a realigned stretch keeps its old numbering under a prefix.
Routes, measures and events, and why a measure is not length
1. The route is the reference frame
A route is a line with a defined start, direction and identifier. Everything else is expressed relative to it, so the identifier has to be exact β in this dataset, county, route number, postmile prefix, postmile suffix and alignment together.
2. The measure is a coordinate in one dimension
Postmile, milepost, chainage, kilometre point, station: a number that increases along the route. Stored on the geometry, it is the M ordinate; stored in a table, it is just a column.
3. Events are what you locate
A point event has one measure β a bridge, a crash, a sign. A line event has two β a speed limit, a pavement type, a maintenance contract. Together they are the reason the system exists.
4. Measure is not length
Roads get realigned and the numbering is not renumbered, so a route can have a gap where a bypass shortened it, or a repeat where it lengthened. Measures are a legal register, not a ruler.
5. Dynamic segmentation puts events on the map
Overlaying several event tables onto one route splits it wherever any of them changes, producing segments that are homogeneous in every attribute. That operation is the heart of the method.
6. Know when not to use it
Anything off the network β a building, a parcel, a field β has no route, and forcing one on it is worse than a coordinate.
Locating a Caltrans inventory by route and postmile
The data used on this page
Caltrans's state highway network and event layers from its ArcGIS feature services, through lrs.py β the download code from the reading guide as a module.
%%writefile lrs.py
"""Caltrans linear referencing: the reading guide's fetch and to_frame, as a module."""
import hashlib
import urllib.parse
import geopandas as gpd
import numpy as np
import requests
from shapely.geometry import LineString
SERVICE = "https://caltrans-gis.dot.ca.gov/arcgis/rest/services/CHhighway/{layer}/FeatureServer/0/query"
# Every page this guide requests, as Caltrans served it on 9 October 2026 (the layers are revised as
# counts are updated): FROZEN holds one file per request, named by a SHA-1 of the live URL.
FROZEN = "https://data.spatialworkflow.io/caltrans-2026-10-09/{key}.json"
def query(layer, where, out_sr=4326, want_m=False):
"""Every feature of a Caltrans layer that matches where, following the service's pages."""
features, offset = [], 0
while True:
params = {"where": where, "outFields": "*", "f": "json", "outSR": out_sr,
"resultOffset": offset, "resultRecordCount": 2000}
if want_m:
params["returnM"] = "true" # without this there is no measure
url = SERVICE.format(layer=layer) + "?" + urllib.parse.urlencode(params)
key = hashlib.sha1(url.encode()).hexdigest()[:16] # the live request: requests.get(url)
page = requests.get(FROZEN.format(key=key), timeout=180).json()
features += page["features"]
if not page.get("exceededTransferLimit"): # the service says when there is more
return features
offset += len(page["features"])
def to_frame(features, crs):
"""One row per path, the measures kept as an array per row."""
rows, geoms, measures = [], [], []
for f in features:
for path in f["geometry"]["paths"]:
a = np.asarray(path, dtype="float64")
rows.append(f["attributes"])
geoms.append(LineString(a[:, :2]))
measures.append(a[:, 2])
frame = gpd.GeoDataFrame(rows, geometry=geoms, crs=crs)
frame["m"] = measures
return frame
def routes(where, out_sr=3310):
"""State highway centrelines with postmiles, projected by the server (California Albers)."""
frame = to_frame(query("SHN_Lines", where, out_sr=out_sr, want_m=True), out_sr)
frame["pm_route"] = [f"{r.County}-{r.Route}{r.PMPrefix or ''}{r.PMSuffix or ''}-{r.AlignCode}"
for r in frame.itertuples()] # the full route key
return frame
def point_at_measure(line, m, target):
"""The position at a measure along one line, or None if the line does not carry it."""
xy, m = np.asarray(line.coords), np.asarray(m)
if not m.min() - 1e-9 <= target <= m.max() + 1e-9:
return None
distance = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(xy, axis=0).T))])
order = np.argsort(m) # np.interp needs increasing x
return line.interpolate(float(np.interp(target, m[order], distance[order])))
Overwriting lrs.py
Example 1 β why "Route 101 postmile 49.64" is not a location
statewide = routes("Route=101")
spans = (statewide.groupby("County")
.agg(bPM=("bPM", "min"), ePM=("ePM", "max"),
odo_from=("bOdometer", "min"), odo_to=("eOdometer", "max"))
.sort_values("odo_from"))
print(spans.to_string())
match = [c for c, r in spans.iterrows() if r.bPM <= 49.64 <= r.ePM]
print(f"\ncounties whose postmiles include 49.64: {', '.join(match)}")
print(f"postmile prefixes in use on this route: "
f"{sorted(set(p or '(none)' for p in statewide.PMPrefix))}")
bPM ePM odo_from odo_to
County
MEN 0.103 106.801 0.000 627.004
LA 0.000 38.190 0.000 39.549
VEN 0.000 43.622 39.522 83.063
SB 0.000 90.988 83.036 173.617
SLO 0.000 69.322 173.590 242.772
MON 0.000 101.316 242.745 341.846
SBT 0.000 7.550 341.826 349.293
SCL 0.028 52.550 349.257 402.188
SM 0.000 26.107 402.152 428.295
SF 0.000 9.561 428.259 806.820
MRN 0.000 27.568 438.495 466.100
SON 0.000 56.219 465.865 521.812
HUM 0.000 137.144 626.769 761.323
DN 0.000 46.492 761.147 806.600
counties whose postmiles include 49.64: MEN, SB, SLO, MON, SCL, SON, HUM
postmile prefixes in use on this route: ['(none)', 'L', 'M', 'R', 'S', 'T']
Every county starts its postmiles at zero, so the same number occurs once per county the route passes through β seven of them contain 49.64. The odometer column is the second measure system on the same geometry: one continuous run for the whole route, from Los Angeles at zero to Del Norte at 806, which is what you want for a length and useless for finding an asset in a filing system.
Two rows in that table do not fit the pattern, and both are worth knowing about. Mendocino's unprefixed segments report an odometer starting at 0.000 rather than continuing from Sonoma, and San Francisco's maximum of 806.820 belongs to a three-tenths-of-a-mile block whose other carriageway is numbered 433.4 β a 373-mile disagreement between the two sides of the same street. A published LRS is a register maintained by people, and it has entries like these in it.
Example 2 β locating a whole inventory, and checking it
from lrs import query
bridges = pd.DataFrame([f["attributes"] for f in
query("State_Highway_Bridges", "CO='SLO' AND RTE='00101'")])
bridges["PM"] = pd.to_numeric(bridges.PM, errors="coerce")
bridges = bridges.dropna(subset=["PM", "LAT", "LON"])
published = gpd.GeoSeries([Point(x, y) for x, y in zip(bridges.LON, bridges.LAT)],
crs=4326).to_crs(3310)
located, gap = [], []
for (_, row), pub in zip(bridges.iterrows(), published):
best = None
for seg in shn.itertuples():
lo, hi = min(seg.m[0], seg.m[-1]), max(seg.m[0], seg.m[-1])
if lo - 0.001 <= row.PM <= hi + 0.001:
p = point_at_measure(seg.geometry, seg.m, row.PM)
d = p.distance(pub)
if best is None or d < best:
best = d
located.append(best is not None)
gap.append(best)
print(f"located {sum(located)} of {len(bridges)} bridges from the postmile alone")
g = pd.Series(gap).dropna()
print(f"distance from the published coordinate: median {g.median():.0f} m, "
f"90th percentile {g.quantile(0.9):.0f} m, max {g.max():.0f} m")
located 93 of 93 bridges from the postmile alone
distance from the published coordinate: median 20 m, 90th percentile 58 m, max 144 m
Every bridge is found. The median twenty-metre gap is what a postmile rounded to a hundredth of a mile (Β±8 m) plus a carriageway offset looks like β the measure locates the asset on the road, not the structure in space.
Example 3 β the two measure systems disagree with the ruler
for seg in shn.sort_values("bPM").itertuples():
measured = abs(seg.m[-1] - seg.m[0])
geometric = seg.geometry.length / 1609.344
print(f"{seg.pm_route:16} PM {seg.bPM:7.3f}β{seg.ePM:7.3f} "
f"measure span {measured:7.3f} mi geometry {geometric:7.3f} mi "
f"difference {1000 * (geometric - measured):+6.0f} ft")
SLO-101-Right PM 0.000β 8.108 measure span 8.108 mi geometry 8.118 mi difference +10 ft
SLO-101-Right PM 8.198β 19.538 measure span 11.340 mi geometry 11.329 mi difference -11 ft
SLO-101R-Right PM 19.548β 25.817 measure span 6.269 mi geometry 6.263 mi difference -6 ft
SLO-101-Right PM 25.825β 32.976 measure span 7.151 mi geometry 7.157 mi difference +6 ft
SLO-101R-Right PM 32.976β 34.567 measure span 1.591 mi geometry 1.597 mi difference +6 ft
SLO-101-Right PM 34.616β 49.063 measure span 14.447 mi geometry 14.454 mi difference +7 ft
SLO-101-Right PM 49.067β 49.471 measure span 0.404 mi geometry 0.414 mi difference +10 ft
SLO-101-Right PM 49.477β 67.456 measure span 17.979 mi geometry 17.988 mi difference +9 ft
SLO-101R-Right PM 67.456β 69.322 measure span 1.866 mi geometry 1.849 mi difference -17 ft
The gaps between one segment's end postmile and the next one's start are not errors either: they are postmile equations, where the numbering jumps because the road was rebuilt and nobody renumbered fifty miles of asset records to suit.
Why a measure beats a coordinate for an asset
Why a measure beats a coordinate for an asset
An asset record outlives the geometry it was digitised against. Realign a curve, resurvey the centreline, upgrade from a 1980s digitising tablet to GPS, and every coordinate in the inventory is now slightly wrong β but "postmile 49.64" still means the same bridge, because the measure is defined against the route as a register, not against the map.
Why the route key is never just the road number
Road numbers repeat between jurisdictions, restart at boundaries, and split into carriageways that are separate lines. Caltrans needs county, route, prefix, suffix and alignment; a British LRS needs the road number, a section and a direction. Any system that keys events on the road number alone will eventually put a crash on the wrong side of the state.
Why this is not just a database convention
Linear referencing changes what operations mean. "Intersect" becomes "overlay measures". "Buffer" becomes "extend the measure range". "Distance" becomes "difference of measures", which is along the road rather than through the hills. Once the data is on routes, the analysis is one-dimensional, and a great deal of it becomes simpler.
Units, direction, divided highways and ramps
- Units: miles in the US, kilometres nearly everywhere else, and chainage in metres on rail. Never mix them in one column.
- Direction of increasing measure is a property of the route, not of travel.
- Divided highways are usually two routes with their own measures, sometimes one route with a side code.
- Ramps and connectors are their own short routes, with their own measures.
- Rivers use the same idea with river kilometres, measured upstream from the mouth.
- Rail uses mileposts and chains β 80 chains to the mile, and the numbering survives from the 1840s.
Internal links
- M values explained: geometry that carries a measure β where the measure lives
- Postmile and milepost systems explained β prefixes, equations and resets
- Dynamic segmentation explained β overlaying events onto routes
- How to read a linear referencing system in Python β downloading routes with their measures
- How to find the measure of a point on a route β the reverse lookup
- Street networks as graphs explained β the other way to model a road network
FAQ
What is linear referencing?
Locating things by position along a route β a route identifier and a measure β rather than by coordinates.
What is a measure?
A number that increases along the route: postmile, milepost, chainage or kilometre point.
Why not just use coordinates?
Because assets are managed against a route register that outlives any particular digitisation of the centreline, and because measures make along-the-road arithmetic trivial.
What is an event?
Anything located on a route: a point event has one measure, a line event has a from and a to.
Is the measure the same as the length?
No. Realignments leave gaps and overlaps in the numbering, so measured spans and geometric lengths differ.
Which formats keep measures?
Geometry with an M ordinate in a few formats; most workflows carry the measure in a column instead.