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.

Three placements on the same winding lineThe same curved road is labelled three ways. Horizontal placement puts the text flat regardless of direction and reads poorly against the line. Parallel placement rotates the whole label to one segment's angle, so it drifts away from a curving line. Curved placement distributes characters along the line so the label follows every bend.Curved placement is the only one that survives a bendHorizontalMill Laneno relationship to the lineParallelMill Laneone angle for the whole wordCurvedMillLaneeach character follows the line

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.

Buffer size decides whether the label readsOver a busy hatched background, a label with no buffer is hard to separate from the lines behind it. A label with an over-large buffer becomes a solid blob that hides the map underneath. A label with a buffer just under a millimetre is legible while the map remains visible around it.Enough halo to separate, not enough to hide the mapno bufferMill Lanecompetes with the backgroundbuffer 3 mmMill Lanea white block over the mapbuffer 0.9 mmMill Lanelegible, map still visible

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.

Three ways to handle a label that will not fitA short cul-de-sac has no room for its name. Dropping the label leaves the feature unlabelled. Forcing the label on causes it to overlap the neighbouring street name. Displacing it into free space with a thin leader line keeps both labels readable and the association clear.A leader line beats both dropping and overlappingdroppedHigh Streetthe stub has no nameforcedHigh StreetMill Closetwo labels, one spacecalloutHigh StreetMill Closeboth readable, link explicit

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.22 LTR3.9lineSettings() available; placement flags moved here from QgsPalLayerSettings in 3.16.
3.28 LTR3.9Behaviour matches this page.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12QgsPalLayerSettings.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. MapOrientation is 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.