Add a Text Annotation to a Map in PyQGIS

Labels come from data. Annotations do not — they are the source note in the corner, the arrow pointing at the anomaly, the "provisional, do not circulate" that has to appear on every export. QGIS keeps them in a separate system from labelling, with its own classes, its own storage and its own way of deciding where a thing sits when the map moves.

This recipe belongs to Labeling & Annotations in PyQGIS. It covers creating text annotations from Python, anchoring them to a map coordinate rather than to the screen, using the annotation layer introduced in QGIS 3.16, styling with HTML, and making sure the notes survive a save.

Map-anchored and screen-anchored annotationsA map anchored annotation is attached to a coordinate, so panning the map carries it along and it can leave the view entirely. A screen anchored annotation is attached to a position on the canvas, so it stays in the same corner whatever the map shows, which is what a source note or a draft stamp needs.The anchor decides what happens when the map movesanchored to a coordinatesubsidence reportedpans with the map, can scroll out of viewanchored to the canvasSource: Ordnance data 2026stays put whatever the map showsboth are annotations; only the anchor differs

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer. The annotation layer arrived in 3.16; the older QgsTextAnnotation still works and is what the canvas annotation tools produce.
  • A project with a map canvas. Most of this needs iface, so a fully headless script uses the annotation-layer route instead.

The simple case: a note pinned to the canvas

QgsTextAnnotation holds a rich-text document and a placement.

from qgis.core import QgsTextAnnotation, QgsProject
from qgis.PyQt.QtGui import QTextDocument, QColor
from qgis.PyQt.QtCore import QPointF, QSizeF

annotation = QgsTextAnnotation()
document = QTextDocument()
document.setHtml(
    '<p style="font-family:sans-serif;font-size:9pt">'
    'Source: Ordnance data 2026<br>Provisional — not for circulation</p>'
)
annotation.setDocument(document)
annotation.setHasFixedMapPosition(False)
annotation.setRelativePosition(QPointF(0.02, 0.94))
annotation.setFrameSizeMm(QSizeF(62, 12))

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

Breakdown: setHasFixedMapPosition(False) is what makes this a screen-anchored note; setRelativePosition() then takes fractions of the canvas width and height, so (0.02, 0.94) is the bottom-left corner and stays there at any zoom. The content is a QTextDocument, which means HTML — inline styles, line breaks, bold, even a small table. Adding it to the project's annotationManager() rather than to the canvas is what makes it part of the project and therefore savable.

setFrameSizeMm() sizes the box in millimetres. Getting it wrong clips the text rather than wrapping it, so it is worth measuring once against a realistic string rather than guessing.

Pinning a note to a place

For a note that belongs to a location — an anomaly, a site, a measurement — the anchor is a map coordinate.

from qgis.core import QgsPointXY

annotation = QgsTextAnnotation()
document = QTextDocument()
document.setHtml('<p style="font-family:sans-serif;font-size:9pt">Subsidence reported 2025-11</p>')
annotation.setDocument(document)

annotation.setHasFixedMapPosition(True)
annotation.setMapPosition(QgsPointXY(432150, 189400))
annotation.setMapPositionCrs(QgsProject.instance().crs())
annotation.setFrameOffsetFromReferencePointMm(QPointF(8, -14))
annotation.setFrameSizeMm(QSizeF(46, 10))

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

Breakdown: setMapPositionCrs() matters and is easy to skip: without it the coordinate is interpreted in the project CRS anyway, but the annotation will not follow correctly if the project is later reprojected. Setting it explicitly makes the annotation transform properly. The frame offset is the leader-line vector in millimetres from the anchor to the box corner — a positive x and negative y put the note up and to the right, with QGIS drawing the connector automatically.

Anatomy of a map-anchored annotationThe anchor is a coordinate in a named CRS. The frame offset is a vector in millimetres from that anchor to the annotation box. The frame size fixes the box dimensions, and text longer than the box is clipped rather than wrapped. QGIS draws the connector between anchor and frame.Four settings, and one of them silently clips your textsetMapPosition()a coordinate, in a CRSframeOffsetin millimetresSubsidence reported 2025-11a QTextDocument — HTML allowedsetFrameSizeMm()text too long?it is clipped,not wrapped

The annotation layer, for anything scripted

QGIS 3.16 added QgsAnnotationLayer, which holds annotation items as ordinary map layer content. It is the better route for anything generated, because the items behave like layer content: they respect layer order, opacity, scale ranges and visibility.

from qgis.core import (
    QgsAnnotationLayer, QgsAnnotationPointTextItem, QgsProject,
    QgsCoordinateTransformContext, QgsTextFormat, QgsPointXY,
)
from qgis.PyQt.QtGui import QColor

layer = QgsAnnotationLayer(
    "Site notes", QgsAnnotationLayer.LayerOptions(QgsCoordinateTransformContext())
)
layer.setCrs(QgsProject.instance().crs())

fmt = QgsTextFormat()
fmt.setSize(9)
fmt.setColor(QColor("#17211d"))

item = QgsAnnotationPointTextItem("Subsidence reported 2025-11", QgsPointXY(432150, 189400))
item.setFormat(fmt)
layer.addItem(item)

QgsProject.instance().addMapLayer(layer)

Breakdown: The layer takes a LayerOptions carrying a transform context, which is what lets its items reproject when the project CRS changes. Items come in several kinds — QgsAnnotationPointTextItem, QgsAnnotationLineTextItem for text along a line, QgsAnnotationMarkerItem, QgsAnnotationPolygonItem — and they all take the same QgsTextFormat or symbol objects used elsewhere in QGIS, so a house style applies to annotations too.

Because it is a layer, it can be hidden by a map theme, given a scale range so notes appear only when zoomed in, and placed above or below other layers deliberately.

Making them persist

Annotations added to the annotation manager are written into the project file. Items in an annotation layer are written into the project file too, as part of that layer's definition. Neither survives if the project is never saved, and neither is exported with the data.

project = QgsProject.instance()
project.setDirty(True)
project.write()

Breakdown: In a plugin, setDirty(True) alone is the polite move — it prompts the user rather than writing over their file. In a batch script, write() with no argument saves back to the loaded path. What neither does is put the note into a GeoPackage or a shapefile: annotations are project furniture, so a colleague who receives only the data receives none of them.

A reusable stamp for exports

The recurring real-world use is a stamp — a status, a date, a reference number — that must appear on every output and must be easy to remove. Building it as a function with a recognisable marker makes both halves reliable.

from qgis.core import QgsProject, QgsTextAnnotation
from qgis.PyQt.QtGui import QTextDocument
from qgis.PyQt.QtCore import QPointF, QSizeF

MARKER = "\u200b"          # a zero-width space, invisible but findable


def clear_stamps():
    manager = QgsProject.instance().annotationManager()
    for annotation in list(manager.annotations()):
        document = annotation.document()
        if document and MARKER in document.toPlainText():
            manager.removeAnnotation(annotation)


def stamp(text, corner=(0.02, 0.95)):
    clear_stamps()
    annotation = QgsTextAnnotation()
    document = QTextDocument()
    document.setHtml(
        f'<p style="font-family:sans-serif;font-size:9pt;color:#b91c1c">'
        f'{MARKER}{text}</p>'
    )
    annotation.setDocument(document)
    annotation.setHasFixedMapPosition(False)
    annotation.setRelativePosition(QPointF(*corner))
    annotation.setFrameSizeMm(QSizeF(70, 10))
    QgsProject.instance().annotationManager().addAnnotation(annotation)
    return annotation

Breakdown: A zero-width space is an unusual but effective marker — invisible to the reader, present in toPlainText(), and vanishingly unlikely to appear in a note somebody wrote by hand. That is what makes clear_stamps() safe to run against a project full of other people's annotations. Calling it at the top of stamp() makes the whole function idempotent: run it five times with different text and you get one stamp, not five stacked on top of each other.

The pattern generalises to anything a script owns and a user does not. Where several scripts each want their own set, use a distinct marker string per script and keep them in one shared constants module, so nothing ever clears somebody else's notes.

Reading and clearing what is there

Scripts that add annotations should be able to remove their own.

manager = QgsProject.instance().annotationManager()
print(len(manager.annotations()))

for annotation in list(manager.annotations()):
    document = annotation.document()
    if document and "Provisional" in document.toPlainText():
        manager.removeAnnotation(annotation)

Breakdown: Iterating over a copy of the list is necessary because removal mutates the underlying collection. Matching on the text content is crude but effective, and it is what makes a "stamp every export as provisional, then clear the stamps" workflow safe to run repeatedly. For annotation-layer items the equivalent is layer.items() keyed by item id, which is cleaner because each item has a stable identifier rather than needing to be recognised by its contents.

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.16 LTR3.7QgsAnnotationLayer and the item classes introduced alongside the older annotation manager.
3.22 LTR3.9QgsAnnotationLineTextItem added for text following a line.
3.28 LTR3.9Annotation items gain per-item z-index ordering within a layer.
3.34 LTR3.12Baseline for this page.
3.40+3.12Annotation items support callouts linking item to anchor.

Troubleshooting

  • The note is cut off. setFrameSizeMm() is too small; annotations clip rather than wrap.
  • The note moved when the project CRS changed. setMapPositionCrs() was never set, so the coordinate was never transformable.
  • The note disappeared after reopening. The project was not saved. Annotations live in the project file only.
  • HTML styling has no effect. The document was set with setPlainText() rather than setHtml(), or the style is on an element QTextDocument does not support.
  • Annotations do not appear in a layout. They are canvas furniture; the layout has its own label and picture items. Duplicate the note as a layout item, or use an annotation layer, which does render in layouts.
  • The script keeps adding duplicates. It never removes its previous run's annotations. Tag them recognisably and clear before adding.

Conclusion

Use setHasFixedMapPosition(False) for a note that belongs to the page and True with an explicit CRS for one that belongs to a place. For anything generated by a script, prefer QgsAnnotationLayer — the items behave like layer content, render in layouts, and can be cleared by id rather than by guessing at their text.

Frequently Asked Questions

What is the difference between an annotation and a label? A label is generated from a feature's attributes by the labelling engine and moves with the data. An annotation is a fixed piece of content you placed. Use labelling for anything per-feature; see rule-based labels.

Can annotations be exported to an image? Canvas annotations appear in a canvas image export because they are drawn on the canvas. Annotation-layer items appear in both canvas and layout exports, which is one more reason to prefer them.

How do I attach a note to a specific feature? Annotation items are placed by coordinate, not by feature reference. Read the feature's geometry, take a representative point, and place the item there — regenerating the annotations when the data changes.

Can I style the frame? Yes — setFillSymbol() on the annotation controls the box, taking an ordinary QgsFillSymbol, so it can carry a stacked symbol with a shadow.