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.
Prerequisites
- QGIS 3.40 LTR or newer, or the QGIS 4 series.
- A plugin skeleton with
initGuiandunload, as in creating a QGIS plugin with Plugin Builder. - Something worth searching: a layer, a lookup table or an API.
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.
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, 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.
fetchResultstouched a layer oriface; move that intocloneortriggerResult. - 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.
clonereturnsselfinstead 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.