Labeling & Annotations in PyQGIS

Text is what turns a styled map into a communicative one. A choropleth or a road network may render beautifully, but until you attach names, values, and explanatory notes, readers cannot interpret it. PyQGIS exposes the full labeling engine — the same PAL (Placement of Automated Labels) engine the GUI drives — through QgsPalLayerSettings, QgsTextFormat, and the labeling wrappers, plus a separate annotation system for fixed map furniture. This cluster sits inside PyQGIS Cartography & Data Visualization and shows you how to script labels and annotations that are reproducible, version-controlled, and consistent across every map you produce.

Labeling differs from styling in an important way. Where the styling pipeline answers how a feature looks, labeling answers what text appears, where it sits, and which features win when space runs out. That last part — conflict resolution between competing labels — is why the engine has so many knobs. Master the core classes here and you will rarely touch the layer properties dialog again.

Scripting labels pays off most when you produce many maps. A hand-configured label style in the GUI lives in one project file and is tedious to replicate. The same configuration expressed in Python is a function you call against any layer, version with git, and apply identically across an atlas of a hundred sheets. The classes you meet below — QgsTextFormat, QgsPalLayerSettings, the labeling wrappers, and the annotation items — are the complete vocabulary you need to make that happen, and they compose the same way whether you are labeling one point or an entire road network.

Prerequisites

  • QGIS 3.34 LTR (bundles Python 3.12). Examples note where the 3.28 LTR (Python 3.9) and 3.40/3.44 lines diverge.
  • A loaded vector layer with attributes worth labeling — a string field for names and ideally a numeric field for filtering by importance.
  • Comfort running code in the QGIS Python Console, or a standalone script that has called QgsApplication.initQgis().
  • Familiarity with Programmatic Layer Styling, since labels share the unit and color conventions used by symbols.

How PyQGIS Labeling Fits Together

Before writing code, it helps to see the assembly. Labeling is a small object graph: a text format describes appearance, a layer settings object wires that format to a field or expression and decides placement, and a labeling wrapper attaches the settings to the layer.

PyQGIS labeling object graph and label anatomy Left side shows QgsTextFormat feeding QgsPalLayerSettings, wrapped by QgsVectorLayerSimpleLabeling and attached to a layer. Right side shows a point feature with a buffered label and a leader offset. QgsTextFormat font, size, color, buffer QgsPalLayerSettings fieldName, placement SimpleLabeling attached to layer Label anatomy feature point offset / leader Riverside text + buffer halo

Building a Text Format

QgsTextFormat is the appearance container shared by labels, annotations, and several other text-bearing components. Configure it once and reuse it. The format owns the font, its size and unit, the color, and nested settings objects for the buffer (halo), background, shadow, and drop-shadow.

from qgis.PyQt.QtGui import QColor, QFont
from qgis.core import (
    QgsTextFormat,
    QgsTextBufferSettings,
    QgsUnitTypes,
)

text_format = QgsTextFormat()
text_format.setFont(QFont("Noto Sans", 10))
text_format.setSize(10)
text_format.setSizeUnit(QgsUnitTypes.RenderPoints)
text_format.setColor(QColor("#17211d"))

buffer = QgsTextBufferSettings()
buffer.setEnabled(True)
buffer.setSize(1.0)
buffer.setSizeUnit(QgsUnitTypes.RenderMillimeters)
buffer.setColor(QColor("#ffffff"))
buffer.setOpacity(0.9)
text_format.setBuffer(buffer)

Breakdown: The QgsTextBufferSettings halo is the single most effective readability fix for labels over busy backgrounds — a one-millimetre white buffer lifts dark text off aerial imagery or dense linework. Setting setSizeUnit(RenderPoints) keeps font size print-stable, whereas RenderMapUnits would scale text with zoom. On QGIS 3.28 the API is identical; only the bundled font catalogue differs between platforms, so prefer a font you know is installed (e.g. "Noto Sans") over a system-default name.

Wiring Settings to a Layer

QgsPalLayerSettings joins the text format to the data. Its fieldName accepts either a plain attribute name or — when isExpression is True — a full expression. You then wrap it in QgsVectorLayerSimpleLabeling and hand it to the layer.

from qgis.core import (
    QgsPalLayerSettings,
    QgsVectorLayerSimpleLabeling,
    QgsProject,
)

layer = QgsProject.instance().mapLayersByName("cities")[0]

settings = QgsPalLayerSettings()
settings.setFormat(text_format)
settings.fieldName = "name"
settings.isExpression = False
settings.placement = QgsPalLayerSettings.OverPoint

labeling = QgsVectorLayerSimpleLabeling(settings)
layer.setLabeling(labeling)
layer.setLabelsEnabled(True)
layer.triggerRepaint()

Breakdown: Three calls actually switch labels on: setLabeling() installs the configuration, setLabelsEnabled(True) flips the master toggle (without it nothing draws), and triggerRepaint() forces a canvas redraw. Forgetting the second line is the most common reason scripted labels silently fail to appear. In QGIS 3.40+ the placement enums also exist on Qgis.LabelPlacement, but the QgsPalLayerSettings-scoped names used here remain valid across 3.28 through 3.44.

Expression-Based Labels

Static field names rarely survive contact with real data. Expression labels let you concatenate fields, format numbers, and conditionally hide text. Set isExpression = True and pass an expression string to fieldName.

settings = QgsPalLayerSettings()
settings.setFormat(text_format)
settings.isExpression = True
settings.fieldName = (
    "\"name\" || '\\n' || format_number(\"population\", 0) || ' people'"
)
settings.placement = QgsPalLayerSettings.OverPoint
settings.multilineAlign = QgsPalLayerSettings.MultiAlignCenter

layer.setLabeling(QgsVectorLayerSimpleLabeling(settings))
layer.setLabelsEnabled(True)
layer.triggerRepaint()

Breakdown: The expression builds a two-line label — the city name, a literal newline ('\n'), then a thousands-separated population. format_number keeps large values legible. Because the expression spans multiple lines, multilineAlign centres the wrapped text. Note the escaping: field names are wrapped in escaped double quotes (\"name\") and string literals in single quotes, exactly as the QGIS expression engine expects. The same expression language drives data-defined overrides, which is how you push per-feature font sizes or colors — and it is the foundation for the rule filters covered in Add Rule-Based Labels in PyQGIS.

Data-Defined Overrides

A single text format applies one appearance to every label. Often you want the appearance itself to respond to the data — bigger fonts for bigger cities, a different color for a flagged category, or showing labels only for features above a threshold. That is the job of data-defined properties, attached through the settings object's dataDefinedProperties() collection. Each property is keyed by an enum on QgsPalLayerSettings.

from qgis.core import QgsPalLayerSettings, QgsProperty

settings = QgsPalLayerSettings()
settings.setFormat(text_format)
settings.fieldName = "name"
settings.placement = QgsPalLayerSettings.OverPoint

props = settings.dataDefinedProperties()
# Font size scales with population, clamped between 8 and 18 points
props.setProperty(
    QgsPalLayerSettings.Size,
    QgsProperty.fromExpression("scale_linear(\"population\", 0, 5000000, 8, 18)"),
)
# Only label features whose population exceeds 50,000
props.setProperty(
    QgsPalLayerSettings.Show,
    QgsProperty.fromExpression("\"population\" > 50000"),
)
settings.setDataDefinedProperties(props)

layer.setLabeling(QgsVectorLayerSimpleLabeling(settings))
layer.setLabelsEnabled(True)
layer.triggerRepaint()

Breakdown: QgsProperty.fromExpression() turns any expression into a data-defined value. The Size property uses scale_linear to map population into an 8–18 point range, so prominence reflects magnitude without manual binning. The Show property acts as a per-feature on/off switch — features failing the test are simply never considered by the placement engine, which also lightens its workload. Dozens of properties exist (Color, BufferSize, OffsetXY, LabelRotation, and more); they all follow this same setProperty(enum, QgsProperty) pattern. Data-defined overrides are the bridge between flat styling and the per-class behavior you get from rule-based labeling.

Placement, Priority, and Obstacles

Placement is where the PAL engine earns its name. For points, placement selects strategies such as OverPoint, AroundPoint, or OrderedPositionsAroundPoint; for lines, Line, Curved, or Horizontal; for polygons, OverPoint (centroid), Horizontal, or Free. When labels compete for the same pixels, two mechanisms decide the winner: per-layer priority (0–10) and whether features act as obstacles that repel other layers' labels.

from qgis.core import QgsPalLayerSettings

road_settings = QgsPalLayerSettings()
road_settings.setFormat(text_format)
road_settings.fieldName = "road_name"
road_settings.placement = QgsPalLayerSettings.Curved
road_settings.priority = 8                    # 0 (low) .. 10 (high)
road_settings.lineSettings().setPlacementFlags(
    QgsPalLayerSettings.OnLine | QgsPalLayerSettings.MapOrientation
)

# Treat building polygons as obstacles so road labels avoid them
building_settings = QgsPalLayerSettings()
building_settings.setFormat(text_format)
building_settings.fieldName = "addr"
building_settings.obstacle = True
building_settings.obstacleSettings().setFactor(1.5)

Breakdown: priority = 8 tells the engine to keep road labels even when lower-priority labels must be dropped. Curved placement bends street names along the line geometry, and lineSettings() (the typed sub-settings object introduced in the 3.x line) controls whether labels sit on, above, or below the line. Marking buildings as obstacles with an obstacleSettings().setFactor(1.5) makes the engine treat them as 1.5x their footprint when avoiding overlaps. For polygon thematic maps, pair these placement choices with the renderer work in Graduated & Categorized Renderers so labels reinforce the color classes rather than fight them.

Annotations: Fixed Text on the Map

Labels are bound to features. Annotations are not — they are free-floating callouts, titles, or legends pinned either to a map coordinate or to a screen position. The modern path uses an annotation layer holding QgsAnnotationItem subclasses; the classic path uses QgsTextAnnotation managed by QgsMapCanvas.

from qgis.core import (
    QgsProject,
    QgsAnnotationLayer,
    QgsAnnotationPointTextItem,
    QgsPointXY,
    QgsCoordinateReferenceSystem,
)

project = QgsProject.instance()
anno_layer = QgsAnnotationLayer(
    "Map notes",
    QgsAnnotationLayer.LayerOptions(project.transformContext()),
)
anno_layer.setCrs(QgsCoordinateReferenceSystem("EPSG:4326"))

item = QgsAnnotationPointTextItem("Survey area", QgsPointXY(12.49, 41.89))
item.setFormat(text_format)
anno_layer.addItem(item)

project.addMapLayer(anno_layer)
anno_layer.triggerRepaint()

Breakdown: QgsAnnotationLayer behaves like any other layer — it appears in the tree, exports with the map, and respects CRS. QgsAnnotationPointTextItem (available from QGIS 3.22 onward, fully fleshed out by 3.34) anchors text to a real-world coordinate, so it pans and zooms with the map. This is the recommended approach for new code. The legacy QgsTextAnnotation route still works for canvas-only callouts but does not persist into layouts as cleanly. Annotation layers also share the same QgsTextFormat you built earlier, keeping typography consistent between feature labels and map notes.

The Legacy QgsTextAnnotation Route

You will still encounter QgsTextAnnotation in older scripts and plugins, so it is worth recognizing. It stores HTML-formatted text in a QTextDocument and is managed by the project's QgsAnnotationManager rather than living in the layer tree.

from qgis.PyQt.QtGui import QTextDocument
from qgis.core import QgsTextAnnotation, QgsProject, QgsPointXY

doc = QTextDocument()
doc.setHtml("<b>Study site A</b><br/>collected 2024-09")

annotation = QgsTextAnnotation()
annotation.setDocument(doc)
annotation.setMapPosition(QgsPointXY(12.49, 41.89))
annotation.setHasFixedMapPosition(True)

QgsProject.instance().annotationManager().addAnnotation(annotation)

Breakdown: setHasFixedMapPosition(True) pins the callout to a coordinate; set it False to pin to a screen position instead. The HTML body allows mixed formatting that a single QgsTextFormat cannot express. For new projects prefer the annotation-layer approach above — it is cleaner, layout-aware, and easier to manage programmatically — but reach for QgsTextAnnotation when you must edit or remove annotations a legacy script created.

Connecting Labels to Styling and Renderers

Labels never live in isolation. Their job is to clarify whatever the renderer is communicating, so coordinate the two. When a layer is styled by a categorized renderer, mirror its categories in your label colors or rule filters so a reader's eye links a label to its class instantly. When it is styled by a graduated renderer, consider data-defined font sizing that tracks the same numeric field the color ramp uses, reinforcing magnitude through two visual channels at once.

# Tint label text to match the renderer's class color for a category
from qgis.core import QgsProperty, QgsPalLayerSettings

props = settings.dataDefinedProperties()
props.setProperty(
    QgsPalLayerSettings.Color,
    QgsProperty.fromExpression(
        "CASE WHEN \"type\" = 'park' THEN '#15803d' "
        "WHEN \"type\" = 'water' THEN '#2563eb' "
        "ELSE '#17211d' END"
    ),
)
settings.setDataDefinedProperties(props)

Breakdown: A CASE expression maps category values to the same hex colors the renderer uses, so park labels read green and water labels read blue without maintaining three separate rules. This keeps the typography subordinate to, but coherent with, the symbology you set in Programmatic Layer Styling. For maps where the classes themselves carry the message — population bands, density quintiles — build the labeling alongside the work in Graduated & Categorized Renderers so both layers classify the data the same way.

Callouts: Connecting Displaced Labels

When the placement engine pushes a label away from its feature to avoid an overlap, the link between text and point can become ambiguous. Callouts draw a leader line from the label back to the feature, restoring that connection. They are configured on the settings object through a QgsSimpleLineCallout (or balloon callout) and enabled with setCallout() plus setCalloutEnabled().

from qgis.PyQt.QtGui import QColor
from qgis.core import (
    QgsPalLayerSettings,
    QgsSimpleLineCallout,
    QgsUnitTypes,
)

settings = QgsPalLayerSettings()
settings.setFormat(text_format)
settings.fieldName = "name"
settings.placement = QgsPalLayerSettings.AroundPoint
settings.dist = 4                      # push label 4 units off the point
settings.distUnits = QgsUnitTypes.RenderMillimeters

callout = QgsSimpleLineCallout()
callout.lineSymbol().setColor(QColor("#2f3b35"))
callout.lineSymbol().setWidth(0.3)
callout.setMinimumLength(2)
callout.setMinimumLengthUnit(QgsUnitTypes.RenderMillimeters)

settings.setCallout(callout)
settings.setCalloutsEnabled(True)

Breakdown: AroundPoint placement combined with dist = 4 deliberately offsets labels, which is when callouts earn their keep. QgsSimpleLineCallout draws a thin leader from label to anchor; setMinimumLength suppresses the line when the label sits close enough that a connector would be visual clutter. Callouts were introduced in QGIS 3.10 and are fully stable across 3.28–3.44, making them a safe default for dense point maps where every label cannot sit exactly on its feature.

Inspecting and Cloning Existing Labeling

Production scripts often need to read a layer's current labeling before changing it — to clone a style across layers, audit a project, or toggle one property without rebuilding everything. The labeling object round-trips cleanly: layer.labeling() returns the active configuration, and for simple labeling its settings() method hands back the QgsPalLayerSettings you can copy and adapt.

from qgis.core import (
    QgsVectorLayerSimpleLabeling,
    QgsProject,
)

source = QgsProject.instance().mapLayersByName("cities")[0]
targets = QgsProject.instance().mapLayersByName("towns")

if source.labelsEnabled() and isinstance(
    source.labeling(), QgsVectorLayerSimpleLabeling
):
    base_settings = source.labeling().settings()
    for layer in targets:
        cloned = QgsVectorLayerSimpleLabeling(base_settings.clone())
        layer.setLabeling(cloned)
        layer.setLabelsEnabled(True)
        layer.triggerRepaint()

Breakdown: Guard with labelsEnabled() and an isinstance check before reading settings, because a layer may have rule-based or no labeling at all, and settings() only exists on the simple wrapper. Call .clone() on the settings — never reuse the same object across layers, since they would then share mutable state and one edit would silently change them all. This clone-and-attach pattern is how you propagate a hand-tuned style across a whole project from a single source layer.

Rendering Labeled Maps in Standalone Scripts

Inside the QGIS desktop the canvas repaints automatically. In a headless script — a scheduled export, a server task — you drive a QgsMapSettings and QgsMapRendererParallelJob yourself, and labels render only if they are enabled on each layer and label rendering is left on in the map settings.

from qgis.core import (
    QgsMapSettings,
    QgsMapRendererParallelJob,
    QgsProject,
)
from qgis.PyQt.QtCore import QSize
from qgis.PyQt.QtGui import QColor

layer = QgsProject.instance().mapLayersByName("cities")[0]

settings = QgsMapSettings()
settings.setLayers([layer])
settings.setBackgroundColor(QColor("#f6f3ea"))
settings.setOutputSize(QSize(1600, 1000))
settings.setExtent(layer.extent())
# Label rendering is on by default; this makes the intent explicit
settings.setFlag(QgsMapSettings.DrawLabeling, True)

job = QgsMapRendererParallelJob(settings)
job.start()
job.waitForFinished()
job.renderedImage().save("/tmp/labeled_map.png", "PNG")

Breakdown: setFlag(QgsMapSettings.DrawLabeling, True) is the headless equivalent of the canvas label toggle; clearing it produces an unlabeled render even when layers have labeling configured. QgsMapRendererParallelJob runs the PAL engine off the main thread, and waitForFinished() blocks until placement and drawing complete. The label priority and obstacle settings from earlier matter most here — in a fixed-size export there is no zooming to relieve crowding, so conflict resolution decides exactly which labels survive.

Compatibility Notes

Feature3.28 LTR (Py 3.9)3.34 LTR (Py 3.12)3.40 / 3.44
QgsPalLayerSettings placement enumsscoped namesscoped names (baseline)scoped + Qgis.LabelPlacement
lineSettings() / obstacleSettings() typed sub-objectsavailableavailableavailable
QgsAnnotationPointTextItemavailable (3.22+)recommendedrecommended
QgsTextAnnotation (legacy canvas)availabledeprecated for new codeavailable

Pin scripts to the scoped QgsPalLayerSettings.OverPoint-style enums for the widest compatibility; they are stable from 3.28 through 3.44. Only adopt Qgis.LabelPlacement if you have dropped support for the older LTR.

Key Takeaways

  • Labeling is a three-object graph: QgsTextFormat (look) → QgsPalLayerSettings (what + where) → a labeling wrapper attached to the layer.
  • Always call setLabelsEnabled(True) and triggerRepaint() after setLabeling(), or nothing renders.
  • A QgsTextBufferSettings halo is the cheapest readability win available.
  • Use expressions for anything beyond a single field; they unlock multiline, formatting, and conditional text.
  • priority and obstacle/obstacleSettings() resolve label conflicts; tune them rather than disabling collision detection.
  • Prefer QgsAnnotationLayer + annotation items over legacy QgsTextAnnotation for new, layout-friendly map notes.

Frequently Asked Questions

Why don't my labels appear even though setLabeling() ran without error? The most likely cause is a missing layer.setLabelsEnabled(True). setLabeling() installs configuration but does not switch the master label flag on. Also confirm the layer is visible, the field name is spelled exactly as in the attribute table, and you called triggerRepaint().

What's the difference between a label and an annotation? A label is generated from feature attributes and is positioned automatically by the PAL engine, one per feature. An annotation is a single piece of free text you place yourself, anchored to a coordinate or screen position, independent of any feature. Use labels for data; use annotations for titles, callouts, and notes.

How do I make some labels win when they overlap? Raise the priority (0–10) of the important layer and mark interfering features as obstacles with obstacle = True. The PAL engine drops lower-priority labels first when space is tight, so you rarely need to disable collision handling entirely.

Can I scale label size with the map instead of keeping it print-fixed? Yes. Set text_format.setSizeUnit(QgsUnitTypes.RenderMapUnits) (or RenderMetersInMapUnits) so text grows and shrinks with zoom. Keep RenderPoints or RenderMillimeters when you need stable output for printed layouts.

Do annotation layers export into print layouts? Yes. QgsAnnotationLayer participates in the layer tree and renders into layout map items like any vector or raster layer, which is its main advantage over the legacy canvas-only QgsTextAnnotation.

How do I label only some features without a separate filter layer? Use a data-defined Show property: props.setProperty(QgsPalLayerSettings.Show, QgsProperty.fromExpression("\"population\" > 50000")). Features failing the expression are excluded from placement entirely, which is more efficient than rendering then hiding them. For multi-class logic across scales, graduate to rule-based labeling.

Can a single layer carry more than one labeling style at once? Not through simple labeling, which holds exactly one QgsPalLayerSettings. When you need several distinct label treatments on the same layer — different fields, fonts, or scale bands — switch to QgsRuleBasedLabeling, where each rule contributes its own settings and the engine renders all matching rules together.