Place Curved Labels Along Lines in PyQGIS
Road names that follow the road, river names that bend with the river — curved labels are the difference between a map that looks drawn and one that looks generated. QGIS's labelling engine does the hard part; the work in PyQGIS is choosing the right dozen settings out of a very large class, and understanding why a label you asked for is not on the map.
This recipe belongs to Labelling and Annotations in PyQGIS. It covers curved placement along lines, repeating labels on long features, halos that keep text readable over busy backgrounds, and the priority and obstacle rules that decide which labels are drawn when they cannot all fit.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer.
- A line layer with a name field worth labelling.
- A projected CRS if you use map-unit offsets; millimetre units are safer and are used here.
Enable curved labels
from qgis.core import (
QgsPalLayerSettings, QgsTextFormat, QgsVectorLayerSimpleLabeling, QgsProject,
)
from qgis.PyQt.QtGui import QFont, QColor
layer = QgsProject.instance().mapLayersByName("roads")[0]
settings = QgsPalLayerSettings()
settings.fieldName = "name"
settings.placement = QgsPalLayerSettings.Curved
settings.maxCurvedCharAngleIn = 25
settings.maxCurvedCharAngleOut = -25
text_format = QgsTextFormat()
text_format.setFont(QFont("Noto Sans", 9))
text_format.setColor(QColor("#17211d"))
settings.setFormat(text_format)
layer.setLabeling(QgsVectorLayerSimpleLabeling(settings))
layer.setLabelsEnabled(True)
layer.triggerRepaint()
Breakdown: fieldName can be a plain field name or, with settings.isExpression = True, any expression — concat("name", ' (', "class", ')') for instance. Curved placement is only valid for line geometry; on polygons or points it falls back silently, which is why a curved setting that "does nothing" is usually applied to the wrong layer. The two character-angle limits control how sharp a bend the engine will follow before giving up on a label position: the inside angle is positive, the outside negative, and loosening them past about 40 degrees produces text that is technically on the line and painful to read.
Repeat labels on long features
A single label on a twelve-kilometre road is invisible unless the reader happens to be looking at that part of it.
settings.repeatDistance = 120
settings.repeatDistanceUnit = QgsPalLayerSettings.MM # millimetres on the page
settings.dist = 1.5 # offset from the line
settings.distUnits = QgsPalLayerSettings.MM
settings.lineSettings().setPlacementFlags(
QgsLabeling.OnLine | QgsLabeling.MapOrientation
)
Breakdown: repeatDistance in page millimetres means the spacing is consistent on the printed map regardless of scale — the property you want for a printed atlas, as opposed to map units which change apparent spacing with every zoom. dist lifts the text off the line so the stroke does not cut through the descenders; 1 to 2 mm is usually enough. MapOrientation keeps labels upright relative to the map rather than flipping upside down on lines drawn right-to-left, which is the single setting that stops a road network looking like it was labelled by someone standing on their head.
Keep text readable over anything
from qgis.core import QgsTextBufferSettings
buffer_settings = QgsTextBufferSettings()
buffer_settings.setEnabled(True)
buffer_settings.setSize(0.9)
buffer_settings.setSizeUnit(QgsUnitTypes.RenderMillimeters)
buffer_settings.setColor(QColor("#fffdf7"))
buffer_settings.setOpacity(0.9)
text_format.setBuffer(buffer_settings)
settings.setFormat(text_format)
Breakdown: A buffer — a halo drawn behind the glyphs — is what makes dark text legible over aerial imagery, a hillshade, or a dense line network. Just under a millimetre is a good default: enough to separate the text from its background, small enough that adjacent letters do not merge. Slight transparency stops the halo reading as a solid white blob when labels crowd together. Set the buffer on the format before assigning the format to the settings, since both are value objects and later edits to a copy do not propagate.
Decide which labels survive
The engine will not overlap labels. When they cannot all fit, priority and obstacle settings decide who wins.
settings.priority = 8 # 0 (lowest) to 10 (highest)
settings.obstacle = True # the road itself blocks other labels
settings.obstacleFactor = 1.5
settings.displayAll = False
settings.zIndex = 2
Breakdown: priority ranks this layer's labels against every other layer's — motorway names at 8 and footpath names at 3 produce a map that drops the right things first. obstacle makes the layer's features push labels away, so a road name from another layer will not sit across this road; obstacleFactor above 1 makes it push harder. displayAll = True forces every label to be drawn regardless of collisions, which is occasionally useful for diagnosis and almost never right for a finished map. zIndex controls draw order among labels that do end up overlapping.
Label only some features
settings.fieldName = "name"
settings.isExpression = False
layer.setLabeling(QgsVectorLayerSimpleLabeling(settings))
layer.setSubsetString("\"class\" IN ('motorway', 'primary')")
Breakdown: A subset string filters the layer itself, so both rendering and labelling see only those features. To label a subset while still drawing everything, use rule-based labelling instead — one rule per condition, each with its own settings — which is covered in Add Rule-Based Labels in PyQGIS and mirrors the structure of a rule-based renderer.
Callouts for the labels that cannot fit
Some features have nowhere to put their name — a short cul-de-sac, a river reduced to a stub at the map edge, a cluster of paths in a park. Rather than dropping those labels or letting them collide, move them away and connect them with a leader line.
from qgis.core import QgsSimpleLineCallout, QgsUnitTypes
from qgis.PyQt.QtGui import QColor
callout = QgsSimpleLineCallout()
callout.setEnabled(True)
callout.lineSymbol().setColor(QColor("#59645f"))
callout.lineSymbol().setWidth(0.2)
callout.setMinimumLength(2)
callout.setMinimumLengthUnit(QgsUnitTypes.RenderMillimeters)
settings.setCallout(callout)
settings.placement = QgsPalLayerSettings.Curved
settings.setObstacleSettings(settings.obstacleSettings())
Breakdown: A callout draws only when the engine has actually moved the label away from its feature, so enabling it costs nothing on the labels that fit — there is no visual change until one is displaced. setMinimumLength() suppresses the tiny stubs that appear when a label shifts by half a millimetre and would otherwise read as specks of dirt on the map. The line symbol is an ordinary QgsLineSymbol, so a dashed or curved leader is available if the cartography calls for it.
Callouts pair naturally with allowing labels to move: a label that may not be displaced never needs one. The combination that works well on dense networks is curved placement for the labels that fit along their line, permission to move for the ones that do not, and a thin grey callout so the reader can always tell which feature a displaced label belongs to. Where even that is too crowded, the honest answer is to raise the scale at which the layer's labels appear at all.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.22 LTR | 3.9 | lineSettings() available; placement flags moved here from QgsPalLayerSettings in 3.16. |
| 3.28 LTR | 3.9 | Behaviour matches this page. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | QgsPalLayerSettings.Placement members are scoped; adds allow-degraded-placement options for dense networks. |
Troubleshooting
- No labels appear at all.
setLabelsEnabled(True)was not called, or the field contains nulls. Both are silent. - Labels are straight rather than curved. The layer is not a line layer, or the placement was set on a copy of the settings object that was never assigned back.
- Only a few features are labelled. Collisions. Raise the priority, allow repeats, reduce the font size, or diagnose with
displayAll = True. - Text follows the line but is upside down.
MapOrientationis not set, so labels follow the direction the line was digitised in. - Labels sit on top of the line. Increase
dist, and confirm the unit is millimetres rather than map units. - The halo looks blocky at print resolution. The buffer size is in map units and scaled up with the export DPI. Use render millimetres for anything printed — see Export the Map Canvas to an Image.
Conclusion
Curved line labels are placement = Curved with sensible character-angle limits, a small offset off the line, repeats measured in page millimetres, and a sub-millimetre buffer for legibility. When labels go missing, the cause is the collision engine rather than the settings — adjust priority and obstacles rather than forcing every label to display.
Frequently Asked Questions
Why do some labels still not appear at high zoom? The engine needs a run of line long enough to fit the text within the angle limits. A short or very sinuous segment may have no valid position at all.
Can I label both sides of a line? Not with one label. Use two labelling rules with different offsets and filters, or a single label with an expression combining both values.
How do I stop labels repeating too often?
Raise repeatDistance. Setting it to zero disables repetition entirely and gives one label per feature.
Does curved placement work on polygon outlines?
Not directly. Convert the polygons to lines with native:polygonstolines and label that layer, keeping the polygons for fill.
How do I match the label font to a print layout? Set the font size in points and the buffer in millimetres, then export through a layout rather than the canvas, so the page units are honoured — see Automated Map Layout Generation.