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.
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.
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.
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 version | Python | Notes |
|---|---|---|
| 3.28 LTR | 3.9 | Identical API. QgsCoordinateTransform requires a context in all 3.x. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | Qgis.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.
QgsPointXYtakes 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(). QgsCsExceptionon 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
ReverseTransformon the same object. geometry.transform()seems to have done nothing. It returns0on 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.