Loading a WFS Layer in a Background Task

Add a WFS layer to a QGIS project without freezing the window: construct the layer inside QgsTask.run because the capabilities request is synchronous,…

TL;DR: construct the QgsVectorLayer inside QgsTask.run() — the constructor performs a synchronous capabilities request that would otherwise freeze the window — and add it to the project from finished(), which runs on the main thread. This page is part of the network requests and remote data in plugins guide.

Complete Runnable Code

python
"""Load a WFS layer without blocking the QGIS interface."""
from urllib.parse import urlencode
from qgis.core import QgsProject, QgsTask, QgsVectorLayer


def wfs_uri(url: str, typename: str, version: str = "2.0.0",
            srsname: str = "EPSG:4326", auth_cfg: str = "") -> str:
    """Build a WFS provider URI. Every value is escaped by urlencode."""
    params = {"url": url, "typename": typename, "version": version,
              "srsname": srsname, "restrictToRequestBBOX": "1"}
    if auth_cfg:
        params["authcfg"] = auth_cfg
    return urlencode(params)


class LoadWfsTask(QgsTask):
    """Builds a remote layer off the main thread and registers it when finished."""

    def __init__(self, uri: str, name: str):
        super().__init__("Loading %s" % name, QgsTask.CanCancel)
        self._uri = uri
        self._name = name
        self._layer: QgsVectorLayer | None = None
        self._error = ""

    def run(self) -> bool:
        """Worker thread: construct the layer, which performs the capabilities request."""
        try:
            layer = QgsVectorLayer(self._uri, self._name, "WFS")
        except Exception as exc:                  # provider construction can raise
            self._error = str(exc)
            return False

        if self.isCanceled():
            return False
        if not layer.isValid():
            self._error = layer.error().summary() or "the service returned no usable layer"
            return False

        self._layer = layer
        return True

    def finished(self, ok: bool) -> None:
        """Main thread: the only place the project may be touched."""
        if ok and self._layer is not None:
            QgsProject.instance().addMapLayer(self._layer)
        else:
            print("WFS load failed: %s" % (self._error or "cancelled"))


def load_wfs(url: str, typename: str, name: str) -> LoadWfsTask:
    """Submit the load and return the task so a caller can connect to its signals."""
    from qgis.core import QgsApplication
    task = LoadWfsTask(wfs_uri(url, typename), name)
    QgsApplication.taskManager().addTask(task)
    return task
Which thread does what when a remote layer loads A sequence diagram showing the plugin submitting a task, the worker constructing the layer and performing the capabilities request, and the finished callback registering the layer on the main thread. main thread task manager worker addTask(task) run() construct + capabilities valid? finished(ok) addMapLayer() here

Architecture Breakdown

Why constructing the layer is the slow part

QgsVectorLayer(uri, name, "WFS") does not merely record a URL. The provider issues a GetCapabilities request, parses the response, resolves the feature type and its schema, and works out the layer’s extent and geometry type. On a healthy service that is a second; on a busy or distant one it is ten, and every one of those seconds is a frozen window if the call is on the GUI thread.

Moving it into run() costs nothing in complexity — it is the same single line — and removes the entire class of “QGIS hangs when I click the button” reports.

finished() is where the project is touched

QgsProject is a main-thread object. Adding the layer from run() may appear to work and will eventually corrupt state, because the project and the layer tree are being read by the rendering machinery at the same moment.

The task system exists to make this easy: finished() is delivered on the main thread with the result of run(), and it is the only place in this pattern where the project appears.

The URI is a query string

The WFS provider takes its configuration as a URL-encoded parameter string, which is why the helper uses urlencode rather than string concatenation. A typename containing a colon — the common namespace:layer form — must be escaped, and hand-built URIs get this wrong often enough that it is worth never doing.

What each part of a WFS URI does A grid of four URI parameters — url, typename, version and srsname — showing what each controls and the symptom produced when it is wrong. controls wrong value gives url the service endpoint an invalid layer typename which feature type an empty layer version the protocol dialect a parse failure srsname the requested CRS features in the wrong place

restrictToRequestBBOX=1 is worth knowing about: it tells the provider to request only the current view’s extent rather than the whole dataset, which turns an unusable layer over a large service into a responsive one.

Diagnosing an Invalid Layer

An invalid remote layer gives you very little to work with by default, but the causes cluster into three groups that can be told apart with one browser request and one careful comparison.

Why the remote layer came back invalid A decision tree over three causes of an invalid WFS layer: the service was unreachable, the typename does not exist, and the response could not be parsed as the declared version. The layer is not valid — why? no response network or proxy test the URL first unknown type check typename case-sensitive parse failure try another version 1.1.0 vs 2.0.0

isValid() returning False is the provider’s way of saying it could not build a usable layer, and layer.error().summary() usually names the reason. Three causes cover most cases, and they are distinguishable without guessing: fetch the capabilities document in a browser to prove the service is reachable, compare the typename exactly — they are case-sensitive and often namespaced — and try the other protocol version, since a service advertising 2.0.0 sometimes only really implements 1.1.0.

Reporting Progress to the User

python
from qgis.core import QgsApplication


def load_with_feedback(iface, url: str, typename: str, name: str) -> None:
    """Submit a load and report the outcome on the message bar."""
    task = LoadWfsTask(wfs_uri(url, typename), name)

    def done():
        if task.status() == QgsTask.Complete:
            iface.messageBar().pushSuccess("My Plugin", "Loaded %s" % name)
        else:
            iface.messageBar().pushWarning("My Plugin", "Could not load %s" % name)

    task.taskCompleted.connect(done)
    task.taskTerminated.connect(done)
    QgsApplication.taskManager().addTask(task)

Because the task carries a description, QGIS also shows it in the task manager widget with a cancel button — which is the whole reason to prefer QgsTask over a bare thread for work a user initiated.

Keeping the Layer Usable Once It Is Loaded

Loading the layer is only half the job; a remote layer that is technically present but painfully slow to draw will be blamed on the plugin that added it. Two settings do most of the work here.

restrictToRequestBBOX limits each request to the current view, which turns a service holding millions of features into something that behaves like a local layer at every zoom level. Without it the provider may attempt to fetch the whole feature type on the first render, and on a large service that request never usefully completes.

Setting a sensible scale-visibility range is the second. A detailed feature type that is meaningless at 1:2 000 000 should simply not be requested at that scale, and telling the layer so costs one call. Between them, the two turn “the plugin made QGIS slow” into a layer that behaves the way users expect remote data to behave.

Production Best Practices

  • Never construct a remote layer on the GUI thread. The capabilities request is synchronous.
  • Check isValid() inside run() so the failure is reported, not registered.
  • Build the URI with urlencode, never by concatenation.
  • Set restrictToRequestBBOX for large services, or the first render fetches everything.
  • Keep a reference to the task if you connect to its signals; the manager owns it, but your lambdas need it alive.
  • Pass the authentication configuration id as authcfg, so the provider applies stored credentials itself.

Frequently Asked Questions

Can I do this for other remote providers?

Yes — the same pattern applies to WMS, WCS, ArcGIS REST and OGC API layers, and to any QgsVectorLayer over a slow path such as a remote GeoPackage. What changes is only the provider key and the URI format; the thread discipline is identical.

Does the layer keep working after the task ends?

Yes. The task constructs the layer and hands it over; once registered, it belongs to the project and fetches data as the canvas needs it. Those later fetches happen on QGIS’s own threads, so they do not freeze the interface either.

What happens if the user cancels?

isCanceled() returns True at the next check and run() returns False, so finished() sees failure and does nothing. The partially constructed layer is dropped with the task. Note that cancellation cannot interrupt the capabilities request itself, so a cancel during a slow response takes effect only once it returns.

Should I cache the capabilities document?

The provider does some caching of its own, and for a service you load repeatedly it is worth enabling Qt’s disk cache rather than building your own layer. Where a plugin offers a list of available typenames, though, caching that list in settings for a session is a reasonable saving — it is a request the user pays for before they have even chosen anything.

How do I list the available layers on a service?

Fetch the capabilities document yourself and parse the feature type names out of it, using the non-blocking JSON pattern adapted for XML. Populate the chooser from that rather than making the user type a typename, which is both unfriendly and the single most common source of empty layers.

Is a memory layer a reasonable alternative?

Sometimes. Fetching features once into a memory layer gives you a snapshot with no further network traffic, which suits analysis and makes runs reproducible. The trade is that it does not update and does not scale past what fits in memory, so it is a good fit for a bounded extract and a poor one for a browsing tool.