Create Geometry from WKT and GeoJSON in PyQGIS

Geometry rarely starts life inside QGIS. It arrives as a WKT string in a database export, a GeoJSON fragment from a web API, a hex-encoded WKB column from PostGIS, or a plain list of coordinate pairs in a CSV. QgsGeometry can be built from all of them, but each route has its own way of failing — and most of them fail quietly, by returning an empty geometry rather than raising an error.

This recipe belongs to Features, Geometries & Memory Layers. It covers each input format, the checks that catch a bad parse before it reaches a layer, how Z and M values survive the trip, and how to export geometries back to text for logs, APIs and other tools.

Many encodings, one geometry objectFour common encodings enter on the left: WKT text, WKB bytes or hex, a GeoJSON geometry object and plain coordinate lists. Each has a dedicated factory: fromWkt, fromWkb, a JSON conversion through QgsJsonUtils or OGR, and fromPointXY or fromPolylineXY. All of them produce a QgsGeometry, which then needs an isNull and isGeosValid check before it is used.Pick the factory that matches the encodingWKT textPOLYGON((…))WKB bytes / hex0103000000…GeoJSON object{"type": "Point"…}coordinate lists[(x, y), (x, y)]fromWkt()fromWkb()QgsJsonUtils / OGRfromPolylineXY()QgsGeometrycheck isNull()then isGeosValid()

Prerequisites

  • QGIS 3.34 LTR or newer, or the QGIS 4 series, with the Python console or a standalone script.
  • A target CRS in mind. Geometry objects carry no CRS of their own — the layer they are added to supplies it — so you need to know what coordinate system the incoming numbers are in.

Parse WKT and WKB

Well-known text is the most readable encoding and the one most likely to arrive hand-edited, which makes it the one most likely to be wrong. QgsGeometry.fromWkt never raises; a string it cannot parse becomes a null geometry.

from qgis.core import QgsGeometry, QgsWkbTypes

def geometry_from_wkt(text):
    geom = QgsGeometry.fromWkt(text.strip())
    if geom.isNull():
        raise ValueError(f"unparseable WKT: {text[:60]!r}")
    return geom

parcel = geometry_from_wkt(
    "POLYGON((571200 5934000, 571260 5934000, 571260 5934045, 571200 5934045, 571200 5934000))")
print(QgsWkbTypes.displayString(parcel.wkbType()), parcel.area())

line_z = geometry_from_wkt("LINESTRING Z (0 0 12.5, 10 0 13.1, 20 5 14.0)")
print(line_z.constGet().is3D(), [v.z() for v in line_z.vertices()])

# WKB as bytes, e.g. from a database cursor or a hex column
hex_wkb = "0101000000000000000000F03F0000000000000040"
pt = QgsGeometry()
pt.fromWkb(bytes.fromhex(hex_wkb))
print(pt.asWkt())          # Point (1 2)

Breakdown: Stripping whitespace avoids the most common trivial failure — a trailing newline or tab from a spreadsheet cell. isNull() is the right test for a failed parse; isEmpty() is true for valid but empty geometries such as POINT EMPTY, which some databases emit for missing values. The Z values survive because WKT declares them; constGet() gives access to the underlying QgsAbstractGeometry where dimension checks live. fromWkb is an instance method that fills an existing geometry, unlike the static fromWkt, and it accepts both ISO and extended WKB, so PostGIS EWKB with an embedded SRID is read correctly — the SRID itself is ignored.

Convert GeoJSON objects

GeoJSON is the format web APIs speak. The geometry part is a small JSON object; there are two dependable ways to turn it into a QgsGeometry, depending on whether you have a single geometry or a whole feature collection.

Geometry object or feature collectionFor a single GeoJSON geometry dictionary, the shortest route is to dump it to a string and let OGR create a geometry, then convert to WKB and into QgsGeometry. For a full FeatureCollection string, QgsJsonUtils.stringToFeatureList returns QgsFeature objects with geometry and attributes in one call, given fields from stringToFields. GeoJSON coordinates are always longitude then latitude in WGS 84.Geometry dict or whole collection?single geometry dict{"type": "LineString", "coordinates": …}FeatureCollection string{"type": "FeatureCollection", …}ogr.CreateGeometryFromJson→ ExportToWkb → QgsGeometryQgsJsonUtils.stringToFeatureList→ features with attributescoordinates are always lon, lat in EPSG:4326

import json
from osgeo import ogr
from qgis.core import QgsGeometry, QgsJsonUtils

def geometry_from_geojson(obj):
    """obj is a dict like {"type": "Point", "coordinates": [lon, lat]}"""
    ogr_geom = ogr.CreateGeometryFromJson(json.dumps(obj))
    if ogr_geom is None:
        raise ValueError(f"bad GeoJSON geometry: {str(obj)[:60]}")
    geom = QgsGeometry()
    geom.fromWkb(ogr_geom.ExportToIsoWkb())
    return geom

route = geometry_from_geojson(
    {"type": "LineString", "coordinates": [[13.37, 52.51], [13.40, 52.52], [13.42, 52.53]]})
print(route.length())       # in degrees — reproject before measuring

text = open("/data/inbox/stations.geojson", encoding="utf-8").read()
fields = QgsJsonUtils.stringToFields(text)
features = QgsJsonUtils.stringToFeatureList(text, fields)
print(len(features), "features with fields", fields.names())

Breakdown: GDAL's OGR bindings ship with every QGIS installation, so routing a single geometry through CreateGeometryFromJson adds no dependency and handles every geometry type including GeometryCollection. ExportToIsoWkb keeps Z and M values in the ISO form QGIS expects. For a complete document, QgsJsonUtils parses features and their properties in one step — stringToFields infers the schema from the properties, so you can create a matching memory layer and add the features directly. The length printed for the route is in degrees, which is meaningless as a distance; GeoJSON is always WGS 84 longitude and latitude, so measure after transforming the coordinates or with QgsDistanceArea.

Build geometry from coordinate lists

When coordinates come as plain numbers — CSV columns, a GPS log, values computed in a loop — the typed factories are faster and clearer than formatting a WKT string only to parse it again.

from qgis.core import (QgsGeometry, QgsPointXY, QgsPoint, QgsLineString,
                       QgsPolygon)

xy = [(571200, 5934000), (571260, 5934000), (571260, 5934045), (571200, 5934045)]

point = QgsGeometry.fromPointXY(QgsPointXY(*xy[0]))
line = QgsGeometry.fromPolylineXY([QgsPointXY(x, y) for x, y in xy])
ring = [QgsPointXY(x, y) for x, y in xy] + [QgsPointXY(*xy[0])]
polygon = QgsGeometry.fromPolygonXY([ring])

# with Z values: build the abstract geometry, then wrap it
track = QgsLineString([QgsPoint(x, y, z) for x, y, z in
                       [(0, 0, 410.2), (35, 12, 412.8), (71, 30, 415.0)]])
track_geom = QgsGeometry(track)
print(polygon.isGeosValid(), track_geom.constGet().is3D())

Breakdown: The XY factories are 2D only: QgsPointXY has no Z, so elevation is dropped silently. For 3D data, build a QgsLineString or QgsPolygon from QgsPoint objects, which carry Z and M, and wrap it in QgsGeometry — the wrapper takes ownership. Polygon rings must be closed: fromPolygonXY closes an open ring for you in current releases, but adding the first point at the end yourself makes the intent explicit and keeps older versions happy. Each list passed to fromPolygonXY after the first is a hole.

Validate before you store

A parsed geometry can still be unusable: a self-intersecting polygon, a line with a single vertex, a ring wound the wrong way for a strict consumer. Checking at the point of creation means the error message can name the source row, which is impossible once the geometry is one of fifty thousand in a layer.

Three checks in orderEach new geometry passes three checks. isNull catches a failed parse. The geometry type check compares the parsed type with the layer type, for example a MultiPolygon arriving for a Polygon layer, which can be fixed by converting to multi. isGeosValid catches topological problems such as self-intersections, which makeValid can usually repair. Only geometries that pass all three are added.Catch the failure where you can still name the rowisNull()parse failedlog the raw inputskip the rowtype matches layer?Polygon vs MulticonvertToMultiType()or create Multi layerisGeosValid()self-intersectionsmakeValid() repairslog what changedaddFeatures(...)only clean geometry

from qgis.core import QgsWkbTypes

def clean_for_layer(geom, layer, row_id):
    if geom.isNull():
        raise ValueError(f"row {row_id}: no geometry")
    target = layer.wkbType()
    if QgsWkbTypes.isMultiType(target) and not geom.isMultipart():
        geom.convertToMultiType()
    if QgsWkbTypes.geometryType(geom.wkbType()) != QgsWkbTypes.geometryType(target):
        raise ValueError(f"row {row_id}: {geom.type()} into {layer.geometryType()} layer")
    if not geom.isGeosValid():
        fixed = geom.makeValid()
        print(f"row {row_id}: repaired invalid geometry")
        geom = fixed
    return geom

Breakdown: The type check compares geometry families — point, line, polygon — so a single polygon going into a multipolygon layer is converted rather than rejected, while a line going into a polygon layer is refused. makeValid uses GEOS's repair, which can change the geometry type (a bow-tie polygon becomes a multipolygon), so it runs before the layer type is checked in production code if repairs are common. For a whole layer rather than individual rows, fixing invalid geometries with Processing is faster.

Load a whole file of WKT rows

The checks above pay off when they run inside a loader. The pattern below reads a text export with an identifier and a WKT column, builds every geometry, keeps a list of rejected rows with the reason, and adds the good features in a single call. It is the same shape whether the input is a CSV, a database cursor or a list returned by an API.

import csv
from qgis.core import QgsVectorLayer, QgsFeature, QgsProject

layer = QgsVectorLayer("MultiPolygon?crs=EPSG:25832&field=parcel_id:string(20)",
                       "parcels (import)", "memory")
good, rejected = [], []
with open("/data/inbox/parcels_wkt.csv", newline="", encoding="utf-8") as fh:
    for row in csv.DictReader(fh, delimiter=";"):
        try:
            geom = clean_for_layer(geometry_from_wkt(row["wkt"]), layer, row["parcel_id"])
        except ValueError as err:
            rejected.append((row["parcel_id"], str(err)))
            continue
        f = QgsFeature(layer.fields())
        f["parcel_id"] = row["parcel_id"]
        f.setGeometry(geom)
        good.append(f)

layer.dataProvider().addFeatures(good)
layer.updateExtents()
QgsProject.instance().addMapLayer(layer)
print(f"{len(good)} loaded, {len(rejected)} rejected")
for pid, why in rejected[:10]:
    print("  ", pid, why)

Breakdown: Declaring the layer as MultiPolygon lets single polygons and multipolygons coexist, with clean_for_layer promoting the singles. Collecting rejections instead of stopping at the first one means a single pass tells you everything that is wrong with the file, and printing the first ten keeps the console readable when a systematic problem rejects thousands. If the file is large and well-formed, the delimited text provider reads a WKT column directly and is faster — but it gives no per-row diagnosis, which is exactly what an import from an unfamiliar source needs first.

Write geometry back out

Exporting is the mirror image, and every format has a method: asWkt(precision), asWkb(), asJson(precision). Precision matters when the output is going into logs or an API payload, where full double precision wastes space and implies an accuracy the data does not have.

print(parcel.asWkt(2))      # Polygon ((571200 5934000, 571260 5934000, ...))
print(parcel.asJson(1))     # {"type":"Polygon","coordinates":[[[571200.0,...]]]}
wkb_hex = parcel.asWkb().toHex().data().decode()
print(wkb_hex[:24], "…")

Breakdown: asJson writes the geometry's coordinates as they are, without reprojecting; if the layer is not in EPSG:4326 the result is valid JSON but not valid GeoJSON. For a full feature with properties in WGS 84, QgsJsonExporter with setDestinationCrs handles both. asWkb() returns a QByteArray, which is why the hex conversion takes three calls.

QGIS version compatibility

All factories shown work in QGIS 3.x and QGIS 4. QgsGeometry.fromWkt accepts case-insensitive type names and both POINT Z (…) and POINTZ(…) forms. makeValid gained its method and keep-collapsed options in 3.28; the default call shown is portable. On QGIS 4, QgsWkbTypes enum members are reached as Qgis.WkbType and Qgis.GeometryType; the QgsWkbTypes helper functions remain.

Troubleshooting

  • Every geometry is null. The WKT uses a comma as decimal separator, or has a prefix such as SRID=4326; — strip the SRID part before calling fromWkt.
  • Points land near 0,0 or in the ocean. The numbers are latitude, longitude rather than longitude, latitude; swap them when building the points.
  • Z values disappear. The geometry was built through a …XY factory or added to a 2D layer; create a PointZ or LineStringZ layer.
  • fromWkb returns an empty geometry. The input is a hex string, not bytes; convert with bytes.fromhex.

Conclusion

Use fromWkt and fromWkb for text and binary encodings, OGR or QgsJsonUtils for GeoJSON, and the typed factories for coordinate lists — QgsPoint rather than QgsPointXY when Z matters. Check for null, matching type and validity at the moment of creation, and export with an explicit precision.

Frequently Asked Questions

Does a QgsGeometry know its CRS? No. It is a bare shape; the CRS belongs to the layer or to a QgsCoordinateTransform you apply. Keep track of it alongside the geometry.

How do I parse EWKT like SRID=25832;POINT(1 2)? Split at the semicolon, use the number to choose the layer CRS, and pass the remainder to fromWkt.

Can I create curved geometries from WKT? Yes. CIRCULARSTRING, COMPOUNDCURVE and CURVEPOLYGON parse into curved geometry types that QGIS draws as true arcs.

Is shapely faster for bulk parsing? For millions of WKT strings, often yes; you can convert between shapely and QGIS geometries through WKB, as shown in converting between QgsGeometry and shapely.