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
NaNthat poisons a sum. - A
fidcolumn appears out of nowhere, or the one you needed disappears. - You cannot import
geopandasin the QGIS Python, or cannot importqgisin 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.
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
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.
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
qgisandgeopandasfrom 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-packagesover 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
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
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.
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
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
Internal links
- How to Automate QGIS with Python (PyQGIS): The Complete Workflow
- PyQGIS vs GeoPandas: Which to Use for GIS Automation
- How to Run QGIS Processing Algorithms from Python
- How to Batch Process Layers in QGIS with PyQGIS
- How to Connect GeoPandas to PostGIS
- How to Read and Write GeoPackage Files in Python
- Cannot Transform Naive Geometries
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.