How to Create a Choropleth Map in Python with GeoPandas

On this page

Problem statement

A choropleth colours each area by a value — density by district, unemployment by region — and in GeoPandas the drawing is one argument, plot(column="density"). The map's message is decided before and around that call: whether the values joined to every area, which classification turns values into colours, and what happens to the areas with no value. Each of those can change what a reader sees without changing a single number.

This page maps Germany's 401 districts and 38 regions from Eurostat and measures each decision: a boundary vintage that leaves seven districts blank, three classification schemes that tell three different stories, and a "natural breaks" legend that changes between runs.

Quick answer

A continuous color gradient versus the same values grouped into discrete classes. Continuous gradient Classified (quantiles) spatialworkflow.io
The same values shown as a continuous gradient and grouped into discrete (quantile) classes.

If your polygon layer already contains the numeric column you want to map, the shortest workflow is:

import geopandas as gpd
import matplotlib.pyplot as plt

gdf = gpd.read_file("data/de_districts.shp")

ax = gdf.plot(
    column="density",
    cmap="OrRd",
    legend=True,
    figsize=(10, 8),
    edgecolor="black",
    linewidth=0.3
)

ax.set_title("Population Density by District")
ax.set_axis_off()

plt.show()
print(len(gdf), "districts, density", gdf["density"].min(), "to", gdf["density"].max(), "people/km²")
401 districts, density 35.1 to 4828.6 people/km²

That is the whole drawing. The rest of this page is about the decisions that make it say the right thing.

Load polygons, check the column, plot, classify, save

What a choropleth needs before it is honest. before you colour anythingThe value is normalised, not a raw countper capita, per km²A classification scheme is chosenscheme=Missing values are handled explicitlymissing_kwdsThe colour map suits the datasequential or divergingA legend states the unitslegend_kwdsThe CRS suits the area shownequal-area for comparisonMapping raw counts by region mostly maps where the people are spatialworkflow.io
The first item is the one that most often turns a map into a population map.

Load polygon data into a GeoDataFrame

GeoPandas can read shapefiles, GeoJSON, and many other GIS formats through read_file().

import geopandas as gpd

gdf = gpd.read_file("data/de_nuts3.gpkg")

print(gdf[["NUTS_ID", "NAME_LATN", "density"]].head(3))
print(gdf.geom_type.value_counts().to_dict())
print(gdf.crs)
  NUTS_ID                    NAME_LATN  density
0   DE254   Nürnberg, Kreisfreie Stadt   2854.4
1   DE255  Schwabach, Kreisfreie Stadt    997.9
2   DE256           Ansbach, Landkreis     95.1
{'MultiPolygon': 401}
EPSG:3035

Check that the geometry type is Polygon or MultiPolygon. A choropleth map is designed for area features, not points.

If your layer contains mixed geometry types, filter to polygons before plotting:

gdf = gdf[gdf.geom_type.isin(["Polygon", "MultiPolygon"])].copy()

If you are working with a shapefile:

gdf = gpd.read_file("data/de_districts.shp")

Check the attribute column you want to map

Before plotting, inspect the available fields and confirm the target column is numeric.

print(list(gdf.columns))
print(gdf["density"].dtype)
print(gdf["density"].describe().round(1).to_dict())
['NUTS_ID', 'NAME_LATN', 'density', 'gdp_per_head', 'geometry']
float64
{'count': 394.0, 'mean': 557.0, 'std': 732.8, 'min': 35.1, '25%': 117.8, '50%': 209.6, '75%': 725.1, 'max': 4828.6}

The count is 394 of 401: seven districts have no density. They are all in Thuringia, whose districts were redrawn after the 2021 NUTS boundaries this map uses, so the 2024 figures carry codes the 2021 geometry does not have. A join on a code is only as good as the two vintages agreeing.

If the values are stored as text, convert them with pandas:

import pandas as pd

gdf["density"] = pd.to_numeric(gdf["density"], errors="coerce")

Using errors="coerce" turns invalid values into NaN, which you can handle later.

Create a basic choropleth map

The core GeoPandas choropleth workflow uses plot() with the column argument.

import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(10, 8))

gdf.plot(
    column="density",
    ax=ax,
    legend=True
)

plt.show()

This colors each polygon according to the value in density.

Improve the map styling

A default plot works, but it is usually better to control color, borders, title, and axes.

fig, ax = plt.subplots(figsize=(10, 8))

gdf.plot(
    column="density",
    ax=ax,
    cmap="YlGnBu",
    legend=True,
    edgecolor="black",
    linewidth=0.2
)

ax.set_title("Population Density by Administrative Area", fontsize=14)
ax.set_axis_off()

plt.show()

A sequential colormap such as OrRd or YlGnBu suits a value that only goes up; a diverging one needs a meaningful midpoint.

Handle missing values

Missing values often appear as blank polygons unless you define a missing-data style.

fig, ax = plt.subplots(figsize=(10, 8))

gdf.plot(
    column="density",
    ax=ax,
    cmap="YlGnBu",
    legend=True,
    edgecolor="black",
    linewidth=0.2,
    missing_kwds={
        "color": "lightgrey",
        "edgecolor": "red",
        "hatch": "///",
        "label": "Missing values"
    }
)

ax.set_title("Population Density by Administrative Area")
ax.set_axis_off()

plt.show()

You can also remove records with null values before plotting:

gdf_clean = gdf[gdf["density"].notna()].copy()
print(len(gdf), "->", len(gdf_clean), "districts;", sorted(gdf.loc[gdf["density"].isna(), "NAME_LATN"]))
401 -> 394 districts; ['Eisenach, Kreisfreie Stadt', 'Ilm-Kreis', 'Saalfeld-Rudolstadt', 'Schmalkalden-Meiningen', 'Sonneberg', 'Suhl, Kreisfreie Stadt', 'Wartburgkreis']

Dropping them leaves a hole in Thuringia with no explanation; missing_kwds keeps the hole and labels it.

Classify values for clearer interpretation

A continuous scale is not always the best choice. If values are unevenly distributed, classified ranges are easier to read.

GeoPandas supports classification schemes through the scheme argument. This requires the optional mapclassify package.

Install it if needed:

pip install mapclassify

Then use a classification method such as quantiles:

fig, ax = plt.subplots(figsize=(10, 8))

gdf.plot(
    column="density",
    ax=ax,
    cmap="OrRd",
    legend=True,
    scheme="quantiles",
    k=5,
    edgecolor="black",
    linewidth=0.2
)

ax.set_title("Population Density by Quantile Class")
ax.set_axis_off()

plt.show()

How many districts each scheme puts in each class decides what the map says:

import mapclassify

values = gdf["density"].dropna()
for name in ("EqualInterval", "Quantiles", "FisherJenks"):
    c = getattr(mapclassify, name)(values, k=5)
    print(f"{name:14} counts {c.counts.tolist()}  upper bounds {[round(b) for b in c.bins]}")
top = mapclassify.EqualInterval(values, k=5).bins[-2]
print("darkest equal-interval class:", sorted(gdf.loc[gdf["density"] > top, "NAME_LATN"]))
EqualInterval  counts [315, 53, 19, 5, 2]  upper bounds [994, 1952, 2911, 3870, 4829]
Quantiles      counts [79, 79, 78, 79, 79]  upper bounds [107, 162, 301, 973, 4829]
FisherJenks    counts [256, 60, 52, 24, 2]  upper bounds [386, 998, 1905, 3073, 4829]
darkest equal-interval class: ['Berlin', 'München, Kreisfreie Stadt']

Equal intervals put 315 of 394 districts in the lightest class and two — Berlin and Munich — in the darkest: a map of Germany that is pale everywhere except two cities. Quantiles give every class about 79 districts, at the price of putting 301 and 973 people/km² in neighbouring classes. Fisher-Jenks finds the gaps in the data instead.

natural_breaks is k-means with random starting points, so two runs can give two legends (Example 3). What each scheme assumes is the subject of choropleth classification explained.

Districts per class and upper class bounds for German population density under three classification schemes. districts per class, darkest last (people per km²)schemedistricts per classupper boundsEqualInterval315 · 53 · 19 · 5 · 2994 · 1952 · 2911 · 3870 · 4829Quantiles79 · 79 · 78 · 79 · 79107 · 162 · 301 · 973 · 4829FisherJenks256 · 60 · 52 · 24 · 2386 · 998 · 1905 · 3073 · 4829394 German districts, 2024; the darkest equal-interval class is Berlin and Munich spatialworkflow.io
What the mapclassify loop above printed.

Save the choropleth map

To export the final map to an image:

from pathlib import Path
import matplotlib.pyplot as plt

Path("output").mkdir(parents=True, exist_ok=True)

fig, ax = plt.subplots(figsize=(10, 8))

gdf.plot(
    column="density",
    ax=ax,
    cmap="OrRd",
    legend=True,
    edgecolor="black",
    linewidth=0.2
)

ax.set_title("Population Density by Administrative Area")
ax.set_axis_off()

plt.savefig("output/population_density_map.png", dpi=300, bbox_inches="tight")
plt.close()
print(f"{Path('output/population_density_map.png').stat().st_size / 1e3:.0f} KB")
597 KB

Use dpi=300 for reports or print output. bbox_inches="tight" helps remove unnecessary margins.

German districts and regions as choropleths

The data used on this page

Germany's districts and regions from Eurostat (© EuroGeographics for the administrative boundaries; statistics © European Union): the NUTS 2021 boundaries at 1:20 million from GISCO, level 3 (400 districts and the "Extra-Regio" code) and level 2 (38 regions), joined to three Eurostat tables — population density for 2024 (demo_r_d3dens), GDP per inhabitant for 2023 (nama_10r_3gdp) and the 2024 unemployment rate for ages 15–74 (lfst_r_lfu3rt). The district layer is also written as a shapefile, the format the first examples read.

import io
from pathlib import Path

import geopandas as gpd
import pandas as pd
import requests

def eurostat(dataset, key, year):
    r = requests.get(f"https://ec.europa.eu/eurostat/api/dissemination/sdmx/2.1/data/{dataset}/{key}"
                     f"?format=SDMX-CSV&startPeriod={year}&endPeriod={year}", timeout=300)
    r.raise_for_status()
    return pd.read_csv(io.StringIO(r.text))

def nuts(level):
    g = gpd.read_file("https://gisco-services.ec.europa.eu/distribution/v2/nuts/geojson/"
                      f"NUTS_RG_20M_2021_4326_LEVL_{level}.geojson")
    return g[g["CNTR_CODE"] == "DE"][["NUTS_ID", "NAME_LATN", "geometry"]]

Path("data").mkdir(exist_ok=True)
density = eurostat("demo_r_d3dens", "A.PER_KM2.", 2024).set_index("geo")["OBS_VALUE"]
gdp = eurostat("nama_10r_3gdp", "A.EUR_HAB.", 2023).set_index("geo")["OBS_VALUE"]
lfs = eurostat("lfst_r_lfu3rt", "all", 2024)
unemployment = lfs[(lfs["sex"] == "T") & (lfs["age"] == "Y15-74") & (lfs["isced11"] == "TOTAL")].set_index("geo")["OBS_VALUE"]

nuts3 = nuts(3).to_crs(3035)
nuts3["density"] = nuts3["NUTS_ID"].map(density)
nuts3["gdp_per_head"] = nuts3["NUTS_ID"].map(gdp)
nuts3.to_file("data/de_nuts3.gpkg")
nuts3.to_file("data/de_districts.shp")
nuts2 = nuts(2).to_crs(3035)
nuts2["unemployment_rate"] = nuts2["NUTS_ID"].map(unemployment)
nuts2.to_file("data/de_nuts2.gpkg")
print(len(nuts3), "districts,", nuts3["density"].notna().sum(), "with a density;",
      len(nuts2), "regions,", nuts2["unemployment_rate"].notna().sum(), "with a rate")
401 districts, 394 with a density; 38 regions, 37 with a rate

Example 1: Basic choropleth from a shapefile

import geopandas as gpd
import matplotlib.pyplot as plt

gdf = gpd.read_file("data/de_districts.shp")

fig, ax = plt.subplots(figsize=(10, 8))

gdf.plot(
    column="density",
    ax=ax,
    cmap="OrRd",
    legend=True
)

ax.set_title("Population Density by District")
ax.set_axis_off()

plt.show()
print(list(gdf.columns)[:4], len(gdf))
['NUTS_ID', 'NAME_LATN', 'density', 'gdp_per_he'] 401

The shapefile has quietly renamed a column: gdp_per_head is eleven characters and the format allows ten, so it comes back as gdp_per_he. Code that plots column="gdp_per_head" from the shapefile fails with a KeyError; the GeoPackage keeps the name, which is why the next examples read that.

Example 2: Choropleth from GeoJSON with custom styling

import geopandas as gpd
import matplotlib.pyplot as plt

gdf = gpd.read_file("data/de_nuts3.gpkg")

fig, ax = plt.subplots(figsize=(11, 8))

gdf.plot(
    column="gdp_per_head",
    ax=ax,
    cmap="YlGnBu",
    legend=True,
    edgecolor="white",
    linewidth=0.5,
    missing_kwds={"color": "lightgrey", "label": "No data"}
)

ax.set_title("GDP per Inhabitant by District, 2023 (EUR)", fontsize=14)
ax.set_axis_off()

plt.show()
print(gdf["gdp_per_head"].isna().sum(), "districts drawn as No data;",
      gdf.loc[gdf["gdp_per_head"].idxmax(), "NAME_LATN"], "highest at", gdf["gdp_per_head"].max())
7 districts drawn as No data; Wolfsburg, Kreisfreie Stadt highest at 185100.0

Example 3: Choropleth with classified value ranges

This example requires mapclassify to be installed.

import geopandas as gpd
import matplotlib.pyplot as plt

gdf = gpd.read_file("data/de_nuts2.gpkg")

fig, ax = plt.subplots(figsize=(10, 8))

gdf.plot(
    column="unemployment_rate",
    ax=ax,
    cmap="PuRd",
    legend=True,
    scheme="fisher_jenks",
    k=5,
    edgecolor="black",
    linewidth=0.2
)

ax.set_title("Unemployment Rate by Region, 2024 (%)")
ax.set_axis_off()

plt.show()

import mapclassify
rates = gdf["unemployment_rate"].dropna()
import numpy as np
print("missing:", gdf.loc[gdf["unemployment_rate"].isna(), "NAME_LATN"].tolist())
print("fisher_jenks:           ", [round(float(b), 1) for b in mapclassify.FisherJenks(rates, k=5).bins])
for seed in (1, 2, 3):
    np.random.seed(seed)
    print(f"natural_breaks (seed {seed}):", [round(float(b), 1) for b in mapclassify.NaturalBreaks(rates, k=5).bins])
missing: ['Trier']
fisher_jenks:            [2.8, 3.3, 3.7, 4.3, 5.6]
natural_breaks (seed 1): [2.8, 3.3, 3.7, 4.3, 5.6]
natural_breaks (seed 2): [2.8, 3.3, 3.7, 4.3, 5.6]
natural_breaks (seed 3): [2.9, 3.4, 3.7, 4.3, 5.6]

natural_breaks is mapclassify's NaturalBreaks, a k-means classifier with random starting points, and the third seed moved two class boundaries. A map made twice from the same data should not change its legend, so this example uses fisher_jenks, which finds the optimal breaks deterministically.

Example 4: Export the final map image

from pathlib import Path
import geopandas as gpd
import matplotlib.pyplot as plt

Path("output").mkdir(parents=True, exist_ok=True)

gdf = gpd.read_file("data/de_nuts3.gpkg")

fig, ax = plt.subplots(figsize=(12, 9))

gdf.plot(
    column="gdp_per_head",
    ax=ax,
    cmap="viridis",
    legend=True,
    edgecolor="black",
    linewidth=0.3
)

ax.set_title("GDP per Inhabitant by District, 2023")
ax.set_axis_off()

plt.savefig("output/gdp_per_head.png", dpi=300, bbox_inches="tight")
plt.close()
print(sorted(p.name for p in Path("output").glob("*.png")))
['gdp_per_head.png', 'population_density_map.png']

Three decisions between a table and a map

Classification schemes for choropleth maps and when each is appropriate. the scheme decides the storyhow it splitsbest whenequal_intervalequal value rangesvalues are evenly spreadquantiles✓equal class countsvalues are skewedfisher_jenks✓gaps in the dataclusters exist naturallyuser_definedthresholds you choosethe breaks carry meaningGerman districts, k=5: equal intervals leave 315 of 394 in one class spatialworkflow.io
Skewed data mapped with equal intervals: 315 districts in the lightest class, Berlin and Munich alone in the darkest.

A choropleth makes three mappings, and each failed differently here. Area to value is a join, and a join is only as good as the two vintages agreeing: the 2024 density figures use Thuringia's redrawn district codes, the 2021 boundaries do not, and seven districts came through with no value. Value to class is the classification, and it is an analytical choice rather than a styling one: equal intervals put 315 of 394 districts in the lightest class and only Berlin and Munich in the darkest, quantiles force about 79 into each, and Fisher-Jenks follows the gaps in the data. Class to colour is the legend, and it should not depend on chance — which is why the classified example uses Fisher-Jenks rather than the randomly started natural_breaks.

The values themselves should be rates or densities. A district is coloured by its area on the page, so a raw total — people, not people per km² — mostly shows which districts are large.

Text columns, vintages and point layers

  • A numeric column stored as text plots as categories; convert it with pd.to_numeric(..., errors="coerce") first.
  • Missing values disappear by default. missing_kwds draws them in a neutral colour with a legend entry; dropping them leaves an unexplained hole.
  • Check boundary and statistics vintages before joining: matching codes from different years is how Thuringia lost seven districts.
  • Points are not areas. For point data use proportional symbols or a density surface, not a choropleth.

If you need more background, read What Is a Choropleth Map in GIS.

For related GeoPandas tasks, see How to Read a Shapefile in Python with GeoPandas and How to Reproject Spatial Data in Python (GeoPandas).

If you need to prepare output data for other tools, see How to Export GeoJSON in Python with GeoPandas.

If your layer draws in the wrong place or looks distorted, check Why GeoPandas Plot Is Not Showing the Correct Map.

FAQ

How do I create a choropleth map in GeoPandas?

Load a polygon dataset with gpd.read_file(), then call gdf.plot(column="your_field", legend=True). Add cmap, edgecolor, and figsize for better output.

Does GeoPandas work with shapefiles and GeoJSON for choropleth maps?

Yes. GeoPandas supports both shapefiles and GeoJSON through read_file(), so you can use the same choropleth workflow with either format.

What column type should I use for a choropleth map?

Use a numeric column such as integer or float. If your values are strings, convert them to numeric before plotting.

How do I handle missing values in a GeoPandas choropleth?

Use missing_kwds to style polygons with null values, or remove null rows with gdf[gdf["field"].notna()].

How do I join attribute values from a CSV before making a choropleth?

Read the CSV with pandas and merge() it onto the polygon GeoDataFrame using a shared key column, keeping the GeoDataFrame on the left so the geometry is preserved: gdf = gdf.merge(df, on="region_id", how="left"). Then plot the merged numeric column as usual.

How do I add a north arrow, scale bar, or basemap to a GeoPandas choropleth?

GeoPandas does not draw those itself; use the contextily package for a basemap and matplotlib for annotations. Reproject your layer to EPSG:3857 first with gdf.to_crs(epsg=3857), then call contextily.add_basemap(ax) after plotting.

How do I control the color classification breakpoints manually?

Pass an explicit list to classification_kwds with the user_defined scheme: gdf.plot(column="density", scheme="user_defined", classification_kwds={"bins": [10, 50, 100, 500]}). This requires mapclassify and gives you fixed, reproducible class edges instead of data-driven ones.

How do I move or resize the choropleth legend?

The continuous color bar is controlled with legend_kwds, for example legend_kwds={"shrink": 0.5, "label": "Density"}. For classified maps the legend is a standard matplotlib legend, so you can reposition it afterward with ax.get_legend().set_bbox_to_anchor((1, 0.5)).