Postmile and Milepost Systems Explained
On this page
Problem statement
A postmile looks like a number and behaves like a filing code. On US 101 in California, postmiles restart at zero in each of the fourteen counties the route crosses; a realigned stretch keeps the old numbering under a prefix letter, so the same county can hold postmile 5.0 twice; and where the road was rebuilt shorter or longer, the numbering simply jumps, leaving 18 miles of gaps in the run.
None of that is corruption. It is what a measure looks like after eighty years of maintenance by an organisation that cannot renumber a million asset records every time a bypass opens. Working with it means reading the prefix, the suffix, the county and the alignment as part of the number.
Quick answer
The postmile is never the whole key:
import pandas as pd
from lrs import routes
us101 = routes("Route=101 AND AlignCode='Right'")
us101["pfx"] = us101.PMPrefix.fillna("")
use = (us101.groupby("pfx")
.agg(segments=("Route", "size"),
miles=("geometry", lambda s: round(sum(x.length for x in s) / 1609.344, 1))))
print(use.to_string())
print(f"\ncounties on this route: {us101.County.nunique()}, "
f"each starting its own postmiles at zero")
segments miles
pfx
65 558.3
L 2 0.9
M 5 2.2
R 54 234.7
S 1 1.4
T 10 2.7
counties on this route: 14, each starting its own postmiles at zero
An unprefixed postmile covers most of the route. The R prefix — realigned — covers another 235 miles, and its numbers overlap the unprefixed ones, because the realignment was numbered against the old alignment it replaced.
The whole identifier, restarts, prefixes and equations
1. Read the whole identifier
District, county, route, prefix, postmile, suffix and alignment. Dropping any one of them makes the reference ambiguous somewhere on the network.
2. Expect the numbering to restart
Postmiles restart at county lines in California, at state lines in most interstate systems, and at section boundaries in the UK. The reset is the point: it keeps the numbers small and local.
3. Expect prefixes to mean "this is a different alignment"
R for realigned, M for a former alignment retained, L, S, T for spur, temporary and transition alignments. They exist so that the old numbers can keep working on the ground.
4. Expect equations where the numbering jumps
A postmile equation is a declared discontinuity: the road runs continuously and the numbering does not. Any code that assumes end-of-one equals start-of-next will mislocate everything after the first one.
5. Never interpolate across a discontinuity
Interpolating a measure between two segments that are not numbered continuously invents a position. Locate within a segment, or not at all.
6. Carry the key on every event
An event table keyed on route and postmile alone will silently attach to the wrong county, the wrong carriageway or the wrong alignment.
Where Caltrans postmiles jump and repeat
The data used on this page
Caltrans's state highway network and its event layers — traffic counts (Traffic_AADT), bridges (State_Highway_Bridges) and functional classification — from the Caltrans ArcGIS feature services. lrs.py packages the download code from the reading guide as a module: query() pages through any layer, routes() returns centrelines with their postmiles, projected by the server to California Albers (EPSG:3310), and point_at_measure() interpolates a position along one line.
%%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])))
Writing lrs.py
Example 1 — where the numbering jumps
gaps = []
for county, sub in us101.groupby("County"):
sub = sub.sort_values("bPM")
reach = None
for seg in sub.itertuples():
if reach is not None and seg.bPM > reach + 1e-9:
gaps.append((county, round(reach, 3), round(seg.bPM, 3),
round(seg.bPM - reach, 3)))
reach = max(reach or 0.0, seg.ePM)
gaps = pd.DataFrame(gaps, columns=["county", "numbering ends", "resumes", "gap (mi)"])
print(f"{len(gaps)} gaps in the postmile run, totalling {gaps['gap (mi)'].sum():.2f} miles")
print(gaps.nlargest(5, "gap (mi)").to_string(index=False))
45 gaps in the postmile run, totalling 18.47 miles
county numbering ends resumes gap (mi)
SBT 0.312 3.297 2.985
HUM 2.920 5.108 2.188
MON 41.048 43.185 2.137
HUM 77.967 79.574 1.607
MON 19.899 21.143 1.244
Those miles of road exist and are driven every day. They are numbered under a prefix, or under a neighbouring route, or they are a genuine equation — but a query for "postmile 42 to 43 in Monterey county" returns nothing, and nothing is the correct answer.
Example 2 — where the same postmile occurs twice
clashes = []
for county, sub in us101.groupby("County"):
rows = sorted((s.bPM, s.ePM, s.pfx) for s in sub.itertuples())
for i, (b1, e1, p1) in enumerate(rows):
for b2, e2, p2 in rows[i + 1:]:
if b2 < e1 - 1e-9:
clashes.append((county, p1 or "(none)", round(b1, 3), round(e1, 3),
p2 or "(none)", round(b2, 3), round(e2, 3),
round(min(e1, e2) - b2, 3)))
clashes = pd.DataFrame(clashes, columns=["county", "prefix A", "from A", "to A",
"prefix B", "from B", "to B", "overlap (mi)"])
print(f"{len(clashes)} overlapping postmile ranges within a county")
print(clashes.nlargest(4, "overlap (mi)").to_string(index=False))
24 overlapping postmile ranges within a county
county prefix A from A to A prefix B from B to B overlap (mi)
MEN R 42.463 48.994 (none) 47.512 48.933 1.421
LA S 0.000 1.329 (none) 0.058 11.470 1.271
SF R 4.688 5.139 T 4.691 5.260 0.448
SCL R 4.553 29.122 (none) 28.760 35.345 0.362
In Mendocino, postmiles 47.5 to 48.9 exist on both the R alignment and the unprefixed one — 1.4 miles of road where "postmile 48" needs the prefix to mean anything.
Example 3 — parsing the code people actually write down
import re
CODE = re.compile(r"^(?P<district>\d{2})-(?P<county>[A-Z]{2,3})-(?P<route>\d+[A-Z]?)-"
r"(?P<prefix>[A-Z]?)(?P<postmile>\d+(?:\.\d+)?)(?P<suffix>[A-Z]?)$")
for code in ["05-SLO-101-49.64", "01-MEN-101-R48.20", "04-SF-101-T5.10",
"05-SLO-101-49.64R", "SLO-101-49.64"]:
m = CODE.match(code)
if m:
d = m.groupdict()
print(f"{code:20} county {d['county']:4} route {d['route']:4} "
f"prefix {d['prefix'] or '-':2} postmile {float(d['postmile']):7.2f} "
f"suffix {d['suffix'] or '-'}")
else:
print(f"{code:20} does not parse — no district, so the reference is incomplete")
05-SLO-101-49.64 county SLO route 101 prefix - postmile 49.64 suffix -
01-MEN-101-R48.20 county MEN route 101 prefix R postmile 48.20 suffix -
04-SF-101-T5.10 county SF route 101 prefix T postmile 5.10 suffix -
05-SLO-101-49.64R county SLO route 101 prefix - postmile 49.64 suffix R
SLO-101-49.64 does not parse — no district, so the reference is incomplete
Why numbering is not maintained continuously
Why the numbering is not maintained continuously
Renumbering a postmile means reissuing every sign, every maintenance record, every bridge report and every legal description that refers to it. A jump in the numbers costs one row in an equation table; a renumbering costs a county's worth of paperwork. Every long-lived LRS makes the same trade.
Why prefixes are better than renumbering
When a curve is realigned, the old postmiles stay valid on the old alignment and the new stretch gets a prefix. Records made before the work keep working, records made after are unambiguous, and the discontinuity is confined to one letter in the key.
Why other countries do the same thing differently
The UK uses a road number, a section identifier and a chainage in metres that restarts at each section. Rail uses mileposts with chains, surviving from the company that built the line. New Zealand uses route position kilometres with their own displacement table. All of them are the same design: a local measure plus a key that says which local.
Suffixes, odometers, rounding and county codes
- Suffixes mark a second occurrence of a postmile within the same prefix, and are rarer than prefixes.
- Odometer columns in the same dataset give the continuous measure, which is the one to use for lengths.
- Postmiles are rounded to hundredths of a mile in most event tables — about eight metres.
- County abbreviations are not postal codes: SLO, SB, SBT and SCL are four different counties.
- Route numbers repeat between state, US and interstate systems in some states; the route type matters.
- Historic records may cite a postmile under a numbering that has since been equated away.
Internal links
- Linear referencing explained: routes, measures and events — the model this implements
- M values explained: geometry that carries a measure — where the numbers live on the geometry
- Route calibration explained — why measured and geometric length differ
- An event's measure falls outside the route — what to do about prefixes and gaps
- How to read a linear referencing system in Python — getting the fields out
- Census geographies explained — another hierarchy where the key is not one field
FAQ
What is a postmile?
A measure along a state highway, restarting at each county line, with a prefix letter identifying the alignment.
What does the R prefix mean?
Realigned: a stretch of road rebuilt on a new alignment, numbered separately so the old postmiles stay valid.
Why does my postmile query return nothing?
Either the number falls in a gap left by an equation, or the prefix, county or alignment in the key does not match.
Can two places share a postmile?
Yes — across counties always, and within a county wherever alignments overlap.
What is a postmile equation?
A declared discontinuity in the numbering where the road was rebuilt shorter or longer than the measure it replaced.
Is a milepost the same thing?
Nearly. Mileposts are the signed markers; postmiles are the register. Many states use the words interchangeably.