Create a Custom CRS from a PROJ String in PyQGIS
The EPSG registry holds thousands of coordinate reference systems, and it is still not enough. A construction site uses a local grid scaled to ground distance. A mine surveys in a transverse Mercator centred on the pit. An older municipal dataset is in a system its surveyors defined in the 1970s and nobody registered. A drone mapping project uses a low-distortion projection centred on the project area. For all of these, the data is only usable once QGIS knows the CRS — which means defining it yourself.
This recipe belongs to Coordinate Reference Systems in PyQGIS. It builds CRSs from PROJ strings and WKT, validates them against known control points, registers them as user CRSs so they appear in the CRS selector, and deals with the portability problem that custom CRSs create.
Prerequisites
- QGIS 3.40 LTR or newer, or the QGIS 4 series. All use PROJ 9, which prefers WKT2 but reads PROJ strings.
- The CRS parameters from the surveyor or the data's documentation, and ideally at least one control point with coordinates known in both the custom CRS and a standard one.
- Before defining anything, a check that the CRS is genuinely missing: search
QgsCoordinateReferenceSystem("EPSG:…")and the CRS selector for the projection name first. Many "custom" systems are registered under a name nobody recognised.
Build the CRS and check it is valid
QgsCoordinateReferenceSystem.fromProj parses a PROJ string; fromWkt parses WKT. Either returns a CRS object whose isValid() says whether PROJ accepted the definition.
from qgis.core import Qgis, QgsCoordinateReferenceSystem
SITE_GRID = (
"+proj=tmerc +lat_0=51.5 +lon_0=-0.12 +k=1.00002 "
"+x_0=5000 +y_0=10000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs"
)
site = QgsCoordinateReferenceSystem.fromProj(SITE_GRID)
if not site.isValid():
raise ValueError("PROJ rejected the definition")
print("description:", site.description() or "(unnamed)")
print("geographic?", site.isGeographic(), "units:", site.mapUnits())
print(site.toWkt(Qgis.CrsWktVariant.Wkt2_2019Simplified))
Breakdown: A valid CRS is only one PROJ accepted, not one that is correct — a typo in lon_0 still produces a valid, wrong system. towgs84 with seven zeros states that the datum is equivalent to WGS 84 for transformation purposes; omit it and PROJ may warn that the datum shift is unknown, and transformations to other datums become ballpark only. Printing WKT2 shows how PROJ interpreted every parameter, which is the quickest way to spot a misread value: an origin latitude of 51.5 should appear as such, not as radians. WKT2 is also the form to store and share, because it carries the datum and axis order explicitly where a PROJ string relies on conventions.
Surveyors rarely hand over a PROJ string. More often the parameters arrive as a table in a PDF — "central meridian 0°07′12″ W, scale factor 1.00002, false origin 5,000 m E, 10,000 m N" — and the translation is where errors creep in. Convert sexagesimal angles to decimal degrees with the sign carried through (west and south are negative), confirm whether the false origin applies at the natural origin or at a different latitude, and check whether "scale factor" means the projection's k or a combined scale-and-elevation factor applied afterwards. When the documentation names a projection method, look up its exact PROJ name rather than assuming tmerc: an oblique stereographic or a Lambert conformal conic behaves completely differently with the same numbers.
Validate against a control point
The only real test of a custom CRS is a point whose coordinates are known in both systems. Transform one into the other and compare.
from qgis.core import QgsCoordinateTransform, QgsPointXY, QgsProject
wgs84 = QgsCoordinateReferenceSystem("EPSG:4326")
to_site = QgsCoordinateTransform(wgs84, site, QgsProject.instance())
CONTROL = [
# name, lon, lat, published E, published N
("CP7", -0.119162, 51.502181, 5231.418, 10412.906),
("CP9", -0.123840, 51.498774, 4906.221, 10033.500),
]
for name, lon, lat, e, n in CONTROL:
p = to_site.transform(QgsPointXY(lon, lat))
de, dn = p.x() - e, p.y() - n
print(f"{name}: ΔE {de:+.3f} m ΔN {dn:+.3f} m → {(de**2 + dn**2) ** 0.5:.3f} m")
Breakdown: Remember that QgsPointXY takes x then y, so for WGS 84 that is longitude before latitude. Two or more control points spread across the site catch errors a single point cannot: a scale factor error grows with distance from the origin, and a rotation shows as residuals changing direction between points. Residuals of a few millimetres to a centimetre are what a correct definition gives; tens of metres point at a wrong origin or false easting; a consistent offset of around a hundred metres is the classic signature of a missing or wrong datum shift. Transform basics are covered in transforming point coordinates between CRSs.
Register it as a user CRS
A CRS object in a script is enough to assign it to layers in that script. To make it selectable in QGIS's CRS dialog — and reusable by name — register it in the user CRS database.
from qgis.core import QgsApplication
registry = QgsApplication.coordinateReferenceSystemRegistry()
name = "Riverside Site Grid 2026"
existing = [d for d in registry.userCrsList() if d.name == name]
if existing:
site = QgsCoordinateReferenceSystem(f"USER:{existing[0].id}")
else:
crs_id = registry.addUserCrs(site, name, Qgis.CrsDefinitionFormat.Wkt)
if crs_id == -1:
raise RuntimeError("could not add user CRS")
site = QgsCoordinateReferenceSystem(f"USER:{crs_id}")
print(site.authid(), site.description())
Breakdown: User CRSs are stored in the QGIS profile's qgis.db and get ids starting at 100000, referenced as USER:100000. Checking by name first keeps the script idempotent — running it twice does not register two copies. Storing the definition as WKT rather than PROJ keeps datum and axis information intact. Once registered, the CRS appears under User-defined Coordinate Systems in every CRS selector, so colleagues on the same profile can pick it by name.
Keep it portable
User CRS ids are local to one QGIS profile. The same id on another machine may be a different system or nothing at all. Projects are protected because they store the full definition alongside the id; scripts and shared files are not, unless you make them so.
from pathlib import Path
from qgis.core import QgsVectorLayer
wkt_file = Path("/srv/gis/crs/riverside_site_grid.wkt")
wkt_file.write_text(site.toWkt(Qgis.CrsWktVariant.Wkt2_2019))
shared = QgsCoordinateReferenceSystem.fromWkt(wkt_file.read_text())
assert shared.isValid()
setting_out = QgsVectorLayer("/data/site/setting_out_points.gpkg", "setting out", "ogr")
if not setting_out.crs().isValid():
setting_out.setCrs(shared)
setting_out.saveDefaultStyle()
Breakdown: Keeping the WKT in a file under version control, next to the scripts that use it, makes the definition the shared source of truth rather than any one person's profile. Scripts build the CRS from that file every time, never from a USER: id. GeoPackage stores a layer's CRS definition inside the file, so data written in the custom CRS carries it along; shapefiles store it in a .prj sidecar that is easy to lose. The fallback assignment covers files delivered without a CRS, as discussed in handling missing CRS. If the organisation uses the system widely, the lasting fix is to ask the surveyors whether it can be registered with EPSG or published as a named definition in a shared PROJ database.
QGIS version compatibility
fromProj arrived in QGIS 3.10 (earlier releases use createFromProj4), and coordinateReferenceSystemRegistry().addUserCrs in 3.18 (earlier releases use saveAsUserCrs). Qgis.CrsWktVariant replaced QgsCoordinateReferenceSystem.WKT2_2019 in 3.36, and Qgis.CrsDefinitionFormat dates from 3.24. The QGIS 4 series accepts only the scoped enums. PROJ versions bundled with installers may interpret edge-case PROJ strings slightly differently, which is another reason to store WKT2.
Troubleshooting
isValid()is False. A parameter name is misspelt or a value is not a number; PROJ rejects the whole string.- Everything is offset by roughly 100 m. The datum shift is missing or wrong.
- Offsets grow with distance from the origin. The scale factor
kis wrong or omitted. - The CRS is missing on a colleague's machine. A
USER:id was used; share the WKT instead. - Coordinates are swapped. Axis order differs between the WKT you imported and the data; check the
AXISentries in WKT2.
Conclusion
Search the registry before inventing a CRS. When one is genuinely needed, build it from the surveyor's parameters with fromProj or fromWkt, prove it against two or more control points, register it as a user CRS for convenience, and share the WKT2 definition — not the local id — with every script and colleague that needs it.
Frequently Asked Questions
Can I create a local engineering grid with no link to the earth? Yes, as an engineering CRS in WKT, but it cannot be transformed to or from any geographic CRS. Only use one when the data will never meet real-world layers.
What is a low-distortion projection and do I need one? A projection centred on the project area with a scale factor chosen so grid distances match ground distances. It matters where engineering measurements from the map must agree with tape measurements on site.
Does a custom CRS work in QGIS Server and qgis_process? Yes, if the definition is available — in the project file, the data file, or built from WKT in the script.
How do I add a datum grid transformation to a custom CRS? Reference the grid in the PROJ pipeline or the WKT's bound CRS, and make sure the grid file is installed in the PROJ data directory on every machine that uses it.