Build a Measure Tool with QgsDistanceArea in PyQGIS

QGIS has a measure tool, but plugins often need their own: measuring along a route and writing the length into a form, checking a setback distance with a pass/fail answer, measuring the area of a proposed plot and comparing it with a minimum. Building one is a good exercise in the map tool API — clicks, mouse moves, rubber bands — and in getting measurement right, because a measure tool that uses planar distances in a geographic or Web Mercator project gives answers that are wrong by tens of percent.

This recipe belongs to Custom Map Tools & Canvas Interaction. It builds a map tool that collects vertices, previews them with a rubber band, measures ellipsoidally with QgsDistanceArea in any CRS, updates a live readout, converts units, snaps to features and reports results.

A measure tool's event loopLeft clicks add vertices to a list and a rubber band; mouse moves update a temporary last segment so the user sees the line before clicking; each change recomputes length or area with QgsDistanceArea on the ellipsoid; the result is shown live. A right click finishes, reports the final value and resets for the next measurement.Click, move, measure, finishleft clickadd vertexmouse movepreview segmentQgsDistanceAreaellipsoidallength / arearight clickreportreset

Prerequisites

  • QGIS 3.34 LTR or newer, or the QGIS 4 series, inside QGIS Desktop.
  • Familiarity with custom map tools from creating a custom map tool.

Set up ellipsoidal measurement

QgsDistanceArea measures lengths and areas on an ellipsoid when given the source CRS and an ellipsoid, regardless of the map's projection. Configuring it from the project gives the same results as QGIS's own tools.

from qgis.core import QgsDistanceArea, QgsProject, QgsUnitTypes

def make_measurer(canvas):
    da = QgsDistanceArea()
    project = QgsProject.instance()
    da.setSourceCrs(canvas.mapSettings().destinationCrs(), project.transformContext())
    da.setEllipsoid(project.ellipsoid() or "EPSG:7030")
    return da

from qgis.utils import iface
da = make_measurer(iface.mapCanvas())
print("ellipsoidal:", da.willUseEllipsoid(), "| ellipsoid:", da.ellipsoid())

Breakdown: The source CRS is the canvas CRS, because clicked points arrive in canvas coordinates. The project ellipsoid — set in Project Properties — is what QGIS's measure tool and $area use; falling back to WGS 84 (EPSG:7030) covers projects with none set. willUseEllipsoid() confirms ellipsoidal measurement is active. In Web Mercator, planar distances are inflated by the scale factor — about 1.6 times at 50° latitude — which is exactly the error ellipsoidal measurement avoids.

Collect vertices with a rubber band preview

The map tool records clicked points and draws them with a rubber band; a second, temporary point follows the mouse so the user sees the next segment before clicking.

Fixed vertices and a moving tipThe rubber band holds the clicked vertices plus one extra point that follows the mouse. On each mouse move only that last point is moved, so the preview segment stretches to the cursor. On click the moving point becomes fixed and a new moving point is added. Length is measured over all points including the moving one for the live readout.Fixed points, plus one that follows the cursorcursorclicked vertices (solid) + preview to cursor (dashed)

from qgis.gui import QgsMapTool, QgsRubberBand
from qgis.core import QgsWkbTypes, QgsPointXY
from qgis.PyQt.QtGui import QColor
from qgis.PyQt.QtCore import Qt

class MeasureTool(QgsMapTool):
    def __init__(self, canvas, area=False, on_result=None):
        super().__init__(canvas)
        self.canvas = canvas
        self.area = area
        self.on_result = on_result
        gtype = QgsWkbTypes.PolygonGeometry if area else QgsWkbTypes.LineGeometry
        self.band = QgsRubberBand(canvas, gtype)
        self.band.setColor(QColor(37, 99, 235, 90 if area else 255))
        self.band.setStrokeColor(QColor("#2563eb"))
        self.band.setWidth(2)
        self.points = []
        self.setCursor(Qt.CrossCursor)

    def canvasReleaseEvent(self, event):
        point = self.toMapCoordinates(event.pos())
        if event.button() == Qt.LeftButton:
            self.points.append(point)
            self.band.addPoint(point, True)
            self.band.addPoint(point, True)      # moving tip
        elif event.button() == Qt.RightButton and self.points:
            self.finish()

    def canvasMoveEvent(self, event):
        if not self.points:
            return
        self.band.movePoint(self.toMapCoordinates(event.pos()))
        self.update_readout(self.points + [self.toMapCoordinates(event.pos())])

Breakdown: A line rubber band for distances and a polygon rubber band for areas give the right preview in each mode. Each click adds the point twice: once as a fixed vertex and once as the moving tip that movePoint drags on mouse moves, so the preview segment stretches to the cursor. Right click finishes. toMapCoordinates converts the mouse position to canvas CRS coordinates — the CRS the measurer was set up with. The readout is computed from the fixed points plus the cursor position.

Measure and format results

The measurement itself is one call; presenting it well — sensible units and precision — is what makes the tool pleasant.

    def update_readout(self, pts, final=False):
        da = make_measurer(self.canvas)
        if self.area and len(pts) >= 3:
            from qgis.core import QgsGeometry
            value = da.measureArea(QgsGeometry.fromPolygonXY([pts + [pts[0]]]))
            text = da.formatArea(value, 2, QgsUnitTypes.AreaSquareMeters, True)
        elif not self.area and len(pts) >= 2:
            value = da.measureLine(pts)
            text = da.formatDistance(value, 2, QgsUnitTypes.DistanceMeters, False)
        else:
            return
        iface.statusBarIface().showMessage(("Final: " if final else "") + text)
        return value

    def finish(self):
        value = self.update_readout(self.points, final=True)
        if self.on_result and value is not None:
            self.on_result(value, list(self.points))
        self.reset()

    def reset(self):
        self.points = []
        self.band.reset(QgsWkbTypes.PolygonGeometry if self.area else QgsWkbTypes.LineGeometry)

    def deactivate(self):
        self.reset()
        super().deactivate()

Breakdown: measureLine and measureArea return metres and square metres on the ellipsoid. formatDistance and formatArea produce readable strings with units, switching to kilometres or hectares when the last argument allows automatic unit selection. The status bar is an unobtrusive place for the live value; the final value goes to a callback so the plugin decides what to do with it — fill a form field, compare with a threshold, log it. Resetting in deactivate clears the rubber band when the user switches tools.

Show segment lengths on the canvas

For routes with several legs — a pipe run from valve to valve, a walking route between stops — users want each segment's length, not just the total. Small text items placed at segment midpoints show them without cluttering a dialog.

from qgis.gui import QgsMapCanvasItem
from qgis.PyQt.QtCore import QRectF, QPointF
from qgis.PyQt.QtGui import QFont, QPen

class SegmentLabel(QgsMapCanvasItem):
    def __init__(self, canvas, map_point, text):
        super().__init__(canvas)
        self.map_point, self.text = map_point, text
        self.updatePosition()

    def updatePosition(self):
        self.prepareGeometryChange()
        self.setPos(self.toCanvasCoordinates(self.map_point))

    def boundingRect(self):
        return QRectF(-40, -18, 80, 16)

    def paint(self, painter, option=None, widget=None):
        painter.setFont(QFont("Sans", 8))
        painter.setPen(QPen(QColor("#17211d")))
        painter.drawText(QRectF(-40, -18, 80, 16), Qt.AlignCenter, self.text)

def label_segments(canvas, pts, da):
    items = []
    for a, b in zip(pts, pts[1:]):
        mid = QgsPointXY((a.x() + b.x()) / 2, (a.y() + b.y()) / 2)
        length = da.measureLine(a, b)
        items.append(SegmentLabel(canvas, mid, da.formatDistance(length, 1, QgsUnitTypes.DistanceMeters, False)))
    return items

Breakdown: Each label is a small canvas item anchored at a segment's midpoint in map coordinates, so it stays in place as the map pans and zooms. Measuring each pair of consecutive points with the same QgsDistanceArea keeps segment lengths consistent with the total. Store the returned items on the tool and remove them from the scene in reset() and deactivate(), or they linger after the measurement ends; drawing a custom map canvas item covers the canvas item lifecycle.

Snap to features while measuring

Measuring "from this building corner to that hydrant" needs snapping. The canvas's snapping utilities snap mouse positions to features according to the project's snapping settings.

    def snapped(self, event):
        match = self.canvas.snappingUtils().snapToMap(event.pos())
        return match.point() if match.isValid() else self.toMapCoordinates(event.pos())

Breakdown: Replacing self.toMapCoordinates(event.pos()) with self.snapped(event) in both event handlers makes clicks and the preview snap to vertices and segments when snapping is enabled in the project. Using the project's snapping configuration means users control it with the familiar snapping toolbar. Snapping to features with QgsSnappingUtils covers custom snapping configurations and visual snap indicators.

Check a rule with the measurement

Many plugins measure to decide something: is a proposed building at least 3 m from the boundary, is a plot above the minimum size. A callback turns the measure tool into a checker.

From measurement to decisionThe tool's result callback receives the measured distance. Compared with the rule, here a 3 metre minimum setback, it produces a pass or fail message in the message bar, and can write the value and verdict into the selected feature's attributes so the check is recorded.Measure, compare, recordmeasured3.42 mruleminimum 3.0 mPASSmessage + record

MIN_SETBACK = 3.0

def check_setback(distance, points):
    if distance >= MIN_SETBACK:
        iface.messageBar().pushSuccess("Setback", f"{distance:.2f} m — meets the {MIN_SETBACK} m minimum")
    else:
        iface.messageBar().pushCritical("Setback", f"{distance:.2f} m — below the {MIN_SETBACK} m minimum")

tool = MeasureTool(iface.mapCanvas(), area=False, on_result=check_setback)
iface.mapCanvas().setMapTool(tool)

Breakdown: The callback receives the final distance and the clicked points, so it can both judge and record. The message bar gives immediate, colour-coded feedback; for an audit trail, write the distance and verdict into an attribute of the selected feature through the edit buffer. The same structure handles area rules with area=True. Wiring the tool to a toolbar button, as in adding a toolbar button to a plugin, makes it a one-click check.

QGIS version compatibility

QgsMapTool, QgsRubberBand, QgsDistanceArea and snapping utilities work on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. On QGIS 4, unit enums are Qgis.DistanceUnit.Meters and Qgis.AreaUnit.SquareMeters, geometry types Qgis.GeometryType.Line and Polygon, and Qt enums are scoped (Qt.MouseButton.LeftButton, Qt.CursorShape.CrossCursor).

Troubleshooting

  • Distances are too long in Web Mercator. Planar measurement was used; configure QgsDistanceArea with source CRS and ellipsoid.
  • The preview does not follow the mouse. The moving tip point was not added after each click.
  • The rubber band stays after switching tools. Reset it in deactivate.
  • Snapping does not work. Snapping is disabled in the project, or the tool uses unsnapped coordinates.

Conclusion

Configure QgsDistanceArea with the canvas CRS and project ellipsoid, collect clicks into a rubber band with a moving tip for preview, measure with measureLine or measureArea and format with automatic units, snap with the canvas's snapping utilities, report through a callback that can check rules and record results, and clean up when the tool is deactivated.

Frequently Asked Questions

Can I measure along existing features instead of clicking? Yes — measure the selected feature's geometry with the same QgsDistanceArea.

How do I show segment lengths? Measure each consecutive pair and display them in a list or as canvas labels.

Does the tool work in a 3D view? No; 2D canvas map tools only.

Why does my result differ slightly from QGIS's measure tool? Different ellipsoid or CRS settings; use the project ellipsoid and the canvas CRS as shown, and both tools agree.

Can I measure in feet? Pass QgsUnitTypes.DistanceFeet to formatDistance or convert with QgsUnitTypes.fromUnitToUnitFactor.