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.

Two routes from a GeoDataFrame to a layerRoute one: write the frame to a GeoPackage with to_file and load it as an ogr layer; robust, persistent, and type mapping is done by GDAL. Route two: build a memory layer whose fields are derived from the frame's dtypes, convert each geometry to WKB and add features in one batch; no disk, but types and geometry must be mapped by hand. Either way the layer is added to the project and styled.Through a file, or straight into memoryGeoDataFrameto_file → GeoPackageQgsVectorLayer(path, …, "ogr")persistent · GDAL maps typesmemory layerfields from dtypes · WKBno disk · you map typeslayer on the map

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.crs before 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.

Pandas dtypes to QGIS field typesA mapping table from pandas dtypes to memory layer field types: integer dtypes including nullable Int64 map to int8 for 64-bit safety, floats to double, booleans to bool, datetime64 to datetime, and object or string to string. Unknown dtypes fall back to string so no column is dropped.One rule per dtype, string as the fallbackpandas dtypeint64 / Int64float64bool / booleandatetime64object / stringmemory layer typeint8 (64-bit)doublebooldatetimestring

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.

Three checks after loadingAfter loading, compare three things between the frame and the layer: feature count, which catches rejected features; total bounds, which catch a CRS mismatch because coordinates land in the wrong place; and a sample of attribute values, which catches type conversion such as large integers truncated or dates turned into strings.Count, bounds, valuesfeature countlen(gdf) = layercatches rejectstotal boundssame extentcatches CRS mix-upssample valuesspot-check rowscatches type casts

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.crs after every to_crs.
  • Integer columns became doubles. The column had missing values, so pandas made it float; use astype("Int64") before loading.
  • to_file fails 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.