Create a Categorized Renderer in PyQGIS

Colouring features by a field is the most common thematic map there is: land use by class, roads by type, sites by status. Doing it by hand is a dialog and a click; doing it from code is what lets a script produce a consistently styled map every night, with the same colour for the same class every time — which is the part that matters, because a legend where "residential" is blue on Monday and green on Tuesday is worse than no legend.

This recipe belongs to Graduated and Categorized Renderers. It covers building categories from the data, assigning colours from a ramp or a fixed mapping, labelling and ordering the legend, handling values that appear later, and keeping the result readable when a field has forty distinct values.

How a categorized renderer is assembledThe renderer reads a field, finds its unique values, and pairs each with a symbol and a legend label. Features whose value matches a category are drawn with that symbol. A final catch-all category with an empty value draws everything that matches nothing else, which is what prevents new values from appearing as invisible features.A category is a value, a symbol and a labelfield valuesresidentialcommercialindustrialopen spaceuniqueValues on the fieldcategoriesResidentialCommercialIndustrialOpen spaceOtherthe mapeach feature drawnwith its category symboland the legend matchesWithout the catch-all, a value added next month draws nothing at all

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • A vector layer with a field holding a manageable number of distinct values.
  • The layer loaded in a project, or at least valid — styling applies to the layer object either way.

Build categories from the data

from qgis.core import (QgsProject, QgsCategorizedSymbolRenderer, QgsRendererCategory,
                       QgsSymbol, QgsStyle)

layer = QgsProject.instance().mapLayersByName("Land use")[0]
field = "use_class"

index = layer.fields().indexOf(field)
values = sorted(v for v in layer.uniqueValues(index) if v)

ramp = QgsStyle.defaultStyle().colorRamp("Spectral")

categories = []
for position, value in enumerate(values):
    symbol = QgsSymbol.defaultSymbol(layer.geometryType())
    colour = ramp.color(position / max(len(values) - 1, 1))
    symbol.setColor(colour)
    categories.append(QgsRendererCategory(value, symbol, str(value)))

layer.setRenderer(QgsCategorizedSymbolRenderer(field, categories))
layer.triggerRepaint()

Breakdown: uniqueValues() asks the provider for the distinct values, which on a database layer is a SELECT DISTINCT rather than a full scan — much faster than iterating features. Filtering out empty values keeps NULL and blank strings out of the legend, where they appear as an unlabelled entry nobody can interpret. QgsSymbol.defaultSymbol() builds a symbol matching the layer's geometry type, so the same code works for points, lines and polygons. Sampling the ramp by position spreads the colours across it evenly; the max(..., 1) guard prevents a division by zero when there is a single category. A QgsRendererCategory is exactly its three arguments: the value to match, the symbol, and the legend label.

Use a fixed colour mapping instead

For anything with an established convention — land use classes, road types, a corporate scheme — the colours should not come from a ramp.

from qgis.PyQt.QtGui import QColor

COLOURS = {
    "residential": "#f6c667",
    "commercial": "#8ab4f8",
    "industrial": "#c98a8a",
    "open space": "#8fc98a",
}

categories = []
for value, hex_colour in COLOURS.items():
    symbol = QgsSymbol.defaultSymbol(layer.geometryType())
    symbol.setColor(QColor(hex_colour))
    categories.append(QgsRendererCategory(value, symbol, value.title()))

Breakdown: A dictionary makes the mapping reviewable and, crucially, stable — the same class gets the same colour in every map the script produces, which is the property that lets somebody compare two sheets. Building the categories from the dictionary rather than from the data also means a class that happens to be absent from this extract still appears in the legend, which is usually what a series wants. Where the mapping lives in a file rather than in code, a small CSV or JSON alongside the script keeps cartographic decisions out of the logic.

What a new class does to your coloursColours sampled across a ramp are positioned by how many values are present, so adding a fourth class shifts every colour and two months of maps can no longer be compared. A fixed mapping assigns each class its own colour, so a new class takes a new colour and the existing three are untouched.Same data plus one class, two very different legendssampled from a ramplast monththis monthevery colour movedthe two maps cannot be comparedfixed mappinglast monththree unchanged, one addedthe series stays readable

Add a catch-all for unexpected values

other_symbol = QgsSymbol.defaultSymbol(layer.geometryType())
other_symbol.setColor(QColor("#9aa39f"))
categories.append(QgsRendererCategory("", other_symbol, "Other"))

renderer = QgsCategorizedSymbolRenderer(field, categories)
layer.setRenderer(renderer)

Breakdown: A category with an empty value acts as the fallback for anything not matched by another category. Without it, a value that appears in the data after the style was written is drawn with nothing — the features are simply invisible, with no warning, which is one of the more dangerous silent failures in cartography because the map looks fine. A neutral grey labelled "Other" makes the gap visible instead. In a scheduled job it is worth going further and logging when the catch-all matches anything, since that is the signal that the classification needs updating.

What a missing category looks likeWithout a catch-all, features whose class was added after the style was written are drawn with no symbol, so a district silently disappears from the map and nothing in the legend suggests anything is missing. With a catch-all, the same features are drawn in neutral grey and labelled Other, making the gap obvious and fixable.A map with a hole looks exactly like a mapno catch-allnothinga new class draws no symboland the legend says nothingwith a catch-allOtherdrawn in grey, listed in the legendsomebody notices and fixes it

Order and label the legend

The legend reads in category order, which is the order you built them in — and that is rarely the order a reader wants.

renderer = layer.renderer()

ordered = sorted(renderer.categories(),
                 key=lambda c: (c.value() == "", str(c.label())))

layer.setRenderer(QgsCategorizedSymbolRenderer(field, ordered))

Breakdown: Sorting by label puts the legend in alphabetical order, and the leading tuple element pushes the catch-all to the bottom where it belongs — a legend ending in "Other" reads correctly, one starting with it does not. Where categories have a natural sequence that is not alphabetical, sort by an explicit ordering list instead: land-use classes usually want a designed order, not an accidental one. Labels are independent of values, which is what lets a database code such as R1 appear in the legend as "Residential — low density" without touching the data.

Keep it readable when there are too many values

Beyond about eight categories a legend stops being interpretable: nobody can hold twelve colours in mind while reading a map, and adjacent hues become indistinguishable at the size a category patch is printed.

The fix is nearly always in the data rather than the renderer. Group the values into a smaller classification — either with an expression as the renderer's attribute, or by adding a grouped field:

renderer = QgsCategorizedSymbolRenderer(
    "CASE "
    "WHEN \"use_class\" IN ('R1', 'R2', 'R3') THEN 'Residential' "
    "WHEN \"use_class\" IN ('C1', 'C2') THEN 'Commercial' "
    "ELSE 'Other' END",
    categories)

Breakdown: The renderer's attribute can be a full QGIS expression rather than a field name, which means the grouping lives in the style and needs no change to the data — useful when the source is read-only or shared. The categories then match the expression's output values, so they are Residential and Commercial rather than the codes. The trade is performance: an expression is evaluated per feature at render time, so on a very large layer a materialised field is faster. The expression language is covered in Working with QGIS Expressions.

QGIS version compatibility

QGIS versionPythonNotes
3.22 LTR3.9QgsCategorizedSymbolRenderer and QgsRendererCategory as shown.
3.28 LTR3.9Identical.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12Identical; the categorized panel gained value-matching helpers that do not affect the API.

QgsSymbol.defaultSymbol() takes the layer's geometry type, which is a scoped enumeration on newer releases — passing layer.geometryType() directly is correct on all of them and avoids the question.

Troubleshooting

  • Some features are invisible. Their value matches no category. Add the catch-all.
  • The legend shows raw codes. The label defaults to the value. Pass a readable label as the third argument.
  • All categories are the same colour. The symbol object was reused across categories — each needs its own, since setColor() mutates it.
  • uniqueValues() is slow. The layer is large and file-based. Restrict with a subset string first, or read the distinct values from the source database.
  • The style does not appear. triggerRepaint() was not called, or the renderer was built and never assigned with setRenderer().
  • Colours change between runs. The ramp is sampled over a value list whose length varies. Use a fixed mapping for anything comparable.

Conclusion

Build a category per value with its own symbol and a readable label, colour them from a ramp for exploration and from a fixed mapping for anything published, and always add a catch-all so a new value is grey rather than invisible. Sort the legend deliberately, and when a field has too many values group them with an expression rather than shipping a legend nobody can read.

Frequently Asked Questions

How is this different from a graduated renderer? Categorized matches discrete values; graduated splits a continuous number into ranges — see Classify a Layer with Natural Breaks in PyQGIS.

Can I categorize on more than one field? Not directly, but the attribute can be an expression combining fields, such as "class" || ' - ' || "status". For genuinely multi-dimensional symbology, a rule-based renderer is the better tool.

How do I change one category's colour afterwards? Find its index with renderer.categoryIndexForValue(), then call renderer.updateCategorySymbol() with a new symbol.

Does the order of categories affect drawing? Yes — later categories draw over earlier ones where features overlap, which matters for overlapping polygons and for points.

Can I save this style for reuse? Yes, as a QML file — see Save and Load a QML Style in PyQGIS.

How do I keep the same colours when the data gains a class? Use a fixed mapping rather than a sampled ramp. A ramp sampled across however many values happen to be present reassigns every colour the moment the count changes, which makes two months' maps incomparable.

Can categories use a symbol other than the default? Yes — build any symbol you like and pass it to the category, including one loaded from the style library with QgsStyle.defaultStyle().symbol(name). That is how an organisation's standard symbols end up in a scripted map.

Why do my colours look different in the layout? Layouts render at a different resolution and may use a different colour profile. Check the export settings rather than the renderer.