How to Move Data Between QGIS and GeoPandas

On this page

The two halves of the Python GIS world are better together than either is alone. GeoPandas is where tabular work is pleasant — joins, groupbys, reshaping, anything that would be a chore feature by feature. QGIS is where the algorithms, the styling, and the print layouts live. A good pipeline uses both, and the only question is how the data crosses between them without losing its CRS, its field types, or its nulls on the way.

Problem statement

You want to do part of a workflow in one and part in the other, and the handoff keeps going wrong:

  • Writing a file for every step is slow and litters the working directory with intermediates nobody reads.
  • The CRS is lost or wrong after the crossing — a layer that was EPSG:27700 arrives with no CRS, or with one QGIS cannot use.
  • Field types drift — integers become floats, dates become Qt objects, missing numbers become NaN that poisons a sum.
  • A fid column appears out of nowhere, or the one you needed disappears.
  • You cannot import geopandas in the QGIS Python, or cannot import qgis in your project virtualenv.
  • Nothing is obvious about direction — converting QGIS → GeoPandas and GeoPandas → QGIS use different mechanisms and neither is documented in one place.

Two functions, to_geodataframe(layer) and to_qgis_layer(gdf), carry geometry, attributes and CRS across intact — and there is a rule for when a GeoPackage on disk is simpler. Everything below was run with QGIS 4.2 and GeoPandas 1.2 in one interpreter, on Rutland's 462 food businesses from the Food Standards Agency, its schools, and its 21,253 Land Registry parcels.

Quick answer

There are three routes across, and the boring one is usually right.

Three routes between QGIS and GeoPandas compared. three routes acrossGeoPackageWKB in memoryPostGIScostone big writeper-feature✓no conversioninspectable✓yes — open it✕no✓yesbest forone handoffsmall + frequentdata already thereStart with the file; move to WKB when I/O is measurably in the way spatialworkflow.io
Start with the file. Move to WKB only when the file I/O is measurably in the way.

Route 1 — a GeoPackage on disk. Both sides read and write it natively, CRS survives, and the intermediate is inspectable when something looks wrong.

# GeoPandas → QGIS
gdf.to_file("tmp/food.gpkg", layer="food", driver="GPKG")
layer = QgsVectorLayer("tmp/food.gpkg|layername=food", "food", "ogr")

# QGIS → GeoPandas
gdf = gpd.read_file("tmp/food.gpkg", layer="food")

Route 2 — in memory, via WKB. No disk, no driver, exact geometry.

from shapely import wkb

def to_geodataframe(layer):
    names = [f.name() for f in layer.fields()]
    records, geoms = [], []
    for feat in layer.getFeatures():
        records.append(dict(zip(names, feat.attributes())))
        g = feat.geometry()
        geoms.append(wkb.loads(bytes(g.asWkb())) if not g.isNull() else None)
    return gpd.GeoDataFrame(records, geometry=geoms, crs=layer.crs().toWkt() or None)

Route 3 — PostGIS as the shared store. Both sides speak to the same database; nothing converts at all. Best when the data is already there.

What you get — from Route 1: the file handoff, done properly

QGIS sees: [('fid', 'Integer64'), ('fhrsid', 'Integer64'), ('name', 'String'), ('business_type', 'String'), ('rating', 'Real'), ('rating_date', 'DateTime'), ('hygiene', 'Integer64')] EPSG:27700
GeoPandas gets back: {'fhrsid': 'int64', 'name': 'str', 'business_type': 'str', 'rating': 'float64', 'rating_date': 'datetime64[ms, UTC]', 'hygiene': 'float64', 'geometry': 'geometry'} 27700 338

Decide who owns what, then pick a route across

Decide which side owns which part of the work

Before writing any conversion code, put the boundary in the right place. Crossing twice per row is a design smell; crossing twice per workflow is normal and cheap.

  • GeoPandas is better at attribute joins, groupby aggregation, reshaping, time series, statistics, anything you would reach for pandas to do, and anything that must run in a plain virtualenv.
  • QGIS is better at its algorithm catalogue (network analysis, terrain, GRASS), styling, print layouts and atlases, and reading exotic formats through its providers.

A good pipeline usually looks like: clean and join in GeoPandas → hand to QGIS for the spatial algorithms and the map → done. One crossing each way. PyQGIS vs GeoPandas works through the choice in more detail.

Route 1: the file handoff, done properly

A GeoPackage handoff from GeoPandas to QGIS and back. one container, named layersGeoDataFramejoins, groupbywork.gpkgto_file(GPKG)processing.run|layername=…GeoDataFrameread_file()The intermediate still exists when the result looks wrong spatialworkflow.io
One container, named layers, and the intermediate is a file you can open when the numbers look wrong.
TMP = Path("tmp")
TMP.mkdir(exist_ok=True)
food.to_file(TMP / "work.gpkg", layer="food", driver="GPKG")

uri = f"{TMP / 'work.gpkg'}|layername=food"
layer = QgsVectorLayer(uri, "food", "ogr")
print("QGIS sees:", [(f.name(), f.typeName()) for f in layer.fields()], layer.crs().authid())

processing.run("native:buffer", {
    "INPUT": uri, "DISTANCE": 25, "SEGMENTS": 8,
    "DISSOLVE": False, "OUTPUT": f"ogr:dbname='{TMP / 'work.gpkg'}' table=\"food_buffer\" (geom)",
})
back = gpd.read_file(TMP / "work.gpkg", layer="food_buffer")
print("GeoPandas gets back:", back.dtypes.astype(str).to_dict(), back.crs.to_epsg(), len(back))
QGIS sees: [('fid', 'Integer64'), ('fhrsid', 'Integer64'), ('name', 'String'), ('business_type', 'String'), ('rating', 'Real'), ('rating_date', 'DateTime'), ('hygiene', 'Integer64')] EPSG:27700
GeoPandas gets back: {'fhrsid': 'int64', 'name': 'str', 'business_type': 'str', 'rating': 'float64', 'rating_date': 'datetime64[ms, UTC]', 'hygiene': 'float64', 'geometry': 'geometry'} 27700 338

The CRS, the float rating with its gaps and the text crossed both ways. Two things changed. The integer hygiene column, which GeoPandas held as a nullable Int64, came back as float64, because it has missing values and the reader turns an integer column with nulls into floats — a GeoPandas-side decision, since QGIS read the same column as Integer64. And the rating date went out naive and came back as UTC after passing through a QGIS algorithm, which matters the moment it is compared with a naive timestamp.

Three details make this reliable. Always use GeoPackage rather than shapefile — no ten-character field truncation, no .prj sidecar to lose, one file. Always include |layername= on the way in; without it you get the first layer, which is only correct by luck. To write a second layer into the same file from Processing, use the ogr:dbname=… table=… form above — a file.gpkg|layername=x string as an output path is not the same thing. And keep the intermediates in a tmp/ directory that the job creates and deletes, so a debugging session can look at them and a clean run leaves nothing behind.

Route 2: QGIS layer → GeoDataFrame in memory

from qgis.core import QgsFeatureRequest
from qgis.PyQt.QtCore import QDate, QDateTime
from shapely import wkb


def to_geodataframe(layer, expression=None):
    """Convert a QgsVectorLayer (or QgsFeatureSource) to a GeoDataFrame."""
    if not layer.crs().isValid():
        raise ValueError(f"{layer.name()} has no valid CRS")
    request = QgsFeatureRequest()
    if expression:
        request.setFilterExpression(expression)

    names = [f.name() for f in layer.fields()]
    rows, geoms = [], []
    for feat in layer.getFeatures(request):
        values = []
        for v in feat.attributes():
            if isinstance(v, QDateTime):
                v = v.toPyDateTime() if v.isValid() else None
            elif isinstance(v, QDate):
                v = v.toPyDate() if v.isValid() else None
            values.append(v)
        rows.append(dict(zip(names, values)))
        geom = feat.geometry()
        geoms.append(wkb.loads(bytes(geom.asWkb())) if geom and not geom.isNull() else None)

    frame = pd.DataFrame(rows, columns=names)
    return gpd.GeoDataFrame(frame, geometry=geoms, crs=layer.crs().toWkt())


first = next(layer.getFeatures())
print("raw attribute types:", [type(v).__name__ for v in first.attributes()])
converted = to_geodataframe(layer)
print(converted.dtypes.astype(str).to_dict(), converted.crs.to_epsg())
print("rating nulls:", int(converted["rating"].isna().sum()), "| empty frame keeps columns:",
      list(to_geodataframe(layer, "\"rating\" > 99").columns)[:4])
raw attribute types: ['int', 'int', 'str', 'str', 'float', 'QDateTime', 'int']
{'fid': 'int64', 'fhrsid': 'int64', 'name': 'str', 'business_type': 'str', 'rating': 'float64', 'rating_date': 'datetime64[us]', 'hygiene': 'float64', 'geometry': 'geometry'} 27700
rating nulls: 38 | empty frame keeps columns: ['fid', 'fhrsid', 'name', 'business_type']

In QGIS 4.2 a null attribute arrives in Python as a plain None, and a date or datetime field arrives as a Qt QDate or QDateTime object — which pandas would store as object and never treat as a date. The function converts those, keeps the column order and the columns themselves even when a filter matches nothing, and refuses a layer without a valid CRS. It passes the CRS as WKT rather than authid() so that a CRS with no authority code still crosses as a valid CRS.

What a QGIS 4.2 feature hands to Python, and what a memory layer accepts back. the values, not the geometry, are the hard partQGIS → Python✓a null arrives as None✓a date arrives as QDateTime✓Integer64 arrives as int✓fid is one of the fieldsPython → memory layer✕NaN is stored, not null✕numpy int64 is refused✕datetime is refused; QDateTime works✕a refusal only returns FalseEvery item on the right failed silently until the function checked for it spatialworkflow.io
Measured on Rutland’s food businesses: the geometry crossed cleanly; the attribute values are where both functions earn their length.

Route 2, the other direction: GeoDataFrame → QGIS layer

from qgis.PyQt.QtCore import QDateTime, QMetaType

QTYPE_FOR = {
    "int64": QMetaType.Type.LongLong, "Int64": QMetaType.Type.LongLong, "int32": QMetaType.Type.Int,
    "float64": QMetaType.Type.Double, "float32": QMetaType.Type.Double, "bool": QMetaType.Type.Bool,
}


def to_qgis_layer(gdf, name="layer"):
    geom_types = gdf.geom_type.dropna().unique()
    if len(geom_types) != 1:
        raise ValueError(f"mixed geometry types: {sorted(geom_types)}")
    if gdf.crs is None:
        raise ValueError("the GeoDataFrame has no CRS")

    layer = QgsVectorLayer(f"{geom_types[0]}?crs={gdf.crs.to_wkt()}", name, "memory")
    attr_cols = [c for c in gdf.columns if c != gdf.geometry.name]
    layer.dataProvider().addAttributes([
        QgsField(col, QMetaType.Type.QDateTime if str(gdf[col].dtype).startswith("datetime64")
                 else QTYPE_FOR.get(str(gdf[col].dtype), QMetaType.Type.QString))
        for col in attr_cols
    ])
    layer.updateFields()

    feats = []
    for row in gdf.itertuples(index=False):
        f = QgsFeature(layer.fields())
        shape = getattr(row, gdf.geometry.name)
        if shape is not None and not shape.is_empty:
            geometry = QgsGeometry()
            geometry.fromWkb(shape.wkb)
            f.setGeometry(geometry)
        f.setAttributes([to_qgis_value(getattr(row, c)) for c in attr_cols])
        feats.append(f)

    added, _ = layer.dataProvider().addFeatures(feats)
    if not added:
        raise RuntimeError(layer.dataProvider().lastError())
    layer.updateExtents()
    return layer


def to_qgis_value(value):
    """NaN/NaT/NA to None, numpy scalars to Python, Timestamps to QDateTime."""
    if value is None or (not isinstance(value, (str, bytes)) and pd.isna(value)):
        return None
    if isinstance(value, pd.Timestamp):
        return QDateTime(value.to_pydatetime())
    if isinstance(value, np.generic):
        return value.item()
    return value


memory = to_qgis_layer(food, "food")
print(memory.isValid(), memory.featureCount(), memory.crs().authid(),
      [(f.name(), f.typeName()) for f in memory.fields()])
True 338 EPSG:27700 [('fhrsid', 'int8'), ('name', 'string'), ('business_type', 'string'), ('rating', 'double'), ('rating_date', 'datetime'), ('hygiene', 'int8')]

Every guard in this function is there because the obvious version failed. QgsGeometry.fromWkb is an instance method in PyQGIS — QgsGeometry.fromWkb(shape.wkb) raises a TypeError — so the geometry is created empty and filled. Values go through to_qgis_value, because the memory provider refused a NumPy int64 for a 64-bit integer field and a Python datetime for a datetime field ("Could not convert value"), and addFeatures() reported that only by returning False: the first version of this function built a valid layer with no features in it, and nothing raised. Hence the check on the return value. And missing values are turned into None, because of what a NaN does once it is inside QGIS:

naive = QgsVectorLayer("Point?crs=EPSG:27700&field=rating:double", "naive", "memory")
for value in food["rating"]:
    f = QgsFeature(naive.fields())
    f.setAttributes([float(value)])                 # NaN goes in as a number
    naive.dataProvider().addFeatures([f])
print("sum of ratings, NaN kept:   ", naive.aggregate(Qgis.Aggregate.Sum, "rating")[0])
print("sum of ratings, NaN -> null:", memory.aggregate(Qgis.Aggregate.Sum, "rating")[0])
print("QGIS thinks the NaNs are null:", any(f["rating"] is None for f in naive.getFeatures()))
sum of ratings, NaN kept:    None
sum of ratings, NaN -> null: 1429.0
QGIS thinks the NaNs are null: False

A NaN written into a double field is stored as a value, not a null, and one of them was enough to turn the layer's sum into nothing at all.

Route 3: share a PostGIS database

When the data already lives in PostGIS, neither side converts anything.

from sqlalchemy import create_engine

engine = create_engine("postgresql+psycopg2://", connect_args={
    "host": "localhost", "port": 55433, "dbname": "gis"})      # user and password from PGUSER/PGPASSWORD
food.to_postgis("rutland_food", engine, if_exists="replace")
five_star = gpd.read_postgis("SELECT * FROM rutland_food WHERE rating = 5", engine, geom_col="geometry")
print("GeoPandas:", len(five_star), "five-star businesses")

pg = QgsDataSourceUri()
pg.setConnection("localhost", "55433", "gis", "", "")
pg.setDataSource("public", "rutland_food", "geometry", "rating = 5")
shared = QgsVectorLayer(pg.uri(False), "rutland_food", "postgres")
print("QGIS:     ", shared.featureCount(), "five-star businesses,", shared.crs().authid())
GeoPandas: 252 five-star businesses
QGIS:      252 five-star businesses, EPSG:27700

This is the best option at scale, because the database does the filtering and neither process ever holds the whole table. Keep the credentials out of the code — here they come from PGUSER and PGPASSWORD, which both libpq clients read — see connecting GeoPandas to PostGIS and PostGIS layers from PyQGIS.

Solve the environment question once

The two libraries can live in one interpreter, and it is worth arranging.

  • conda-forge is the clean answer: one environment with qgis and geopandas from the same channel, with matched GDAL and PROJ.
  • A virtualenv over the system QGIS works too. This page ran in one created with --system-site-packages over QGIS 4.2 on Ubuntu, with GeoPandas installed by pip. It worked — and it loaded two builds of GDAL into one process, QGIS's 3.12.2 and the one inside the pyogrio wheel, 3.12.4 — so test that arrangement on the machine that runs the job. On Windows, install from the OSGeo4W Shell so the right interpreter is targeted.
  • Or keep them apart on purpose and let the GeoPackage be the interface. Two processes, two environments, one file between them — which is also the most robust arrangement for a scheduled job, because neither side can break the other's dependencies.

Round trips with Rutland's food businesses and schools

Moving a QGIS layer into a GeoDataFrame without converting vertex by vertex. the two lines that carry the geometry acrossgeom = feat.geometry()shp  = wkb.loads(bytes(geom.asWkb()))gdf  = gpd.GeoDataFrame(frame, geometry=geoms,crs=layer.crs().toWkt())①②①WKB is the common currency. It avoids a per-vertex Python conversion andis the one encoding both libraries agree on exactly.②Take the CRS from the layer rather than assuming. A QGIS layer can carry acustom CRS with no EPSG code at all.A null geometry becomes None; dates and the CRS need the same explicit care spatialworkflow.io
Route 2 wraps these lines with the date, null and CRS handling.

The data used on this page

Every food business the Food Standards Agency lists for Rutland, with its rating, rating date and inspection scores (Open Government Licence); Rutland's schools from Geofabrik's 1 January 2026 OpenStreetMap extract (© OpenStreetMap contributors, ODbL); and HM Land Registry's INSPIRE parcels for Rutland (© Crown copyright and database rights, HM Land Registry; OGL). PostGIS 3.4 runs in a local container.

import json
import os
import time
from pathlib import Path

import geopandas as gpd
import numpy as np
import pandas as pd
import requests
from osgeo import gdal

gdal.UseExceptions()
for folder in ("data", "out"):
    Path(folder).mkdir(exist_ok=True)
os.environ.update({"PGHOST": "localhost", "PGPORT": "55433", "PGDATABASE": "gis",
                   "PGUSER": "user", "PGPASSWORD": "guide"})           # a throwaway container's credentials
session = requests.Session()
if not Path("data/parcels.gpkg").exists():
    Path("data/food.json").write_bytes(session.get(
        # the FSA register file for Rutland as saved on 9 October 2026 (updated daily); live:
        # ratings.food.gov.uk/api/open-data-files/FHRS879en-GB.json
        "https://data.spatialworkflow.io/fhrs-879-2026-10-09.json", timeout=120).content)
    Path("data/rutland.shp.zip").write_bytes(session.get(
        "https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip", timeout=300).content)
    gdal.VectorTranslate("data/schools.gpkg", "/vsizip/data/rutland.shp.zip/gis_osm_pois_a_free_1.shp",
                         layerName="schools", dstSRS="EPSG:27700", reproject=True, where="fclass = 'school'",
                         selectFields=["name"])
    Path("data/Rutland.zip").write_bytes(session.get(
        "https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip", timeout=300).content)
    gdal.VectorTranslate("data/parcels.gpkg", "/vsizip/data/Rutland.zip/Land_Registry_Cadastral_Parcels.gml",
                         layerName="parcels", selectFields=["INSPIREID"])

found = json.loads(Path("data/food.json").read_text())["FHRSEstablishment"]["EstablishmentCollection"]
records = pd.json_normalize(found if isinstance(found, list) else found["EstablishmentDetail"])
xy = records[["Geocode.Longitude", "Geocode.Latitude"]].apply(pd.to_numeric, errors="coerce")
food = gpd.GeoDataFrame({
    "fhrsid": records["FHRSID"].astype("int64"),
    "name": records["BusinessName"],
    "business_type": records["BusinessType"],
    "rating": pd.to_numeric(records["RatingValue"], errors="coerce"),        # "Exempt" etc. become NaN
    "rating_date": pd.to_datetime(records["RatingDate"], errors="coerce"),
}, geometry=gpd.points_from_xy(xy.iloc[:, 0], xy.iloc[:, 1]), crs=4326).to_crs(27700)
scores = pd.DataFrame({"fhrsid": records["FHRSID"].astype("int64"),
                       "hygiene": pd.array(records.get("Scores.Hygiene"), dtype="Int64")})
food = food[food.geometry.is_valid & ~food.geometry.is_empty].merge(scores, on="fhrsid", how="left",
                                                                    validate="one_to_one")

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import (Qgis, QgsApplication, QgsDataSourceUri, QgsFeature, QgsField, QgsGeometry,
                       QgsProject, QgsVectorLayer, QgsWkbTypes)
qgs = QgsApplication([], False)
qgs.initQgis()
from processing.core.Processing import Processing
import processing
Processing.initialize()
print(f"QGIS {Qgis.QGIS_VERSION.split('-')[0]}, GeoPandas {gpd.__version__} | {len(food)} of {len(records)} "
      f"food businesses located, {int(food['rating'].isna().sum())} without a numeric rating")
QGIS 4.2.2, GeoPandas 1.2.0 | 338 of 462 food businesses located, 38 without a numeric rating

Example 1: A round-trip test

Do not trust a conversion you have not round-tripped.

def round_trip_report(gdf, name):
    back = to_geodataframe(to_qgis_layer(gdf, name))
    problems = []
    if len(back) != len(gdf):
        problems.append(f"feature count {len(gdf)} -> {len(back)}")
    if not back.crs.equals(gdf.crs):
        problems.append("CRS changed")
    if not back.geometry.geom_equals_exact(gdf.geometry.reset_index(drop=True), tolerance=0).all():
        problems.append("geometry changed")
    for col in gdf.columns.drop(gdf.geometry.name):
        if str(back[col].dtype) != str(gdf[col].dtype):
            problems.append(f"{col}: {gdf[col].dtype} -> {back[col].dtype}")
    print(f"{name:9} {len(gdf):>6} features: {'; '.join(problems) or 'round trip clean'}")


parcels = gpd.read_file("data/parcels.gpkg", layer="parcels")
custom = food.to_crs("+proj=tmerc +lat_0=49 +lon_0=-2 +k=0.9996012717 +x_0=400000 +y_0=-100000 "
                     "+ellps=airy +units=m +no_defs")
round_trip_report(food, "food")
round_trip_report(parcels, "parcels")
round_trip_report(custom, "custom CRS")

from pyproj import Geod, Transformer
x, y = 486000, 308000                                      # Oakham, in either grid
as_custom = Transformer.from_crs(custom.crs, 4326, always_xy=True).transform(x, y)
as_27700 = Transformer.from_crs(27700, 4326, always_xy=True).transform(x, y)
print(f"the same Oakham coordinates in WGS 84, custom vs EPSG:27700: "
      f"{Geod(ellps='WGS84').inv(*as_custom, *as_27700)[2]:.0f} m apart")
print("a memory layer URI with no crs= gets:", QgsVectorLayer("Point", "no crs", "memory").crs().authid())
food         338 features: hygiene: Int64 -> float64
parcels    21253 features: INSPIREID: int32 -> int64
custom CRS    338 features: CRS changed; hygiene: Int64 -> float64
the same Oakham coordinates in WGS 84, custom vs EPSG:27700: 115 m apart
a memory layer URI with no crs= gets: EPSG:4326

Run it once against a representative layer — including one with nulls, one with a date field, and one with an unusual CRS — and you will find whichever of the usual bugs your version has. This one keeps the features and the geometry exactly; the dtype changes it reports are the ones a frame rebuilt from Python values makes, and are worth knowing rather than fixing. The CRS change is subtler. The custom definition — British National Grid's projection written out by hand, with no datum named — came back as EPSG:27700: QGIS recognised the parameters and substituted the registered CRS, which carries the OSGB36 datum the hand-written string never mentioned. Coordinates are untouched, but a later reprojection to WGS 84 would now apply a datum shift the original CRS did not have. At Oakham the two readings of the same coordinates land 115 m apart. A round-trip test is how you find that out before a map is wrong by that much. And never let a memory layer's CRS default: a URI with no crs= produced a layer in EPSG:4326, whatever the coordinates were.

Example 2: GeoPandas for the join, QGIS for the algorithm

The workflow this whole article exists to enable.

# 1. Tabular work where it is pleasant (the scores were merged in the setup, validate="one_to_one")
rated = food[food["hygiene"].notna()].copy()
rated["months_since_inspection"] = (pd.Timestamp("2026-01-01") - rated["rating_date"]).dt.days / 30.4

# 2. Hand over
rated.to_file("tmp/rated.gpkg", layer="rated", driver="GPKG")

# 3. Spatial work where QGIS is stronger
processing.run("native:joinbynearest", {
    "INPUT": "tmp/rated.gpkg|layername=rated",
    "INPUT_2": "data/schools.gpkg|layername=schools",
    "FIELDS_TO_COPY": ["name"], "PREFIX": "school_",
    "NEIGHBORS": 1, "MAX_DISTANCE": 2000,
    "OUTPUT": "out/food_nearest_school.gpkg",
})

# 4. Back for the summary
result = gpd.read_file("out/food_nearest_school.gpkg")
print(len(rated), "rated businesses ->", len(result), "within 2 km of a school")
print(result.groupby("school_name")["hygiene"].agg(["count", "mean"]).sort_values("count", ascending=False)
      .head(5).round(1).to_string())
294 rated businesses -> 294 within 2 km of a school
                                         count  mean
school_name                                         
Oakham School                               75   2.6
Uppingham School                            55   1.8
Catmose College                             11   3.2
Ryhall Church Of England Primary School      9   2.2
Ketton C Of E Primary School                 9   1.7

The merge in the setup carried validate="one_to_one", the kind of assertion that catches a duplicated key before it silently doubles your feature count — the same instinct as checking counts in and out of a batch run. The count check here is the same idea: MAX_DISTANCE drops any business more than 2 km from a school, and printing both numbers is what shows that none were dropped in Rutland, where every rated business is within 2 km of one. Hygiene scores run the wrong way for a league table — 0 is the best — so a lower mean is better.

Example 3: Feeding an algorithm result straight back to pandas

processing.run can write to a temporary output; read it back without touching disk yourself.

out = processing.run("native:buffer", {
    "INPUT": "tmp/rated.gpkg|layername=rated",
    "DISTANCE": 250, "SEGMENTS": 16, "DISSOLVE": False,
    "OUTPUT": "TEMPORARY_OUTPUT",
})["OUTPUT"]

print(type(out).__name__)
buffers = to_geodataframe(out)
print(len(buffers), "buffers,", f"{buffers.area.sum() / 1e6:.2f} km²")
QgsVectorLayer
294 buffers, 57.63 km²

Example 4: Handling the fid column

GeoPackage keeps an integer primary key called fid, and it appears and disappears depending on direction.

plain = gpd.read_file("out/food_nearest_school.gpkg")
with_fid = gpd.read_file("out/food_nearest_school.gpkg", fid_as_index=True)
print("fid in columns:", "fid" in plain.columns, "| index:", plain.index[:3].tolist(), "vs", with_fid.index[:3].tolist())

qgis_side = to_geodataframe(QgsVectorLayer("out/food_nearest_school.gpkg", "r", "ogr"))
print("from QGIS, fid is a column:", "fid" in qgis_side.columns)
qgis_side.to_file("out/append_target.gpkg", layer="food", driver="GPKG")
try:
    qgis_side.to_file("out/append_target.gpkg", layer="food", driver="GPKG", mode="a")
except Exception as exc:
    print("appending the same fids:", type(exc).__name__, str(exc).splitlines()[0][:90])
qgis_side.drop(columns="fid").to_file("out/append_target.gpkg", layer="food", driver="GPKG", mode="a")
print("appended without fid:", len(gpd.read_file("out/append_target.gpkg", layer="food")), "rows")
fid in columns: False | index: [0, 1, 2] vs [1, 2, 3]
from QGIS, fid is a column: True
appending the same fids: FieldError Could not find field index for field 'fid'
appended without fid: 588 rows

GeoPandas does not read fid at all unless asked, while the QGIS field list includes it — so a frame that came through QGIS carries a column of primary keys. Written to a new GeoPackage, that column quietly became the table's primary key rather than a field, and appending the same frame again then failed because there was no fid field to write to. Drop it and let the driver assign new ones.

Example 5: When to take the file route

def timed(label, fn):
    start = time.perf_counter()
    out = fn()
    print(f"{label:34} {time.perf_counter() - start:5.2f} s")
    return out

parcel_layer = QgsVectorLayer("data/parcels.gpkg|layername=parcels", "parcels", "ogr")
timed("QGIS -> GeoPandas, in memory", lambda: to_geodataframe(parcel_layer))
timed("QGIS -> GeoPandas, via GeoPackage", lambda: gpd.read_file("data/parcels.gpkg"))
timed("GeoPandas -> QGIS, in memory", lambda: to_qgis_layer(parcels, "parcels"))
timed("GeoPandas -> QGIS, via GeoPackage", lambda: (parcels.to_file("tmp/parcels.gpkg", driver="GPKG"),
      QgsVectorLayer("tmp/parcels.gpkg", "p", "ogr").featureCount()))
QGIS -> GeoPandas, in memory        0.16 s
QGIS -> GeoPandas, via GeoPackage   0.03 s
GeoPandas -> QGIS, in memory        0.36 s
GeoPandas -> QGIS, via GeoPackage   0.09 s

For one big handoff of 21,253 parcels the file route was the faster of the two in each direction here: GDAL writes and reads in C, while the in-memory route crosses the Python/C++ boundary once per feature and builds a row each time. The in-memory route earns its place for small layers converted many times — inside a loop over wards, say — where a file per step is the bigger cost.

Two libraries built on the same GDAL

Four crossing pitfalls between QGIS and GeoPandas, matched to fixes. four crossings that never raisePasses silentlyWhat to do insteadcrs().authid() returns an emptystringcheck crs().isValid() and raiseNaN written into a double fieldconvert NaN to None before settingnumpy int64 or datetime into amemory layerconvert values; checkaddFeatures()QGIS's fid column written backdrop fid; let the driver assignNone of these fails at the boundary; all of them fail later spatialworkflow.io
Every one of these passes silently; none of them raises at the crossing.

The two libraries agree about more than they disagree about, and knowing where the agreement is makes the crossing simple. Both are built on GDAL/OGR for I/O, PROJ for coordinate systems, and GEOS for geometry predicates. A GeoPackage written by one is read by the other with the same geometry, because the same library wrote and read it. That is why the file route is not a compromise: it is the two libraries sharing their common substrate.

Where they differ is in the model above those libraries. GeoPandas is a DataFrame with a geometry column: columns have dtypes, missing values are NaN or NaT or pd.NA depending on dtype, and the CRS is a single object attached to the geometry column. QGIS is a layer of features: fields have Qt types, missing values are nulls, dates are Qt objects, and the CRS belongs to the layer. Almost every crossing bug lives in that gap — a NaN written into a double field, an integer column turned to floats because it had a gap, a QDateTime landing in a DataFrame as an object column that no date method understands.

What to check about the CRS when a layer crosses between QGIS and GeoPandas. the CRS is the part that fails quietlyauthid() can come back emptya custom CRS has no EPSG codePass the WKT, not the authidcrs=layer.crs().toWkt()Assert the CRS aftera memory URI without crs= became 4326Expect a custom CRS to be matchedQGIS turned one into EPSG:27700A wrong CRS is the failure that produces plausible output, which is why it deserves its own check spatialworkflow.io
Which is the next paragraph, and the reason Example 1 is a round-trip test.

The CRS deserves its own note because it fails so quietly. layer.crs().authid() returns a string like 'EPSG:27700' — and an empty string for a layer with no CRS, such as a shapefile without its .prj. GeoPandas accepts crs='' and simply ends up with no CRS, so the loss is silent; check layer.crs().isValid() and raise. In the other direction the trap is a custom CRS. gdf.crs.to_string() returns a PROJ string when there is no authority code, and a memory-layer URI built from it made a valid layer with an invalid CRS — no error, just a layer QGIS cannot place. The WKT from gdf.crs.to_wkt() worked, which is why the functions above use WKT both ways. The trap is the same one cannot transform naive geometries is about, met from the other side.

On performance, the guidance is unfashionably simple: measure before optimising the crossing. In the run above the GeoPackage route beat the in-memory one in both directions for a single large handoff, and it has the enormous debugging advantage that the intermediate still exists when the result looks wrong.

Geometry types, dates and wide tables

Seconds to move 21,253 Rutland parcels between QGIS and GeoPandas, in memory and through a GeoPackage. 21,253 parcels, one crossingto GeoPandas, memory0.46 sto GeoPandas, file0.08 sto QGIS, memory0.82 sto QGIS, file0.59 s00.5 s1 sOne large handoff: the file route won both ways, because GDAL does the per-feature work in C spatialworkflow.io
Example 5 again, for scale: every edge case below is also a reason to prefer the file when a single crossing will do.

Mixed geometry types

A GeoDataFrame can hold polygons and multipolygons in one column. A QGIS memory layer declared as Polygon accepted a MultiPolygon feature without complaint in QGIS 4.2 and stored it as a multipolygon — so the mismatch surfaces later, when the layer is written to a format that enforces one type. Normalise before converting — shapely.force_2d and gdf.geometry.apply(lambda g: g if g.geom_type.startswith("Multi") else MultiPolygon([g])), or split into one layer per type — which is why to_qgis_layer refuses mixed types outright.

Z and M values

Point and PointZ are different WKB types. A Point Z geometry added to a memory layer declared as Point kept its Z in QGIS 4.2, so again the mismatch appears on export rather than at the crossing:

from shapely.geometry import MultiPolygon, Point, Polygon

def add(layer, shape):
    geometry = QgsGeometry()
    geometry.fromWkb(shape.wkb)
    feature = QgsFeature(layer.fields())
    feature.setGeometry(geometry)
    return layer.dataProvider().addFeatures([feature])[0]

polygons = QgsVectorLayer("Polygon?crs=EPSG:27700", "polygons", "memory")
square = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
print("MultiPolygon into Polygon layer:", add(polygons, MultiPolygon([square])),
      [QgsWkbTypes.displayString(f.geometry().wkbType()) for f in polygons.getFeatures()])
points = QgsVectorLayer("Point?crs=EPSG:27700", "points", "memory")
print("Point Z into Point layer:       ", add(points, Point(486000, 308000, 120)),
      [f.geometry().asWkt() for f in points.getFeatures()])
MultiPolygon into Polygon layer: True ['MultiPolygon']
Point Z into Point layer:        True ['Point Z (486000 308000 120)']

Declare PointZ when you mean it, and use native:dropmzvalues when you do not.

Date and datetime fields

Date and datetime fields arrive as QDate and QDateTime objects, which pandas stores as object. Convert them in the loop, as to_geodataframe does, or route through a GeoPackage and let GDAL do the mapping — the file route returned datetime64 directly.

Very wide attribute tables

The row-per-feature approach in to_geodataframe allocates a Python object per value. For a layer with 200 fields and a million features that is real memory. Request only the fields you need with QgsFeatureRequest().setSubsetOfAttributes([...], layer.fields()), which is also considerably faster.

The geometry column name

GeoPandas writes its geometry column as geometry; QGIS and GDAL often name theirs geom. Neither cares, but code that hard-codes one of them does — use gdf.geometry.name rather than the literal string, and remember that a subset string or SQL sent to a GeoPackage has to use the real name.

Encoding

Non-ASCII attribute values — the é in a café's name, say — survive a GeoPackage cleanly, because it is UTF-8 throughout. Shapefiles depend on a .cpg sidecar to say what encoding their .dbf uses; one more reason the handoff format should be GeoPackage — see shapefile column names truncated for the related family of problems.

The page's QGIS session ends here:

QgsProject.instance().clear()
qgs.exitQgis()
print("QGIS closed")
QGIS closed

FAQ

What is the simplest way to move data between QGIS and GeoPandas?

Write a GeoPackage and read it back. Both libraries use GDAL underneath, so geometry and CRS survive and field types map cleanly (an integer column with gaps comes back to GeoPandas as floats); the intermediate file is inspectable when a result looks wrong; and it costs three lines. Move to in-memory conversion only when profiling shows the file I/O actually matters.

How do I convert a QgsVectorLayer to a GeoDataFrame?

Iterate layer.getFeatures(), build a dict of attributes per feature, convert each geometry with shapely.wkb.loads(bytes(feature.geometry().asWkb())), and construct the GeoDataFrame with crs=layer.crs().toWkt(), which also carries a custom CRS. In QGIS 4.2 nulls already arrive as None, but dates arrive as QDate/QDateTime objects that need converting, and an explicit columns= list keeps the schema for an empty layer.

How do I turn a GeoDataFrame into a QGIS layer without writing a file?

Create a memory layer whose URI carries the geometry type and CRS — QgsVectorLayer("Polygon?crs=EPSG:27700", "name", "memory") — add fields mapped from the DataFrame dtypes, then add one QgsFeature per row. fromWkb is an instance method: create QgsGeometry() and call .fromWkb(shape.wkb) on it. Convert NaN to None before setting attributes — a NaN stored in a double field is a value, and one was enough to make the layer's sum come back empty.

Can I import both qgis and geopandas in the same Python?

Yes. The cleanest route is a conda-forge environment containing both, which guarantees matched GDAL and PROJ builds. This page used a virtualenv over the system QGIS with GeoPandas from pip, which worked but loaded two GDAL builds into one process; on Windows, use the OSGeo4W Shell. Keeping them in separate environments and exchanging GeoPackages is a perfectly good third option.

Why did my CRS disappear after converting?

layer.crs().authid() returns an empty string when the layer has no CRS, and GeoPandas accepts crs='' as meaning no CRS at all — silently. Going the other way, a custom CRS written into a memory-layer URI as a PROJ string gave a valid layer with an invalid CRS. Check layer.crs().isValid() before converting, pass WKT in both directions, and raise rather than letting a CRS-less layer travel further into the pipeline.

Why do my null values become zeros or NaNs?

Because the two systems represent missing data differently. Pandas uses NaN for a missing float; QGIS uses a null. Converting a NaN straight into a double field stores it as a value, which QGIS does not treat as null and which poisons aggregates. In the other direction, an integer column with nulls becomes float64 when GeoPandas reads it. Translate explicitly, and check dtypes after the crossing.

Which is faster for large data?

For one big handoff, the GeoPackage route won in both directions for 21,253 parcels — GDAL writes in C, while the in-memory route crosses the Python/C++ boundary once per feature. In-memory conversion earns its place for small layers converted many times, such as inside a loop over wards. Measure with your data rather than assuming; the crossover point moves with attribute width.