Add Label and HTML Items to a Layout in PyQGIS

A printed map is half text: a title, a subtitle, a source note, a date, a disclaimer, a paragraph explaining what the colours mean, sometimes a table of figures. QGIS layouts have two text items. The label item handles short text — titles, captions, notes — with fonts, alignment and embedded expressions. The HTML frame handles long or rich content — formatted paragraphs, lists, tables — that can flow across several frames and pages. Both can be created and filled entirely from Python.

This recipe belongs to Automated Map Layout Generation. It adds labels with text formats and alignment, embeds expressions for dates and data, uses HTML in labels for mixed formatting, adds HTML frames for long text and tables, and keeps text readable in exports.

Label or HTML frameA label item suits short text: titles, subtitles, source notes and dates, with one text format, alignment and expressions, optionally rendered as simple HTML. An HTML frame suits long or structured content: paragraphs, lists and tables styled with CSS, which can flow from one frame to another across pages. Both evaluate QGIS expressions inside the text.Short text or long content?QgsLayoutItemLabeltitles, captions, notesone text formatexpressions [% %]simple HTML optionalQgsLayoutItemHtmlparagraphs, lists, tablesCSS stylingflows across framesmulti-page

Prerequisites

  • QGIS 3.34 LTR or newer, or the QGIS 4 series.
  • A layout to add to, created in code or loaded from a template as in loading a layout from a QPT template.
  • The fonts you intend to use installed on every machine that exports.

Add a title label

A label needs text, a text format and a position and size. QgsTextFormat sets font, size, colour and optional buffer or background, exactly as for map labels.

from qgis.core import (QgsProject, QgsLayoutItemLabel, QgsTextFormat, QgsLayoutPoint,
                       QgsLayoutSize, QgsUnitTypes)
from qgis.PyQt.QtGui import QFont, QColor
from qgis.PyQt.QtCore import Qt

layout = QgsProject.instance().layoutManager().layoutByName("District report")

title = QgsLayoutItemLabel(layout)
title.setId("title")
title.setText("Green space provision by district")
fmt = QgsTextFormat()
fmt.setFont(QFont("Noto Sans", 20, QFont.Bold))
fmt.setSize(20)
fmt.setColor(QColor("#17211d"))
title.setTextFormat(fmt)
title.setHAlign(Qt.AlignLeft)
title.setVAlign(Qt.AlignVCenter)
title.attemptMove(QgsLayoutPoint(12, 10, QgsUnitTypes.LayoutMillimeters))
title.attemptResize(QgsLayoutSize(260, 14, QgsUnitTypes.LayoutMillimeters))
layout.addLayoutItem(title)

Breakdown: setTextFormat (QGIS 3.24 and newer) replaces the older setFont and setFontColor calls and gives labels the same text engine as map labelling — buffers, shadows and backgrounds included. Setting both the font's point size and the format's size keeps them consistent. An explicit id makes the item easy to find and update later. Fixed position and size in millimetres place the title precisely; adjustSizeToText() shrinks the frame to fit instead, which is handy for labels whose text length varies.

Add data-driven text with expressions

Text inside [% %] is evaluated as a QGIS expression when the layout renders. That covers dates, counts, project metadata and values computed from layers.

Expressions inside label textA source note label contains static text with expression brackets: the current date formatted as day month year, the project title from project variables, and a count of features from an aggregate over the parks layer. At render time each bracket is replaced by its value, so the note stays correct every time the layout is exported.Static text, live valuesSource: [% @project_title %] · [% aggregate('parks', 'count', "id") %] parksExported [% format_date(now(), 'd MMMM yyyy') %]Source: City Parks 2026 · 214 parks · Exported 2 October 2026

note = QgsLayoutItemLabel(layout)
note.setId("source_note")
note.setText(
    "Source: [% @project_title %], green space survey 2026 · "
    "[% aggregate(layer:='parks', aggregate:='count', expression:=\"park_id\") %] parks mapped · "
    "Exported [% format_date(now(), 'd MMMM yyyy') %]")
small = QgsTextFormat()
small.setFont(QFont("Noto Sans", 7))
small.setSize(7)
small.setColor(QColor("#59645f"))
note.setTextFormat(small)
note.attemptMove(QgsLayoutPoint(12, 198, QgsUnitTypes.LayoutMillimeters))
note.attemptResize(QgsLayoutSize(260, 6, QgsUnitTypes.LayoutMillimeters))
layout.addLayoutItem(note)
note.refreshExpressionContext()
print(note.currentText())

Breakdown: @project_title comes from the project's metadata; aggregate counts features in another layer; format_date(now(), …) writes the export date. Because these are evaluated at render time, the note is correct whenever the layout is exported, with no code changes. Within an atlas, the expression context also includes @atlas_feature, so captions can describe each page's feature. currentText() shows the evaluated string, which is the easiest way to test expressions from Python.

Mix formatting with HTML labels

A single label has one text format. For a word in bold, a coloured term or a line break, switch the label to HTML mode and use simple tags.

legend_note = QgsLayoutItemLabel(layout)
legend_note.setMode(QgsLayoutItemLabel.ModeHtml)
legend_note.setText(
    "<p style='font-family:Noto Sans; font-size:8pt; color:#2f3b35'>"
    "Districts below the target of <b>9 m² per resident</b> are shown in "
    "<span style='color:#b91c1c'><b>red</b></span>.<br>"
    "Target: [% @green_target %] m² per resident.</p>")
legend_note.attemptMove(QgsLayoutPoint(200, 150, QgsUnitTypes.LayoutMillimeters))
legend_note.attemptResize(QgsLayoutSize(80, 18, QgsUnitTypes.LayoutMillimeters))
layout.addLayoutItem(legend_note)

Breakdown: In HTML mode the label renders its text with a web engine, so inline CSS controls fonts, sizes and colours, and tags such as <b>, <br> and <span> work. Expressions still work inside the HTML. Keep HTML labels short and simple; complex markup belongs in an HTML frame. Because the text engine differs, an HTML label's fonts can look slightly different from a plain label's at the same size — compare them in an export rather than on screen.

Add an HTML frame for long content

For a paragraph of explanation, a list of methods or a table of figures, an HTML frame is the right item. Its content can be set directly or loaded from a URL, styled with CSS, and can flow into further frames when it is longer than one box.

Content flowing between framesAn HTML multiframe holds one document. Its first frame on page one shows the start of the content; when the content is longer than the frame, it continues in a second frame on page two, and the layout can add pages automatically until all content is shown. Frames can be resized independently.One document, several framesQgsLayoutItemHtmlone HTML documentCSS styledframe 1, page 1start of contentframe 2, page 2continuesresize modeadd pagesuntil it fits

from qgis.core import QgsLayoutItemHtml, QgsLayoutFrame
from qgis.PyQt.QtCore import QRectF

rows = "".join(
    f"<tr><td>{f['name']}</td><td style='text-align:right'>{f['green_m2_per_res']:.1f}</td></tr>"
    for f in sorted(QgsProject.instance().mapLayersByName("districts")[0].getFeatures(),
                    key=lambda f: f["green_m2_per_res"]))
html_doc = f"""
<style>
  body {{ font-family: 'Noto Sans'; font-size: 8pt; color: #2f3b35; }}
  table {{ border-collapse: collapse; width: 100%; }}
  td, th {{ padding: 1mm 2mm; border-bottom: 0.2mm solid #d9d3c4; }}
</style>
<h3>Green space per resident</h3>
<table><tr><th>District</th><th style='text-align:right'>m²</th></tr>{rows}</table>
"""

html = QgsLayoutItemHtml(layout)
html.setContentMode(QgsLayoutItemHtml.ManualHtml)
html.setHtml(html_doc)
frame = QgsLayoutFrame(layout, html)
frame.attemptSetSceneRect(QRectF(200, 20, 85, 120))
html.addFrame(frame)
html.setResizeMode(QgsLayoutItemHtml.ExtendToNextPage)
html.loadHtml()
layout.addMultiFrame(html)

Breakdown: An HTML item is a multiframe: the content lives in QgsLayoutItemHtml, and one or more QgsLayoutFrame objects show parts of it on the page. ManualHtml content mode uses the string set with setHtml; loadHtml() renders it. With ExtendToNextPage, content that does not fit continues in a new frame on a new page, which is how a long table becomes a multi-page appendix. Generating the table rows from the layer means the table always matches the map. For tables of attributes specifically, the dedicated attribute table item is simpler; HTML frames win when you need custom formatting or content from several sources.

Update text across many layouts

Projects accumulate layouts — one per district, per theme, per year — and text that should be identical drifts: a copyright year here, an old department name there. A small loop finds and fixes every label at once.

import re

manager = QgsProject.instance().layoutManager()
changed = 0
for lay in manager.printLayouts():
    for item in lay.items():
        if isinstance(item, QgsLayoutItemLabel):
            old = item.text()
            new = re.sub(r"© \d{4}", "© [% year(now()) %]", old)
            new = new.replace("Dept. of Planning", "Planning & Environment Office")
            if new != old:
                item.setText(new)
                changed += 1
print(changed, "labels updated across", len(manager.printLayouts()), "layouts")

Breakdown: Iterating every print layout's items and filtering for labels reaches text in all of them, including layouts nobody has opened for months. Replacing a hard-coded copyright year with an expression means it never needs updating again — the general lesson being that anything that changes should be an expression or a variable, not literal text. Run such a sweep on a copy of the project first and save only when the count of changes matches what you expect.

Keep text consistent and readable

A few practical rules prevent the commonest text problems in automated layouts.

def text_format(size, bold=False, colour="#2f3b35", family="Noto Sans"):
    f = QgsTextFormat()
    font = QFont(family, int(size))
    font.setBold(bold)
    f.setFont(font)
    f.setSize(size)
    f.setColor(QColor(colour))
    return f

STYLES = {"title": text_format(20, True, "#17211d"),
          "subtitle": text_format(12, False, "#2f3b35"),
          "note": text_format(7, False, "#59645f")}
for item_id, style in (("title", "title"), ("source_note", "note")):
    item = layout.itemById(item_id)
    if item is not None:
        item.setTextFormat(STYLES[style])

Breakdown: Defining text styles once and applying them by role keeps every layout in a series typographically consistent — the same title size and note colour on every page. Use sizes that survive printing: notes at 6–7 pt are the practical minimum, body text 8–10 pt. Check that the chosen font family exists where exports run; if it does not, Qt substitutes another font silently and layouts change. Embedding fonts in PDF exports is automatic for installed fonts.

QGIS version compatibility

QgsLayoutItemLabel, label HTML mode, QgsLayoutItemHtml and frames work on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. setTextFormat on labels requires QGIS 3.24 or newer; older code uses setFont and setFontColor. On QGIS 4, enums are scoped: QgsLayoutItemLabel.Mode.ModeHtml, QgsLayoutItemHtml.ContentMode.ManualHtml, QgsLayoutMultiFrame.ResizeMode.ExtendToNextPage, Qt.AlignmentFlag.AlignLeft.

Troubleshooting

  • Expressions print literally. A bracket is unbalanced or a function name is wrong; test with currentText().
  • HTML frame is empty. loadHtml() was not called after setHtml, or the content mode is still URL.
  • Text is cut off. The label frame is too small; enlarge it or call adjustSizeToText().
  • Fonts change on the server. The font is not installed there; install it or choose a common family.

Conclusion

Use labels for short text with a QgsTextFormat and explicit ids, embed [% %] expressions for dates, metadata and counts, switch to HTML mode for small mixed formatting, use HTML frames for long text and custom tables that flow across pages, and define text styles once so every layout in a series matches.

Frequently Asked Questions

Can a label show a value from the atlas feature? Yes: [% "name" %] or [% attribute(@atlas_feature, 'name') %] inside an atlas layout.

Can HTML frames load content from a web page? Yes, with QgsLayoutItemHtml.Url mode and a URL; local files work too.

How do I rotate a label? Use setItemRotation(angle) on the item.

Can I use Markdown? Recent QGIS releases support Markdown in labels as a text mode; check QgsLayoutItemLabel modes on your version.