Transform Point Coordinates Between CRS in PyQGIS

Converting a latitude and longitude into a projected easting and northing is three lines of PyQGIS, and every one of them has a detail that matters. The transform needs a context or it may choose a different datum path from the one the GUI uses. It is expensive to construct and cheap to reuse, so building it inside a loop is the difference between a script that finishes and one that crawls. And it raises rather than returning a sentinel when a coordinate falls outside the target projection's valid area.

This page is a focused recipe within Coordinate Reference Systems in PyQGIS. It covers a single transform, reusing one across many points, inverting the direction, and handling the failures that a real dataset will produce.

The three inputs a transform needsA source coordinate reference system, a target coordinate reference system and a transform context combine into a QgsCoordinateTransform. A geographic point in degrees passes through it and emerges as a projected point in metres. The context is highlighted as the argument that decides which datum path is used when several exist.Three inputs build the transform; one point goes through itsource CRSEPSG:4326target CRSEPSG:32633transform contextpicks the datum pathQgsCoordinateTransformexpensive to buildcheap to reusein — degrees12.4924, 41.8902out — metres789 038, 4 641 418

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • The EPSG codes of both systems. Prefer codes over raw WKT, which drifts between PROJ versions.
  • For datum shifts between older national grids and WGS84, the PROJ transformation grids installed alongside QGIS.

Transform a single point

Three objects: two CRSs and a context.

from qgis.core import (
    QgsCoordinateReferenceSystem,
    QgsCoordinateTransform,
    QgsPointXY,
    QgsProject,
)

source = QgsCoordinateReferenceSystem("EPSG:4326")
target = QgsCoordinateReferenceSystem("EPSG:32633")
transform = QgsCoordinateTransform(source, target, QgsProject.instance())

point = QgsPointXY(12.4924, 41.8902)          # longitude, latitude
projected = transform.transform(point)
print(f"{projected.x():.2f}, {projected.y():.2f}")

Breakdown: QgsPointXY takes x then y — longitude before latitude for a geographic CRS. Writing them the way people say them out loud is the single most common error here, and it puts the result somewhere plausible rather than raising. The third argument to QgsCoordinateTransform is the transform context: passing QgsProject.instance() makes the script use the same datum path the GUI would, which matters whenever more than one path exists between two datums. Omitting it can introduce sub-metre shifts that nobody notices until the data is overlaid with someone else's.

Reuse the transform

Constructing a transform resolves a datum path through PROJ, which is far too expensive to repeat per point.

from qgis.core import QgsCoordinateTransform, QgsProject

project = QgsProject.instance()
transform = QgsCoordinateTransform(source, target, project)   # once

converted = []
for feature in layer.getFeatures():
    point = feature.geometry().asPoint()
    converted.append(transform.transform(point))

print(len(converted), "point(s) converted")

Breakdown: Building the transform outside the loop is the whole optimisation, and on a large layer it is the difference between seconds and minutes. geometry().asPoint() returns a QgsPointXY for a single-point geometry and raises for anything else, so guard with wkbType() on a layer that might contain multi-points. The transform object is stateless with respect to the points passed through it, so reusing it is entirely safe.

Invert the direction

The same transform runs backwards, which avoids constructing a second one.

from qgis.core import QgsCoordinateTransform

back = transform.transform(projected, QgsCoordinateTransform.ReverseTransform)
print(f"{back.x():.6f}, {back.y():.6f}")

Breakdown: ReverseTransform sends the point from target back to source using the same resolved datum path, which is what makes a round trip return the original coordinates rather than something a few centimetres away. Constructing a fresh transform with the CRSs swapped would work but might resolve a different path, so reusing this one is both faster and more consistent.

Handle coordinates outside the valid area

Every projection has a domain. A UTM zone is defined for a 6° band; feed it a point from the other side of the world and PROJ raises.

Inside, at the edge, and beyond a projection's domainA world strip shows a UTM zone's six degree band highlighted. A point inside the band transforms accurately. A point just outside the band still transforms but with growing distortion. A point on the far side of the world raises a QgsCsException rather than returning a value.A transform can succeed, degrade, or raiseEPSG:32633 band−180°12°180°ABCA · inside the bandaccurate to millimetresreturns a pointB · just outsidedistortion grows with distancereturns a point anywayC · far outsideno valid solutionraises QgsCsException

from qgis.core import QgsCsException

converted, failed = [], []
for feature in layer.getFeatures():
    try:
        converted.append(transform.transform(feature.geometry().asPoint()))
    except QgsCsException as exc:
        failed.append((feature.id(), str(exc)))

print(f"{len(converted)} converted, {len(failed)} failed")
for fid, message in failed[:5]:
    print(f"  feature {fid}: {message}")

Breakdown: QgsCsException is the specific exception PROJ failures surface as — catching bare Exception here would also swallow the TypeError from asPoint() on a non-point geometry, hiding a different bug. Collecting the failures rather than aborting means one bad row does not lose the other ten thousand, and reporting the first few with their IDs gives you something to investigate. Case B in the diagram is the dangerous one: it does not raise, so a point outside the zone silently returns a distorted coordinate — which is why choosing a projection appropriate to the data's extent matters more than error handling does.

Transform whole geometries

For anything beyond a point, transform the geometry rather than its vertices.

from qgis.core import QgsGeometry

geometry = QgsGeometry(feature.geometry())
if geometry.transform(transform) != 0:
    raise RuntimeError("geometry transform failed")

Breakdown: QgsGeometry.transform() mutates the geometry in place and returns a status integer where 0 means success — the opposite convention from the point method, and a genuine trap. Copy-constructing with QgsGeometry(other) first means the feature's own geometry is not modified. For whole layers, native:reprojectlayer is the right tool and handles all of this internally; see Coordinate Reference Systems in PyQGIS.

Check which datum path was chosen

When two datums are related by more than one published transformation, PROJ picks one — and the choice can shift results by metres. Asking which path was used turns an invisible decision into a recorded one.

Two paths between the same two datumsA source datum and a target datum are joined by two candidate transformations. A three-parameter Helmert shift is always available and accurate to a few metres. A national grid file is accurate to centimetres but only present if the grid has been installed. Choosing between them silently is what produces results that differ from a colleague's by metres.Same two datums, two answers metres apartsource datuma national gridtarget datumWGS843-parameter Helmert shiftalways available · accurate to a few metresgrid shift filecentimetre accuracy · only if installed

from qgis.core import QgsDatumTransform

operations = QgsDatumTransform.operations(source, target)
for op in operations:
    print(f"{'*' if op.isAvailable else ' '} {op.accuracy:>6.2f} m  {op.name}")
print("in use:", transform.coordinateOperation() or "(PROJ default)")

Breakdown: QgsDatumTransform.operations() lists every published path between the two datums with its stated accuracy, and isAvailable is False for grid-based operations whose grid file is not installed — which is exactly the situation where PROJ silently falls back to a coarser path. coordinateOperation() returns the PROJ pipeline string actually in use, or an empty string when the default was taken. Logging that string alongside your results is what makes a discrepancy against someone else's numbers diagnosable rather than mysterious.

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.28 LTR3.9Identical API. QgsCoordinateTransform requires a context in all 3.x.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12Qgis.TransformDirection replaces QgsCoordinateTransform.ReverseTransform; the old spelling still resolves.

The transform classes have been stable across the whole 3.x line.

Troubleshooting

  • The result is in the wrong hemisphere. Latitude and longitude were passed in the wrong order. QgsPointXY takes x (longitude) first.
  • Coordinates are slightly off compared with the GUI. No transform context was passed, so PROJ chose a different datum path. Pass QgsProject.instance().
  • QgsCsException on some features. Those coordinates fall outside the target projection's valid area. Choose a projection suited to the data's extent, or exclude the outliers.
  • The transform is very slow. It is being constructed inside the loop. Build it once outside.
  • A round trip does not return the original coordinates. Two separately constructed transforms may resolve different paths. Use ReverseTransform on the same object.
  • geometry.transform() seems to have done nothing. It returns 0 on success and mutates in place — check the return value, and remember the geometry you passed has already changed.

Conclusion

Build the transform once with an explicit context, pass longitude before latitude, reuse the same object for the reverse direction, and catch QgsCsException per feature so one out-of-domain coordinate does not lose the run. For anything larger than a handful of points, reproject the whole layer with a Processing algorithm instead.

Frequently Asked Questions

Why do my transformed coordinates land in the wrong place? Almost always the argument order. QgsPointXY(x, y) takes longitude first and latitude second, which is the reverse of how coordinates are usually spoken and written.

Do I really need to pass a transform context? Yes. It tells PROJ which datum transformation path to use when several exist, so a scripted result matches what the GUI produces. Without it, sub-metre shifts can appear silently.

Why does my transform raise QgsCsException? The coordinate falls outside the target projection's valid area — a point from another continent fed to a UTM zone, for example. Catch it per feature and either exclude the point or pick a projection appropriate to the data's extent.

How do I transform in the opposite direction? Call transform.transform(point, QgsCoordinateTransform.ReverseTransform) on the same object rather than constructing a swapped one. Reusing it guarantees the same datum path, so a round trip returns the original coordinates.

Is it faster to transform a whole geometry or its points one at a time? The whole geometry, substantially. QgsGeometry.transform() walks the coordinates in C++ with one Python call, whereas transforming vertices individually crosses the language boundary once per point.

Can one transform object be reused across threads? Treat it as single-threaded. Build a separate transform per worker rather than sharing one.

Does the transform cache anything? Yes — the resolved pipeline, which is why reuse is fast.

Is EPSG:4326 the same as WGS84? Effectively yes for most purposes, though WGS84 has been realised several times and the differences are at the centimetre level.