How to Move Data Between QGIS and GeoPandas
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 as
Noneor as 4326. - Field types drift β integers become floats, dates become strings, nulls become
NaNbecome0. - 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.
The goal: a pair of functions, to_geodataframe(layer) and to_qgis_layer(gdf), that round-trip geometry, attributes, and CRS faithfully β plus a clear rule for when not to bother and just write a GeoPackage.
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, types and CRS survive, and the intermediate is inspectable when something looks wrong.
# GeoPandas β QGIS
gdf.to_file("tmp/parcels.gpkg", layer="parcels", driver="GPKG")
layer = QgsVectorLayer("tmp/parcels.gpkg|layername=parcels", "parcels", "ogr")
# QGIS β GeoPandas
import geopandas as gpd
gdf = gpd.read_file("tmp/parcels.gpkg", layer="parcels")
Route 2 β in memory, via WKB. No disk, no driver, exact geometry.
import geopandas as gpd
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().authid() 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.
Step-by-step solution
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/SAGA), 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
from pathlib import Path
import geopandas as gpd
TMP = Path("tmp"); TMP.mkdir(exist_ok=True)
gdf.to_file(TMP / "work.gpkg", layer="parcels_clean", driver="GPKG")
Then in QGIS β either as a layer or straight into an algorithm:
from qgis.core import QgsVectorLayer
import processing
uri = f"{TMP / 'work.gpkg'}|layername=parcels_clean"
layer = QgsVectorLayer(uri, "parcels_clean", "ogr")
processing.run("native:buffer", {
"INPUT": uri, "DISTANCE": 25, "SEGMENTS": 8,
"DISSOLVE": False, "OUTPUT": str(TMP / "work.gpkg|layername=parcels_buffer"),
})
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. 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
import geopandas as gpd
import pandas as pd
from shapely import wkb
def to_geodataframe(layer, expression=None):
"""Convert a QgsVectorLayer (or QgsFeatureSource) to a GeoDataFrame."""
from qgis.core import QgsFeatureRequest
request = QgsFeatureRequest()
if expression:
request.setFilterExpression(expression)
names = [f.name() for f in layer.fields()]
rows, geoms = [], []
for feat in layer.getFeatures(request):
values = [None if v is None or (hasattr(v, "isNull") and v.isNull()) else v
for v in feat.attributes()]
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().authid() or None)
Three things this handles that a naive version does not. QVariant nulls become Python None rather than an opaque object pandas will store as dtype=object. Passing columns=names keeps the column order and preserves columns even when the layer has zero features. And the CRS comes from authid(), which yields 'EPSG:27700' β a string both pyproj and GeoPandas understand.
Route 2, the other direction: GeoDataFrame β QGIS layer
from qgis.core import QgsVectorLayer, QgsFeature, QgsField, QgsGeometry
from qgis.PyQt.QtCore import QVariant
import numpy as np
QVARIANT_FOR = {
"int64": QVariant.LongLong, "int32": QVariant.Int,
"float64": QVariant.Double, "float32": QVariant.Double,
"bool": QVariant.Bool, "datetime64[ns]": QVariant.DateTime,
}
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)}")
crs = gdf.crs.to_string() if gdf.crs else ""
layer = QgsVectorLayer(f"{geom_types[0]}?crs={crs}", name, "memory")
attr_cols = [c for c in gdf.columns if c != gdf.geometry.name]
layer.dataProvider().addAttributes([
QgsField(col, QVARIANT_FOR.get(str(gdf[col].dtype), QVariant.String))
for col in attr_cols
])
layer.updateFields()
feats = []
for _, row in gdf.iterrows():
f = QgsFeature(layer.fields())
if row.geometry is not None and not row.geometry.is_empty:
f.setGeometry(QgsGeometry.fromWkb(row.geometry.wkb))
f.setAttributes([
None if (v is None or (isinstance(v, float) and np.isnan(v))) else v
for v in (row[c] for c in attr_cols)
])
feats.append(f)
layer.dataProvider().addFeatures(feats)
layer.updateExtents()
return layer
The memory-layer URI carries the CRS β "Polygon?crs=EPSG:27700" β which is why a layer built this way is never CRS-less. The NaN β None conversion is the fiddly part and the one most home-grown versions get wrong: pandas represents a missing number as NaN, QGIS expects a null, and a NaN written into a double field becomes a real value that breaks every downstream sum.
Route 3: share a PostGIS database
When the data already lives in PostGIS, neither side converts anything.
# GeoPandas
import geopandas as gpd
from sqlalchemy import create_engine
engine = create_engine("postgresql://user:pass@gis-db/parcels")
gdf = gpd.read_postgis("SELECT * FROM public.parcels WHERE status = 'active'", engine, geom_col="geom")
gdf.to_postgis("parcels_clean", engine, if_exists="replace")
# QGIS reads the same table
from qgis.core import QgsVectorLayer, QgsDataSourceUri
uri = QgsDataSourceUri()
uri.setConnection("gis-db", "5432", "parcels", "user", "pass")
uri.setDataSource("public", "parcels_clean", "geom", "", "id")
layer = QgsVectorLayer(uri.uri(False), "parcels_clean", "postgres")
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 β see connecting GeoPandas to PostGIS and the environment-variable pattern in driving a pipeline from a YAML config.
Solve the environment question once
The two libraries can live in one interpreter, and it is worth arranging.
- conda-forge is the clean answer:
conda create -n gis -c conda-forge qgis geopandas python=3.11gives one environment where both import, with matched GDAL and PROJ. - The QGIS Python can gain GeoPandas. On Linux,
python3 -m pip install --user geopandasagainst the interpreter that already importsqgisusually works because the GDAL is shared. 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.
Code examples
Example 1: A round-trip test
Do not trust a conversion you have not round-tripped.
def test_round_trip(gdf):
layer = to_qgis_layer(gdf, "test")
back = to_geodataframe(layer)
assert len(back) == len(gdf), "feature count changed"
assert set(back.columns) >= set(gdf.columns), "columns lost"
assert back.crs == gdf.crs, f"CRS changed: {gdf.crs} β {back.crs}"
assert back.geometry.geom_type.equals(gdf.geometry.geom_type), "geometry types changed"
assert (back.geometry.area.round(6) == gdf.geometry.area.round(6)).all(), "geometry changed"
print("round trip clean:", len(gdf), "features")
Run it once against a representative layer β including one with nulls, one with a date field, and one with a multipolygon β and you will find whichever of the three usual bugs your version has.
Example 2: GeoPandas for the join, QGIS for the algorithm
The workflow this whole article exists to enable.
import geopandas as gpd
import pandas as pd
import processing
# 1. Tabular work where it is pleasant
parcels = gpd.read_file("data/parcels.gpkg", layer="parcels")
owners = pd.read_csv("data/owners.csv", dtype={"parcel_id": str})
joined = parcels.merge(owners, on="parcel_id", how="left", validate="one_to_one")
joined["value_per_m2"] = joined["value"] / joined.geometry.area
active = joined[joined["status"].eq("active")]
# 2. Hand over
active.to_file("tmp/active.gpkg", layer="active", driver="GPKG")
# 3. Spatial work where QGIS is stronger
processing.run("native:joinbynearest", {
"INPUT": "tmp/active.gpkg|layername=active",
"INPUT_2": "data/stations.gpkg|layername=stations",
"FIELDS_TO_COPY": ["station_name"],
"NEIGHBORS": 1,
"MAX_DISTANCE": 2000,
"OUTPUT": "out/parcels_with_station.gpkg",
})
# 4. Back for the summary
result = gpd.read_file("out/parcels_with_station.gpkg")
print(result.groupby("station_name")["value_per_m2"].describe())
validate="one_to_one" in the merge is 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.
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.
import processing
from qgis.core import QgsVectorLayer
out = processing.run("native:buffer", {
"INPUT": "tmp/active.gpkg|layername=active",
"DISTANCE": 250, "SEGMENTS": 16, "DISSOLVE": False,
"OUTPUT": "TEMPORARY_OUTPUT",
})["OUTPUT"]
layer = out if isinstance(out, QgsVectorLayer) else QgsVectorLayer(out, "buffered", "ogr")
gdf = to_geodataframe(layer)
print(gdf.area.sum())
Example 4: Handling the fid column
GeoPackage keeps an integer primary key called fid, and it appears and disappears depending on direction.
gdf = gpd.read_file("out/result.gpkg") # fid is usually the index, not a column
gdf = gdf.reset_index(drop=True)
# writing: let the driver assign fids rather than carrying stale ones
gdf.drop(columns=[c for c in ("fid",) if c in gdf.columns]).to_file(
"out/clean.gpkg", layer="clean", driver="GPKG"
)
Carrying a stale fid into a write is the cause of the "UNIQUE constraint failed" error that appears when appending, and of features silently overwriting each other.
Example 5: A tiny module you can copy into any project
"""qgis_pandas.py β the two functions, plus the file route as a fallback."""
from pathlib import Path
import tempfile
import geopandas as gpd
def via_file(gdf, name="layer"):
"""Most robust route: write a GeoPackage and return the QGIS URI."""
tmp = Path(tempfile.mkdtemp(prefix="qgis_pandas_")) / "exchange.gpkg"
gdf.to_file(tmp, layer=name, driver="GPKG")
return f"{tmp}|layername={name}"
def read_via_file(uri_or_path, layer=None):
path, _, suffix = str(uri_or_path).partition("|layername=")
return gpd.read_file(path, layer=layer or suffix or None)
When the in-memory conversion misbehaves on an unusual data type, switching a call to via_file costs one line and always works β GDAL is doing the type mapping instead of you.
Explanation
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 byte-identical 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 None depending on dtype, and the CRS is a single object attached to the geometry column. QGIS is a layer of features: fields have QVariant types, missing values are typed nulls, and the CRS belongs to the layer. Almost every crossing bug lives in that gap β NaN written into a double field, an int64 column silently promoted because pandas needed a NaN, a QVariant null landing in a DataFrame as an object that neither isna() nor comparison handles.
The CRS deserves its own note because it fails so quietly. layer.crs().authid() returns a string like 'EPSG:27700', which GeoPandas accepts directly β but it returns an empty string for a layer with no CRS, and passing '' to GeoDataFrame(crs=...) is not the same as passing None. On the way back, gdf.crs.to_string() may produce a full WKT rather than an authority code for a custom CRS, which a memory-layer URI will not accept. Prefer gdf.crs.to_authority() when you know the CRS is a registered one, and fail loudly when either side has no CRS at all rather than letting a nameless layer through β the trap that cannot transform naive geometries is entirely about.
On performance, the guidance is unfashionably simple: measure before optimising the crossing. Writing a 200 MB GeoPackage takes a few seconds; converting the same data feature by feature through Python objects can easily take longer, because every feature crosses the C++/Python boundary and builds a dict. The in-memory route wins when the layer is small and the crossing happens often β inside a loop over districts, say. The file route wins for one big handoff, and it has the enormous debugging advantage that the intermediate still exists when the result looks wrong.
Edge cases or notes
Mixed geometry types
A GeoDataFrame can hold polygons and multipolygons in one column; a QGIS memory layer cannot. Normalise before converting β gdf.geometry = gdf.geometry.apply(lambda g: g if g.geom_type.startswith("Multi") else MultiPolygon([g])) β or split into one layer per type. The error, if you skip this, is that features of the minority type are dropped without comment.
Z and M values
Point and PointZ are different WKB types. A layer with Z values converts to shapely geometries that keep the third ordinate, but a memory layer URI of "Point?crs=β¦" will drop it. Use "PointZ?crs=β¦" when you need it, and native:dropmzvalues when you do not.
Date and datetime fields
QVariant.DateTime values arrive as QDateTime objects, which pandas stores as object. Convert explicitly with pd.to_datetime(series.astype(str)), or route through a GeoPackage and let GDAL do the mapping.
Very wide attribute tables
The dict-per-feature approach in to_geodataframe allocates a dictionary per row. 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 defaults to geometry; GeoPackage often names the column geom. Neither cares, but code that hard-codes one of them does β use gdf.geometry.name rather than the literal string.
Encoding
Non-ASCII attribute values survive GeoPackage cleanly. They do not always survive shapefile, which is one more reason the handoff format should be GeoPackage β see shapefile column names truncated for the related family of problems.
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, field types, and CRS all survive exactly; 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().authid(). Convert QVariant nulls to None as you go, and pass an explicit columns= list so the schema survives 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 with QgsGeometry.fromWkb(row.geometry.wkb). Convert NaN to None before setting attributes or the nulls become real numbers.
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. Installing GeoPandas into the QGIS Python with pip also usually works on Linux and via the OSGeo4W Shell on Windows. 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 an empty string is not None β GeoPandas will accept it and end up with something unusable. Check layer.crs().isValid() before converting and raise if it is not, 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 typed null. Converting a NaN straight into a double field stores a real value, and converting a QVariant null straight into a DataFrame produces an object column that isna() does not detect. Translate explicitly in both directions.
Which is faster for large data?
For one big handoff, the GeoPackage route usually wins β GDAL writes in C, while the in-memory route crosses the Python/C++ boundary once per feature and builds a dictionary each time. In-memory conversion wins for small layers converted many times, such as inside a loop over districts. Measure with your data rather than assuming; the crossover point moves with attribute width.