Add a Chart to a Print Layout in PyQGIS
Diagrams on the map work for small, simple charts repeated at every feature. Many reports need something else: one proper chart beside the map — a time series for the area shown, a bar chart ranking the districts, a profile along the route. Print layouts can hold charts like any other item, and PyQGIS can produce them as part of the same script that builds the map, so the chart and the map always describe the same data.
This recipe belongs to Diagrams & Charts on Maps. It adds a matplotlib chart to a layout as an SVG picture, produces a different chart for every atlas page, introduces the native chart item available on recent QGIS releases, and keeps charts visually consistent with the map.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series, with matplotlib (included with the official installers).
- A print layout, created by hand or in code as in adding a map item and setting its extent.
Make the chart with matplotlib
The chart is produced as an SVG file. Saving with text kept as text and a transparent background makes it sit cleanly on the layout page.
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from qgis.core import QgsProject, QgsFeatureRequest
districts = QgsProject.instance().mapLayersByName("districts")[0]
req = (QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry)
.setSubsetOfAttributes(["name", "pop_2026"], districts.fields()))
rows = sorted(((f["name"], f["pop_2026"] or 0) for f in districts.getFeatures(req)),
key=lambda r: r[1])
plt.rcParams.update({"font.family": "sans-serif", "font.size": 7,
"svg.fonttype": "none", "axes.spines.top": False,
"axes.spines.right": False})
fig, ax = plt.subplots(figsize=(80 / 25.4, 70 / 25.4)) # 80 × 70 mm
ax.barh([r[0] for r in rows], [r[1] / 1000 for r in rows], color="#2563eb")
ax.set_xlabel("population (thousands)")
fig.tight_layout()
chart_path = "/data/reports/charts/population_by_district.svg"
fig.savefig(chart_path, transparent=True)
plt.close(fig)
Breakdown: Setting the figure size in inches from the intended size in millimetres means the chart's fonts and line widths come out at their true print size — a 7 pt label stays 7 pt in the PDF. Keeping SVG text as text lets it render with the same fonts as the rest of the layout and stay selectable in the exported PDF. A transparent background lets the layout's own page colour or frame show through. More chart types are covered in plotting layer data with matplotlib.
Add the chart as a picture item
A picture item displays an SVG or raster image at a position and size on the page. SVGs stay sharp at any zoom and in PDF exports.
from qgis.core import (QgsLayoutItemPicture, QgsLayoutPoint, QgsLayoutSize,
QgsUnitTypes)
layout = QgsProject.instance().layoutManager().layoutByName("District report")
chart = QgsLayoutItemPicture(layout)
chart.setId("population_chart")
chart.setPicturePath(chart_path)
chart.setResizeMode(QgsLayoutItemPicture.Zoom)
chart.attemptMove(QgsLayoutPoint(205, 25, QgsUnitTypes.LayoutMillimeters))
chart.attemptResize(QgsLayoutSize(80, 70, QgsUnitTypes.LayoutMillimeters))
layout.addLayoutItem(chart)
Breakdown: Giving the item an id makes it easy to find later with layout.itemById("population_chart"), for example to swap the picture path in a later run. Zoom resize mode scales the SVG to fit the frame while keeping its aspect ratio; because the SVG was created at exactly 80 × 70 mm, it fills the frame without scaling. The layout stores the path, not the image, so regenerating the SVG and re-exporting the layout updates the chart without touching the layout.
One chart per atlas page
In an atlas, each page shows one feature — one district, one catchment. The chart should change with it. Generating one SVG per feature, named by a key, and pointing the picture item at an expression achieves that.
from qgis.core import QgsLayoutObject, QgsProperty
series = QgsProject.instance().mapLayersByName("district_population_series")[0]
by_district = {}
for f in series.getFeatures():
by_district.setdefault(f["code"], []).append((f["year"], f["population"]))
for code, points in by_district.items():
points.sort()
fig, ax = plt.subplots(figsize=(80 / 25.4, 45 / 25.4))
ax.plot([p[0] for p in points], [p[1] / 1000 for p in points], color="#0f766e")
ax.set_ylabel("population (k)")
fig.tight_layout()
fig.savefig(f"/data/reports/charts/{code}.svg", transparent=True)
plt.close(fig)
chart.dataDefinedProperties().setProperty(
QgsLayoutObject.PictureSource,
QgsProperty.fromExpression("'/data/reports/charts/' || attribute(@atlas_feature, 'code') || '.svg'"))
chart.refreshDataDefinedProperty(QgsLayoutObject.PictureSource)
Breakdown: Generating every chart before export keeps the export itself fast and simple. The data-defined picture source is evaluated for each atlas page, with @atlas_feature holding the current coverage feature, so the item loads the chart named after that page's district code. Use a stable business key rather than a feature id in file names, so charts and features stay matched even if the layer is rewritten. Atlas expressions and dynamic text covers the atlas variables available.
The native chart item
Recent QGIS releases include a native chart layout item that draws bar and line charts directly from a layer's attributes, without matplotlib, and can filter to the current atlas feature automatically. Where it is available, it avoids managing files altogether.
import qgis.core as core
if hasattr(core, "QgsLayoutItemChart"):
native = core.QgsLayoutItemChart(layout)
native.setId("native_chart")
native.attemptMove(QgsLayoutPoint(205, 100, QgsUnitTypes.LayoutMillimeters))
native.attemptResize(QgsLayoutSize(80, 60, QgsUnitTypes.LayoutMillimeters))
layout.addLayoutItem(native)
print("native chart item available; configure its plot and series in the item properties")
else:
print("this QGIS version has no native chart item; use matplotlib pictures")
Breakdown: Testing for the class keeps one script working on both older LTRs and newer releases. The native item's plot types, series and styling are configured through its plot object, whose API is new and still evolving; inspect it with help(core.QgsLayoutItemChart) on your version, or configure the item once in the Layout Designer, save the layout as a template, and load the template from Python as described in loading a layout from a template. For publication-quality control over every detail, matplotlib remains the more flexible tool.
Chart only what the map shows
A chart beside a zoomed-in map should describe the features in that map, not the whole layer. The map item knows its extent, so the chart script can filter features to it before plotting.
from qgis.core import QgsFeatureRequest, QgsCoordinateTransform
map_item = layout.itemById("main_map")
extent = map_item.extent() # in the map item's CRS
to_layer = QgsCoordinateTransform(map_item.crs(), districts.crs(), QgsProject.instance())
layer_extent = to_layer.transformBoundingBox(extent)
visible = [(f["name"], f["pop_2026"] or 0)
for f in districts.getFeatures(QgsFeatureRequest().setFilterRect(layer_extent))
if f.geometry().intersects(layer_extent)]
print(len(visible), "districts in the map frame")
Breakdown: The map item's extent is a rectangle in the map's CRS; transforming it to the layer's CRS before filtering keeps the comparison correct when the two differ. setFilterRect uses the spatial index to find candidates quickly, and the exact intersects test removes features whose bounding box touches the frame but whose shape does not. Feeding visible to the plotting code in place of rows gives a chart that always matches the map, and in an atlas the same code can run per page after the atlas has set each page's extent.
Match charts to the map
A chart that uses different colours, fonts or category order from the map forces readers to translate between them. Take styling from the map rather than choosing it twice.
landuse = QgsProject.instance().mapLayersByName("landuse")[0]
renderer = landuse.renderer() # a QgsCategorizedSymbolRenderer
colours = {str(cat.value()): cat.symbol().color().name() for cat in renderer.categories()}
labels = {str(cat.value()): cat.label() for cat in renderer.categories()}
areas = {}
for f in landuse.getFeatures():
areas[str(f["class"])] = areas.get(str(f["class"]), 0) + f.geometry().area() / 1e4
order = [str(cat.value()) for cat in renderer.categories() if str(cat.value()) in areas]
fig, ax = plt.subplots(figsize=(80 / 25.4, 60 / 25.4))
ax.barh([labels[k] for k in order], [areas[k] for k in order],
color=[colours[k] for k in order])
ax.set_xlabel("area (ha)")
fig.tight_layout()
fig.savefig("/data/reports/charts/landuse_area.svg", transparent=True)
plt.close(fig)
Breakdown: Reading colours and labels from the renderer's categories guarantees the chart and the map agree, and following the renderer's category order means the legend, the map and the chart list classes the same way. If the map's style changes, rerunning the script updates the chart to match. The same approach works for graduated renderers through renderer.ranges().
QGIS version compatibility
Picture items and data-defined picture sources work on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. The native chart layout item is only present on newer releases — test for it as shown. On QGIS 4, QgsUnitTypes.LayoutMillimeters is Qgis.LayoutUnit.Millimeters and the picture resize mode is QgsLayoutItemPicture.ResizeMode.Zoom.
Troubleshooting
- The chart is blurry in the PDF. It was saved as PNG at low DPI; save SVG, or PNG at 300 DPI.
- Fonts in the chart look different. The font named in matplotlib is not installed where QGIS renders the layout; use a font available on that machine.
- Every atlas page shows the same chart. The picture path is fixed rather than data-defined, or the expression does not reference
@atlas_feature. - Charts lag behind the data. They are files; regenerate them before every export.
Conclusion
Draw charts with matplotlib at their true print size and save as SVG with text kept as text, place them as picture items, generate one file per feature and use a data-defined source for atlases, use the native chart item where your QGIS version has it, and take colours and order from the map's renderer so chart and map read as one.
Frequently Asked Questions
Can the chart be interactive in a PDF? No. PDFs are static; for interactive charts, publish HTML instead.
Can a layout HTML frame show a chart? Yes — an HTML item can render a JavaScript chart library, though fonts and print fidelity are harder to control than with SVG.
How do I place the chart relative to the map? Use the map item's position and size to compute the chart's, so they stay aligned when the map moves.
Can charts use data from the visible map extent only? Yes: filter features with the map item's extent before plotting, or use the native chart's filtering where available.