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.

Parts of an arrow symbolAn arrow symbol drawn along a line has a shaft with a start width and an end width, and a head with a length and a thickness. The head can be at the end, at the start, or at both ends. Setting different start and end widths makes a tapered arrow. The shaft is filled with any fill symbol, including gradients.Shaft widths, head length, head thicknessstart widthend widthhead lengthhead thickness

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:pointstopath or 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.

Curved arrows separate directionsTwo straight arrows between the same pair of places, one in each direction, lie on top of each other. Curved arrows bend to the side, so the outbound and return flows separate into two visible arcs, each with its own width. Curvature is applied to lines with three or more vertices or generated from a two-point line.Opposing flows on separate arcsstraight: overlapcurved: separate

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.

Direction along a networkSmall arrows repeated along each street segment show one-way direction without dominating the map. They follow the line's digitizing direction, so lines must be digitized in the direction of travel or reversed where a direction field says so. Two-way streets get no arrows.Small, repeated, following the lineone-way: arrows every 20 mmtwo-way: no arrows

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.