Download Files with QgsFileDownloader in PyQGIS
Plugins and scripts often need to fetch files: a zipped shapefile from an open data portal, a GeoPackage release from a project's website, a style library, a model file. Python's urllib or requests will do it, but inside QGIS they ignore the proxy and authentication configured in QGIS's options, and a blocking download freezes the interface until it finishes. QgsFileDownloader uses QGIS's own network stack — proxy, SSL settings, authentication configurations — and runs asynchronously with progress and cancellation.
This recipe belongs to Web Services and Remote Data in PyQGIS. It downloads a file asynchronously with progress and errors, uses an authentication configuration, verifies the download, unzips and loads it, queues several downloads, and shows the blocking alternative for headless scripts.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series.
- A file URL. For protected downloads, an authentication configuration created in QGIS's options, referenced by its id.
Download asynchronously
QgsFileDownloader starts as soon as it is created and reports through signals. Keeping a reference until it finishes is essential, or Python garbage-collects it mid-download.
from qgis.core import QgsFileDownloader, QgsMessageLog, Qgis
from qgis.PyQt.QtCore import QUrl
URL = "https://data.example.org/downloads/parks_2026.zip"
TARGET = "/data/inbox/parks_2026.zip"
_active = {}
def start_download(url, target):
dl = QgsFileDownloader(QUrl(url), target, delayStart=True)
dl.downloadProgress.connect(lambda done, total:
QgsMessageLog.logMessage(f"{done / 1e6:.1f} / {total / 1e6:.1f} MB", "Downloads", Qgis.Info)
if total > 0 else None)
dl.downloadCompleted.connect(lambda u: QgsMessageLog.logMessage(f"saved {target}", "Downloads", Qgis.Success))
dl.downloadError.connect(lambda errors: QgsMessageLog.logMessage("; ".join(errors), "Downloads", Qgis.Critical))
dl.downloadCanceled.connect(lambda: QgsMessageLog.logMessage("canceled", "Downloads", Qgis.Warning))
dl.downloadExited.connect(lambda: _active.pop(target, None))
_active[target] = dl
dl.startDownload()
return dl
start_download(URL, TARGET)
Breakdown: delayStart=True lets you connect every signal before the request begins, so a very fast download cannot finish before its completion handler is attached. Progress reports bytes received and the total, which is −1 when the server sends no length. Exactly one of completed, error or canceled fires, then downloadExited always fires — the right place to drop the reference held in _active. Logging to the QGIS message log keeps a record users can see in the Log Messages panel, as in logging messages to the QGIS message log.
Show progress and allow cancellation
In a plugin, users should see progress and be able to cancel. A message bar item with a progress bar and a Cancel button is the idiomatic QGIS pattern.
from qgis.PyQt.QtWidgets import QProgressBar, QPushButton
from qgis.utils import iface
def download_with_bar(url, target):
dl = start_download(url, target)
bar = iface.messageBar().createMessage("Downloading", target.split("/")[-1])
progress = QProgressBar()
progress.setMaximum(100)
cancel = QPushButton("Cancel")
bar.layout().addWidget(progress)
bar.layout().addWidget(cancel)
item = iface.messageBar().pushWidget(bar, Qgis.Info)
dl.downloadProgress.connect(lambda done, total:
progress.setValue(int(100 * done / total)) if total > 0 else progress.setMaximum(0))
cancel.clicked.connect(dl.cancelDownload)
dl.downloadExited.connect(lambda: iface.messageBar().popWidget(item))
return dl
Breakdown: A message bar item stays visible without blocking work, and removing it in downloadExited cleans up whatever the outcome. When the total size is unknown, setting the progress bar's maximum to zero shows an indeterminate "busy" animation instead of a stuck bar. cancelDownload stops the transfer and deletes the partial file. For heavier post-processing after the download, hand off to a background task.
Use an authentication configuration
For protected files, pass the id of an authentication configuration. QGIS applies the credentials — basic auth, OAuth2, client certificates — without the script ever seeing them.
dl = QgsFileDownloader(QUrl("https://secure.example.org/exports/assets.gpkg"),
"/data/inbox/assets.gpkg", authcfg="abc1234", delayStart=True)
dl.downloadError.connect(lambda e: print("failed:", e))
dl.downloadCompleted.connect(lambda u: print("downloaded"))
_active["assets"] = dl
dl.startDownload()
Breakdown: The authcfg parameter takes the configuration id shown in QGIS's Authentication options. Storing credentials there — encrypted with the master password — keeps them out of scripts and project files, and lets administrators rotate passwords without editing code. Setting up configurations from Python is covered in storing credentials with QgsAuthManager.
Verify, unzip and load
A completed download is not necessarily a correct one: a proxy error page saved as a .zip, a truncated file, a changed release. Checking size or checksum before use catches these.
import hashlib
import zipfile
from pathlib import Path
from qgis.core import QgsVectorLayer, QgsProject
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def on_completed(_url):
expected = "3b5d…" # published checksum, if available
digest = sha256(TARGET)
if expected and not digest.startswith(expected.rstrip("…")):
print("checksum mismatch:", digest)
return
if not zipfile.is_zipfile(TARGET):
print("not a zip file — maybe an error page")
return
out_dir = Path(TARGET).with_suffix("")
zipfile.ZipFile(TARGET).extractall(out_dir)
for shp in out_dir.rglob("*.shp"):
layer = QgsVectorLayer(str(shp), shp.stem, "ogr")
if layer.isValid():
QgsProject.instance().addMapLayer(layer)
Breakdown: Comparing a SHA-256 checksum with the one the publisher lists proves the file is complete and unchanged. Checking that a "zip" really is a zip catches the common case of an HTML error or login page saved under the expected name. Extracting into a folder named after the archive keeps releases separate. GDAL can also read inside zip files without extracting, using /vsizip/ paths — QgsVectorLayer("/vsizip//data/inbox/parks_2026.zip/parks.shp", …) — which avoids clutter for read-only use.
Queue several downloads
Starting dozens of downloads at once overloads servers and connections. A small queue that keeps a few in flight is friendlier and more reliable.
The queue exists for two reasons beyond politeness. Servers frequently throttle or reject clients that open many simultaneous connections, so a burst of fifty requests can produce fifty failures where three at a time would all succeed. And the outcome of a batch is only useful as a whole: a single report at the end — 22 downloaded, 2 failed, with the error for each — is what a person or a scheduled job needs to decide whether to continue.
from collections import deque
class DownloadQueue:
def __init__(self, jobs, parallel=3, on_done=None):
self.pending = deque(jobs) # (url, target) pairs
self.parallel, self.on_done = parallel, on_done
self.running, self.results = {}, {}
def start(self):
while self.pending and len(self.running) < self.parallel:
url, target = self.pending.popleft()
dl = QgsFileDownloader(QUrl(url), target, delayStart=True)
dl.downloadCompleted.connect(lambda u, t=target: self.results.__setitem__(t, "ok"))
dl.downloadError.connect(lambda e, t=target: self.results.__setitem__(t, "; ".join(e)))
dl.downloadExited.connect(lambda t=target: self._finished(t))
self.running[target] = dl
dl.startDownload()
def _finished(self, target):
self.running.pop(target, None)
if self.pending:
self.start()
elif not self.running and self.on_done:
self.on_done(self.results)
jobs = [(f"https://data.example.org/tiles/dem_{i:03d}.tif", f"/data/inbox/dem_{i:03d}.tif")
for i in range(1, 25)]
queue = DownloadQueue(jobs, parallel=3, on_done=lambda r: print(sum(v == "ok" for v in r.values()), "ok"))
queue.start()
Breakdown: At most three downloads run at once; each exit starts the next pending job, and the final exit reports all results. Binding the target in each lambda's default argument keeps results attached to the right file. Keeping the queue object referenced — here in the variable queue — keeps every downloader alive. Failed downloads are recorded rather than retried automatically; retrying once after a short delay is a sensible extension for flaky servers.
Blocking downloads in headless scripts
Scheduled scripts without an interface do not need asynchrony, but still benefit from QGIS's network settings. QgsBlockingNetworkRequest downloads synchronously through the same stack.
from qgis.core import QgsBlockingNetworkRequest
from qgis.PyQt.QtNetwork import QNetworkRequest
req = QgsBlockingNetworkRequest()
if req.get(QNetworkRequest(QUrl(URL)), forceRefresh=True) != QgsBlockingNetworkRequest.NoError:
raise RuntimeError(req.errorMessage())
Path(TARGET).write_bytes(bytes(req.reply().content()))
Breakdown: The blocking request honours proxy, SSL and authentication settings like the asynchronous downloader, and returns the whole response in memory — fine for files of tens of megabytes, not for multi-gigabyte downloads, which should stream to disk with QgsFileDownloader and an event loop. forceRefresh bypasses QGIS's network cache so scheduled jobs always fetch the current file.
QGIS version compatibility
QgsFileDownloader with delayStart and authcfg, and QgsBlockingNetworkRequest, are available on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. On QGIS 4, enum members are scoped, for example QgsBlockingNetworkRequest.ErrorCode.NoError and Qgis.MessageLevel.Info.
Troubleshooting
- Nothing happens. The downloader was garbage-collected; keep a reference until
downloadExited. - Works in a browser, fails in QGIS. Proxy or SSL settings in QGIS differ; check Options → Network.
- The file is tiny and will not open. An error or login page was saved; check content and authentication.
- The interface freezes. A blocking request or
requests.getran on the main thread; useQgsFileDownloader.
Conclusion
Download with QgsFileDownloader so QGIS's proxy, SSL and authentication settings apply, connect signals before starting and keep a reference until exit, show progress and cancellation in the message bar, verify checksums and file types before unzipping and loading, queue many downloads a few at a time, and use the blocking request only in headless scripts.
Frequently Asked Questions
Can I resume an interrupted download?
Not with QgsFileDownloader; restart it, or use HTTP range requests through the network access manager for very large files.
Does it follow redirects? Yes, through QGIS's network access manager.
Can I download straight into memory?
Use QgsNetworkContentFetcher or a blocking request for small payloads.
Where should downloaded files go?
Into a folder the user chose or a plugin data folder under the QGIS profile, never into the plugin's own directory, which is replaced on every plugin update. QgsApplication.qgisSettingsDirPath() gives the profile path.
How do I avoid downloading the same file twice?
Check the target's existence and checksum before starting, or compare the server's Last-Modified header with the local file's time.
Is requests ever fine?
In standalone scripts without proxies or QGIS authentication, yes; inside QGIS, prefer the QGIS classes.