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.
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.
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.
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
QgsGeometrymethods. - 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_polygonswith extents,shapely.coverage_simplifyfor topology-preserving simplification of coverages (shapely 2.1 with GEOS 3.12+),shapely.STRtreebulk 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
TypeErrorfromfrom_wkb. AQByteArraywas passed; wrap it inbytes().- 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.