Convert Between QgsGeometry and Shapely

QGIS and shapely both wrap the GEOS library, so they agree on what a geometry is and on most operations. They differ in everything around it: shapely 2 offers vectorised functions over NumPy arrays of geometries, a huge ecosystem builds on it, and many snippets on the web assume it; QGIS offers curved geometries, M values, coordinate transforms and its own rendering. Converting between them is a routine bridge, and getting it right — fast, lossless, and with clear handling of edge cases — makes mixed scripts simple.

This recipe belongs to PyQGIS and the Python Data Stack. It converts single geometries and whole layers in both directions, shows what survives the trip and what does not, and identifies the shapely operations that are worth converting for.

WKB both waysQgsGeometry.asWkb produces bytes that shapely.from_wkb reads; shapely's to_wkb or the wkb attribute produces bytes that QgsGeometry.fromWkb reads. Both libraries use GEOS for most operations, so results agree. QGIS adds curves, M values and CRS-aware tools; shapely adds vectorised array operations and a large ecosystem.Two wrappers around the same engineQgsGeometrycurves, M valuestransforms, renderingQGIS APIshapelyvectorised arraysecosystemnumpy, GeoPandasasWkb() → from_wkb()to_wkb() → fromWkb()GEOS underneath both

Prerequisites

  • QGIS 3.34 LTR or newer, or the QGIS 4 series.
  • Shapely 2.0 or newer in QGIS's Python. Some QGIS installers ship it; otherwise see installing Python packages into QGIS. Check with import shapely; shapely.__version__.

Convert a single geometry

WKB is the lossless bridge: binary, compact, and read natively by both libraries. A pair of small functions covers everyday use.

import shapely
from qgis.core import QgsGeometry

def to_shapely(qgeom):
    if qgeom is None or qgeom.isNull():
        return None
    return shapely.from_wkb(bytes(qgeom.asWkb()))

def to_qgis(sgeom):
    g = QgsGeometry()
    if sgeom is None:
        return g
    g.fromWkb(shapely.to_wkb(sgeom, include_srid=False))
    return g

q = QgsGeometry.fromWkt("Polygon ((0 0, 10 0, 10 10, 0 10, 0 0))")
s = to_shapely(q)
print(type(s).__name__, s.area)                # Polygon 100.0
back = to_qgis(s.buffer(1, quad_segs=4))
print(back.asWkt(1)[:50], "…", round(back.area(), 2))

Breakdown: asWkb() returns a QByteArray; wrapping it in bytes() gives shapely the plain bytes it expects. In the other direction, to_wkb without an SRID produces standard ISO WKB that fromWkb reads into an existing QgsGeometry. A null QGIS geometry maps to None, and None maps to an empty QgsGeometry, so missing geometries pass through cleanly in both directions. The buffer round trip shows an operation done in shapely and its result returned to QGIS without any loss of precision.

Convert whole layers at once

Shapely 2 functions accept arrays. Collecting WKB from all features and converting in one call is dramatically faster than calling to_shapely per feature, and the result is a NumPy array ready for vectorised operations.

import numpy as np
from qgis.core import QgsProject, QgsFeatureRequest

parcels = QgsProject.instance().mapLayersByName("parcels")[0]
req = QgsFeatureRequest().setNoAttributes()
fids, wkbs = [], []
for f in parcels.getFeatures(req):
    fids.append(f.id())
    wkbs.append(bytes(f.geometry().asWkb()) if f.hasGeometry() else None)

geoms = shapely.from_wkb(wkbs)                 # numpy array of shapely geometries
areas = shapely.area(geoms)
compact = 4 * np.pi * areas / shapely.length(geoms) ** 2
print(len(geoms), "parcels; median compactness", round(float(np.nanmedian(compact)), 3))

Breakdown: setNoAttributes() skips reading attribute values, since only geometry is needed. shapely.from_wkb on a list returns an object array; None entries become missing values that every shapely function skips. Vectorised functions such as area and length run in compiled code across the whole array — computing a compactness index for a hundred thousand parcels takes a fraction of a second. Keeping the fids list in the same order lets you write results back to the layer by feature id.

Per-feature versus vectorisedRelative time to compute a measure over one hundred thousand geometries. A Python loop creating one shapely object per feature and calling a method on it is slowest. Collecting WKB and converting once, then calling a vectorised function, is several times faster. The vectorised route also returns a NumPy array that can be written back to the layer in one batch.100,000 geometriesper-feature loopcollect WKB, one call≈ 5× fasterillustrative — the gap grows with layer size

Write results back to the layer

Results from vectorised work come back as arrays aligned with the feature ids. One provider call writes them all.

from qgis.core import QgsField
from qgis.PyQt.QtCore import QVariant

prov = parcels.dataProvider()
if parcels.fields().indexOf("compactness") < 0:
    prov.addAttributes([QgsField("compactness", QVariant.Double)])
    parcels.updateFields()
idx = parcels.fields().indexOf("compactness")
prov.changeAttributeValues({fid: {idx: None if np.isnan(v) else float(v)}
                            for fid, v in zip(fids, compact)})

simplified = shapely.simplify(geoms, tolerance=0.5, preserve_topology=True)
prov.changeGeometryValues({fid: to_qgis(g) for fid, g in zip(fids, simplified)
                           if g is not None})
parcels.triggerRepaint()

Breakdown: Converting NumPy floats with float() and NaN to None gives the provider plain Python values it stores correctly. changeGeometryValues takes a dictionary of feature id to QgsGeometry, so geometry edits made in shapely go back in one call too. Writing through the provider skips the undo stack; use an edit session instead when a person should be able to undo the change, as in undoing edits with the edit buffer.

What survives the trip

WKB carries everything in the OGC simple features model, but each side has things the other lacks. Knowing them prevents surprises.

What crosses the bridgePreserved both ways: points, lines, polygons, multi types, geometry collections and Z values. QGIS to shapely loses curves, which must be segmentized first, and M values, which shapely does not support before version 2.1 in all operations. Shapely to QGIS preserves everything shapely can represent. Neither side carries the CRS in plain WKB; track it separately.Simple features cross; curves and CRS do notpreservedpoints, lines, polygonsmulti + collectionsZ valueslost to shapelycurves → segmentizeM values (mostly)QGIS-only featuresnever in WKBthe CRStrack it alongside

from qgis.core import QgsAbstractGeometry

arc = QgsGeometry.fromWkt("CircularString (0 0, 5 5, 10 0)")
if arc.constGet().hasCurvedSegments():
    arc = QgsGeometry(arc.constGet().segmentize(0.01, QgsAbstractGeometry.MaximumDifference))
print(to_shapely(arc).geom_type, len(to_shapely(arc).coords), "vertices")

z = QgsGeometry.fromWkt("LineString Z (0 0 100, 10 0 105)")
print(to_shapely(z).has_z, list(to_shapely(z).coords))

Breakdown: Shapely cannot represent circular arcs; segmentizing in QGIS first, with a tolerance in map units (here one centimetre), turns them into ordinary lines. Z values cross intact and most shapely operations carry them along, though some — notably overlays — interpolate or drop them. The CRS is never in plain WKB on either side, so keep it with the data: alongside an array, in the GeoDataFrame's crs, or as the layer's CRS when writing back.

Validate and repair in bulk

Validity checks are a good example of work that vectorises well. Shapely can test, explain and repair every geometry of a layer in three array calls, and the results map straight back to feature ids for writing or reporting.

valid = shapely.is_valid(geoms)
reasons = shapely.is_valid_reason(geoms[~valid])
print(int((~valid).sum()), "invalid geometries")
for fid, why in list(zip(np.array(fids)[~valid], reasons))[:5]:
    print(fid, why)

repaired = shapely.make_valid(geoms[~valid])
area_change = np.abs(shapely.area(repaired) - shapely.area(geoms[~valid]))
print("largest area change after repair:", float(area_change.max()) if len(area_change) else 0)

Breakdown: is_valid returns a boolean array; indexing with its negation selects only the invalid geometries for the slower explanation and repair calls. is_valid_reason gives the same GEOS messages as QGIS's validity check, so results agree with the geometry validity report. Comparing areas before and after make_valid flags repairs that changed a shape substantially, which deserve a look before they are written back with changeGeometryValues. Whether to do this in shapely or with QGIS's own native:fixgeometries is mostly a matter of where the rest of the script lives — the GEOS repair underneath is the same.

When shapely is worth the round trip

For single operations QGIS already offers, converting just to call the shapely version adds overhead and gains nothing. The conversion pays off when:

  • The work is vectorised over many geometries — measures, predicates against one geometry, distance matrices — where shapely's array functions are much faster than a Python loop over QgsGeometry methods.
  • A library expects shapely — scikit-learn feature engineering, networkx graphs built from lines, osmnx, momepy morphology metrics.
  • You need a shapely-only function — shapely.voronoi_polygons with extents, shapely.coverage_simplify for topology-preserving simplification of coverages (shapely 2.1 with GEOS 3.12+), shapely.STRtree bulk queries.
tree = shapely.STRtree(geoms)
pairs = tree.query(geoms, predicate="intersects")      # 2 × N array of index pairs
touching = pairs[:, pairs[0] != pairs[1]]
print(touching.shape[1] // 2, "pairs of intersecting parcels")

Breakdown: STRtree.query with an array of geometries returns all intersecting pairs in one call, a bulk operation with no direct QGIS equivalent short of a Python loop over a spatial index. Removing self-pairs and halving the count gives unique pairs. This is the kind of task — a whole-layer neighbour search — where the round trip is clearly worth it; compare the loop-based approach in finding gaps and overlaps.

QGIS version compatibility

The WKB conversion works on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. Shapely 2.0 or newer is required for the vectorised functions; coverage_simplify needs shapely 2.1 and GEOS 3.12. On QGIS 4, QgsAbstractGeometry.MaximumDifference is written QgsAbstractGeometry.SegmentationToleranceType.MaximumDifference, and QgsField takes QMetaType.Type.Double.

Troubleshooting

  • TypeError from from_wkb. A QByteArray was passed; wrap it in bytes().
  • Curves came out as straight lines between three points. The geometry was not segmentized with a fine enough tolerance before conversion.
  • Results are in the wrong place after writing back. A CRS transform happened on one side only; WKB never carries the CRS.
  • GEOS version warnings. QGIS and shapely bundle different GEOS builds; results are compatible, but very new shapely functions may need a newer GEOS than QGIS ships.

Conclusion

Convert through WKB in both directions, batch whole layers into one from_wkb call and use shapely's vectorised functions, write results back with single provider calls, segmentize curves and track the CRS yourself, and convert only when vectorisation, a library or a shapely-only function makes the trip worthwhile.

Frequently Asked Questions

Is converting through WKT text good enough? It works, but WKT is slower and can lose precision through decimal formatting. Use WKB.

Can shapely and QGIS share memory? No. Each holds its own GEOS objects; conversion always copies, which is cheap through WKB.

Do GeoPandas geometries convert the same way? Yes. A GeoSeries holds shapely geometries; gdf.geometry.to_wkb() gives the bytes for QGIS.

Which library is faster for buffers? They call the same GEOS function. Speed differences come from looping in Python versus vectorised calls.