Add a Locator Filter to a QGIS Plugin

The search box in the bottom-left corner of QGIS — the locator, opened with Ctrl+K — finds layers, features, actions, settings and bookmarks as you type. Plugins can add their own searches to it. An asset register becomes searchable by asset number, a gazetteer by place name, an internal API by job reference, all without a dialog, a dock widget or a toolbar button: type a short prefix, a few characters, press Enter, and the map jumps there.

This recipe belongs to QGIS Plugin Boilerplate & Structure. It writes a QgsLocatorFilter that searches a project layer, handles the background thread the locator runs searches in, acts on the chosen result, adds a filter backed by a web API, and registers the filters from a plugin.

Type, search in the background, act on the main threadThe user types asset 1043 in the locator bar. QGIS matches the prefix asset to the plugin's filter, clones the filter and calls fetchResults in a worker thread with the query 1043. The clone emits resultFetched for each match, such as TR-10432 Oak Mill Lane and TR-10433. Results appear in the locator drop-down under the filter's display name. When the user presses Enter, triggerResult runs on the main thread with the chosen result and zooms the canvas to the feature.Two threads, two methodsasset 1043▏worker threadclone()fetchResults("1043")resultFetched.emit(…)Asset registerTR-10432 · Oak, Mill LaneTR-10433 · Lime, Mill LaneTR-10436 · Ash, Park Rdmain threadtriggerResult → zoom, selectin fetchResults, never touchiface, the canvas, widgets or live layersread from what clone() prepared

Prerequisites

A filter that searches a layer

A locator filter subclasses QgsLocatorFilter and implements a handful of methods. The two that matter are fetchResults, which runs in a background thread and emits results, and triggerResult, which runs on the main thread when a result is chosen.

from qgis.core import (
    QgsLocatorFilter, QgsLocatorResult, QgsFeatureRequest, QgsProject,
    QgsVectorLayerFeatureSource, QgsExpression,
)
from qgis.utils import iface


class AssetFilter(QgsLocatorFilter):
    LAYER_NAME = "street_trees"

    def __init__(self, source=None, layer_id=None):
        super().__init__()
        self.source = source
        self.layer_id = layer_id

    def name(self):
        return "asset_register"

    def displayName(self):
        return "Asset register"

    def prefix(self):
        return "asset"

    def priority(self):
        return QgsLocatorFilter.Priority.High

    def clone(self):
        layers = QgsProject.instance().mapLayersByName(self.LAYER_NAME)
        if not layers:
            return AssetFilter()
        layer = layers[0]
        return AssetFilter(QgsVectorLayerFeatureSource(layer), layer.id())

    def fetchResults(self, string, context, feedback):
        if self.source is None or len(string) < 3:
            return
        pattern = QgsExpression.quotedString(f"%{string}%")
        request = (QgsFeatureRequest()
                   .setFilterExpression(f'"asset_id" ILIKE {pattern} OR "street" ILIKE {pattern}')
                   .setLimit(30))
        for feature in self.source.getFeatures(request):
            if feedback.isCanceled():
                return
            result = QgsLocatorResult()
            result.filter = self
            result.displayString = f'{feature["asset_id"]} · {feature["species"]}, {feature["street"]}'
            result.setUserData({"layer_id": self.layer_id, "fid": feature.id()})
            self.resultFetched.emit(result)

    def triggerResult(self, result):
        data = result.userData()
        layer = QgsProject.instance().mapLayer(data["layer_id"])
        if layer is None:
            return
        layer.selectByIds([data["fid"]])
        iface.mapCanvas().zoomToSelected(layer)
        iface.mapCanvas().flashFeatureIds(layer, [data["fid"]])

Breakdown: QGIS calls clone() on the main thread before each search and runs fetchResults on the copy in a worker thread, so clone is where anything thread-unsafe is turned into something safe: a QgsVectorLayerFeatureSource is a snapshot of the layer's provider that can be iterated from another thread, whereas the layer itself must not be. The prefix lets users type asset 1043 to search only this filter; without a prefix the filter also runs for unprefixed searches if enabled in the locator settings. Checking feedback.isCanceled() matters because every keystroke cancels the previous search. setUserData stores what triggerResult needs — the layer id and feature id — rather than the feature itself. Flashing the feature after zooming draws the eye to it, the same effect the built-in feature search uses.

Safe in the worker, main thread onlyLeft column, safe in fetchResults: a QgsVectorLayerFeatureSource created in clone, plain Python lists and dictionaries copied in clone, QgsBlockingNetworkRequest for HTTP, and emitting resultFetched. Right column, main thread only: QgsVectorLayer objects, iface and the map canvas, message bar and widgets, and changing selections. Violating this crashes QGIS intermittently rather than raising an error.Crashes here are intermittent, so get it right up frontfine in fetchResults✓ QgsVectorLayerFeatureSource✓ plain data copied in clone()✓ QgsBlockingNetworkRequest✓ resultFetched.emittriggerResult only✗ QgsVectorLayer objects✗ iface, map canvas✗ message bar, widgets✗ selections, edits

Register the filter from the plugin

Filters are registered with the interface when the plugin loads and must be deregistered when it unloads.

class AssetSearchPlugin:
    def __init__(self, iface):
        self.iface = iface
        self.filters = []

    def initGui(self):
        for f in (AssetFilter(), JobFilter()):
            self.iface.registerLocatorFilter(f)
            self.filters.append(f)

    def unload(self):
        for f in self.filters:
            self.iface.deregisterLocatorFilter(f)
        self.filters.clear()

Breakdown: registerLocatorFilter takes ownership on the C++ side, but the Python object must stay referenced — keeping the filters in a list on the plugin does that and gives unload something to deregister. Forgetting to deregister leaves a filter that points at unloaded plugin code, which crashes QGIS the next time someone types in the locator. Users can enable, disable and change the prefix of every registered filter in Settings → Options → Locator, so pick a short, memorable default and a clear display name.

A filter backed by a web API

Locator filters are a natural front end for remote lookups — a job system, a geocoder, a register kept outside GIS. Network requests in fetchResults should use QGIS's blocking request, which is safe in the worker thread and respects proxy and authentication settings.

import json
from qgis.PyQt.QtCore import QUrl, QUrlQuery
from qgis.PyQt.QtNetwork import QNetworkRequest
from qgis.core import (
    QgsBlockingNetworkRequest, QgsCoordinateReferenceSystem, QgsCoordinateTransform,
    QgsPointXY, QgsRectangle,
)


class JobFilter(QgsLocatorFilter):
    URL = "https://jobs.example.internal/api/search"

    def __init__(self):
        super().__init__()
        self.setUseWithoutPrefix(False)

    def name(self): return "job_search"
    def displayName(self): return "Works jobs"
    def prefix(self): return "job"
    def clone(self): return JobFilter()

    def fetchResults(self, string, context, feedback):
        if len(string) < 4:
            return
        url = QUrl(self.URL)
        query = QUrlQuery()
        query.addQueryItem("q", string)
        url.setQuery(query)
        request = QgsBlockingNetworkRequest()
        request.setAuthCfg("jobs001")
        if request.get(QNetworkRequest(url), False, feedback) != QgsBlockingNetworkRequest.NoError:
            return
        for job in json.loads(bytes(request.reply().content()))["results"][:20]:
            if feedback.isCanceled():
                return
            result = QgsLocatorResult()
            result.filter = self
            result.displayString = f'{job["ref"]}{job["title"]}'
            result.description = job.get("status", "")
            result.setUserData({"lon": job["lon"], "lat": job["lat"]})
            self.resultFetched.emit(result)

    def triggerResult(self, result):
        data = result.userData()
        canvas = iface.mapCanvas()
        to_canvas = QgsCoordinateTransform(QgsCoordinateReferenceSystem("EPSG:4326"),
                                           canvas.mapSettings().destinationCrs(),
                                           QgsProject.instance())
        point = to_canvas.transform(QgsPointXY(data["lon"], data["lat"]))
        canvas.setCenter(point)
        canvas.zoomScale(1500)
        canvas.refresh()

Breakdown: Passing the locator's feedback into the network request aborts the HTTP call when the user keeps typing, so a slow API does not queue up stale requests. A minimum query length of four characters keeps the API from being hit for every keystroke. description adds a second line under the result, useful for status. Coordinates from the API are transformed into the canvas CRS in triggerResult, on the main thread, where touching the canvas is safe. The HTTP pattern is covered in more depth in making HTTP requests with QgsNetworkAccessManager.

Defaults the user can changeThe locator options list each filter with a checkbox for enabled, a checkbox for default so it runs without a prefix, and an editable prefix. The plugin sets the initial values: a unique name used as the settings key, a short prefix like asset, and default inclusion off for slow filters such as web APIs so that typing in the locator stays fast.Pick defaults that keep the locator fastfilterprefixenabledin unprefixed searchAsset registerassetyesyes — local, fastWorks jobsjobyesno — networkname() is the settings key — never change it between plugin versions

Defaults, prefixes and performance

Every registered filter appears in Options → Locator, where users can disable it, change its prefix and choose whether it runs for searches typed without a prefix. The plugin sets the starting point: name() is the key under which those settings are stored, so it must be unique and stable across versions; prefix() should be short and unlikely to collide with built-in prefixes such as l for layers or f for features; and filters that call networks or scan large tables should not run on every unprefixed keystroke. Call setUseWithoutPrefix(False) in their constructor, as the job filter does, and keep local filters fast by limiting results and requiring a minimum query length.

Test the filter without the locator

Because the search logic lives in two ordinary methods, a filter can be exercised directly — in the Python console while developing, or in a pytest suite in CI — without typing into the locator at all.

from qgis.core import QgsFeedback, QgsLocatorContext

def run_filter(filter_, query):
    worker = filter_.clone()
    found = []
    worker.resultFetched.connect(found.append)
    worker.fetchResults(query, QgsLocatorContext(), QgsFeedback())
    return found

results = run_filter(AssetFilter(), "1043")
print(len(results), "results")
for r in results[:5]:
    print(" ", r.displayString, r.userData())

assert all(len(r.displayString) < 80 for r in results), "labels too long for the drop-down"
assert run_filter(AssetFilter(), "10") == [], "short queries should not search"

Breakdown: Calling clone() first mirrors exactly what QGIS does, so a filter whose clone forgets to prepare its feature source fails here the same way it would in the locator — silently, with no results. Connecting resultFetched to a list's append collects results synchronously, because fetchResults emits them on the calling thread when called directly. The assertions encode the filter's contract: labels short enough to read in the drop-down, and no search for queries below the minimum length. With a small GeoPackage fixture holding a dozen test assets, this runs in milliseconds and catches regressions in the expression or the display format, as in unit testing a QGIS plugin with pytest.

QGIS version compatibility

QgsLocatorFilter and registerLocatorFilter have been available since QGIS 3.0. QgsLocatorResult.setUserData and userData() replaced direct attribute access in 3.18. QgsLocatorFilter.Priority.High is the scoped enum form required on the QGIS 4 series, as is QgsBlockingNetworkRequest.ErrorCode.NoError. QgsVectorLayerFeatureSource is the stable way to read layers from background threads on every version.

Troubleshooting

  • QGIS crashes when typing in the locator. fetchResults touched a layer or iface; move that into clone or triggerResult.
  • No results appear. The filter was not registered, is disabled in the options, or the prefix differs from what you typed.
  • Stale results flash up. Cancellation is not checked inside the loop.
  • Crash after reloading the plugin. The filter was not deregistered in unload.
  • Results work once then stop. clone returns self instead of a new filter.

Conclusion

Implement clone to prepare thread-safe snapshots, fetchResults to search them in the background with cancellation checks, and triggerResult to act on the map from the main thread. Store identifiers in the result's user data, register filters in initGui and deregister them in unload, and choose a stable name, a short prefix and conservative defaults so the locator stays fast.

Frequently Asked Questions

Can a filter add actions to a result, such as "open in browser"? Yes. Recent releases support result actions: set them on the result and handle triggerResultFromAction.

Does the filter work in QGIS Server or scripts? No. The locator is a desktop interface feature.

Can one filter search several layers? Yes — prepare a feature source for each in clone, and group results by setting result.group.

How do I test a locator filter? Call clone() and then fetchResults directly with a QgsFeedback, connecting resultFetched to a list, in a headless test.