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.

A linestring whose vertices carry X, Y and M, with M increasing along the line. four numbers per vertex; two of them are a positionM 0.00x0 y0M 2.15x1 y1M 4.80x2 y2M 7.35x3 y3M 9.02x4 y4LINESTRING M (x0 y0 0.00, x1 y1 2.15, x2 y2 4.80, …)M takes no part in length, area, distance or any predicate spatialworkflow.io
Four numbers per vertex; only two of them are a position.

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.

Which operations and formats preserve an M ordinate. what keeps the measuresoperationM ordinatemeasures columnread from an Esri servicekept, with returnMkeptto_crs()dropped, silentlykeptGeoParquet write/readkeptkeptGeoJSON / GPKG / SHP / FGBrefusedstringifiedsimplify(), segmentize()recomputedsilently misalignedshapely 2.1 reads and keeps M; its constructors cannot make itThe dangerous row is the one that succeeds and loses the data spatialworkflow.io
What survives: WKB and Parquet. What does not: everything with a driver.

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.

The M and Z ordinates compared: what each means and which operations use it. the third number, in two slotsZ β€” elevationβœ“a position in a third spatial dimensionβœ“length is the 3D lengthβœ“survives buffer, simplify and mostformatsβœ“constructors take it directlyM β€” measureβœ“a label attached to each vertexβœ“length ignores it completelyβœ“dropped by reprojection, refused bydriversβœ“only WKT and WKB can construct itStoring a postmile as Z makes a line thirty miles tall and measurably longer spatialworkflow.io
The same third number, in two slots that behave nothing alike.

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_LineInterpolatePoint and ST_LocateAlong all 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 substring and simplify cannot carry M automatically.
  • NaN measures are legal and mean "unknown here", which is not the same as zero.

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.