Load a GeoDataFrame into QGIS
The other half of working with GeoPandas from QGIS is bringing results back: the buffer-and-dissolve you did in pandas, the frame read from a web API, the output of a scikit-learn model with a geometry column. QGIS needs a layer to display, style and export, and there are two good ways to make one — through a file, which is simple and robust, or directly into a memory layer, which avoids disk and keeps everything in the session.
This recipe belongs to PyQGIS and the Python Data Stack. It covers both routes, maps pandas dtypes to QGIS field types, deals with mixed geometry types, NULLs and dates, and applies a style so the new layer is immediately readable.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series, with GeoPandas and shapely 2 installed into QGIS's Python.
- A GeoDataFrame with a CRS set. Check
gdf.crsbefore anything else; a frame without a CRS produces a layer QGIS will place wherever it guesses.
Route 1: through a GeoPackage
Writing to a file and loading it is the most robust route. GDAL handles type mapping, the result survives a restart, and the same file is readable by every other tool.
import geopandas as gpd
from qgis.core import QgsVectorLayer, QgsProject
gdf = gpd.read_file("/data/inbox/sites.geojson").to_crs(25832)
gdf["buffer_m"] = 250
gdf["geometry"] = gdf.buffer(gdf["buffer_m"])
path = "/data/results/analysis.gpkg"
gdf.to_file(path, layer="site_buffers", driver="GPKG")
layer = QgsVectorLayer(f"{path}|layername=site_buffers", "site buffers", "ogr")
if not layer.isValid():
raise RuntimeError("could not load the written layer")
QgsProject.instance().addMapLayer(layer)
print(layer.featureCount(), layer.crs().authid(), layer.fields().names())
Breakdown: to_file with the GPKG driver writes one table into the GeoPackage, creating the file if needed and replacing that table if it exists. The |layername= suffix tells the OGR provider which table to open — necessary because a GeoPackage can hold many. The CRS travels inside the file, so QGIS reads exactly what GeoPandas wrote. This route is the right default for results anyone else will use, and for anything large: GeoPackage writing is fast and the layer gets a proper spatial index.
Route 2: straight into a memory layer
For intermediate results inside a session — something to look at, style and throw away — a memory layer avoids files altogether. The work is mapping the frame's columns to QGIS field types and its geometry to WKB.
import pandas as pd
from qgis.core import QgsFeature, QgsGeometry, QgsCoordinateReferenceSystem
def qgis_type(dtype):
if pd.api.types.is_bool_dtype(dtype):
return "bool"
if pd.api.types.is_integer_dtype(dtype):
return "int8"
if pd.api.types.is_float_dtype(dtype):
return "double"
if pd.api.types.is_datetime64_any_dtype(dtype):
return "datetime"
return "string"
def gdf_to_memory_layer(gdf, name, geom_type=None):
gtype = geom_type or gdf.geom_type.dropna().iloc[0]
if gdf.geom_type.str.startswith("Multi").any() and not gtype.startswith("Multi"):
gtype = "Multi" + gtype
cols = [c for c in gdf.columns if c != gdf.geometry.name]
fields = "".join(f"&field={c}:{qgis_type(gdf[c].dtype)}" for c in cols)
epsg = gdf.crs.to_epsg()
layer = QgsVectorLayer(f"{gtype}?crs=EPSG:{epsg}{fields}" if epsg else f"{gtype}{fields.replace('&', '?', 1)}",
name, "memory")
if not epsg:
layer.setCrs(QgsCoordinateReferenceSystem.fromWkt(gdf.crs.to_wkt()))
feats = []
for row, geom in zip(gdf[cols].itertuples(index=False), gdf.geometry):
f = QgsFeature(layer.fields())
f.setAttributes([None if pd.isna(v) else (v.to_pydatetime() if isinstance(v, pd.Timestamp) else v)
for v in row])
if geom is not None and not geom.is_empty:
g = QgsGeometry()
g.fromWkb(geom.wkb)
if gtype.startswith("Multi"):
g.convertToMultiType()
f.setGeometry(g)
feats.append(f)
layer.dataProvider().addFeatures(feats)
layer.updateExtents()
return layer
mem = gdf_to_memory_layer(gdf, "site buffers (memory)")
QgsProject.instance().addMapLayer(mem)
Breakdown: The memory URI declares geometry type, CRS and every field in one string — by EPSG code where the frame's CRS has one, with a WKT fallback set on the layer for custom systems — as described in creating a memory layer. Using int8 (64-bit) for integers avoids overflow on large ids. If any geometry is multipart, the layer is declared as the multi type and every geometry is promoted, so a frame mixing Polygon and MultiPolygon loads without rejected features. Pandas missing values (NaN, NaT, pd.NA) become None, which QGIS stores as NULL, and pandas timestamps become Python datetimes. geom.wkb is shapely's binary encoding, read by QgsGeometry.fromWkb without loss.
Handle mixed geometry types
A GeoDataFrame can hold points, lines and polygons in one column; a QGIS layer cannot. The frame must be split, or the geometry homogenised, before loading.
for gtype, part in gdf.groupby(gdf.geom_type.str.replace("Multi", "")):
lyr = gdf_to_memory_layer(part, f"result ({gtype})", geom_type=gtype)
QgsProject.instance().addMapLayer(lyr)
print(gtype, lyr.featureCount())
Breakdown: Grouping by geometry family — with the "Multi" prefix stripped so Polygon and MultiPolygon stay together — produces one layer per family. Mixed results are common after overlays in GeoPandas: an intersection of polygons can produce lines and points where shapes merely touch. Often those degenerate pieces should be dropped rather than loaded; filter with gdf[gdf.geom_type.isin(["Polygon", "MultiPolygon"])] when only areas matter. Writing mixed geometry to GeoPackage fails for the same reason, so the split is needed on both routes.
Keep data and CRS consistent
Two subtle problems survive a successful load: coordinates in a different CRS from the one declared, and attribute values silently converted.
def verify(gdf, layer, key):
assert layer.featureCount() == len(gdf), "feature count differs"
ext = layer.extent()
gx = gdf.total_bounds
assert abs(ext.xMinimum() - gx[0]) < 1e-6 * max(1, abs(gx[0])), "extent differs"
for _, row in gdf.sample(min(5, len(gdf)), random_state=1).iterrows():
f = next(layer.getFeatures(f"\"{key}\" = '{row[key]}'"))
for col in gdf.columns.drop(gdf.geometry.name):
a, b = row[col], f[col]
if pd.isna(a):
continue
assert str(a)[:19] == str(b)[:19] or a == b, f"{col}: {a!r} vs {b!r}"
print("verified")
verify(gdf, mem, "site_id")
Breakdown: Feature count catches features rejected for geometry type. Comparing extents catches the classic mistake of reprojecting the frame but declaring the old CRS — the layer then appears hundreds of kilometres away or not at all. Sampling a few rows by key compares values loosely enough to tolerate harmless representation differences (a pandas timestamp versus a QDateTime printing slightly differently) while catching real changes. Running a check like this in scripts that hand results to other people is cheap insurance.
Write several results into one GeoPackage
An analysis usually produces more than one frame: the main result, intermediate layers worth keeping, a summary table without geometry. Writing them as tables in one GeoPackage and adding them to the project as a group keeps the output tidy and self-contained.
from qgis.core import QgsLayerTreeGroup
results = {"site_buffers": gdf, "site_summary": summary_gdf, "flagged_sites": flagged}
path = "/data/results/site_analysis_2026-10.gpkg"
for table, frame in results.items():
if "geometry" in frame.columns:
frame.to_file(path, layer=table, driver="GPKG")
else:
gpd.GeoDataFrame(frame).to_file(path, layer=table, driver="GPKG")
root = QgsProject.instance().layerTreeRoot()
group = root.insertGroup(0, "Site analysis 2026-10")
for table in results:
lyr = QgsVectorLayer(f"{path}|layername={table}", table, "ogr")
QgsProject.instance().addMapLayer(lyr, addToLegend=False)
group.addLayer(lyr)
Breakdown: Each to_file call with a different layer adds a table to the same GeoPackage. A frame without a geometry column is written as an attribute-only table, which QGIS opens as a plain table you can join to the spatial layers. Adding layers with addToLegend=False and then placing them in a named group keeps them together at the top of the Layers panel, as described in organising layer tree groups. Naming the file and group after the run date makes successive analyses easy to tell apart.
Style the new layer
A newly loaded layer gets a random colour. A couple of lines give it a style that carries meaning — here a graduated renderer on a numeric result column.
from qgis.core import QgsGraduatedSymbolRenderer, QgsStyle, QgsClassificationJenks
renderer = QgsGraduatedSymbolRenderer("score")
renderer.setClassificationMethod(QgsClassificationJenks())
renderer.updateClasses(mem, 5)
renderer.updateColorRamp(QgsStyle.defaultStyle().colorRamp("Viridis"))
mem.setRenderer(renderer)
mem.triggerRepaint()
Breakdown: Natural breaks with five classes and a perceptually uniform ramp is a sensible default for a continuous result; classifying with natural breaks explains the options. If a style already exists for this kind of output, load it from a QML file instead, as in saving and loading QML styles, so every run looks the same.
QGIS version compatibility
Both routes work on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. The memory provider's int8 type needs 3.28 or newer; use integer on older releases. GeoPandas 0.14 and later, with shapely 2, are assumed. On QGIS 4, setting None as an attribute is the native NULL; on QGIS 3, None is also accepted by setAttributes and stored as NULL.
Troubleshooting
- Some features are missing. Mixed geometry types; split the frame by family or filter degenerate results.
- The layer appears in the wrong place. The CRS declared does not match the coordinates; check
gdf.crsafter everyto_crs. - Integer columns became doubles. The column had missing values, so pandas made it float; use
astype("Int64")before loading. to_filefails with a field name error. GeoPackage column names are limited; rename long or duplicate names.
Conclusion
Write to GeoPackage and load the file for anything persistent or large; build a memory layer from dtypes and WKB for session-only results; split mixed geometry types by family; convert pandas missing values and timestamps; verify count, extent and sample values; and apply a meaningful style straight away.
Frequently Asked Questions
Can I update an existing QGIS layer from a GeoDataFrame?
Yes: match rows by key and use changeAttributeValues and changeGeometryValues on the provider, or within an edit session.
Is the memory route faster? For small and medium frames, yes. For large frames, GeoPackage writing in compiled code often wins.
Does styling survive if I re-run the script? Not for memory layers. Save a QML and apply it after loading.
Can I load a plain DataFrame without geometry?
Yes, as a None geometry memory layer — useful for joining to existing layers.