PyQGIS Script Crashes with a Segmentation Fault: How to Fix It

Problem statement

The script produces its output, prints "done", and then dies:

wrote data/out/buffered.gpkg
Segmentation fault (core dumped)
$ echo $?
139

Or it crashes halfway through with no message at all, and in a container the exit code is simply 139 (128 + SIGSEGV). There is no Python traceback, because the crash happened in C++ β€” Qt or GDAL or GEOS β€” and the interpreter never got a chance to report anything.

A segfault in PyQGIS is nearly always a lifetime problem: a C++ object was used after the thing that owned it was destroyed, or the application was torn down in the wrong order.

Common causes:

  • QgsApplication.exitQgis() is never called, so cleanup happens at interpreter shutdown in an undefined order
  • exitQgis() is called while layers or a QgsProject still hold references
  • the QgsApplication object is a local variable that goes out of scope while still in use
  • a layer created inside a function is garbage-collected while a Processing algorithm still uses it
  • PyQGIS is used from a thread or a forked worker process
  • the QGIS Python bindings do not match the installed QGIS build
  • a plugin or a mismatched GDAL is loaded into the same process

Quick answer

To stop a PyQGIS script from segfaulting:

  1. keep the QgsApplication in a module-level variable for the whole run
  2. initialise once, in the documented order, and tear down once in a finally
  3. call QgsProject.instance().clear() and drop layer references before exitQgis()
  4. keep a Python reference to every layer for as long as it is used
  5. never fork, thread, or reuse a QGIS process β€” run one task per process
import sys
from qgis.core import QgsApplication, QgsVectorLayer, QgsProject

QgsApplication.setPrefixPath("/usr", True)
qgs = QgsApplication([], False)          # module level: alive for the whole run
qgs.initQgis()

try:
    layer = QgsVectorLayer("data/raw/parcels.gpkg", "parcels", "ogr")
    if not layer.isValid():
        raise SystemExit("layer failed to load")
    QgsProject.instance().addMapLayer(layer)
    print(layer.featureCount(), "features")
finally:
    QgsProject.instance().clear()        # release layers first
    qgs.exitQgis()                       # then tear down the application

The order in the finally block is the fix in most cases: release what the application owns, then shut the application down.

What a segfault actually means here

Triage table of PyQGIS segfault causes and their fixes.
Six lifetime problems, and the fix for each β€” none of them is a Python bug.

Step-by-step solution

Vertical steps of the PyQGIS application lifecycle from prefix path to exitQgis.
Initialise in this order, tear down in the reverse one β€” deviations crash at exit.

Confirm it is a segfault, not an exception

python run.py; echo "exit=$?"

Exit code 139 is SIGSEGV (128 + 11); 134 is SIGABRT, usually an assertion inside Qt. Both mean the process was killed by a signal, so no except block ran and no atexit handler fired. On Linux you can get a stack trace:

ulimit -c unlimited
gdb -q -batch -ex run -ex bt --args python run.py 2>&1 | tail -40

Even a rough backtrace tells you whether the crash is in Qt, GEOS or GDAL, which narrows the search enormously.

Hold the application object for the whole run

A very common shape puts initialisation in a function. When the function returns, the local qgs is collected, the C++ QgsApplication is destroyed, and everything created from it becomes a dangling pointer.

# crashes: qgs dies when init() returns
def init():
    qgs = QgsApplication([], False)
    qgs.initQgis()

init()
layer = QgsVectorLayer(...)     # boom, sooner or later

# correct: module-level, alive for the process
QgsApplication.setPrefixPath("/usr", True)
QGS = QgsApplication([], False)
QGS.initQgis()

If you prefer a function, return the object and keep it bound at module scope, or use a context manager (see the examples below).

Initialise in the documented order

import os, sys

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")   # before any Qt import
sys.path.append("/usr/share/qgis/python/plugins")       # for the processing package

from qgis.core import QgsApplication
QgsApplication.setPrefixPath("/usr", True)              # before construction
QGS = QgsApplication([], False)                          # False = no GUI
QGS.initQgis()                                           # providers, CRS database

from processing.core.Processing import Processing        # import after initQgis
import processing
Processing.initialize()                                  # registers native: algorithms

Importing processing before initQgis() is a classic crash-at-first-run: the algorithm registry is populated against an application that does not exist yet.

Tear down in the reverse order

finally:
    QgsProject.instance().clear()      # drops layers held by the project
    del layer                          # drop your own references
    QGS.exitQgis()

Skipping exitQgis() is not harmless. Without it, the C++ objects are destroyed during interpreter shutdown, in whatever order Python's garbage collector happens to pick β€” which is precisely the situation that produces a crash after the script's last line.

Keep Python references alive

The Python objects are thin wrappers, and when the wrapper is collected the underlying object may go with it. Anything still in use needs a reference.

# risky: the layer has no owner after the function returns
def load(path):
    return QgsVectorLayer(path, "tmp", "ogr").featureCount()

# safe: the project owns it, or you keep the name bound
layer = QgsVectorLayer(path, "parcels", "ogr")
QgsProject.instance().addMapLayer(layer)      # project takes ownership

The same applies to QgsProcessingContext, QgsCoordinateTransformContext and feedback objects passed into processing.run() β€” bind them to a name that outlives the call.

Do not thread or fork

Qt object trees are bound to the thread that created them, and fork() copies a process without copying its threads or native locks. Both produce crashes that look random.

# do not do this
from concurrent.futures import ThreadPoolExecutor
ThreadPoolExecutor().map(run_qgis_task, files)

# do this: one QGIS process per task, driven from an ordinary Python parent
import subprocess
for path in files:
    subprocess.run([sys.executable, "qgis_task.py", str(path)], check=True)

A subprocess per file also isolates crashes: file 47 taking down its own process costs you one file, not the run. qgis_process β€” the CLI shipped with QGIS β€” is a ready-made version of the same idea.

Rule out a version mismatch

Python bindings compiled against a different QGIS build will crash in ways no code change fixes.

from qgis.core import Qgis
print("QGIS", Qgis.QGIS_VERSION)
import qgis, sys
print("bindings:", qgis.__file__)
print("python  :", sys.executable)
qgis_process --version          # what the installed QGIS reports

If the bindings come from a pip package or a conda environment while qgis_process reports a system install, that mismatch is the bug. Use the interpreter QGIS ships with, or install qgis from the same channel as everything else.

Code examples

Example 1: a context manager that always tears down correctly

"""qgis_session.py β€” one place that knows the lifecycle."""
import os, sys
from contextlib import contextmanager

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

from qgis.core import QgsApplication, QgsProject

@contextmanager
def qgis_session(prefix="/usr", plugins="/usr/share/qgis/python/plugins"):
    QgsApplication.setPrefixPath(prefix, True)
    app = QgsApplication([], False)
    app.initQgis()

    if plugins not in sys.path:
        sys.path.append(plugins)
    from processing.core.Processing import Processing
    Processing.initialize()

    try:
        yield app
    finally:
        QgsProject.instance().clear()
        app.exitQgis()
from qgis_session import qgis_session
import processing

with qgis_session():
    result = processing.run("native:buffer", {
        "INPUT": "data/raw/parcels.gpkg",
        "DISTANCE": 50,
        "OUTPUT": "data/out/buffered.gpkg",
    })
    print(result["OUTPUT"])

The finally runs even when the body raises, so a failing algorithm no longer leaves a half-torn-down application behind.

Example 2: one process per file, crashes contained

# driver.py β€” an ordinary Python script, no QGIS imports at all
import subprocess, sys
from pathlib import Path

files = sorted(Path("data/raw").glob("*.gpkg"))
ok, failed = [], []

for path in files:
    proc = subprocess.run(
        [sys.executable, "qgis_task.py", str(path)],
        capture_output=True, text=True, timeout=900,
    )
    if proc.returncode == 0:
        ok.append(path.name)
    else:
        failed.append((path.name, proc.returncode, proc.stderr.strip()[-400:]))

print(f"{len(ok)} ok, {len(failed)} failed")
for name, code, err in failed:
    marker = " (segfault)" if code in (139, -11) else ""
    print(f"  ! {name}: exit {code}{marker}\n    {err}")

Checking for 139 explicitly turns "it crashed" into a diagnosis you can act on.

Example 3: narrowing down where the crash happens

import faulthandler, sys
faulthandler.enable(file=sys.stderr, all_threads=True)

faulthandler installs a signal handler that prints the Python stack when the process receives SIGSEGV. It cannot tell you which C++ line failed, but it names the Python call that was in flight β€” usually enough to identify the object with the broken lifetime.

Example 4: a minimal reproducer to bisect against

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

from qgis.core import QgsApplication, QgsVectorLayer, QgsProject

QgsApplication.setPrefixPath("/usr", True)
app = QgsApplication([], False)
app.initQgis()

layer = QgsVectorLayer("data/raw/parcels.gpkg", "p", "ogr")
print("valid:", layer.isValid(), "features:", layer.featureCount())

QgsProject.instance().clear()
app.exitQgis()
print("clean exit")

If this crashes, the problem is the environment β€” bindings, prefix path, or a library conflict β€” not your pipeline. If it exits cleanly, add your steps back one at a time.

Explanation

PyQGIS is a set of SIP bindings over a large C++ library. Each Python object holds a pointer to a C++ object, and ownership of that C++ object may sit on either side of the boundary. A segmentation fault is what happens when code follows a pointer to memory that has already been freed β€” so the question is never "what raised?" but "what was destroyed too early?".

Panels contrasting a shared long-lived QGIS process with one process per task.
One process per task turns a crash from a lost run into a lost file.

QgsApplication sits at the root of that ownership graph. It owns the provider registry, the CRS database, the Processing registry and the style manager, and virtually every other object depends on at least one of them. Destroying it while layers are alive leaves those layers pointing at freed registries. Equally, letting the interpreter shut down without calling exitQgis() means the C++ destructors run during Python finalisation, in an order nobody controls β€” which is why so many PyQGIS scripts crash after printing their last line. The crash is the cleanup, not the work.

Reference counting is the second half of the story. A QgsVectorLayer created in Python is owned by Python until you hand it to something else, typically QgsProject.instance().addMapLayer(). If nothing holds a reference, the wrapper is collected and the C++ layer goes with it β€” even though a Processing algorithm may still be using it. That is why the guidance is so consistently "keep a name bound": the name is the ownership.

Threads and forks break the model in a different way. Qt requires that objects be used on the thread that created them, and fork() produces a child that has the parent's memory but none of its threads, leaving native locks held by threads that no longer exist. Neither situation raises; both crash. The practical consequence is that parallelism in QGIS automation is achieved with processes that each initialise QGIS themselves β€” which is exactly what qgis_process does, and why it is the most robust way to batch QGIS work.

Edge cases or notes

  • Exit code 139 vs 134: 139 is SIGSEGV (bad memory access), 134 is SIGABRT (an assertion or an uncaught C++ exception). They usually have different causes.
  • A crash only in Docker: Often a missing QT_QPA_PLATFORM=offscreen, or a container without /dev/shm space for Qt's shared memory.
  • del layer is not enough: Only if nothing else holds a reference β€” QgsProject, a layer tree node, or a Processing context may still own it. Call QgsProject.instance().clear() first.
  • Do not mix conda GDAL with system QGIS: Two GDAL builds in one process is a reliable way to crash inside a driver.
  • The Python Console has its own application: Code that works pasted into QGIS may crash standalone, because the console never runs the init/exit sequence you now own.
  • processing.run with TEMPORARY_OUTPUT: The returned layer lives in the Processing context. Convert it or write it out before the context is destroyed.
  • Qt plugins loaded twice: Setting QT_PLUGIN_PATH to a directory from a different Qt build causes crashes at start-up rather than at exit.

FAQ

Why does my script crash after it prints "done"?

Because the crash is in the cleanup. Without exitQgis(), the C++ objects are destroyed during interpreter shutdown in an arbitrary order. Clear the project and call exitQgis() in a finally block.

Do I really need exitQgis() if the script is about to end anyway?

Yes. It performs an ordered teardown of registries and providers. Leaving it out is the single most common cause of exit-time segfaults, and it also flushes provider caches.

Can I run PyQGIS in a thread pool?

No. Qt objects belong to the thread that created them, and the Processing framework is not thread-safe. Use one process per task, spawned with subprocess, or call qgis_process directly.

Why does the same code work in the QGIS Python Console?

The console runs inside a fully initialised application that it owns and never tears down mid-session. Standalone, that lifecycle is yours to manage β€” which is where the crashes come from.

How do I get any diagnostic information from a segfault?

Call faulthandler.enable() at the top of the script to print the Python stack on SIGSEGV, and run under gdb --args python script.py for a native backtrace. Exit code 139 confirms the signal.

Could a bad input file cause a segfault?

It can β€” a corrupt GeoPackage or a malformed geometry can crash a driver. Isolate it by running one file per process; a crash then costs you one file and names it precisely.

Is qgis_process safer than writing my own PyQGIS script?

For straightforward algorithm runs, yes. It initialises and tears down QGIS correctly for every invocation, and a crash is confined to that one run. Write PyQGIS when you need logic that the CLI cannot express.