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.

One label, several stylesA weather station label rendered with HTML formatting: the station name in bold dark text, a second line with the temperature in a larger blue font, and a third line with the elevation in small grey text. The whole block is placed and checked for collisions as one label, so its parts never separate.Three styles, one label blockBrocken−3.4 °C1,141 mlabel expression'<b>' || "name" || '</b><br>''<span style="color:#2563eb">'…one block for placement

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.

Supported and unsupported HTMLSupported in map labels: bold, italic and underline tags, line breaks, span and font tags with colour, font size, font family, font weight, and on recent releases vertical alignment for superscript and subscript, and images. Not supported: tables, borders, padding, backgrounds per span, and external stylesheets. Buffers, shadows and backgrounds still come from the text format and apply to the whole label.Inline styling yes, page layout noworksb · i · u · brspan: color, font-sizefont-family, font-weightsup/sub on recent releasesdoes nottables, borderspadding, per-span backgroundexternal CSSuse the text format

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", "" — breaks the markup or disappears. Escaping values before inserting them prevents that.

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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))

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.

Typographic detailsExamples of label typography with HTML: an area value with a superscript 2 for square metres, a footnote marker in superscript, a region name in small caps using a smaller upper-case span, and a chemical formula with subscripts. Where superscript tags are not supported, the Unicode characters for superscript two and three are a reliable alternative.Units, markers, small capsunits12,400 m²sup or UnicodemarkersStation A¹footnotessmall capsHARZsmaller capsformulasNO₂sub or Unicode

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.