Create a Choropleth Map in PyQGIS
A choropleth map shades polygons by the value of a numeric attribute, letting readers compare regions at a glance. In PyQGIS the tool for the job is QgsGraduatedSymbolRenderer: you point it at a field, pick a classification method and class count, apply a sequential color ramp, and let QGIS shade every feature by its class. This guide walks through a complete, runnable recipe — including the crucial normalization step that prevents misleading maps — as a focused task within the Graduated & Categorized Renderers in PyQGIS cluster.
The whole point of scripting a choropleth rather than clicking through the Symbology dialog is reproducibility: a script applies the same field, breaks, and ramp to this year's data and next year's, or to twenty county layers in a loop, without drift. The recipe below is deliberately linear so you can lift it into a larger automation pipeline and swap any single decision — the field, the method, the ramp — in isolation.
Prerequisites
- QGIS 3.34 LTR (Python 3.12) with PyQGIS available, or another 3.x release.
- A polygon layer (e.g. counties, census tracts) loaded in the project with a numeric field to map and, ideally, an area or population field for normalization.
- The QGIS Python Console open (
Ctrl+Alt+P). - A grasp of why raw counts mislead on choropleths — covered in step 2 below.
Step 1: Load the Layer and Inspect the Field
Start by grabbing the layer and confirming the target field is numeric and reasonably populated. A choropleth on a field full of nulls or zeros produces a flat, uninformative map.
from qgis.core import QgsProject
layer = QgsProject.instance().mapLayersByName("counties")[0]
field_name = "population"
# Sanity-check the field: type, range, and null count.
idx = layer.fields().indexFromName(field_name)
values = [f[field_name] for f in layer.getFeatures()
if f[field_name] not in (None, "")]
print(f"Features: {layer.featureCount()}, valid values: {len(values)}")
print(f"Min: {min(values)}, Max: {max(values)}")
Breakdown: Iterating once up front catches problems before styling. f[field_name] reads the attribute by name; filtering out None and empty strings avoids TypeError when you compute min/max. If len(values) is far below featureCount(), your map will have many unclassified (grey) polygons. The min/max also give you a quick reality check: if the maximum is wildly larger than the median, the field is right-skewed and a quantile or Jenks classification will read far better than equal interval, which would crush most polygons into the lightest class.
Step 2: Decide Whether to Normalize
This is the step that separates a correct choropleth from a misleading one. Mapping a raw count — total population, total sales — mostly reproduces the size of each polygon: big counties look "high" simply because they are big. Normalize to a rate or density by dividing by area or by another count.
If your layer lacks a ready-made density field, compute one with a virtual field using QgsField and an expression, or precompute it with the field calculator. Here we create an in-memory density value via a layer-scoped expression field:
from qgis.core import QgsField
from qgis.PyQt.QtCore import QVariant
# Add a virtual field: population per square kilometre.
density_field = QgsField("pop_density", QVariant.Double)
layer.addExpressionField(
"population / ($area / 1000000)", density_field
)
field_name = "pop_density"
Breakdown: addExpressionField() registers a virtual field computed on the fly — it is not written to disk, so it is perfect for a transient mapping field. $area returns the geometry area in the layer's CRS units; dividing by 1,000,000 converts square metres to square kilometres (valid only when the layer is in a projected CRS measured in metres). If your layer is in a geographic CRS, reproject it first or use area($geometry) with an transform() wrapper. To shade by an existing per-feature attribute color instead of a class, see Set a Vector Layer Symbol Color in PyQGIS.
Step 3: Choose a Classification Method and Class Count
The classification method decides where the class breaks fall, and the count decides how many shades the eye must distinguish. Four to six classes is the cartographic sweet spot; beyond seven, readers cannot reliably match a polygon's shade to the legend.
from qgis.core import (
QgsGraduatedSymbolRenderer,
QgsClassificationQuantile,
QgsSymbol,
)
class_count = 5
renderer = QgsGraduatedSymbolRenderer(field_name)
renderer.setSourceSymbol(QgsSymbol.defaultSymbol(layer.geometryType()))
renderer.setClassificationMethod(QgsClassificationQuantile())
renderer.updateClasses(layer, class_count)
for r in renderer.ranges():
print(f"{r.lowerValue():.1f} – {r.upperValue():.1f}: {r.label()}")
Breakdown: QgsClassificationQuantile puts an equal number of features in each class, which balances how much map ink each shade gets — a sensible default for choropleths. updateClasses(layer, class_count) scans the field and computes the five break ranges. Printing ranges() confirms the breaks are sensible before you commit. For clustered data where quantile splits look arbitrary, switch to Jenks — see Classify a Layer with Natural Breaks (Jenks) in PyQGIS.
Step 4: Apply a Sequential Color Ramp
Choropleths represent ordered magnitude, so they need a sequential ramp where lightness increases with value. Pull one from the user's style library via QgsStyle.defaultStyle().
from qgis.core import QgsStyle
ramp = QgsStyle.defaultStyle().colorRamp("Blues")
renderer.updateColorRamp(ramp)
Breakdown: colorRamp("Blues") returns a built-in sequential ramp. updateColorRamp() recolors all five classes in light-to-dark order, so the darkest blue marks the highest-density regions. Avoid diverging ramps (RdYlBu) unless your data has a meaningful midpoint, and avoid qualitative ramps entirely — they destroy the sense of order a choropleth depends on. To reverse the direction, call ramp.invert() before passing it.
Step 4b: Format the Legend Labels
The default range labels read like 1234.5 - 6789.0, which is precise but unfriendly. A choropleth's legend is part of the map's message, so round the numbers and add units. setLabelFormat() controls the label template for every class at once.
from qgis.core import QgsRendererRangeLabelFormat
label_format = QgsRendererRangeLabelFormat("%1 – %2 /km²", 0)
renderer.setLabelFormat(label_format, updateRanges=True)
for r in renderer.ranges():
print(r.label())
Breakdown: QgsRendererRangeLabelFormat(template, precision) sets a label template where %1 and %2 are substituted with each class's lower and upper bounds, and the second argument fixes the decimal precision — 0 rounds to whole numbers. Passing updateRanges=True rewrites the labels on the existing ranges in place. The result is a legend that reads 0 – 85 /km² instead of an over-precise machine string, which is what a finished map needs.
Step 5: Set the Renderer and Refresh Legend and Canvas
Assign the renderer to the layer, then explicitly refresh both the canvas and the Layers-panel legend so your new symbology is visible and the swatches appear.
from qgis.utils import iface
layer.setRenderer(renderer)
layer.triggerRepaint()
if iface is not None:
iface.layerTreeView().refreshLayerSymbology(layer.id())
iface.mapCanvas().refresh()
# Optional: persist the styling for reuse.
layer.saveNamedStyle("/tmp/counties_choropleth.qml")
Breakdown: setRenderer() swaps in the graduated renderer; triggerRepaint() invalidates the cached image. refreshLayerSymbology() rebuilds the legend entries that show each class's color and range. The iface guard keeps the snippet runnable in standalone scripts. saveNamedStyle() writes a .qml you can reload on other projects with layer.loadNamedStyle().
Step 6: Wrap It as a Reusable Function
The payoff of scripting arrives when you apply the same choropleth recipe to several layers with one call. Fold the steps into a function that takes the layer, field, class count, and ramp name, and returns the styled layer.
from qgis.core import (
QgsGraduatedSymbolRenderer,
QgsClassificationQuantile,
QgsSymbol,
QgsStyle,
)
from qgis.utils import iface
def apply_choropleth(layer, field, classes=5, ramp_name="Blues"):
"""Style a polygon layer as a quantile choropleth."""
renderer = QgsGraduatedSymbolRenderer(field)
renderer.setSourceSymbol(QgsSymbol.defaultSymbol(layer.geometryType()))
renderer.setClassificationMethod(QgsClassificationQuantile())
renderer.updateClasses(layer, classes)
ramp = QgsStyle.defaultStyle().colorRamp(ramp_name)
if ramp is None:
raise ValueError(f"Color ramp '{ramp_name}' is not installed")
renderer.updateColorRamp(ramp)
layer.setRenderer(renderer)
layer.triggerRepaint()
if iface is not None:
iface.layerTreeView().refreshLayerSymbology(layer.id())
return layer
# Apply to every selected layer in the panel.
for lyr in iface.layerTreeView().selectedLayers():
apply_choropleth(lyr, "pop_density", classes=5, ramp_name="YlGnBu")
Breakdown: Folding the steps into apply_choropleth() makes the recipe a single composable unit; the None check on the ramp turns a silent failure into a clear error. Looping over selectedLayers() styles a whole set of layers identically — the exact reproducibility win that justifies scripting over the dialog. Pass a different ramp_name or classes per call when layers need to differ.
QGIS Version Compatibility
The recipe targets QGIS 3.34 LTR (Python 3.12) and uses only stable APIs.
| QGIS / Python | Notes |
|---|---|
| 3.34 LTR (Python 3.12) | Baseline. Every snippet runs as written. |
| 3.28 LTR (Python 3.9) | Fully supported, including addExpressionField and QgsClassificationQuantile. |
| 3.40 / 3.44 | Identical API; more bundled sequential ramps available by name. |
| Pre-3.10 | setClassificationMethod unavailable; older code used the deprecated createRenderer(layer, field, classes, mode, symbol, ramp) constructor. |
On Python 3.9 builds, QVariant.Double still works; on newer PyQt6-based builds the same enum is reachable as QVariant.Type.Double, but QVariant.Double remains aliased for compatibility.
Troubleshooting
Map is one solid color. You likely set the renderer without calling updateClasses(), so renderer.ranges() is empty. Print it to confirm, then re-run the classification step.
Many polygons render grey/unclassified. Those features hold NULL or out-of-range values. Either filter them out, or call renderer.setClassAttribute() on a cleaned field. Confirm the field is numeric — string fields silently produce empty classes.
Big regions dominate the map. You mapped a raw count instead of a density or rate. Return to step 2 and divide by $area or by population.
updateColorRamp raises AttributeError. colorRamp("Name") returned None because that ramp name is not installed. Run QgsStyle.defaultStyle().colorRampNames() and pick an existing name.
Legend swatches do not update. Calling triggerRepaint() alone refreshes the canvas but not the tree. Add iface.layerTreeView().refreshLayerSymbology(layer.id()).
Conclusion
A trustworthy choropleth is three decisions made deliberately: normalize raw counts into rates, pick a classification method and a modest class count, and apply a sequential ramp that encodes order. With QgsGraduatedSymbolRenderer you can encode all three in a dozen lines, reproduce them across projects, and save the result as a reusable .qml. From here, experiment with Jenks breaks for clustered data and compare how each method reshapes the same map.
Frequently Asked Questions
How many classes should a choropleth have? Four to six. The human eye struggles to match more than about seven shades of one hue back to a legend, so extra classes add noise rather than information.
Should I always normalize the field? Normalize whenever the raw value is a count that scales with polygon size or population (totals, sums). Rates, percentages, medians, and densities are already comparable and can be mapped directly.
Can I use this on a point or line layer?QgsGraduatedSymbolRenderer works on any geometry, but "choropleth" specifically means shaded polygons. On points it produces graduated-color markers; switch the graduated method to size for proportional symbols.
Why a sequential ramp and not a rainbow? Sequential ramps vary lightness monotonically so higher values look unambiguously "more." Rainbow and qualitative ramps lack a perceptual order, so readers cannot tell which color means more.