Use PyQGIS in a Jupyter Notebook

A notebook is a good fit for the kind of GIS work that is really analysis: try a buffer distance, look at the result, adjust, keep a record of what you did. PyQGIS runs there perfectly well — it is a normal Python library once the paths are right — and the result is a document combining code, tables, maps and the reasoning behind them, which is far more useful to a colleague than a script and a folder of outputs.

This recipe belongs to Virtual Environments for GIS. It covers making the QGIS libraries importable from a notebook kernel, initialising the application headless, rendering a map into a cell, and combining PyQGIS with the wider Python data stack without either environment interfering with the other.

What a notebook needs before PyQGIS will importThe notebook kernel starts as an ordinary Python environment. Three additions make PyQGIS work: the QGIS Python library folder on the path, the QGIS prefix path set so resources are found, and an application instance initialised with the offscreen platform. After that the kernel can build layers, run algorithms and render images inline next to data frames and charts.Three additions turn a kernel into a QGIS environmentthe kernelpandas, matplotlibyour usual environmentplus the QGIS librariessys.path and prefix pathsame Python version requiredplus an applicationinitialised offscreenonce per kernelwhat you get in the notebooklayers and algorithms as normal objects, maps rendered inline as imagesattributes as data frames, all in one document with the reasoning

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer, installed on the machine.
  • A notebook environment whose Python is the same minor version as the one QGIS uses — this is the constraint that decides whether any of this works.
  • Jupyter itself, installed either into the QGIS Python or into an environment that can reach the QGIS libraries.

Make the QGIS libraries importable

The simplest arrangement is to install Jupyter into the QGIS Python, using the technique from Install Python Packages into the QGIS Environment. Everything then works with no path manipulation at all. Where that is not desirable, add the QGIS libraries to your own environment:

import os
import sys

QGIS_PREFIX = "/usr"                      # /Applications/QGIS.app/Contents/MacOS on macOS
sys.path.append(f"{QGIS_PREFIX}/share/qgis/python")
sys.path.append(f"{QGIS_PREFIX}/share/qgis/python/plugins")   # for the processing package

os.environ["QT_QPA_PLATFORM"] = "offscreen"

from qgis.core import QgsApplication
print(QgsApplication.qgisVersion() if hasattr(QgsApplication, "qgisVersion") else "imported")

Breakdown: Two folders matter: the Python bindings themselves and the plugins folder, which is where the processing package lives — omit the second and every import works except Processing, which is a confusing half-failure. QT_QPA_PLATFORM set to offscreen must be in place before Qt is imported, which is why it appears above the import rather than after it; without it, a kernel on a machine with no display fails with an error about not connecting to a display server. The prefix differs by platform, and on Windows it is the OSGeo4W root with its own set of environment variables, which makes installing Jupyter into the QGIS Python distinctly the easier route there.

Initialise the application once

from qgis.core import QgsApplication

qgs = QgsApplication([], False)
qgs.setPrefixPath(QGIS_PREFIX, True)
qgs.initQgis()

from processing.core.Processing import Processing
import processing
Processing.initialize()

print(len(QgsApplication.processingRegistry().algorithms()), "algorithms available")

Breakdown: QgsApplication([], False) creates the application with no arguments and no GUI. initQgis() loads the providers — without it, every layer you create is invalid for reasons the error message does not explain. Processing needs its own initialisation in a standalone context, and after it the registry reports several hundred algorithms; a count of zero means the plugins folder is missing from the path. Run this cell exactly once per kernel: calling initQgis() twice is undefined behaviour, so guard it or simply restart the kernel when things get confused. The equivalent standalone-script setup is covered in Running Python Scripts Outside QGIS Desktop.

Render a map into a cell

The payoff of a notebook is seeing the result. Rendering to an image and displaying it inline takes a dozen lines and works with no display attached.

from IPython.display import Image
from qgis.core import (QgsVectorLayer, QgsProject, QgsMapSettings,
                       QgsMapRendererParallelJob)
from qgis.PyQt.QtCore import QSize
from qgis.PyQt.QtGui import QColor

layer = QgsVectorLayer("/data/city.gpkg|layername=parcels", "Parcels", "ogr")
QgsProject.instance().addMapLayer(layer)

settings = QgsMapSettings()
settings.setLayers([layer])
settings.setBackgroundColor(QColor("white"))
settings.setOutputSize(QSize(800, 500))
settings.setExtent(layer.extent())

job = QgsMapRendererParallelJob(settings)
job.start()
job.waitForFinished()
job.renderedImage().save("/tmp/map.png", "PNG")

Image("/tmp/map.png")

Breakdown: QgsMapSettings describes what to draw, at what size and over what extent — it is the same object the canvas uses, which is why the output matches what QGIS desktop would show. QgsMapRendererParallelJob renders on worker threads; waitForFinished() blocks until it is done, which is what you want in a notebook where the next cell expects the file to exist. Saving to a file and displaying it is more reliable than converting the image in memory, and it leaves an artefact you can put in a report. Re-running the cell after changing the styling gives the map-adjust-look loop that makes notebooks worth using for cartography experiments.

The loop a notebook is good atLoad the data, run an analysis step, render the result as an image, inspect both the image and the attribute summary, then adjust a parameter and repeat. Each iteration is recorded in the document, so the reasoning and the outputs stay together rather than being lost in a console history.Every iteration stays in the documentloadlayers and tablesanalyseone algorithmrenderinline imageinspectmap and numbersadjust a parameter and run it againRestart and run all before sharing — hidden state is the notebook's one real hazard

Combine PyQGIS with the data stack

Attributes are more convenient as a data frame, and getting them there is a comprehension:

import pandas as pd

records = [
    {**dict(zip(layer.fields().names(), feature.attributes())),
     "area_m2": feature.geometry().area()}
    for feature in layer.getFeatures()
]
frame = pd.DataFrame.from_records(records)
frame.groupby("ward")["area_m2"].sum().sort_values(ascending=False).head()

Breakdown: Zipping field names with attribute values gives a dictionary per feature, and adding a computed geometry column in the same step avoids a second pass. From there the whole of pandas applies: grouping, joining to a spreadsheet, plotting a distribution. The traffic works both ways — a result computed in pandas can be written back as a new field, or exported to CSV and joined in QGIS. For large layers, narrow the read with a feature request first rather than materialising every attribute, following Speed Up Feature Iteration with QgsFeatureRequest.

Where each half of the work belongsGeometry, coordinate systems, spatial predicates and rendering stay on the PyQGIS side, where the algorithms are. Grouping, joining to spreadsheets, statistics and plotting happen on the data frame side. Attributes cross from layer to frame as records, and computed values cross back as a new field or a join key.Let each side do what it is good atPyQGIS sidegeometry and predicatescoordinate transformsProcessing algorithmsrendering to an imagedata frame sidegrouping and pivotingjoins to spreadsheetsstatistics and chartstables in the notebookattributes as recordsresults as a new field

Exit cleanly and keep notebooks reproducible

qgs.exitQgis() releases the providers and should be called at the end of a session, though in practice a notebook kernel is usually just restarted. The more important discipline is the one every notebook needs and GIS notebooks need more: restart the kernel and run all cells before sharing. A notebook that only works because of a layer created in a cell that was later deleted is worse than a broken script, because it looks like it worked.

Two more habits pay off. Keep the setup — paths, initialisation, imports — in the first cell and nothing else, so the reproducibility question has one answer. And write outputs to files with explicit names rather than leaving them in memory, so the notebook produces artefacts a colleague can check without running anything.

QGIS version compatibility

QGIS versionPythonNotes
3.22 LTR3.9Works; the kernel's Python must also be 3.9.
3.28 LTR3.9Identical.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12Identical setup; no notebook-specific changes.

The binding constraint is always the Python minor version: PyQGIS is a compiled extension built for one version, and a 3.12 build cannot be imported by a 3.10 kernel. Where your data-science environment is pinned elsewhere, run QGIS work in its own kernel and exchange files.

Troubleshooting

  • ModuleNotFoundError: qgis. The bindings folder is not on the path, or the kernel's Python version does not match QGIS's.
  • ImportError for processing. The QGIS plugins folder is missing from sys.path.
  • A display error on a headless machine. Set QT_QPA_PLATFORM=offscreen before importing anything from qgis or PyQt.
  • Layers are always invalid. initQgis() was never called, or the prefix path is wrong so providers were not found.
  • The kernel dies when a cell runs. Usually a second QgsApplication, or a mismatched GDAL between the environment and QGIS. Restart and initialise once.
  • processingRegistry().algorithms() is empty. Processing.initialize() was not called, or the native provider is not registered.

Conclusion

Point the kernel at the QGIS Python libraries and the plugins folder, set the offscreen platform before Qt loads, create and initialise one QgsApplication, and initialise Processing. From there PyQGIS is an ordinary library: build layers, run algorithms, render maps to inline images and move attributes into a data frame. Keep the setup in the first cell, and always restart-and-run-all before sharing.

Frequently Asked Questions

Should I install Jupyter into the QGIS Python or the other way round? Installing Jupyter into the QGIS Python is far simpler and avoids all path manipulation. Reach for the other arrangement only when the notebook environment has dependencies you cannot install alongside QGIS.

Can I use the map canvas in a notebook? No — the canvas is a GUI widget. Render with QgsMapSettings and a renderer job instead, which produces the same output as an image.

Does this work in JupyterLab and VS Code notebooks? Yes. The kernel is what matters; the front end is irrelevant.

Can I run this on a server with no display? Yes, that is exactly what the offscreen platform is for. It is the same arrangement as Run PyQGIS in a Docker Container.

How do I show an interactive map? Export the layer to GeoJSON and use a web-mapping library in the notebook. PyQGIS renders images; interactivity comes from the notebook side.

Is a notebook a good place for production automation? No. Notebooks are for exploration; when the workflow settles, move it into a script that can be scheduled and tested.