Add Stacked Bar Diagrams in PyQGIS

Pies show composition but not size; bar charts show size but make the whole hard to see. A stacked bar does both: one bar per feature whose length is the total, divided into segments for each part. Housing stock by tenure, energy use by source, waste by stream — whenever readers need to see both "how much" and "made of what", a stacked bar diagram is usually the clearest choice that QGIS can draw on a map.

This recipe belongs to Diagrams & Charts on Maps. It builds a stacked bar diagram renderer, orders and colours segments, scales bar length by the total, switches to 100 % bars when only composition matters, and shows how QGIS 3.40's stacked diagram combines several diagram types on one feature.

One bar, total and partsThree districts each with a single stacked bar. Bar length is the total number of dwellings. Each bar is divided into segments for owner occupied, private rented and social rented. A long bar with a large social segment and a short bar with a large private segment can be compared in total and in composition at once.Length is the total, segments are the partsNorthCentreEastowner occupiedprivate rentedsocial rented

Prerequisites

  • QGIS 3.34 LTR or newer for QgsStackedBarDiagram (available since 3.12); 3.40 or newer for the combined QgsStackedDiagram.
  • A layer with numeric fields that are additive parts of a total, all in the same unit.

Build a stacked bar diagram

The setup mirrors other diagram types. With a single-category renderer every bar has the same length, which shows composition only; the next section scales length by the total.

from qgis.core import (QgsProject, QgsStackedBarDiagram, QgsDiagramSettings,
                       QgsSingleCategoryDiagramRenderer, QgsDiagramLayerSettings)
from qgis.PyQt.QtGui import QColor
from qgis.PyQt.QtCore import QSizeF

wards = QgsProject.instance().mapLayersByName("wards")[0]
parts = [("owner_occ", "owner occupied", "#0f766e"),
         ("private_rent", "private rented", "#2563eb"),
         ("social_rent", "social rented", "#b45309")]

settings = QgsDiagramSettings()
settings.categoryAttributes = [f'coalesce("{f}", 0)' for f, _, _ in parts]
settings.categoryLabels = [label for _, label, _ in parts]
settings.categoryColors = [QColor(c) for _, _, c in parts]
settings.diagramOrientation = QgsDiagramSettings.Right
settings.barWidth = 2.5
settings.size = QSizeF(20, 2.5)
settings.penColor = QColor("#ffffff")
settings.penWidth = 0.2
settings.enabled = True

renderer = QgsSingleCategoryDiagramRenderer()
renderer.setDiagram(QgsStackedBarDiagram())
renderer.setDiagramSettings(settings)
wards.setDiagramRenderer(renderer)
wards.setDiagramLayerSettings(QgsDiagramLayerSettings())
wards.triggerRepaint()

Breakdown: coalesce keeps a NULL part from removing the whole bar, since expression arithmetic with NULL gives NULL. A horizontal orientation (Right) suits stacked bars on maps because labels and features are usually wider than tall and horizontal bars sit comfortably under a place name. The bar width is the thickness of the bar; with a fixed-size renderer, size sets the full bar length. White separators between segments keep adjacent colours distinct.

Scale bar length by the total

With the linearly interpolated renderer, bar length follows the sum of the parts, so the diagram shows magnitude as well as composition.

Fixed length versus scaled lengthLeft: every bar has the same length, so only composition is visible and a small ward looks as important as a large one. Right: bar length is interpolated from zero to the largest total, so a ward with twice the dwellings has a bar twice as long, while segments still show the tenure mix.Same composition, now with magnitudefixed: composition onlyscaled: total and composition

from qgis.core import QgsLinearlyInterpolatedDiagramRenderer

total_expr = " + ".join(f'coalesce("{f}", 0)' for f, _, _ in parts)
max_total = max(sum((feat[f] or 0) for f, _, _ in parts) for feat in wards.getFeatures())

scaled = QgsLinearlyInterpolatedDiagramRenderer()
scaled.setDiagram(QgsStackedBarDiagram())
scaled.setDiagramSettings(settings)
scaled.setClassificationAttributeExpression(total_expr)
scaled.setLowerValue(0)
scaled.setUpperValue(max_total)
scaled.setLowerSize(QSizeF(0, 2.5))
scaled.setUpperSize(QSizeF(24, 2.5))
wards.setDiagramRenderer(scaled)
wards.triggerRepaint()
print("longest bar =", max_total, "dwellings")

Breakdown: The classification expression is the total, and the interpolation maps zero to zero length and the largest total to 24 mm. Width (the bar's thickness) stays fixed at 2.5 mm through the size pairs — only the length changes. Starting at zero keeps lengths proportional, which is what makes bars comparable; a non-zero lower value would exaggerate differences. Use the same upper value on every map in a series so bars remain comparable between maps.

Percentage bars for pure composition

When totals differ wildly — a city ward and a rural ward — scaled bars make the small ward's composition unreadable. Bars of equal length showing percentages put the focus entirely on composition.

shares = [f'100 * coalesce("{f}", 0) / nullif({total_expr}, 0)' for f, _, _ in parts]
settings.categoryAttributes = shares
settings.size = QSizeF(20, 2.5)
renderer.setDiagramSettings(settings)
wards.setDiagramRenderer(renderer)       # the fixed-size renderer from above
wards.triggerRepaint()

Breakdown: Each segment's expression computes its percentage of the ward total; nullif turns a zero total into NULL, so empty wards produce no bar instead of a division error. With the fixed-size renderer every bar is 20 mm long and segments show shares directly — the map equivalent of a 100 % stacked bar chart. Pair this map with a separate choropleth or proportional symbol map of totals if magnitude still matters.

Choose segment order and colours

Segment order is the same in every bar. Putting the category of most interest at the start — the left end of horizontal bars — gives it a common baseline, so it can be compared precisely across features; segments in the middle float and are harder to compare.

focus = "social_rent"
ordered = [p for p in parts if p[0] == focus] + [p for p in parts if p[0] != focus]
settings.categoryAttributes = [f'coalesce("{f}", 0)' for f, _, _ in ordered]
settings.categoryLabels = [label for _, label, _ in ordered]
settings.categoryColors = [QColor(c) for _, _, c in ordered]
scaled.setDiagramSettings(settings)
wards.triggerRepaint()

Breakdown: Only the first segment shares a baseline across bars; every later segment starts wherever the previous one ended. Moving the focus category to the front therefore turns a hard comparison into an easy one. Use a strong colour for the focus category and quieter, related colours for the rest, so the eye goes where the story is.

Label bars with their totals

A scaled bar shows relative magnitude, but readers often want the actual number for a few places they care about. Labels on the same layer can carry the total, placed next to the diagram, while the diagram itself stays uncluttered.

from qgis.core import (QgsPalLayerSettings, QgsTextFormat, QgsVectorLayerSimpleLabeling,
                       QgsTextBufferSettings)
from qgis.PyQt.QtGui import QFont

label = QgsPalLayerSettings()
label.isExpression = True
label.fieldName = f"\"ward_name\" || '\\n' || format_number({total_expr}, 0)"
fmt = QgsTextFormat()
fmt.setFont(QFont("Noto Sans", 8))
fmt.setSize(8)
buf = QgsTextBufferSettings()
buf.setEnabled(True)
buf.setSize(0.8)
fmt.setBuffer(buf)
label.setFormat(fmt)
label.placement = QgsPalLayerSettings.AroundPoint
label.dist = 2.0
wards.setLabeling(QgsVectorLayerSimpleLabeling(label))
wards.setLabelsEnabled(True)
wards.triggerRepaint()

Breakdown: The label expression joins the ward name and its formatted total on two lines, reusing the same total expression that sizes the bars, so the number always matches the bar. format_number adds thousands separators. Placing labels around the point with a small offset, and letting the labelling engine treat diagrams as obstacles, keeps text off the bars. A text buffer keeps the label legible over the base map. Label placement and collisions covers fine-tuning when labels and diagrams compete for space.

Combine diagrams on one feature

QGIS 3.40 introduced QgsStackedDiagram, which draws several diagrams — of any type — side by side or one above another for each feature. That allows, for example, a bar for dwellings by tenure next to a pie of household size.

Several diagrams per featureA stacked diagram container holds two sub-diagrams for each feature: a horizontal stacked bar of tenure and a small pie of household size, arranged horizontally with a small spacing. Each sub-diagram has its own settings, categories and colours. The container is available from QGIS 3.40.QgsStackedDiagram: one container, several chartsQgsStackedDiagram3.40+horizontal or verticalstacked bartenurepiehousehold sizedrawn together

from qgis.core import QgsStackedDiagram, QgsPieDiagram

stacked = QgsStackedDiagram()
stacked.setStackedDiagramMode(QgsStackedDiagram.Horizontal)
stacked.setStackedDiagramSpacing(1.0)

pie_settings = QgsDiagramSettings()
pie_settings.categoryAttributes = ['"hh_1"', '"hh_2"', '"hh_3plus"']
pie_settings.categoryColors = [QColor("#b45309"), QColor("#d97706"), QColor("#92400e")]
pie_settings.categoryLabels = ["1 person", "2 people", "3+"]
pie_settings.size = QSizeF(6, 6)
pie_settings.enabled = True

stacked.addSubDiagram(QgsStackedBarDiagram(), settings)
stacked.addSubDiagram(QgsPieDiagram(), pie_settings)
combo = QgsSingleCategoryDiagramRenderer()
combo.setDiagram(stacked)
combo.setDiagramSettings(settings)
wards.setDiagramRenderer(combo)
wards.triggerRepaint()

Breakdown: Each sub-diagram keeps its own settings, so the bar and the pie have independent categories, colours and sizes. The container arranges them horizontally or vertically with a spacing in the diagram's size unit. Use this sparingly: two small charts per feature is about the limit before a map becomes a dashboard. Check the exact method names with help(QgsStackedDiagram) on your version, as the class is new.

Check the result with a reader's eyes

Before publishing, look at the map the way a reader will. Can you find the ward with the most social housing without reading any numbers? Can you tell which wards are large and which small? Does any bar run into a neighbour or off the edge of the page? Stacked bars reward this check because their two messages — total and mix — can compete: if every bar is long, composition is easy to read but totals blur together; if lengths vary enormously, small wards' segments become slivers. When one message clearly matters more, favour it — percentage bars for composition, a separate proportional symbol map for totals — rather than forcing one diagram to do both badly. A quick export of the layout to PDF at the final size, viewed at 100 %, is the most reliable test of all.

QGIS version compatibility

QgsStackedBarDiagram requires QGIS 3.12 or newer and is available on 3.34 LTR, 3.40 LTR and QGIS 4. QgsStackedDiagram requires 3.40. On QGIS 4, orientation and mode enums are scoped, for example QgsDiagramSettings.DiagramOrientation.Right.

Troubleshooting

  • Bars all have the same length. A single-category renderer is in use; switch to the interpolated renderer for magnitude.
  • Some wards have no bar. A part is NULL and was not wrapped in coalesce, or the total is zero in a percentage bar.
  • Segments look different in the legend. Colours and labels were not reordered together with the attributes.
  • AttributeError on QgsStackedDiagram. QGIS is older than 3.40.

Conclusion

Use QgsStackedBarDiagram when both totals and composition matter, scale length by the total from zero with the interpolated renderer, switch to equal-length percentage bars when totals vary too much, put the focus category first for a common baseline, and combine diagrams with QgsStackedDiagram on 3.40 or newer only when two charts genuinely help.

Frequently Asked Questions

Vertical or horizontal bars? Horizontal usually fits better under labels and between features; vertical suits point features in sparse areas.

Can segments have value labels? Not inside the diagram. Add labels with an expression that formats the values, placed next to the bar.

How many segments are too many? More than five or six become hard to tell apart at map size. Group categories first.

Can a stacked bar show negative parts? No. Diagrams assume non-negative values.