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.
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.
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.
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 aftersetHtml, 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.