GeoPandas Basics: Working with Spatial Data in Python

On this page

Problem statement

A first session with GeoPandas is the same five moves whatever the data: read a file, look at what is in it, check its coordinate system, keep the rows you need, and write the result. Each move is one line. What makes the session go right or wrong is what the file turns out to hold — and that only shows if you look at the output of each line rather than past it.

This page makes those five moves on one small real file, OpenStreetMap's land use for Rutland, England's smallest historic county, and stops at each to say what it found. Every step links to the guide that covers it in depth.

Quick answer

Read, inspect, check the CRS, filter, save — five lines on a GeoDataFrame, a pandas table with a geometry column (what a GeoDataFrame is):

import geopandas as gpd

landuse = gpd.read_file("data/rutland/gis_osm_landuse_a_free_1.shp")
print(len(landuse), "polygons in", landuse.crs)

residential = landuse[landuse["fclass"] == "residential"]
residential.to_file("output/residential.geojson", driver="GeoJSON")
print(len(residential), "residential polygons written")
2405 polygons in EPSG:4326
159 residential polygons written

The file is OpenStreetMap land use for Rutland, England's smallest historic county, from the Geofabrik shapefile extract; the section The data used on this page downloads it. Every output on this page was printed by running the code.

The vector workflow pipeline

The vector workflowread_file()InspectCheck.crsFilterPlotto_file()spatialworkflow.io
The core vector workflow, from reading a file to exporting.

Read, inspect, check the CRS, filter, plot, save

A GeoDataFrame is a pandas DataFrame plus an active geometry column. what you get back from read_file()namepopulationgeometryOslo709 037POINT (10.75 59.91)Bergen286 930POINT (5.32 60.39)Trondheim205 163POINT (10.40 63.43)active geometry — this column carries the CRSEverything you know about pandas still applies to the other columns spatialworkflow.io
One special column is the entire difference between a DataFrame and a GeoDataFrame.

Read a shapefile or GeoJSON into a GeoDataFrame

gpd.read_file() reads both formats used here (installing it: setting up a Python GIS environment).

Read a shapefile

A shapefile is several files side by side (.shp, .shx, .dbf, .prj, often .cpg); you point read_file() at the .shp and it finds the rest.

import geopandas as gpd

landuse = gpd.read_file("data/rutland/gis_osm_landuse_a_free_1.shp")
print(landuse.head())
    osm_id  code          fclass                     name                                 geometry
0  3554658  7210  nature_reserve                 Lagoon 3  POLYGON ((-0.69395 52.6626, -0.69361...
1  3554659  7210  nature_reserve                 Lagoon 2  POLYGON ((-0.70015 52.65614, -0.6991...
2  3700148  7202            park                      NaN  POLYGON ((-0.74126 52.66536, -0.7410...
3  3700814  7202            park  Royce Recreation Ground  POLYGON ((-0.74212 52.67041, -0.7405...
4  3700816  7202            park                      NaN  POLYGON ((-0.74291 52.66793, -0.7424...

Read a GeoJSON file

The second layer is Rutland's 15 electoral wards from the ONS boundary service, saved as GeoJSON.

wards = gpd.read_file("data/rutland_wards.geojson")
print(wards.head())
      WD24CD                     WD24NM                                 geometry
0  E05012632               Barleythorpe  POLYGON ((-0.72525 52.68542, -0.7243...
1  E05012633  Braunston & Martinsthorpe  POLYGON ((-0.75976 52.65781, -0.7594...
2  E05012634                 Cottesmore  POLYGON ((-0.67132 52.75641, -0.6707...
3  E05012635                      Exton  POLYGON ((-0.70772 52.72347, -0.709 ...
4  E05012636                   Greetham  POLYGON ((-0.60859 52.75971, -0.6054...

Each call returns a GeoDataFrame; the details of each format are in reading a shapefile and reading GeoJSON.

Inspect the GeoDataFrame structure

Look before using anything:

print(landuse.columns.tolist())
print(landuse.dtypes)
print("geometry column:", landuse.geometry.name)
print(len(landuse), "rows")
['osm_id', 'code', 'fclass', 'name', 'geometry']
osm_id           str
code           int32
fclass           str
name             str
geometry    geometry
dtype: object
geometry column: geometry
2405 rows

Five columns and 2,405 rows. Geofabrik's code is a numeric version of the fclass text, and one column is mostly empty:

print(landuse["name"].isna().sum(), "of", len(landuse), "polygons have no name")
2165 of 2405 polygons have no name

A filter on name would silently skip 91% of the county's land use. Exploring an unfamiliar dataset turns this look into a routine.

Check the coordinate reference system

Both layers report their CRS:

print(landuse.crs)
print(wards.crs)
EPSG:4326
EPSG:4326

Both layers are in longitude and latitude (WGS 84): Geofabrik ships every extract that way, and the ward download asked the ONS service for it. That is fine for reading and plotting, but the units are degrees, so an area computed directly is a number of square degrees. Reproject to a metric CRS — British National Grid here — before measuring:

print(f"{landuse.to_crs(27700).area.sum() / 1e6:,.1f} km² of mapped land use")
268.9 km² of mapped land use

Why degrees cannot be measured is projected vs geographic CRS.

Rutland OpenStreetMap land-use polygons by fclass: forest 1,247, grass 323, farmland 241, residential 159. 2,405 land-use polygons in Rutland, by fclassforest1,247grass323farmland241residential159scrub93farmyard90park40meadow37other classes17507001400value_counts() before filtering: the class you want may be a small share spatialworkflow.io
What value_counts() shows before the filter: residential is 159 of 2,405 polygons, and forest outnumbers everything else combined.

Filter spatial records

A common task is filtering features based on an attribute field. Look at what values the field holds first:

print(landuse["fclass"].value_counts().head(8))
fclass
forest         1247
grass           323
farmland        241
residential     159
scrub            93
farmyard         90
park             40
meadow           37
Name: count, dtype: int64

Example: keep only residential land

residential = landuse[landuse["fclass"] == "residential"]
print(len(residential), "residential polygons")
print(residential["name"].dropna().head().tolist())
159 residential polygons
['Wing', 'Cottesmore Hall Redevelopment', "Permanent Traveller's Camp", 'Manor Farm', 'Lonsdale Park']

You can also remove rows with missing or empty geometry:

with_geometry = landuse[landuse.geometry.notna() & ~landuse.geometry.is_empty]
print(len(landuse) - len(with_geometry), "rows dropped")
0 rows dropped

None here; what null, empty and invalid geometry are, and why they differ, is in null, empty and invalid geometry.

Check geometry types

print(landuse.geom_type.value_counts())
print(wards.geom_type.value_counts())
Polygon         2401
MultiPolygon       4
Name: count, dtype: int64
Polygon    15
Name: count, dtype: int64

Four land-use areas are drawn in more than one piece and come back as MultiPolygon; a shapefile does not distinguish the two, so a polygon layer is often a mix. That is normal. Points or lines in a polygon layer are not.

Plot the data for a quick visual check

A plot is the fastest check that the layer is where it should be:

residential.plot()

For a slightly clearer result, draw the wards underneath so you can see whether the filtered features land where you expect:

ax = wards.plot(figsize=(8, 8), color="white", edgecolor="grey")
residential.plot(ax=ax, color="tab:red")
ax.set_title("Residential land use in Rutland")

A layer in the wrong CRS shows up at once: it lands far away from the other one, or the axes read in hundreds of thousands instead of degrees. Making the map presentable is plotting maps with GeoPandas and Matplotlib.

Save the result to a new file

Save to GeoJSON

residential.to_file("output/residential.geojson", driver="GeoJSON")

Save to a shapefile or GeoPackage

residential.to_file("output/residential.shp")
residential.to_file("output/rutland.gpkg", layer="residential")

import os
shp = sorted(f for f in os.listdir("output") if f.startswith("residential.") and not f.endswith((".geojson", ".png")))
print(shp, f"{sum(os.path.getsize('output/' + f) for f in shp) / 1e3:.1f} kB")
print(f"residential.geojson {os.path.getsize('output/residential.geojson') / 1e3:.1f} kB")
print(f"rutland.gpkg        {os.path.getsize('output/rutland.gpkg') / 1e3:.1f} kB")
['residential.cpg', 'residential.dbf', 'residential.prj', 'residential.shp', 'residential.shx'] 138.8 kB
residential.geojson 181.6 kB
rutland.gpkg        225.3 kB

One shapefile write is five files on disk. Sizes differ too: the .dbf stores every text field at a fixed width — 80 characters for each of the three here — so it is 40 kB for 159 rows, 250 bytes a row however short the values, while GeoJSON writes each coordinate out as text and a GeoPackage is an SQLite database whose tables and spatial index cost about 98 kB before any data — a one-feature GeoPackage of this layer was 98.3 kB. None of this matters at this size, but it is worth knowing which way each format grows; GIS file formats compared goes further.

The 159 Rutland residential polygons written three ways: shapefile, GeoJSON and GeoPackage. the same 159 polygons, written three waysShapefileGeoJSONGeoPackageon disk5 files1 text file1 SQLite filesize138.8 kB181.6 kB225.3 kBwatch fornames cut at 10text coordinates~98 kB overheadMeasured on this layer; each format grows differently spatialworkflow.io
The same 159 polygons written three ways. The GeoPackage is largest here only because of its fixed overhead of about 98 kB.

Rutland land use summarised, filtered and exported

The data used on this page

Two real layers, both for Rutland:

  • Land use — gis_osm_landuse_a_free_1.shp from Geofabrik's daily OpenStreetMap extract (© OpenStreetMap contributors, ODbL); counts drift a little as the map is edited.
  • Wards — Rutland's 15 wards from the ONS December 2024 boundaries (OGL), as GeoJSON.
import io
import os
import zipfile

import geopandas as gpd
import pandas as pd
import requests

pd.set_option("display.width", 120)       # print whole rows instead of eliding columns
pd.set_option("display.max_columns", None)
pd.set_option("display.max_colwidth", 40)
os.makedirs("data/rutland", exist_ok=True)
os.makedirs("output", exist_ok=True)

if not os.path.exists("data/rutland/gis_osm_landuse_a_free_1.shp"):
    extract = requests.get("https://download.geofabrik.de/europe/united-kingdom/england/"
                           "rutland-260101-free.shp.zip", timeout=300)
    extract.raise_for_status()
    zipfile.ZipFile(io.BytesIO(extract.content)).extractall("data/rutland")

if not os.path.exists("data/rutland_wards.geojson"):
    wards = requests.get("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
                         "Wards_December_2024_Boundaries_UK_BGC/FeatureServer/0/query",
                         params={"where": "LAD24NM='Rutland'", "outFields": "WD24CD,WD24NM",
                                 "outSR": 4326, "f": "geojson"}, timeout=120)
    wards.raise_for_status()
    open("data/rutland_wards.geojson", "wb").write(wards.content)

print(len([f for f in os.listdir("data/rutland") if f.endswith(".shp")]), "shapefiles in the extract")
18 shapefiles in the extract

The extract holds a shapefile per theme — roads, buildings, water, places and so on. This page reads only the land-use one.

Example 1: Load a shapefile and summarise it

import geopandas as gpd

landuse = gpd.read_file("data/rutland/gis_osm_landuse_a_free_1.shp")
print(f"{len(landuse):,} features, CRS {landuse.crs}, columns {landuse.columns.tolist()}")
print("bounds:", landuse.total_bounds.round(3))
2,405 features, CRS EPSG:4326, columns ['osm_id', 'code', 'fclass', 'name', 'geometry']
bounds: [-0.855 52.53  -0.395 52.763]

total_bounds is [minx, miny, maxx, maxy] — here, longitudes and latitudes spanning the county, about 0.46° by 0.23°. It is the quickest check that a layer is where you think it is.

Example 2: Load a GeoJSON file and inspect columns

import geopandas as gpd

wards = gpd.read_file("data/rutland_wards.geojson")

print(wards.columns.tolist())
print(wards.geometry.name)
print(wards[["WD24CD", "WD24NM"]].sort_values("WD24NM").head())
['WD24CD', 'WD24NM', 'geometry']
geometry
      WD24CD                     WD24NM
0  E05012632               Barleythorpe
1  E05012633  Braunston & Martinsthorpe
2  E05012634                 Cottesmore
3  E05012635                      Exton
4  E05012636                   Greetham

The geometry column is called geometry whatever the file itself called it — a GeoPackage column named geom also reads back as geometry — and the ward codes are the ONS E05… codes, which are what you would join population or election tables on.

Example 3: Check CRS and geometry types

import geopandas as gpd

wards = gpd.read_file("data/rutland_wards.geojson")

print("CRS:", wards.crs, "| geographic:", wards.crs.is_geographic)
print(wards.geom_type.value_counts())
CRS: EPSG:4326 | geographic: True
Polygon    15
Name: count, dtype: int64

crs.is_geographic is True for a longitude/latitude CRS — the signal to reproject before you measure lengths or areas.

Example 4: Filter features by attribute

import geopandas as gpd

landuse = gpd.read_file("data/rutland/gis_osm_landuse_a_free_1.shp")

commercial = landuse[landuse["fclass"].isin(["commercial", "retail", "industrial"])]
print(commercial["fclass"].value_counts())
print(commercial.loc[commercial["name"].notna(), ["fclass", "name"]].head())
fclass
industrial    30
commercial     8
retail         6
Name: count, dtype: int64
         fclass                        name
19   commercial      Oakham Enterprise Park
205  industrial  Wing Water Treatment Works
461  industrial         Ketton Cement Works
749  industrial                     Tixover
833  industrial                 Glebe Works

isin() keeps any of several values in one step; chaining == comparisons with | does the same thing but is easier to get wrong.

Example 5: Plot and export filtered data

import geopandas as gpd

landuse = gpd.read_file("data/rutland/gis_osm_landuse_a_free_1.shp")
wards = gpd.read_file("data/rutland_wards.geojson")
residential = landuse[(landuse["fclass"] == "residential") & landuse.geometry.notna()]

ax = wards.plot(figsize=(8, 8), color="white", edgecolor="grey")
residential.plot(ax=ax, color="tab:red")
ax.set_title("Residential land use in Rutland")
ax.figure.savefig("output/residential.png", dpi=100)

residential.to_file("output/residential.geojson", driver="GeoJSON")
print(len(gpd.read_file("output/residential.geojson")), "features read back from the GeoJSON")
159 features read back from the GeoJSON

Reading the output back is the cheapest test that the write did what you meant.

What one small file had to say

The libraries underneath GeoPandas and what each one does. what GeoPandas is standing onGeoPandasthe API you useframes & plotspandastables and joinsattributesShapelygeometry operationsbuffer, clip, validpyproj / PROJCRS and transformsto_crs()pyogrio / GDALfile formatsread & writeKnowing which layer an error comes from is half of fixing it spatialworkflow.io
A CRS error is pyproj, a topology error is Shapely, a read error is GDAL.

Every step on this page is one line, and every one of them succeeded. What made them worth doing was reading their output. The column listing showed that 2,165 of 2,405 polygons have no name — invisible to anyone who went straight to filtering. The CRS line said degrees, so the area had to wait for a reprojection before it meant anything (268.9 km² of mapped land use). The geometry types showed four multipolygons in a "polygon" layer. The write showed that one shapefile is five files and that its text fields take the same space however short their values.

None of those raised an error, and none needed more than one line to see.

Invalid geometry, missing labels and big files

Missing or invalid geometry

Some rows may have null geometry values or invalid shapes. Null geometry is dropped with a filter; validity is a separate check:

print(landuse.geometry.isna().sum(), "null geometries")
print(landuse.geometry.is_valid.value_counts())
0 null geometries
True    2405
Name: count, dtype: int64

This extract is clean. Data digitised by hand or exported from a CAD system often is not, and invalid geometry makes overlays and dissolves fail or return wrong areas. How to fix invalid geometries covers the repair.

CRS missing, field names cut short

Copy a shapefile without its .prj and it reads with no CRS; label it only from what the source documents (cannot transform naive geometries). Write a column called area_hectares to a shapefile and it comes back as area_hecta, because the format allows ten characters (shapefile column names truncated).

Large files can be slow

Large parcel or boundary datasets may load slowly and be expensive to plot. You do not have to read a whole file to see what is in it — pyogrio.read_info() reports the count, CRS and fields without loading the features, and rows= reads just the first few:

import pyogrio

info = pyogrio.read_info("data/rutland/gis_osm_buildings_a_free_1.shp")
print(info["features"], "features;", info["crs"], ";", list(info["fields"]))
print(gpd.read_file("data/rutland/gis_osm_buildings_a_free_1.shp", rows=3)[["osm_id", "fclass"]])
5513 features; EPSG:4326 ; ['osm_id', 'code', 'fclass', 'name', 'type']
     osm_id    fclass
0   3700606  building
1   3700607  building
2  59833301  building

read_file() also takes columns= to skip fields and bbox= or mask= to read only the features in an area.

FAQ

What is GeoPandas used for?

GeoPandas is used for working with vector spatial data in Python. Common tasks include reading shapefiles and GeoJSON, filtering features, checking CRS, plotting data, and exporting results.

What is the difference between pandas and GeoPandas?

pandas handles standard tabular data. GeoPandas adds a geometry column so the table can store and work with spatial features such as points, lines, and polygons.

How do I select specific columns from a GeoDataFrame?

Index it like a pandas DataFrame, but keep the geometry column if you want to stay spatial: gdf[["osm_id", "fclass", "geometry"]]. Dropping the geometry column returns a plain DataFrame rather than a GeoDataFrame.

How do I count features by attribute value in GeoPandas?

Use value_counts() on the column, for example gdf["fclass"].value_counts(). This works exactly as in pandas and is a quick way to summarize categories before filtering or plotting.

Can I create a GeoDataFrame from a CSV with latitude and longitude columns?

Yes. Build point geometries from the coordinate columns and set the CRS explicitly:

gdf = gpd.GeoDataFrame(
    df, geometry=gpd.points_from_xy(df["lon"], df["lat"]), crs="EPSG:4326"
)