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 code 05-SLO-101-R49.64 broken into district, county, route, prefix and postmile. five fields in one string05districtSLOcounty101routeRprefix49.64postmilethe postmile restarts at every county line, so the county is part of the numberthe prefix says which alignment, so the same postmile can occur twice in a countySLO-101-49.64 alone matches seven counties on this route spatialworkflow.io
Five fields in one string, and only the last one is a number.

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.

Caltrans postmile prefixes with their meaning and how much of US 101 carries each. six prefixes on one routeprefixmeaningmiles of US 101(none)the original alignment558.3Rrealigned — rebuilt on a new line234.7Ma former alignment retained2.2Ta transition between alignments2.7Sa spur1.4La second line at the same postmiles0.9R covers a third of the route, and its numbers overlap the unprefixed onesRight-hand alignment, statewide; the numbers are the geometry’s own length spatialworkflow.io
Six prefixes on one route; the unprefixed run is only two thirds of it.

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.

A postmile equation: continuous road, discontinuous numbering. the road is continuous; the numbers are notbefore the rebuild8.008.108.20numbering runs straight throughafter a shorter bypass8.1088.198equation: 0.090 mi of numbering skippedRenumbering costs a county of paperwork; an equation costs one row in a table spatialworkflow.io
A postmile equation: the road is continuous, the numbers are not, and that is recorded rather than fixed.

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.

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.