Converting Ellipsoidal to Orthometric Heights in Python

To convert an ellipsoidal height to an orthometric one in Python, build both sides of the transformation as compound coordinate reference systems and pass z to Transformer.transform. PROJ then selects a vertical transformation, looks up the geoid separation for that horizontal position, and returns the orthometric height. Omit either the compound definition or the z argument and the height comes back exactly as it went in, with no error raised. This page is part of the Vertical Datums and Height Systems reference.

How pyproj Decides to Transform a Height

A Transformer is built from two coordinate reference systems, and it can only perform operations those systems describe. Two horizontal definitions describe no vertical relationship, so a transformer built from them has no vertical operation to apply. It is not ignoring the height — it was never given a reason to change it.

The relation between the two heights The identity that governs the conversion, with one worked position. The ellipsoidal height is what a satellite receiver reports; the geoid separation is looked up for that horizontal position; the orthometric height is their difference. Because the separation is a function of position, the conversion is per point rather than a constant offset. h = H + N the governing identity h = 96.412 m ellipsoidal — what GNSS reports N = 49.102 m separation, interpolated from the grid H = 47.310 m orthometric — what levelling measures N is a function of position, so this is a per-point conversion, not an offset.

A compound CRS supplies that reason. It pairs a horizontal CRS with a vertical one, and PROJ’s operation search then includes vertical transformations: geoid grid interpolations, datum offsets, or a combination. The selected operation is what applies the separation.

The other half of the mechanism is the grid itself. A national geoid model is a raster of separation values, distributed separately from PROJ. When it is present the interpolation is centimetre-level; when it is absent PROJ may fall back to a coarse global model and still return an answer. This is the single most important thing to verify, because the fallback is silent and the difference is metres.

Production-Ready Script

# pyproj>=3.5.0 (PROJ 9.x), numpy>=1.24, Python 3.9+
from __future__ import annotations

import numpy as np
from pyproj import CRS, Transformer


class VerticalTransformError(RuntimeError):
    """Raised when the transformation is not actually three-dimensional."""


def build_vertical_transformer(
    src_epsg: int,
    dst_horizontal_epsg: int,
    dst_vertical_epsg: int,
    *,
    require_grid: bool = True,
) -> Transformer:
    """Transformer from a 3D geographic CRS to a compound projected + vertical CRS.

    src_epsg must be a 3D CRS (ellipsoidal height), e.g. 4937 for ETRS89.
    """
    src = CRS.from_epsg(src_epsg)
    dst = CRS.from_string(f"EPSG:{dst_horizontal_epsg}+{dst_vertical_epsg}")

    if len(src.axis_info) < 3:
        raise VerticalTransformError(
            f"EPSG:{src_epsg} is 2D — use the 3D realisation, or z will pass through"
        )
    if not dst.is_compound:
        raise VerticalTransformError("destination is not a compound CRS")

    transformer = Transformer.from_crs(src, dst, always_xy=True)

    if require_grid:
        op = transformer.get_last_used_operation() if hasattr(
            transformer, "get_last_used_operation") else None
        # The operation is only resolved after a first transform on some PROJ builds,
        # so probe with a representative coordinate inside the grid's extent.
        transformer.transform(0.0, 51.5, 0.0)
        _assert_grids_available(transformer)

    return transformer


def _assert_grids_available(transformer: Transformer) -> None:
    op = transformer.get_last_used_operation()
    missing = [g.short_name for g in op.grids if not g.available]
    if missing:
        raise VerticalTransformError(
            f"geoid grid(s) not installed: {', '.join(missing)} — "
            "PROJ would fall back to a coarse global model"
        )


def to_orthometric(
    transformer: Transformer,
    lon: np.ndarray,
    lat: np.ndarray,
    h_ellipsoidal: np.ndarray,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Ellipsoidal height -> orthometric height, vectorised over whole arrays."""
    east, north, H = transformer.transform(
        np.asarray(lon, dtype=float),
        np.asarray(lat, dtype=float),
        np.asarray(h_ellipsoidal, dtype=float),
    )
    separation = np.asarray(h_ellipsoidal) - np.asarray(H)
    if np.allclose(separation, 0.0):
        raise VerticalTransformError(
            "separation is zero everywhere — the transform is horizontal"
        )
    return np.asarray(east), np.asarray(north), np.asarray(H)


if __name__ == "__main__":
    t = build_vertical_transformer(4937, 27700, 5701)   # ETRS89 3D -> BNG + ODN
    lon = np.array([-1.54785, -1.54600])
    lat = np.array([53.80139, 53.80200])
    h = np.array([96.412, 97.001])
    e, n, H = to_orthometric(t, lon, lat, h)
    for i in range(len(H)):
        print(f"h={h[i]:.3f}  ->  H={H[i]:.3f}  (N={h[i] - H[i]:.3f} m)")
What a compound CRS gives the operation search Three stages. Pairing a horizontal system with an explicit vertical one produces a compound definition; PROJ then includes vertical transformations in its operation search and selects one; the selected operation interpolates a geoid grid to produce the separation. Without the compound definition the search has nothing vertical to find. Horizontal + vertical a compound CRS Vertical operation selected by PROJ Separation applied orthometric height operation search interpolate

Key implementation notes:

  • build_vertical_transformer refuses a 2D source rather than transforming it. That refusal is the whole value of the function: a 2D source is the failure this page exists to prevent.
  • The zero-separation assertion in to_orthometric is a second, independent guard. It catches the case where the definitions look right but the operation PROJ chose does nothing vertical.
  • always_xy=True is set for the same reason as in any horizontal transformation — it fixes the argument order to longitude, latitude regardless of the authority axis order.
  • Whole arrays are passed to transform. The grid interpolation is vectorised internally; per-point calls spend their time in the Python loop.

Compatibility Matrix

Component Supported range Notes
pyproj >=3.5.0 get_last_used_operation and grid availability reporting
PROJ 9.x earlier releases handle compound CRS selection less consistently
Source CRS any 3D geographic or geocentric 2D realisations are rejected by the builder
Target CRS compound horizontal + vertical e.g. EPSG:27700+5701, EPSG:25832+5783
Geoid grids national or global install at image build time; PROJ_NETWORK=OFF in production
Array input numpy arrays of equal length broadcasting is not applied — shapes must match

Fallback Strategies

1. The source is already orthometric. Applying this conversion produces an error of exactly 2N. There is no way to detect it from the numbers alone at a single point, but a dataset whose heights differ from local mapped ground levels by roughly the known regional separation is the signature. Record the height system per source, as described on the parent page, rather than inferring it.

Three errors that all look like a vertical offset Three distinct faults and the magnitude each produces. A correction applied to already-corrected data and a sign error both produce an error of exactly twice the separation and are indistinguishable from the numbers alone; a transform that was horizontal produces an error of exactly the separation. Knowing the magnitudes is what turns an offset into a diagnosis. Fault Magnitude Distinguishing evidence Already corrected 2N the survey processing settings Sign convention 2N a benchmark comparison Transform was 2D N input height returned unchanged Two of the three are the same magnitude — only the benchmark separates them.

2. No national grid installed. _assert_grids_available raises. Install the grid package at image build time and set PROJ_NETWORK=OFF so a missing grid is an error rather than a runtime download that may or may not succeed.

3. Points outside the grid extent. A national grid covers a bounded area. Points outside it transform through whatever fallback PROJ finds, which for a site near a border may silently switch model partway through a dataset. Assert the input bounding box against the grid extent before transforming.

4. Mixed vertical datums in one dataset. A merged deliverable can contain sections on different vertical datums. There is no way for one transformer to handle this correctly. Split the dataset by source, transform each with its own transformer, and merge afterwards.

5. Heights that are already project-datum values. A CAD or BIM elevation is not an ellipsoidal height and this conversion does not apply to it. Resolve the project offset first — see Reconciling BIM Project Elevation with a National Datum — and only then treat the values as national-datum heights.

FAQ

How do I know whether my heights are already orthometric?

Ask the survey processing configuration, not the file. Most GNSS post-processing software can output either, and the choice is a setting rather than something recorded in a .csv or a LAS header. The rough magnitude check is that the geoid separation in a given region is a known number — if your heights differ from nearby mapped ground levels by approximately that number, they are ellipsoidal.

Why is my converted height out by exactly twice the separation?

You have applied the correction to data that was already corrected, or you have used a separation with the wrong sign. Both produce an error of 2N. Check the source first: a double correction is far more common than a sign error, because processing software applies the correction by default.

Does the conversion need to be per point?

Yes, for anything survey-grade. The geoid separation varies across a site, and a single value applied to a whole dataset is correct only at the point it was sampled. For a small site the variation may be below tolerance, but that is a decision to make from the numbers rather than an assumption to build in.