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.

A linear reference: a route key, a measure along it, and the event the pair locates. one location, three parts4546474849505152measure β€” postmile along the routeevent: a bridge05-SLO-101-49.64district Β· county Β· route Β· postmilethe route key is four fields; only the last number is the measure spatialworkflow.io
Three parts, and the first one is usually four fields rather than a road number.

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 an asset by coordinate against locating it by route and measure after a realignment. the road moves; the register does notby coordinateβœ•digitised against the 1994 centrelineβœ•the curve is realigned in 2003βœ•every stored point is now beside the roadβœ•nothing in the record says soby route and measureβœ“postmile 49.64, San Luis Obispo countyβœ“the curve is realigned and gets a prefixβœ“the postmile still finds the bridgeβœ“the discontinuity is one row in a tableThis is why highway, rail and pipeline data is referenced this way and not by point spatialworkflow.io
Move the road and the coordinate is wrong; the postmile still finds the bridge.

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.

Point and line event types with the measures each carries. what an event looks likeeventmeasuresexamplepoint eventonea bridge at postmile 49.64line eventfrom and toa speed limit, postmile 12.4 to 18.9point pairone, plus an offseta sign, 30 ft right of centrelinecontinuousa value at every measurepavement roughness, sampled each0.01 mievery one of them also needs the route key, or it locates nothingPoint events carry one measure, line events carry two spatialworkflow.io
Point events carry one measure, line events carry two, and both need the route key.

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.

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.