Use the Arrow Symbol Layer in PyQGIS
Arrows communicate movement: commuters from suburbs to centre, goods between ports, river flow, the direction of one-way streets, the path of a storm. QGIS's arrow symbol layer turns any line into a filled arrow with a shaft and head whose sizes, curvature and fill can be set — and data-defined, which is what makes proportional flow maps possible. Because it is a symbol layer, it stacks with others and works in every renderer.
This recipe belongs to Symbol Layers & Advanced Symbology. It creates arrows on lines, adjusts head and shaft geometry, curves arrows between points, scales width by a flow value, styles the arrow fill, and covers direction-only arrows for networks.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series.
- A line layer — flows between places, river centrelines, streets with a direction field. For flows between point pairs, lines can be built with
native:pointstopathor a virtual layer joining origins and destinations.
Add an arrow symbol layer
The arrow layer replaces or sits on top of a line's usual symbol layer. Widths and head sizes default to millimetres.
from qgis.core import (QgsProject, QgsLineSymbol, QgsArrowSymbolLayer, QgsFillSymbol,
QgsSingleSymbolRenderer, QgsUnitTypes)
flows = QgsProject.instance().mapLayersByName("commuter_flows")[0]
arrow = QgsArrowSymbolLayer()
arrow.setArrowWidth(1.2)
arrow.setArrowStartWidth(1.2)
arrow.setHeadLength(3.0)
arrow.setHeadThickness(1.8)
arrow.setHeadType(QgsArrowSymbolLayer.HeadSingle)
arrow.setArrowType(QgsArrowSymbolLayer.ArrowPlain)
arrow.setIsCurved(False)
arrow.setIsRepeated(False)
arrow.setSubSymbol(QgsFillSymbol.createSimple(
{"color": "#2563eb", "outline_color": "#17211d", "outline_width": "0.15"}))
symbol = QgsLineSymbol()
symbol.changeSymbolLayer(0, arrow)
flows.setRenderer(QgsSingleSymbolRenderer(symbol))
flows.triggerRepaint()
Breakdown: setArrowWidth is the shaft width at the end and setArrowStartWidth at the start; equal values give a parallel shaft. Head length and thickness are measured along and across the line. HeadSingle puts the head at the end of the line — the direction of digitizing — and HeadReversed or HeadDouble change that. The fill is a sub-symbol, so any fill style works, including gradients and outlines. setIsRepeated(False) draws one arrow per feature rather than one per line segment.
Curve arrows between two points
Straight arrows between many origins and destinations overlap along the same corridor. Curving them separates opposite directions and makes flow maps readable.
arrow.setIsCurved(True)
symbol = QgsLineSymbol()
symbol.changeSymbolLayer(0, arrow)
flows.setRenderer(QgsSingleSymbolRenderer(symbol))
# a two-point flow line needs a midpoint to curve; build one offset to the right
from qgis.core import QgsProperty, QgsSymbolLayer
gen = ("make_line(start_point($geometry), "
"project(centroid($geometry), length($geometry) * 0.15, "
"azimuth(start_point($geometry), end_point($geometry)) + pi()/2), "
"end_point($geometry))")
print("geometry generator for curved flows:", gen)
Breakdown: The arrow layer curves lines that have at least three vertices, following a smooth curve through them. Flow lines built from origin–destination pairs have only two, so a midpoint offset to the right of the line — here by 15 % of its length — gives the curve its bend. Because the offset is always to the right of the direction of travel, A→B and B→A bend to opposite sides and separate. Apply the expression in a geometry generator symbol layer wrapping the arrow, as in using a geometry generator symbol layer, or materialise it once with the geometry by expression algorithm.
Scale width by flow volume
A flow map's message is in the widths. Data-defined widths turn a volume field into shaft width, with area or width proportional to the value.
max_flow = max(f["commuters"] or 0 for f in flows.getFeatures())
width_expr = f'0.3 + 4.0 * sqrt(coalesce("commuters", 0) / {max_flow})'
arrow.setDataDefinedProperty(QgsSymbolLayer.PropertyArrowWidth, QgsProperty.fromExpression(width_expr))
arrow.setDataDefinedProperty(QgsSymbolLayer.PropertyArrowStartWidth, QgsProperty.fromExpression(width_expr))
arrow.setDataDefinedProperty(QgsSymbolLayer.PropertyArrowHeadThickness,
QgsProperty.fromExpression(f"1.6 * ({width_expr})"))
arrow.setDataDefinedProperty(QgsSymbolLayer.PropertyArrowHeadLength,
QgsProperty.fromExpression(f"2 + 2 * ({width_expr})"))
symbol = QgsLineSymbol()
symbol.changeSymbolLayer(0, arrow)
flows.setRenderer(QgsSingleSymbolRenderer(symbol))
flows.triggerRepaint()
Breakdown: The width grows with the square root of the flow, normalised by the maximum, from 0.3 mm for tiny flows to about 4.3 mm for the largest; the square root keeps big flows from overwhelming the map. Head thickness and length follow the width, so heads stay in proportion on thick and thin arrows. Using the same expression for start and end width gives parallel shafts; a smaller start width tapers each arrow towards its origin. Filtering out flows below a threshold, with a rule-based renderer, removes the many tiny arrows that would otherwise clutter the map.
Build flow lines from an origin–destination table
Flow data usually arrives as a table — origin, destination, volume — plus a layer of places. Turning it into lines is the first step of any flow map, and doing it in Python keeps the link between table rows and arrows explicit.
import csv
from qgis.core import QgsVectorLayer, QgsFeature, QgsGeometry, QgsPointXY
places = QgsProject.instance().mapLayersByName("municipalities")[0]
centre = {f["code"]: f.geometry().pointOnSurface().asPoint() for f in places.getFeatures()}
od = QgsVectorLayer(f"LineString?crs={places.crs().authid()}&field=origin:string"
"&field=destination:string&field=commuters:integer", "commuter flows", "memory")
rows, missing = [], set()
with open("/data/census/commuting_od.csv", newline="", encoding="utf-8") as fh:
for r in csv.DictReader(fh):
a, b = centre.get(r["origin"]), centre.get(r["destination"])
if a is None or b is None or r["origin"] == r["destination"]:
missing.update(c for c in (r["origin"], r["destination"]) if c not in centre)
continue
f = QgsFeature(od.fields())
f.setAttributes([r["origin"], r["destination"], int(r["commuters"])])
f.setGeometry(QgsGeometry.fromPolylineXY([QgsPointXY(a), QgsPointXY(b)]))
rows.append(f)
od.dataProvider().addFeatures(rows)
QgsProject.instance().addMapLayer(od)
print(len(rows), "flows;", len(missing), "unknown place codes")
Breakdown: Points on surface give each municipality a representative point guaranteed to lie inside it, a better anchor than a centroid for irregular shapes. Each table row becomes a two-point line carrying origin, destination and volume; internal flows (origin equals destination) are skipped because they have no direction to draw. Codes in the table that match no place are collected and reported, which catches code-list mismatches before they silently drop flows from the map. The resulting memory layer is ready for the curved, width-scaled arrows above.
Draw small direction arrows along lines
For networks — one-way streets, river flow, pipe direction — the need is not a big arrow per feature but small chevrons repeated along each line. The arrow layer's repeat mode or a marker line both do it.
from qgis.core import QgsMarkerLineSymbolLayer, QgsMarkerSymbol, QgsRuleBasedRenderer
streets = QgsProject.instance().mapLayersByName("streets")[0]
base = QgsLineSymbol.createSimple({"color": "#c9c2ac", "width": "1.2"})
chevrons = QgsMarkerLineSymbolLayer(True, 20) # rotate with line, every 20 mm
chevrons.setSubSymbol(QgsMarkerSymbol.createSimple(
{"name": "arrowhead", "color": "#17211d", "size": "1.6", "outline_color": "#17211d"}))
oneway_symbol = base.clone()
oneway_symbol.appendSymbolLayer(chevrons)
root = QgsRuleBasedRenderer.Rule(None)
root.appendChild(QgsRuleBasedRenderer.Rule(oneway_symbol, filterExp='"oneway" = \\'yes\\''))
root.appendChild(QgsRuleBasedRenderer.Rule(base.clone(), elseRule=True))
streets.setRenderer(QgsRuleBasedRenderer(root))
streets.triggerRepaint()
Breakdown: A marker line places an arrowhead marker every 20 mm, rotated to follow the line, on top of the street's normal line. A rule applies the chevrons only to one-way streets. Arrows point in the digitizing direction, so the data must be digitized in the direction of travel; for networks with a oneway = -1 convention, add a rule that flips the marker rotation by 180 degrees. Marker lines and their placement options are covered in marker line and interval symbols.
Make flow maps readable
Flow maps fail when too many arrows cross. A few rules keep them legible: draw the largest flows on top by ordering features by volume ascending, so small arrows go underneath; drop flows below a threshold or show them as a separate, faint layer; use a semi-transparent fill so overlapping arrows show through; and keep internal flows (a place to itself) off the map. Feature ordering is set on the renderer:
renderer = flows.renderer()
renderer.setOrderByEnabled(True)
from qgis.core import QgsFeatureRequest
renderer.setOrderBy(QgsFeatureRequest.OrderBy([QgsFeatureRequest.OrderByClause('"commuters"', True)]))
flows.triggerRepaint()
Breakdown: Ordering by volume ascending draws the smallest flows first and the largest last, so major flows are never hidden under minor ones. Combined with square-root widths, a minimum-flow filter and translucent fills, this is usually enough to make a regional commuter map readable without manual editing.
QGIS version compatibility
QgsArrowSymbolLayer and its data-defined properties are available on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. On QGIS 4, enums are scoped: QgsArrowSymbolLayer.HeadType.HeadSingle, QgsArrowSymbolLayer.ArrowType.ArrowPlain and QgsSymbolLayer.Property.ArrowWidth.
Troubleshooting
- Arrows point the wrong way. Lines are digitized backwards; reverse them or use a reversed head type.
- Curved arrows are straight. The lines have only two vertices; add a midpoint.
- Huge arrows swamp the map. Width scales linearly with large values; use a square root and normalise.
- Arrows are drawn per segment. Repetition is on; set
setIsRepeated(False)for one arrow per feature.
Conclusion
Use QgsArrowSymbolLayer for arrows along lines, set shaft and head sizes deliberately, curve flows with an offset midpoint so opposite directions separate, scale widths by the square root of volume with heads following, use repeated markers for direction on networks, and order, filter and lighten flows so the map stays legible.
Frequently Asked Questions
Can arrows have gradient fills? Yes — use a gradient fill as the sub-symbol.
Can I draw arrows between points without making lines?
Use a geometry generator with make_line between two point geometries, for example via a relation.
Do arrows export to SVG? Yes, as vector shapes.
How do I add a legend for flow widths? Use a data-defined size legend on the renderer, or draw three reference arrows for round volumes in the layout so readers can compare.
Can arrows be animated? With the temporal controller, flows can appear over time; the arrows themselves are static.