M Values Explained: Geometry That Carries a Measure
On this page
Problem statement
A linear referencing system needs a measure at every vertex, and the OGC geometry model has a place to put it: the M ordinate, a fourth number alongside X, Y and Z that means whatever the data says it means β a postmile, a chainage, a timestamp.
The trouble is that M is the least supported part of the geometry model. Shapely 2.1 can read an M ordinate and keep it, but cannot construct one. Reprojection drops it silently. In this stack, no OGR driver will write it at all. Knowing exactly what survives which operation is the difference between a working LRS workflow and one that loses its measures somewhere between the download and the map.
Quick answer
M is there if the data has it, and get_coordinates is how you get at it:
import numpy as np, shapely
from shapely import from_wkt, get_coordinates, has_m
route = from_wkt("LINESTRING M (-120.65 35.28 0.0, -120.66 35.31 2.1, -120.69 35.36 5.4)")
print(f"shapely {shapely.__version__}, GEOS {'.'.join(map(str, shapely.geos_version))}")
print(f"has M: {has_m(route)} geometry type: {route.geom_type}")
xym = get_coordinates(route, include_m=True)
print(xym)
print(f"measures: {xym[:, 2]} monotonic: {bool(np.all(np.diff(xym[:, 2]) > 0))}")
shapely 2.1.2, GEOS 3.13.1
has M: True geometry type: LineString
[[-120.65 35.28 0. ]
[-120.66 35.31 2.1 ]
[-120.69 35.36 5.4 ]]
measures: [0. 2.1 5.4] monotonic: True
The M ordinate is not a Z: it does not participate in distance, area or any geometric predicate. It is a label carried along with each vertex, and every operation that rebuilds the geometry has to be asked to carry it.
Check for M, read it out, expect tools to drop it
1. Check whether the source actually has M
has_m on the geometry, hasM on an Esri service, LINESTRING M in the WKT. A service that can return M usually will not unless asked β Esri's REST API needs returnM=true.
2. Read the measures out into an array
get_coordinates(geom, include_m=True) gives an (n, 3) array of X, Y, M. That array, not the geometry, is what the rest of the arithmetic uses.
3. Expect reprojection to drop it
Transforming coordinates rebuilds the geometry through a path that does not carry M. Reproject first, then attach the measures, or keep them in a column from the start.
4. Expect file formats to refuse it
GeoParquet round-trips M because it stores WKB. In this environment every OGR driver β GeoJSON, Shapefile, GeoPackage, FlatGeobuf β raises rather than writing it.
5. Prefer a measures column for anything computed
A column of arrays survives every pandas and GeoPandas operation, can be written to Parquet, and does not silently disappear. The geometry keeps the shape; the column keeps the register.
6. Put M back only at the boundary
If a downstream system wants LINESTRING M, build the WKT at the point of export. It is the one construction route that works.
What survives which operation
Example 1 β what survives which operation
import geopandas as gpd
g = gpd.GeoDataFrame(geometry=[from_wkt("LINESTRING M (-120.9 35.4 10, -120.8 35.5 20)")],
crs=4326)
print(f"as built M kept: {bool(has_m(g.geometry.values)[0])}")
print(f"after to_crs(3310) M kept: {bool(has_m(g.to_crs(3310).geometry.values)[0])}")
g.to_parquet("/tmp/lr_m.parquet")
back = gpd.read_parquet("/tmp/lr_m.parquet")
print(f"GeoParquet round trip M kept: {bool(has_m(back.geometry.values)[0])} "
f"{back.geometry.iloc[0]}")
for driver, path in [("GeoJSON", "/tmp/lr_m.geojson"), ("ESRI Shapefile", "/tmp/lr_m.shp"),
("GPKG", "/tmp/lr_m.gpkg"), ("FlatGeobuf", "/tmp/lr_m.fgb")]:
try:
g.to_file(path, driver=driver)
print(f"{driver:15} wrote, M kept: "
f"{bool(has_m(gpd.read_file(path).geometry.values)[0])}")
except Exception as e:
print(f"{driver:15} {type(e).__name__}: {str(e)[:56]}")
as built M kept: True
after to_crs(3310) M kept: False
GeoParquet round trip M kept: True LINESTRING M (-120.9 35.4 10, -120.8 35.5 20)
GeoJSON GeometryError: Could not create geometry at index 0 for WKB type 107374
ESRI Shapefile GeometryError: Could not create geometry at index 0 for WKB type 107374
GPKG GeometryError: Could not create geometry at index 0 for WKB type 107374
FlatGeobuf GeometryError: Could not create geometry at index 0 for WKB type 107374
The failure is at least loud. The quiet one is to_crs, which succeeds and returns a geometry with no measures on it.
Example 2 β building geometry that has M
xy = np.array([[-120.65, 35.28], [-120.66, 35.31], [-120.69, 35.36]])
m = np.array([0.0, 2.1, 5.4])
try:
shapely.linestrings(xy, m=m)
except TypeError as e:
print(f"shapely.linestrings: {e}")
plain = shapely.linestrings(xy)
print(f"plain constructor -> {plain.geom_type}, has M {bool(has_m(plain))}")
with_z = shapely.linestrings(np.column_stack([xy, m]))
print(f"three columns -> {with_z.wkt[:34]}β¦ (that is Z, not M)")
wkt = "LINESTRING M (" + ", ".join(f"{x} {y} {mm}" for (x, y), mm in zip(xy, m)) + ")"
built = from_wkt(wkt)
print(f"via WKT -> {built.geom_type}, has M {bool(has_m(built))}")
shapely.linestrings: linestrings() got an unexpected keyword argument 'm'
plain constructor -> LineString, has M False
three columns -> LINESTRING Z (-120.65 35.28 0, -12β¦ (that is Z, not M)
via WKT -> LineString, has M True
A third coordinate column becomes Z, which is a different thing: Z is an elevation and takes part in 3D predicates, M is a label and takes part in nothing. Passing measures as Z is the most common way to end up with an LRS whose "elevations" are postmiles.
Example 3 β carrying measures in a column instead
from shapely.geometry import LineString
line = LineString(xy)
df = gpd.GeoDataFrame({"route": ["SLO-101"], "m": [m]}, geometry=[line], crs=4326)
proj = df.to_crs(3310) # measures untouched: they are not geometry
seg = proj.geometry.iloc[0]
along = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(np.asarray(seg.coords), axis=0).T))])
print(f"vertex distances (m): {np.round(along, 1)}")
print(f"measures : {proj.m.iloc[0]}")
print(f"measure at the middle of the geometry: "
f"{np.interp(seg.length / 2, along, proj.m.iloc[0]):.3f}")
df.to_parquet("/tmp/lr_col.parquet")
print(f"round-tripped measures: {gpd.read_parquet('/tmp/lr_col.parquet').m.iloc[0]}")
vertex distances (m): [ 0. 3453.4 9638.8]
measures : [0. 2.1 5.4]
measure at the middle of the geometry: 2.829
round-tripped measures: [0. 2.1 5.4]
Two arrays that are the same length, kept beside each other, with the interpolation written out explicitly. It is less elegant than an M ordinate and it never disappears.
Why M is in the standard but not the tools
Why M exists in the standard but not in the tools
The simple features specification has had XYM and XYZM since the start, because surveying and linear referencing needed them. Most spatial software was built for cartography, where a vertex is a position and nothing more, so M support was never exercised β and a feature nobody exercises is a feature that quietly stops working.
Why dropping M is worse than failing
An error is a bug report. A geometry that comes back from to_crs with the same vertices and no measures is a pipeline that produces plausible output forever: events land at interpolated positions computed from length instead of from the register, and the difference is small enough to look like rounding.
Why the measure is not an elevation
Z and M look alike in an array and behave completely differently. length on an XYZ line is the 3D length; on an XYM line it is the 2D length. A buffer of an XYZ geometry keeps the Z; nothing keeps M. Storing a postmile as Z produces a line that is thirty miles "tall" and measurably longer than the road.
XYZM, shapefiles, PostGIS and decreasing measures
- XYZM geometries carry both;
get_coordinates(include_z=True, include_m=True)returns four columns. - Esri shapefiles have PolylineM as a distinct shape type, which GDAL reads but this stack will not write.
- PostGIS supports M fully β
ST_LineLocatePoint,ST_LineInterpolatePointandST_LocateAlongall work on it. - Measures may decrease along the geometry; nothing in the format requires them to increase.
- Interpolated vertices need an interpolated measure, which is why
substringandsimplifycannot carry M automatically. - NaN measures are legal and mean "unknown here", which is not the same as zero.
Internal links
- Linear referencing explained: routes, measures and events β what the measure is for
- Postmile and milepost systems explained β what the numbers mean
- How to read a linear referencing system in Python β downloading routes with M
- M values are lost when you export the route β the fix guide
- How to build measures onto a line that has none β making your own
- What makes a geometry valid? The OGC rules explained β the model M belongs to
FAQ
What is an M value?
A fourth ordinate on a vertex holding a measure β a postmile, chainage or timestamp β that is not part of the position.
Does shapely support M?
It reads and preserves M from version 2.1, but the constructors cannot create it; build from WKT or WKB.
Why did my measures disappear after reprojecting?
to_crs rebuilds the geometry without the M ordinate. Keep measures in a column, or reattach them after.
Can I store measures as Z?
You can, and it breaks lengths and 3D operations. Z and M are different ordinates.
Which file formats keep M?
GeoParquet and anything WKB-based here. The OGR drivers in this stack refuse to write it.
Is M the same as a timestamp?
It can be β M is whatever the dataset declares. For a GPS track, time is a natural measure.