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.
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.
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.
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.