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 None or as 4326.
  • Field types drift β€” integers become floats, dates become strings, nulls become NaN become 0.
  • 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.

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.

Three routes between QGIS and GeoPandas: a GeoPackage on disk, in-memory WKB conversion, and a shared PostGIS database.
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, 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

A GeoPackage handoff: GeoPandas writes, QGIS reads by layername, processes, writes back, GeoPandas reads the result.
One container, named layers, and the intermediate is a file you can open when the numbers look wrong.
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.11 gives one environment where both import, with matched GDAL and PROJ.
  • The QGIS Python can gain GeoPandas. On Linux, python3 -m pip install --user geopandas against the interpreter that already imports qgis usually 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

Four crossing pitfalls matched to fixes: lost CRS, NaN versus null, mixed geometry types, stale fid.
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 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.

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.