Generate Points Along Lines in PyQGIS

Points spaced along lines are a building block for a surprising number of tasks: kilometre markers along a road, inspection stations every 50 m along a pipe, sampling points for an elevation or noise profile along a river, label anchors along a trail, or evenly spaced observation points for a viewshed. QGIS can generate them in one Processing call or with full control from QgsGeometry.interpolate, and either way each point can carry its distance along the line and the line's direction at that point.

This recipe belongs to Vector Data Manipulation. It generates points at fixed intervals with Processing, adds chainage and direction, controls start and end offsets, writes a custom generator for special spacing rules, and samples raster values at the generated points.

Points at a fixed spacingA curved line with points placed every 100 metres from its start. Each point carries its distance along the line, its chainage, and the line's direction at that point as an angle. A start offset shifts the first point away from the start; an end offset stops points short of the end. The last segment is usually shorter than the spacing.Every 100 m, with distance and direction0 m200 m500 mendeach point: distance along line · angle of the line

Prerequisites

  • QGIS 3.34 LTR or newer, or the QGIS 4 series.
  • A line layer in a projected CRS with metre units, so spacing is in metres.
  • Clean, connected lines. A road split into many short segments gives points restarting at zero on every segment; merge segments into routes first if chainage should run continuously.

Generate points with Processing

native:pointsalonglines places points at a fixed distance along every line, with optional offsets at both ends, and records distance and angle.

import processing
from qgis.core import QgsProject

pipes = QgsProject.instance().mapLayersByName("water_mains")[0]
stations = processing.run("native:pointsalonglines", {
    "INPUT": pipes,
    "DISTANCE": 50,          # metres between points
    "START_OFFSET": 0,
    "END_OFFSET": 0,
    "OUTPUT": "memory:inspection_stations",
})["OUTPUT"]

print(stations.featureCount(), "stations;", stations.fields().names()[-2:])
QgsProject.instance().addMapLayer(stations)

Breakdown: Each output point keeps the attributes of its source line and gains a distance field (metres from the start of that line) and an angle field (the line's direction at the point in degrees clockwise from north). Start and end offsets keep points away from line ends, which is useful when ends are junctions where a station would be ambiguous. The spacing can be data-defined — QgsProperty.fromExpression('CASE WHEN "diameter_mm" > 300 THEN 25 ELSE 50 END') gives larger mains denser stations.

Make chainage continuous along routes

Network layers are often split at every junction, so a road might be thirty separate features. Points generated on each restart at zero, which makes "kilometre 3.2 of route B7" impossible to read off.

Segments versus routesA road stored as three segments produces points whose distance restarts at zero on each segment. Dissolving the segments by route and merging them into one continuous line first gives one line per route, and points generated on it carry continuous chainage from the route's start.Merge segments into routes firstper segment0 · 50 · 100 | 0 · 50 | 0 · 50chainage restartsper route0 · 50 · 100 · 150 · 200 · 250continuous chainage

routes = processing.run("native:dissolve", {
    "INPUT": QgsProject.instance().mapLayersByName("roads")[0],
    "FIELD": ["route_ref"], "OUTPUT": "memory:"})["OUTPUT"]
merged = processing.run("native:mergelines", {
    "INPUT": routes, "OUTPUT": "memory:routes_merged"})["OUTPUT"]

km_posts = processing.run("native:pointsalonglines", {
    "INPUT": merged, "DISTANCE": 1000, "START_OFFSET": 0, "END_OFFSET": 0,
    "OUTPUT": "memory:km_posts"})["OUTPUT"]

Breakdown: Dissolving by route reference gathers all segments of a route into one multiline; native:mergelines joins the parts that touch end to end into single continuous lines. Points on the merged lines carry chainage from the route's start. Routes with gaps or branches stay multipart and their parts are processed separately, so check the result where networks fork. Direction matters too: chainage runs from the line's first vertex, so reverse lines that were digitized against the route's official direction with native:reverselinedirection.

Add readable labels and attributes

Raw distances in metres with decimals are not how people read markers. A computed field turns them into kilometre labels, and the angle can rotate symbols to sit across or along the line.

labelled = processing.run("native:fieldcalculator", {
    "INPUT": km_posts, "FIELD_NAME": "label", "FIELD_TYPE": 2, "FIELD_LENGTH": 20,
    "FORMULA": "\"route_ref\" || ' km ' || format_number(\"distance\" / 1000, 1)",
    "OUTPUT": "memory:km_posts_labelled"})["OUTPUT"]
for f in list(labelled.getFeatures())[:3]:
    print(f["label"], round(f["angle"]))

Breakdown: The label combines the route reference with the chainage in kilometres to one decimal — "B7 km 3.0". With the angle field, a marker symbol's rotation can be data-defined so it sits perpendicular to the road ("angle" + 90), the conventional look for kilometre posts. Data-defined symbol properties explain wiring an attribute to rotation.

Generate points yourself for special rules

Processing covers fixed spacing. Other rules — exactly N points per line, points at given chainages, points that always include both ends — are easy with QgsGeometry.interpolate, which returns the point at a given distance along a line.

from qgis.core import QgsVectorLayer, QgsFeature, QgsGeometry

def points_at(line_geom, distances):
    out = []
    for d in distances:
        p = line_geom.interpolate(d)
        if not p.isNull():
            angle = line_geom.interpolateAngle(d)
            out.append((d, p, angle))
    return out

layer = QgsVectorLayer(f"Point?crs={pipes.crs().authid()}&field=pipe_id:string"
                       "&field=chainage:double&field=angle_deg:double", "ends and quarters", "memory")
feats = []
for f in pipes.getFeatures():
    g = f.geometry()
    L = g.length()
    for d, p, a in points_at(g, [0, L * 0.25, L * 0.5, L * 0.75, L]):
        nf = QgsFeature(layer.fields())
        nf.setAttributes([f["pipe_id"], round(d, 2), round(a * 180 / 3.14159265, 1)])
        nf.setGeometry(p)
        feats.append(nf)
layer.dataProvider().addFeatures(feats)

Breakdown: interpolate(d) walks the line from its start and returns the point at distance d; interpolateAngle(d) returns the line's direction there in radians. Here every pipe gets five points — both ends and the quarter points — regardless of length, a rule Processing does not offer. The same function takes chainages from an inspection log to place recorded defects on the network, which is linear referencing in miniature; linear referencing along lines covers the full technique, including measures stored on vertices.

Place points at given chainages from a table

Field records often refer to positions by chainage: "crack at km 4.35 on route B7", "valve at 1,280 m along main WM-112". Turning such a table into points on the map is the reverse of generating chainage, and interpolate does it directly once each record is matched to its line.

import csv

routes_by_ref = {f["route_ref"]: f.geometry() for f in merged.getFeatures()}
defects = QgsVectorLayer(f"Point?crs={merged.crs().authid()}&field=route_ref:string"
                         "&field=km:double&field=defect:string", "defects", "memory")
rows, missing = [], []
with open("/data/inspections/defects.csv", newline="", encoding="utf-8") as fh:
    for r in csv.DictReader(fh):
        line = routes_by_ref.get(r["route_ref"])
        km = float(r["km"])
        if line is None or km * 1000 > line.length():
            missing.append(r)
            continue
        nf = QgsFeature(defects.fields())
        nf.setAttributes([r["route_ref"], km, r["defect"]])
        nf.setGeometry(line.interpolate(km * 1000))
        rows.append(nf)
defects.dataProvider().addFeatures(rows)
print(len(rows), "defects placed;", len(missing), "could not be located")

Breakdown: Looking routes up by reference in a dictionary keeps the loop fast. Records whose route does not exist, or whose chainage exceeds the route length, are collected rather than silently dropped — they usually point to a typo or a route that has been re-measured. This only works if chainage on the map matches chainage in the field, which is why merging segments into correctly oriented routes, as above, matters so much.

Sample a raster along the points

A common reason to generate points is to read a raster at regular intervals: elevation along a river for a long profile, noise levels along a road, slope along a planned route.

From points to a profilePoints every 25 metres along a river carry chainage. Sampling a DEM at each point adds an elevation value. Plotting elevation against chainage gives a long profile showing the river's gradient, with steps at weirs.Chainage on x, sampled value on ychainage (m)mweir

river = QgsProject.instance().mapLayersByName("river_centreline")[0]
pts = processing.run("native:pointsalonglines", {
    "INPUT": river, "DISTANCE": 25, "START_OFFSET": 0, "END_OFFSET": 0,
    "OUTPUT": "memory:"})["OUTPUT"]
sampled = processing.run("native:rastersampling", {
    "INPUT": pts, "RASTERCOPY": "/data/dem/dem_1m.tif", "COLUMN_PREFIX": "z_",
    "OUTPUT": "memory:river_profile"})["OUTPUT"]

profile = sorted((f["distance"], f["z_1"]) for f in sampled.getFeatures() if f["z_1"] is not None)
drop = profile[0][1] - profile[-1][1]
print(f"{len(profile)} samples, fall {drop:.1f} m over {profile[-1][0] / 1000:.2f} km")

Breakdown: Sampling adds one field per raster band holding the value at each point; z_1 is band 1. Sorting by chainage gives the profile ready to plot, as in plotting layer data with matplotlib. Spacing should match the raster: sampling a 25 m DEM every metre only repeats values. For a full-resolution profile of a single line, extracting an elevation profile reads every pixel the line crosses instead.

Points at line ends and vertices

Sometimes the points needed are not regular at all but structural: the start and end of every line (valves at pipe ends, junction candidates) or every vertex (survey points). Processing has dedicated algorithms for both.

ends = processing.run("native:extractspecificvertices", {
    "INPUT": pipes, "VERTICES": "0,-1", "OUTPUT": "memory:pipe_ends"})["OUTPUT"]
vertices = processing.run("native:extractvertices", {
    "INPUT": pipes, "OUTPUT": "memory:pipe_vertices"})["OUTPUT"]
print(ends.featureCount(), "end points;", vertices.featureCount(), "vertices")

Breakdown: native:extractspecificvertices takes a comma-separated list of vertex indices, where negative values count from the end — 0,-1 gives first and last. Both algorithms add vertex_index, distance and angle fields, so each point knows where it sits on its line. End points are the input for connectivity checks such as finding dangles in a network.

QGIS version compatibility

native:pointsalonglines, native:mergelines, native:extractspecificvertices and native:rastersampling are available with the same parameters on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. QgsGeometry.interpolate and interpolateAngle have existed throughout QGIS 3.

Troubleshooting

  • Chainage restarts every few hundred metres. The lines are split at junctions; dissolve and merge into routes first.
  • Points run in the wrong direction. The lines were digitized backwards; reverse them.
  • Spacing looks wrong. The layer is in degrees; reproject to a metric CRS.
  • Sampled values are NULL. Points fall outside the raster or on NoData.

Conclusion

Use native:pointsalonglines for fixed spacing with offsets, merge segments into routes for continuous chainage, label with formatted kilometres and rotate symbols by angle, use interpolate for custom spacing rules, sample rasters at the points for profiles, and extract end points or vertices when structure rather than spacing matters.

Frequently Asked Questions

Can spacing vary per line? Yes — make DISTANCE data-defined with an expression based on line attributes.

How do I get exactly N points per line? Use interpolate with distances length * i / (N - 1) as shown, or a data-defined spacing of $length / (N - 1).

Do points snap to vertices? No. They are placed at exact distances, usually between vertices.

Can I generate points along polygon outlines? Yes — convert polygons to lines with native:polygonstolines first.