Format Label Buffers, Backgrounds and Shadows in PyQGIS
A label that reads perfectly over a plain background disappears over an aerial photo, a hillshade or a dense street network. Cartographers solve this with three tools: a buffer (a halo of contrasting colour hugging the letters), a background (a shape behind the text, from a subtle rounded box to a road shield), and a shadow (a soft offset that lifts the text off the map). All three live on QgsTextFormat, the object that describes how text looks everywhere in QGIS — labels, layout items, annotations and legend text alike.
This recipe belongs to Labeling & Annotations in PyQGIS. It builds a text format with each of the three effects, shows when each one is the right choice, drives colours from data, adds rich text with HTML, and saves the result as a named text format so every layer and layout can reuse it.
Prerequisites
- QGIS 3.40 LTR or newer, or the QGIS 4 series.
- A vector layer with labels you want to improve. The basics of switching labels on and choosing a field are in the labeling guide.
- The fonts you intend to use installed on every machine that renders the map, including servers.
Start from a text format
A QgsTextFormat holds font, size, colour and the three effect objects. Build it once, attach it to label settings, and enable labels on the layer.
from qgis.PyQt.QtGui import QColor, QFont
from qgis.core import (
QgsProject, QgsTextFormat, QgsPalLayerSettings, QgsVectorLayerSimpleLabeling,
Qgis,
)
villages = QgsProject.instance().mapLayersByName("settlements")[0]
fmt = QgsTextFormat()
font = QFont("Source Sans 3")
font.setWeight(QFont.Weight.DemiBold)
fmt.setFont(font)
fmt.setSize(9.5)
fmt.setSizeUnit(Qgis.RenderUnit.Points)
fmt.setColor(QColor("#1f2a24"))
settings = QgsPalLayerSettings()
settings.fieldName = "name"
settings.setFormat(fmt)
villages.setLabeling(QgsVectorLayerSimpleLabeling(settings))
villages.setLabelsEnabled(True)
villages.triggerRepaint()
Breakdown: Setting size in points keeps text the same physical size on screen and in print, independent of map scale; map units make labels grow and shrink as you zoom, which is occasionally what you want for area names and rarely for anything else. The font weight uses Qt's scoped enum so the code works on the QGIS 4 series. Everything that follows modifies fmt and calls settings.setFormat(fmt) again, because label settings store a copy of the format rather than a reference.
Buffers: the default fix
A buffer draws a halo around each glyph. A thin, light buffer under dark text is the most effective and least intrusive readability fix there is, and it should be the first thing you try.
from qgis.PyQt.QtCore import Qt
from qgis.core import QgsTextBufferSettings
buffer = QgsTextBufferSettings()
buffer.setEnabled(True)
buffer.setSize(0.8)
buffer.setSizeUnit(Qgis.RenderUnit.Millimeters)
buffer.setColor(QColor("#fbf8f0"))
buffer.setOpacity(0.85)
buffer.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
buffer.setFillBufferInterior(True)
fmt.setBuffer(buffer)
settings.setFormat(fmt)
villages.setLabeling(QgsVectorLayerSimpleLabeling(settings))
villages.triggerRepaint()
Breakdown: A buffer of 0.6–1 mm is enough over most imagery; wider buffers start to look like a background and obscure the map around the label. Slightly reducing opacity lets the terrain show through the halo, which reads as more refined than a solid white outline. Round joins avoid spiky corners on letters like M and W. setFillBufferInterior fills the inside of letters so a semi-transparent text colour does not show the buffer through it. A dark buffer under light text is the inverse treatment for dark basemaps and night-mode maps. Whichever you choose, match the buffer colour to the dominant tone of the basemap rather than using pure white or black: a buffer tinted towards the map's paper colour disappears into the design, while a stark white halo draws attention to itself on every label.
Backgrounds: boxes, shields and markers
A background draws a shape behind the text — a rectangle, rounded rectangle, circle, ellipse, an SVG such as a road shield, or any marker symbol. It hides what is beneath, so it suits labels that should dominate: route numbers, points of interest, map annotations.
from qgis.PyQt.QtCore import QSizeF, Qt
from qgis.core import QgsTextBackgroundSettings
roads = QgsProject.instance().mapLayersByName("major_roads")[0]
shield_fmt = QgsTextFormat()
shield_fmt.setFont(QFont("Source Sans 3"))
shield_fmt.setSize(8)
shield_fmt.setColor(QColor("#fffdf7"))
background = QgsTextBackgroundSettings()
background.setEnabled(True)
background.setType(QgsTextBackgroundSettings.ShapeType.ShapeRectangle)
background.setSizeType(QgsTextBackgroundSettings.SizeType.SizeBuffer)
background.setSize(QSizeF(1.2, 0.6))
background.setSizeUnit(Qgis.RenderUnit.Millimeters)
background.setRadii(QSizeF(0.8, 0.8))
background.setFillColor(QColor("#15803d"))
background.setStrokeColor(QColor("#fffdf7"))
background.setStrokeWidth(0.3)
shield_fmt.setBackground(background)
shield = QgsPalLayerSettings()
shield.fieldName = "ref"
shield.placement = Qgis.LabelPlacement.Horizontal
shield.setFormat(shield_fmt)
roads.setLabeling(QgsVectorLayerSimpleLabeling(shield))
roads.setLabelsEnabled(True)
roads.triggerRepaint()
Breakdown: SizeBuffer sizes the shape to the text plus the given padding, so the box grows with longer route numbers; SizeFixed uses an absolute size, which suits circular symbols of constant diameter. Radii round the corners. A thin light stroke separates the shield from dark roads beneath. Horizontal placement keeps shields upright rather than following the line, matching how road atlases draw them. For a real shield graphic, set the type to ShapeSVG and point setSvgFile at an SVG with parameterised fill so the colour can still be set from code, the same mechanism as SVG marker symbols.
Shadows and data-defined colours
A shadow offsets a blurred copy of the text or its background. It gives light text a lift over dark imagery and adds depth to backgrounds; used on every label it makes a map look dated.
from qgis.core import QgsTextShadowSettings, QgsPalLayerSettings, QgsProperty
title_fmt = QgsTextFormat(fmt)
title_fmt.setColor(QColor("#fffdf7"))
title_fmt.setSize(12)
shadow = QgsTextShadowSettings()
shadow.setEnabled(True)
shadow.setShadowPlacement(QgsTextShadowSettings.ShadowPlacement.ShadowLowest)
shadow.setOffsetAngle(135)
shadow.setOffsetDistance(0.5)
shadow.setOffsetUnit(Qgis.RenderUnit.Millimeters)
shadow.setBlurRadius(1.5)
shadow.setColor(QColor("#17211d"))
shadow.setOpacity(0.7)
title_fmt.setShadow(shadow)
towns = QgsPalLayerSettings(settings)
towns.setFormat(title_fmt)
props = towns.dataDefinedProperties()
props.setProperty(QgsPalLayerSettings.Property.Color, QgsProperty.fromExpression(
"CASE WHEN \"status\" = 'city' THEN '#fffdf7' ELSE '#e8efe6' END"))
props.setProperty(QgsPalLayerSettings.Property.Size, QgsProperty.fromExpression(
"CASE WHEN \"population\" > 100000 THEN 13 ELSE 10 END"))
towns.setDataDefinedProperties(props)
Breakdown: ShadowLowest casts the shadow from the lowest enabled component — the background if there is one, otherwise the buffer, otherwise the text — which is what makes a shadowed shield look raised as a unit. The offset angle is measured clockwise from north in QGIS's convention, so 135° is down and to the right, where light from the upper left would cast it. Data-defined properties override the static format per feature; colour and size are the two most useful, and the same approach drives data-defined symbol sizes. Copy-constructing QgsTextFormat(fmt) and QgsPalLayerSettings(settings) reuses the earlier settings without mutating them.
Rich text and a reusable format
Since QGIS 3.14 a text format can interpret a subset of HTML, which lets one label mix weights, sizes and colours — a name in bold with a smaller population figure beneath it. And any finished format can be saved in the style library under a name, so layouts, annotations and other layers use the identical definition.
from qgis.core import QgsStyle
rich = QgsTextFormat(fmt)
rich.setAllowHtmlFormatting(True)
rich_settings = QgsPalLayerSettings(settings)
rich_settings.isExpression = True
rich_settings.fieldName = (
"'<b>' || \"name\" || '</b><br>'"
" || '<span style=\"font-size:7pt;color:#59645f\">pop. '"
" || format_number(\"population\", 0) || '</span>'"
)
rich_settings.setFormat(rich)
villages.setLabeling(QgsVectorLayerSimpleLabeling(rich_settings))
villages.triggerRepaint()
style = QgsStyle.defaultStyle()
style.addTextFormat("Settlement label", fmt, True)
reused = style.textFormat("Settlement label")
print("saved and retrieved:", reused.isValid())
Breakdown: HTML labels are built with an expression that concatenates tags around field values; isExpression tells the label engine the field name is an expression. Supported markup includes bold, italic, colour, font size and line breaks — enough for most two-line labels, not a web page. Escape any field values that could contain < or &, or the label renders as broken markup. addTextFormat with True saves immediately to the user's style database; textFormat retrieves by name. The style library works the same way for symbols and colour ramps, as covered in using the style manager and symbol library.
QGIS version compatibility
QgsTextFormat, QgsTextBufferSettings, QgsTextBackgroundSettings and QgsTextShadowSettings have been stable since QGIS 3.0. HTML formatting arrived in 3.14 and grew more CSS support in later releases. Qgis.RenderUnit replaced QgsUnitTypes.RenderMillimeters in 3.30, Qgis.LabelPlacement replaced QgsPalLayerSettings.Horizontal in 3.26, and QgsPalLayerSettings.Property.Color is the scoped form of the data-defined keys; the QGIS 4 series accepts only the scoped names.
Troubleshooting
- Changes do not appear.
setFormatwas not called again after modifying the format, or the layer was not repainted. - Buffers look jagged. Join style is miter; use round joins.
- The background shape is tiny. Size type is fixed with a zero size; use
SizeBufferwith padding. - HTML tags show as text.
setAllowHtmlFormatting(True)is missing. - Fonts differ on the server. The font is not installed there; QGIS substitutes silently.
Conclusion
Start every label with a text format in point units, reach for a thin light buffer first, use backgrounds where a label should dominate and shadows sparingly for light text on dark imagery. Drive colour and size from data where categories matter, use HTML for mixed two-line labels, and save the finished format to the style library so every map in a series shares the same typography.
Frequently Asked Questions
Can a buffer use a blend mode?
Yes. setBlendMode on the buffer settings, for example multiply over light basemaps, softens the halo into the map.
How do I apply one format to layout labels too?
Retrieve the saved format with QgsStyle.defaultStyle().textFormat(name) and call setTextFormat on the layout label item.
Why do labels overlap despite buffers? Buffers do not affect collision detection much; adjust placement and priority settings instead.
Do these effects slow rendering? Blurred shadows are the most expensive. On maps with thousands of labels, prefer buffers.