Plot Layer Data with Matplotlib in PyQGIS
A map shows where; a chart shows how much, how often and how it changed. Most reports need both: the distribution of building heights next to the map of buildings, the monthly count of incidents next to the hot spot map, the elevation profile next to the route. Matplotlib ships with QGIS on every platform, reads straight from Python lists and NumPy arrays, and writes publication-quality SVG and PNG — so charts can be produced by the same script that produced the analysis.
This recipe belongs to PyQGIS and the Python Data Stack. It sets matplotlib up so it does not interfere with QGIS, plots attribute distributions, categories, relationships and time series, plots raster values, and saves figures ready for print layouts.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series. Matplotlib is included with the official installers; check with
import matplotlib; matplotlib.__version__. - Familiarity with reading attributes into Python, as in analysing an attribute table with pandas.
Set matplotlib up for QGIS
Matplotlib normally opens windows with its own GUI toolkit. Inside QGIS that can freeze the interface or crash it, because two event loops compete. Using the non-interactive Agg backend and saving figures to files avoids the problem entirely.
import matplotlib
matplotlib.use("Agg") # render to files only, no windows
import matplotlib.pyplot as plt
plt.rcParams.update({
"figure.dpi": 100, "savefig.dpi": 200,
"font.size": 9, "axes.spines.top": False, "axes.spines.right": False,
"svg.fonttype": "none", # keep text as text in SVG output
})
Breakdown: matplotlib.use("Agg") must run before pyplot is imported for the first time in the session; putting it at the top of every plotting script is the safe habit. Saving to files works identically inside QGIS, in a standalone script and in a scheduled job, which is exactly what automated reporting needs. svg.fonttype = "none" keeps labels as real text in SVG files, so they stay sharp, searchable and editable in a layout. To show a chart inside a plugin, embed it in a Qt widget with the backend_qtagg canvas instead of calling plt.show().
Plot a distribution
A histogram is the first chart to draw for any numeric field: it shows the range, the typical values, skew and outliers at a glance.
from qgis.core import QgsProject, QgsFeatureRequest
buildings = QgsProject.instance().mapLayersByName("buildings")[0]
req = (QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry)
.setSubsetOfAttributes(["height_m"], buildings.fields()))
heights = [f["height_m"] for f in buildings.getFeatures(req)
if f["height_m"] is not None and not (hasattr(f["height_m"], "isNull") and f["height_m"].isNull())]
fig, ax = plt.subplots(figsize=(5, 3))
ax.hist(heights, bins=40, color="#0f766e", edgecolor="white", linewidth=0.4)
ax.axvline(sorted(heights)[len(heights) // 2], color="#b45309", linestyle="--", label="median")
ax.set_xlabel("building height (m)")
ax.set_ylabel("buildings")
ax.legend(frameon=False)
fig.tight_layout()
fig.savefig("/data/reports/height_hist.svg")
plt.close(fig)
Breakdown: Reading only the one field without geometry keeps this fast on large layers. NULL heights are excluded explicitly rather than turned into zeros, which would create a false spike at the left. Marking the median gives the reader an anchor; for skewed data it is more representative than the mean. plt.close(fig) releases memory — important in a loop that produces many figures, where unclosed figures accumulate until the process runs out.
Compare categories
Horizontal bar charts suit categories with long names — land uses, species, road classes — and make ranking obvious when sorted.
from collections import defaultdict
area_by_use = defaultdict(float)
req = QgsFeatureRequest().setSubsetOfAttributes(["use"], buildings.fields())
for f in buildings.getFeatures(req):
area_by_use[str(f["use"])] += f.geometry().area() / 1e4 # hectares
items = sorted(area_by_use.items(), key=lambda kv: kv[1])
fig, ax = plt.subplots(figsize=(5, 0.35 * len(items) + 0.8))
ax.barh([k for k, _ in items], [v for _, v in items], color="#2563eb")
ax.set_xlabel("footprint area (ha)")
for y, (_, v) in enumerate(items):
ax.text(v, y, f" {v:,.0f}", va="center", fontsize=8)
fig.tight_layout()
fig.savefig("/data/reports/area_by_use.svg")
plt.close(fig)
Breakdown: This chart needs geometry for the area, so the request keeps geometry but trims attributes to the one field. Sorting ascending puts the largest category at the top of a horizontal chart. Scaling the figure height with the number of categories keeps bar thickness constant whether there are five uses or twenty-five. Value labels at the end of each bar remove the need to read values off the axis.
Show a relationship
A scatter plot shows whether two numeric fields move together — building height against floor count, pipe age against burst count. Colouring by a category adds a third variable.
req = QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry).setSubsetOfAttributes(
["height_m", "floors", "use"], buildings.fields())
rows = [(f["height_m"], f["floors"], f["use"]) for f in buildings.getFeatures(req)]
rows = [r for r in rows if all(v is not None and not (hasattr(v, "isNull") and v.isNull()) for v in r)]
fig, ax = plt.subplots(figsize=(5, 3.5))
for use, colour in (("residential", "#0f766e"), ("commercial", "#b45309")):
pts = [(h, n) for h, n, u in rows if u == use]
ax.scatter([p[1] for p in pts], [p[0] for p in pts], s=6, alpha=0.5, color=colour, label=use)
ax.set_xlabel("floors")
ax.set_ylabel("height (m)")
ax.legend(frameon=False, markerscale=3)
fig.tight_layout()
fig.savefig("/data/reports/height_vs_floors.png")
plt.close(fig)
Breakdown: Small markers with transparency reveal density where thousands of points overlap; with very large layers, ax.hexbin is clearer still. Points far from the main trend — a one-floor building 40 m high — are often data errors, which makes a scatter plot a useful quality check as well as an analysis; the attribute validation recipe can turn such observations into rules. PNG suits plots with many thousands of marks, where SVG files become large.
Plot change over time
Date fields become time series by counting or summing per period. Pandas does the resampling; matplotlib draws the line.
import pandas as pd
incidents = QgsProject.instance().mapLayersByName("incidents")[0]
req = QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry).setSubsetOfAttributes(
["reported_at"], incidents.fields())
times = [f["reported_at"].toPyDateTime() for f in incidents.getFeatures(req)
if hasattr(f["reported_at"], "toPyDateTime") and not f["reported_at"].isNull()]
monthly = pd.Series(1, index=pd.DatetimeIndex(times)).resample("MS").sum()
fig, ax = plt.subplots(figsize=(6, 2.6))
ax.plot(monthly.index, monthly.values, color="#15803d", linewidth=1.6)
ax.plot(monthly.index, monthly.rolling(3, center=True).mean(), color="#2f3b35",
linewidth=1, linestyle="--", label="3-month mean")
ax.set_ylabel("incidents per month")
ax.legend(frameon=False)
fig.autofmt_xdate()
fig.tight_layout()
fig.savefig("/data/reports/incidents_monthly.svg")
plt.close(fig)
Breakdown: A series of ones indexed by timestamp, resampled to month starts ("MS"), counts events per month including months with none — which a group-by on month strings would silently omit. A centred rolling mean shows the trend through month-to-month noise. autofmt_xdate rotates date labels so they do not overlap. For QGIS 4, where dates may arrive as Python datetimes, check for toPyDateTime as shown or convert with the cleaning helper from the pandas recipe.
Plot raster values
Raster bands are NumPy arrays away from a histogram. Reading a band, masking NoData, and plotting the distribution is a quick way to understand a DEM or a classification before styling it.
import numpy as np
from qgis.core import QgsRasterLayer
dem = QgsRasterLayer("/data/terrain/dem_10m.tif", "dem")
prov = dem.dataProvider()
block = prov.block(1, dem.extent(), dem.width(), dem.height())
arr = np.frombuffer(bytes(block.data()), dtype=np.float32).reshape(dem.height(), dem.width())
nodata = prov.sourceNoDataValue(1) if prov.sourceHasNoDataValue(1) else None
values = arr[arr != nodata] if nodata is not None else arr.ravel()
fig, ax = plt.subplots(figsize=(5, 3))
ax.hist(values, bins=60, color="#b45309")
ax.set_xlabel("elevation (m)")
ax.set_ylabel("pixels")
fig.tight_layout()
fig.savefig("/data/reports/dem_hist.svg")
plt.close(fig)
Breakdown: Reading the full extent at native resolution gives one value per pixel; for very large rasters, read a reduced width and height to sample instead. The dtype must match the band's data type — Float32 here; reading pixels with QgsRasterBlock covers the mapping and tiling for large files. Masking NoData is essential: without it, a −9999 fill value dominates the histogram.
Put charts into a print layout
Saved SVG charts drop straight into a QGIS print layout as picture items, so maps and charts share one page.
from qgis.core import QgsLayoutItemPicture, QgsLayoutPoint, QgsLayoutSize, QgsUnitTypes
layout = QgsProject.instance().layoutManager().layoutByName("Report")
pic = QgsLayoutItemPicture(layout)
pic.setPicturePath("/data/reports/height_hist.svg")
pic.attemptMove(QgsLayoutPoint(200, 20, QgsUnitTypes.LayoutMillimeters))
pic.attemptResize(QgsLayoutSize(80, 50, QgsUnitTypes.LayoutMillimeters))
layout.addLayoutItem(pic)
Breakdown: SVG pictures scale without blurring in PDF exports, which is why saving charts as SVG pays off. The picture path is stored in the layout, so regenerating the SVG with new data and re-exporting updates the report. Adding a chart to a print layout covers sizing, data-driven charts per atlas page, and QGIS's own chart item.
QGIS version compatibility
Matplotlib with the Agg backend works on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. For embedding in Qt widgets, QGIS 3 uses matplotlib.backends.backend_qtagg with PyQt5 and QGIS 4 the same module with PyQt6; matplotlib 3.5 or newer selects the right bindings. On QGIS 4, QgsUnitTypes.LayoutMillimeters is Qgis.LayoutUnit.Millimeters.
Troubleshooting
- QGIS freezes when plotting. An interactive backend opened a window; call
matplotlib.use("Agg")first and save to files. - Memory grows with every chart. Figures were not closed; call
plt.close(fig). - Text in SVG looks different in the layout. The font is not installed where QGIS runs; use a common font or keep
svg.fonttypeas paths. - Histogram is a single bar. NoData or a placeholder value was not masked.
Conclusion
Use the Agg backend and save to files, read only the fields you need, exclude NULLs rather than zero them, choose histograms, sorted horizontal bars, scatter plots and resampled lines to match the question, mask NoData for rasters, save SVG for layouts and close every figure.
Frequently Asked Questions
Can I use seaborn or plotly? Yes. Seaborn builds on matplotlib and works the same way; plotly writes HTML, which suits web output rather than print layouts.
How do I make charts match the map's colours?
Read symbol colours from the renderer — for a categorized renderer, each category's symbol().color().name() — and pass them to matplotlib.
Can charts update automatically when data changes? Re-run the script; or use the Data Plotly plugin for interactive charts inside QGIS.
What DPI should PNG charts use? 200–300 for print; 100 is enough for screens.