Use HTML-Formatted Labels in PyQGIS
A plain map label has one font, one size and one colour. Good cartography often needs more inside a single label: a station name in bold with its elevation small and grey underneath, a peak's name followed by its height, a parcel number with the owner in italics, a warning value in red. Without HTML formatting, these need two stacked labels that drift apart or collide. With it, one label carries several styles, placed and collision-checked as a single block.
This recipe belongs to Labeling & Annotations. It enables HTML formatting on a layer's labels, writes expressions that emit HTML, styles parts of labels conditionally, escapes attribute values safely, and explains which HTML features labels support.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series. HTML formatting in labels arrived in QGIS 3.14; support for more CSS properties grew through the 3.x series.
- A layer with labelling configured, as in the other labeling recipes.
Enable HTML formatting
HTML formatting is a property of the label's text format. With it enabled, the label text — usually from an expression — is interpreted as HTML.
from qgis.core import (QgsProject, QgsPalLayerSettings, QgsTextFormat,
QgsVectorLayerSimpleLabeling)
from qgis.PyQt.QtGui import QFont, QColor
stations = QgsProject.instance().mapLayersByName("weather_stations")[0]
fmt = QgsTextFormat()
fmt.setFont(QFont("Noto Sans"))
fmt.setSize(9)
fmt.setColor(QColor("#2f3b35"))
fmt.setAllowHtmlFormatting(True)
settings = QgsPalLayerSettings()
settings.isExpression = True
settings.fieldName = """'<b>' || "name" || '</b><br>' ||
'<span style="font-size:11pt; color:#2563eb">' || format_number("temp_c", 1) || ' °C</span><br>' ||
'<span style="font-size:7pt; color:#59645f">' || format_number("elev_m", 0) || ' m</span>'"""
settings.setFormat(fmt)
stations.setLabeling(QgsVectorLayerSimpleLabeling(settings))
stations.setLabelsEnabled(True)
stations.triggerRepaint()
Breakdown: setAllowHtmlFormatting(True) switches the text renderer into HTML mode for this format. The label expression builds a string of HTML: <b> for bold, <br> for line breaks, and <span style="…"> with CSS for size and colour. The format's own font, size and colour act as defaults for any text not overridden by tags. format_number controls decimals and thousands separators; doing formatting in the expression keeps the label consistent with the data's precision.
Know what HTML labels support
Labels render HTML with QGIS's own text engine, not a web browser, so only a subset of HTML and CSS works. Staying within it avoids labels that look fine in one release and break in another.
Think of HTML labels as rich text, not web pages: inline styling of runs of text works; layout does not. Effects that apply to the whole label — buffer, shadow, background shape — still come from the text format, and are drawn around the entire block. Units in font-size should be points (pt) to match the rest of QGIS's text handling; pixel sizes behave differently between screen and print.
Style parts conditionally
Expressions can choose styles per feature, which turns labels into a small data visualisation: values outside a range in red, missing values in grey italics.
settings.fieldName = """
'<b>' || "name" || '</b><br>' ||
CASE
WHEN "temp_c" IS NULL THEN '<i><span style="color:#59645f">no reading</span></i>'
WHEN "temp_c" < 0 THEN '<span style="color:#2563eb; font-size:11pt">' || format_number("temp_c", 1) || ' °C</span>'
WHEN "temp_c" > 30 THEN '<span style="color:#b91c1c; font-size:11pt"><b>' || format_number("temp_c", 1) || ' °C</b></span>'
ELSE '<span style="font-size:11pt">' || format_number("temp_c", 1) || ' °C</span>'
END"""
stations.setLabeling(QgsVectorLayerSimpleLabeling(settings))
stations.triggerRepaint()
Breakdown: A CASE inside the expression picks a different HTML fragment per feature: blue for frost, bold red for heat, grey italics for missing values — checking NULL first, as always in expressions. Conditional styling inside labels complements data-defined properties: data-defined colour changes the whole label, while HTML spans change only part of it. Keep the number of styles small; a label with five colours is harder to read than two labels.
Escape attribute values
Attribute values become part of the HTML. A name containing <, > or & — "Smith & Sons", "
from qgis.core import QgsExpression, qgsfunction
@qgsfunction(args="auto", group="Custom", referenced_columns=[])
def html_escape(value, feature, parent):
"""Escape &, < and > for use in HTML-formatted labels."""
if value is None:
return ""
return (str(value).replace("&", "&").replace("<", "<").replace(">", ">"))
settings.fieldName = "'<b>' || html_escape(\"name\") || '</b><br>' || html_escape(\"operator\")"
stations.setLabeling(QgsVectorLayerSimpleLabeling(settings))
Breakdown: A custom expression function, registered with the @qgsfunction decorator, escapes the three characters that matter in HTML text. Using it around every attribute inserted into the markup makes labels robust against whatever the data contains. Register the function at startup or in a plugin so projects that use it work for everyone; registering a custom expression function covers the details. Recent QGIS releases also include built-in string functions for this; check the expression builder's String group on your version.
Superscripts, units and small caps
Cartographic text often needs typographic details: m² and m³, footnote markers, small capitals for region names. HTML labels can produce many of them.
settings.fieldName = """
'<span style="font-variant:small-caps; letter-spacing:1px">' || upper("region") || '</span><br>' ||
format_number("area_m2", 0) || ' m²<sup>1</sup>'"""
Breakdown: Unicode superscript characters such as ² and ³ render everywhere and are the safest choice for units. <sup> and <sub> tags, supported on recent releases, handle arbitrary superscripts like footnote markers. Small caps can be imitated with an upper-case span at a slightly smaller size where font-variant is not honoured. Test typographic details in an actual export: screen rendering and PDF output can differ for small text.
Build label expressions from Python
Long HTML label expressions are hard to read and easy to break with a missing quote. Building them from small Python helpers keeps each part readable and makes styles reusable across layers.
def span(expr, size=None, colour=None, bold=False, italic=False):
style = []
if size:
style.append(f"font-size:{size}pt")
if colour:
style.append(f"color:{colour}")
inner = expr
if bold:
inner = f"'<b>' || {inner} || '</b>'"
if italic:
inner = f"'<i>' || {inner} || '</i>'"
if style:
return f"'<span style=\"{'; '.join(style)}\">' || {inner} || '</span>'"
return inner
def lines(*parts):
return " || '<br>' || ".join(parts)
expr = lines(
span('html_escape("name")', bold=True),
span("format_number(\"temp_c\", 1) || ' °C'", size=11, colour="#2563eb"),
span("format_number(\"elev_m\", 0) || ' m'", size=7, colour="#59645f"),
)
settings.fieldName = expr
print(expr)
Breakdown: Each helper returns an expression fragment, and lines joins fragments with line breaks, so the structure of the label — name, temperature, elevation — reads directly from the Python code. Styles are parameters rather than hand-typed CSS, which removes a whole class of quoting mistakes. Printing the final expression lets you paste it into the expression builder to preview it on real features before applying it, the quickest way to debug an HTML label. Keep the helpers in a shared module if several layers or projects use the same label style.
Use HTML labels in layouts and legends
HTML-formatted map labels render the same in print layouts, because layouts use the same labelling engine. Layout label items have their own HTML mode, configured separately, as described in adding label and HTML items to a layout.
from qgis.core import QgsLabelingEngineSettings
engine = QgsProject.instance().labelingEngineSettings()
engine.setFlag(QgsLabelingEngineSettings.DrawUnplacedLabels, False)
QgsProject.instance().setLabelingEngineSettings(engine)
Breakdown: HTML labels are larger blocks than single-line labels, so they collide more often; the labelling engine settings decide what happens to labels that cannot be placed. Keeping unplaced labels hidden in exports, and using placement and collision settings such as priority and obstacles, keeps the map readable. Multi-line HTML labels usually work best with "around point" placement for points and "horizontal" placement for polygons.
QGIS version compatibility
HTML label formatting is available from QGIS 3.14 and on 3.34 LTR, 3.40 LTR and QGIS 4. Support for additional CSS properties and tags — including superscript, subscript and vertical alignment — expanded during the 3.2x and 3.3x series; test on the oldest version your projects must support. setAllowHtmlFormatting is the same on all of them.
Troubleshooting
- Tags appear as literal text. HTML formatting is not enabled on the text format.
- A label disappears entirely. An attribute value contains
<or&that broke the markup; escape values. - Sizes look different in print. Pixel units were used in CSS; use points.
- Labels collide more. HTML labels are taller; adjust placement, priority and font sizes.
Conclusion
Enable HTML formatting on the label's text format, build label expressions that emit simple inline HTML — bold, italic, line breaks and styled spans — keep to the supported subset, style parts conditionally with CASE and NULL checked first, escape attribute values, use Unicode or sup/sub for typographic details, and check collisions and exports.
Frequently Asked Questions
Can HTML labels include images?
Recent releases support <img> tags in labels; check your version, and keep images small.
Do HTML labels work with curved placement? Line-following placement uses plain text; HTML formatting applies best to horizontal labels.
Can I combine HTML labels with rule-based labelling? Yes. Each rule has its own settings and text format, so HTML formatting can be enabled for some rules — detailed labels at large scales — and plain labels used for others.
Can I set the font for one word?
Yes, with <span style="font-family:'Noto Serif'">.
Why does my label show HTML code in the attribute table? The expression produces HTML text; only the label renderer interprets it. That is expected — store data plain and add markup only in the label expression.
Is there a performance cost? A small one; for tens of thousands of labels, keep the HTML minimal.