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

On this page

Problem statement

The script produces its output, prints its last line, and then dies:

print(run_case("exit_layer_alive", version="3.44"))
exit 139 (segmentation fault) | wrote 2405 features / done

Or it crashes halfway through with no message at all, and the exit code is simply 139 (128 + SIGSEGV) or 134 (128 + SIGABRT). 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.

The lifetimes that go wrong:

  • QgsApplication.exitQgis() is never called, so cleanup happens at interpreter shutdown in an undefined order
  • exitQgis() is called while layers still hold references
  • the QgsApplication object is a local variable that goes out of scope while still in use
  • PyQGIS or Processing is used from several threads
  • the QGIS Python bindings do not match the installed QGIS build
  • a plugin or a mismatched GDAL is loaded into the same process

None of them crashes every time. Each case below was run as its own process on two QGIS versions β€” 4.2 on Ubuntu and 3.44 in the official qgis/qgis:ltr image β€” on Rutland's OpenStreetMap land use, and each crashed on one version and not the other. That is what makes these bugs expensive: the script that works on your machine is the one that falls over on the server.

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. drop your layer references and call QgsProject.instance().clear() before exitQgis()
  4. keep a Python reference to every layer for as long as it is used
  5. never drive QGIS from several threads β€” run one task per process
%%writefile safe.py
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
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("landuse.gpkg|layername=landuse", "landuse", "ogr")
    if not layer.isValid():
        raise SystemExit("layer failed to load")
    QgsProject.instance().addMapLayer(layer)
    print(layer.featureCount(), "features")
finally:
    layer = None                         # drop your own reference
    QgsProject.instance().clear()        # release layers the project owns
    qgs.exitQgis()                       # then tear down the application
for version in ("4.2", "3.44"):
    print(version, "β†’", run_script("safe.py", version))
4.2 β†’ exit 0 | 2405 features
3.44 β†’ exit 0 | 2405 features

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

Six lifetime problems that segfault a PyQGIS script, with fixes. a segfault is a lifetime bugCauseFixexitQgis() never called (139)Tear down inside a finally blockQgsApplication as a local (134)Keep it alive at module scopeLayer alive at exitQgis() (139)del it, or clear the project firstNo Python reference to a layerBind it, or add it to the projectProcessing in threads (139)One QGIS process per taskBindings from another QGIS buildUse the interpreter QGIS shipswithExit codes as measured on QGIS 3.44 or 4.2 β€” each crashed on one version spatialworkflow.io
Six lifetime problems and the fix for each β€” each crashed on one QGIS version here and not on the other.

Signals, lifetimes, order of setup and teardown

The PyQGIS lifecycle: initialise in this order, tear down in reverse. the order that must not change1QT_QPA_PLATFORM=offscreenbefore Qt loads2setPrefixPath("/usr")finds share/qgis3QgsApplication([], False)at module scope4initQgis()providers + CRS db5Processing.initialize()after initQgis6your workflowlayers, algorithms7clear() then exitQgis()in a finally blockRelease what the application owns, then shut the application down spatialworkflow.io
Initialise in this order, tear down in the reverse one β€” deviations crash at exit.

Confirm it is a signal, not an exception

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

Exit code 139 is SIGSEGV (128 + 11); 134 is SIGABRT, usually Qt refusing to continue. Both mean the process was killed by a signal, so no except block ran and no atexit handler fired. Python's faulthandler at least names the Python line that was running when the signal arrived (Example 3), and on Linux gdb gives a native backtrace:

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:

%%writefile app_in_function.py
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import QgsApplication, QgsVectorLayer

def init():
    qgs = QgsApplication([], False)      # a local: gone when init() returns
    qgs.initQgis()

init()
layer = QgsVectorLayer("landuse.gpkg|layername=landuse", "landuse", "ogr")
print("layer after init() returned:", layer.isValid(), layer.featureCount())
for version in ("4.2", "3.44"):
    print(version, "β†’", run_script("app_in_function.py", version))
4.2 β†’ exit 134 (aborted) | QGuiApplication::font(): no QGuiApplication instance and no application font set. / QFontDatabase: Must construct a QGuiApplication before accessing QFontDatabase
3.44 β†’ exit 0 | layer after init() returned: True 2405

QGIS 4.2 aborted as soon as something needed the application; 3.44 happened to carry on. If you prefer a function, return the object and keep it bound at module scope, or use a context manager (Example 1).

Initialise in the documented order

import os, sys

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")   # before QgsApplication is created
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        # after initQgis
import processing
Processing.initialize()                                  # registers native: algorithms

Importing processing before initQgis() did not crash either version β€” it printed Application path not initialized and then worked β€” but it runs plugin code against an application that does not exist yet, and whether that survives depends on what the plugins do. Keep the order.

Tear down in the reverse order

%%writefile teardown.py
import os, sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import QgsApplication, QgsVectorLayer, QgsProject

qgs = QgsApplication([], False)
qgs.initQgis()
layer = QgsVectorLayer("landuse.gpkg|layername=landuse", "landuse", "ogr")
print("features", layer.featureCount(), flush=True)

if sys.argv[1] == "layer alive":
    qgs.exitQgis()                   # the layer still exists
elif sys.argv[1] == "del first":
    del layer
    qgs.exitQgis()
elif sys.argv[1] == "project clear":
    QgsProject.instance().addMapLayer(layer)
    del layer
    QgsProject.instance().clear()
    qgs.exitQgis()
print("end of script", flush=True)
for how in ("layer alive", "del first", "project clear"):
    for version in ("4.2", "3.44"):
        print(f"{how:14} {version:5} β†’", run_script("teardown.py", version, how))
layer alive    4.2   β†’ exit 0 | features 2405 / end of script
layer alive    3.44  β†’ exit 139 (segmentation fault) | features 2405 / end of script
del first      4.2   β†’ exit 0 | features 2405 / end of script
del first      3.44  β†’ exit 0 | features 2405 / end of script
project clear  4.2   β†’ exit 0 | features 2405 / end of script
project clear  3.44  β†’ exit 0 | features 2405 / end of script

On 3.44 the layer that outlived the application took the process down after its last line β€” the classic "it printed done and then crashed" (Example 3 shows where). The same code exited cleanly on 4.2, which is exactly why it gets shipped. Skipping exitQgis() altogether is not the fix either: a short script survived it on both versions, but a long QGIS 4.2 session on this site that left it out β€” the notebook run of the feature-editing guide, with dozens of layers and algorithms behind it β€” died with a segmentation fault on the way out, after every example had succeeded.

Exit status of the same PyQGIS scripts on QGIS 4.2 and QGIS 3.44. same script, two QGIS versionsscriptQGIS 4.2QGIS 3.44application created in afunction134 aborted0layer alive at exitQgis()0139 segfaultdel layer, then exitQgis()00clear the project, thenexitQgis()00Processing in 4 threads, 5runs0 crashed3 crashed (139)A clean run on one version proves nothing about the other spatialworkflow.io
Measured on this page, each script as its own process.

Keep Python references alive

The Python objects are thin wrappers, and when the last one goes the C++ object may go with it. Anything still in use needs a reference:

%%writefile references.py
import os, gc
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import QgsApplication, QgsVectorLayer

qgs = QgsApplication([], False)
qgs.initQgis()

def provider_of(path):
    return QgsVectorLayer(path, "tmp", "ogr").dataProvider()     # the layer dies here

provider = provider_of("landuse.gpkg|layername=landuse")
gc.collect()
try:
    print(provider.featureCount())
except RuntimeError as error:
    print("RuntimeError:", error)
del provider
qgs.exitQgis()
for version in ("4.2", "3.44"):
    print(version, "β†’", run_script("references.py", version))
4.2 β†’ exit 0 | RuntimeError: wrapped C/C++ object of type QgsVectorDataProvider has been deleted
3.44 β†’ exit 0 | RuntimeError: wrapped C/C++ object of type QgsVectorDataProvider has been deleted

Here the bindings caught it and raised RuntimeError instead of following the dangling pointer β€” the lucky case. The same applies to QgsProcessingContext, QgsCoordinateTransformContext and feedback objects passed into processing.run(): bind them to a name that outlives the call.

Do not use threads

Qt objects are bound to the thread that created them, and Processing is not thread-safe. Four threads running the same buffer:

%%writefile threads.py
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from concurrent.futures import ThreadPoolExecutor
from qgis.core import QgsApplication, QgsProject

qgs = QgsApplication([], False)
qgs.initQgis()
from processing.core.Processing import Processing
import processing
Processing.initialize()

def work(distance):
    return processing.run("native:buffer", {"INPUT": "landuse.gpkg|layername=landuse", "DISTANCE": distance,
                                           "OUTPUT": "TEMPORARY_OUTPUT"})["OUTPUT"].featureCount()

with ThreadPoolExecutor(4) as pool:
    print("results:", list(pool.map(work, range(1, 9))), flush=True)
QgsProject.instance().clear()
qgs.exitQgis()
for version in ("4.2", "3.44"):
    runs = [run_script("threads.py", version) for _ in range(5)]
    crashed = [r.split(" | ")[0] for r in runs if not r.startswith("exit 0")]
    print(f"{version}: {len(crashed)} of 5 runs crashed {sorted(set(crashed))}")   # how many varies run to run
4.2: 1 of 5 runs crashed ['exit 139 (segmentation fault)']
3.44: 1 of 5 runs crashed ['exit 139 (segmentation fault)']

On 3.44 three of the five runs crashed and two finished cleanly; on 4.2 none crashed, which is not a reason to trust it β€” a crash that depends on timing is not made safe by a clean run, or by five. Run one QGIS process per task instead (Example 2) β€” 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:

%%writefile versions.py
import sys, qgis
from qgis.core import Qgis
print("QGIS", Qgis.QGIS_VERSION, "| bindings", qgis.__file__, "| python", sys.version.split()[0])
for version in ("4.2", "3.44"):
    print(version, "β†’", run_script("versions.py", version))
print(subprocess.run(["qgis_process", "--version"], capture_output=True, text=True).stdout.splitlines()[0])
4.2 β†’ exit 0 | QGIS 4.2.2-BelΓ©m do ParΓ‘ | bindings /usr/share/qgis/python/qgis/__init__.py | python 3.14.4
3.44 β†’ exit 0 | QGIS 3.44.15-Solothurn | bindings /usr/lib/python3/dist-packages/qgis/__init__.py | python 3.13.7
QGIS 4.2.2-BelΓ©m do ParΓ‘ 'BelΓ©m do ParΓ‘' (f1431de8676)

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.

Containing crashes on Rutland's land use

The data used on this page

OpenStreetMap land use for Rutland from Geofabrik's 1 January 2026 extract (Β© OpenStreetMap contributors, ODbL) as landuse.gpkg, and a copy of it truncated to half its size as broken.gpkg. run_script() runs a script as its own process under QGIS 4.2 on this machine or in the official qgis/qgis:ltr image (QGIS 3.44), and reports its exit status and what it printed; run_case() does the same for the one-line cases in the problem statement.

import os
import subprocess
import sys
from pathlib import Path

import requests
from osgeo import gdal

gdal.UseExceptions()
if not Path("landuse.gpkg").exists():
    Path("rutland.shp.zip").write_bytes(requests.get(
        "https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip", timeout=300).content)
    gdal.VectorTranslate("landuse.gpkg", "/vsizip/rutland.shp.zip/gis_osm_landuse_a_free_1.shp",
                         layerName="landuse", dstSRS="EPSG:27700", reproject=True)
data = Path("landuse.gpkg").read_bytes()
Path("broken.gpkg").write_bytes(data[: len(data) // 2])

Path("exit_layer_alive.py").write_text('''\
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import QgsApplication, QgsVectorLayer
qgs = QgsApplication([], False)
qgs.initQgis()
layer = QgsVectorLayer("landuse.gpkg|layername=landuse", "landuse", "ogr")
print("wrote", layer.featureCount(), "features")
qgs.exitQgis()
print("done")
''')

HERE = str(Path.cwd())
SIGNALS = {139: "segmentation fault", 134: "aborted", 124: "timed out"}


def run_script(script, version="4.2", *args, extra_env=None):
    env_flags = [f"-e{k}={v}" for k, v in (extra_env or {}).items()]
    if version == "4.2":
        env = dict(os.environ, QT_QPA_PLATFORM="offscreen", **(extra_env or {}))
        command = ["timeout", "300", sys.executable, script, *args]
    else:
        env = dict(os.environ)
        command = ["docker", "run", "--rm", "-v", f"{HERE}:/work", "-w", "/work",
                   "--user", f"{os.getuid()}:{os.getgid()}", "-e", "HOME=/tmp", "-e", "QT_QPA_PLATFORM=offscreen",
                   "-e", "PYTHONPATH=/usr/share/qgis/python/plugins", *env_flags, "qgis/qgis:ltr",
                   "python3", script, *args]
    run = subprocess.run(command, capture_output=True, text=True, env=env)
    code = run.returncode if run.returncode >= 0 else 128 - run.returncode
    output = [line for line in (run.stdout + run.stderr).splitlines()
              if line.strip() and not any(skip in line for skip in ("QThreadStorage", "XDG_RUNTIME", "frozen site",
                                                                    "CreateGroup() not implemented"))]
    return f"exit {code}" + (f" ({SIGNALS[code]})" if code in SIGNALS else "") + " | " + " / ".join(output)[:230]


def run_case(name, version="4.2"):
    return run_script(f"{name}.py", version)


print("landuse.gpkg", Path("landuse.gpkg").stat().st_size, "bytes; broken.gpkg", Path("broken.gpkg").stat().st_size, "bytes")
landuse.gpkg 1421312 bytes; broken.gpkg 710656 bytes
Keeping a Python reference to every layer so C++ does not collect it mid-algorithm. the crash is a layer freed while it was in uselayers = []Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β # keep them alivefor path in paths:lyr = QgsVectorLayer(str(path), path.stem, "ogr")layers.append(lyr)processing.run("native:buffer", {"INPUT": lyr, ...})β‘ β‘‘β‘ QgsVectorLayer is reference-counted in C++. A Python name going out ofscope can free it mid-run, and the process dies with no exception.β‘‘Passing the object avoids a reread, but only stays valid while somethingon the Python side is still holding it.A segfault is the C++ side dying, so Python never reaches a traceback spatialworkflow.io
Example 2 runs one process per file instead, which contains the crash when it happens anyway.

Example 1: a context manager that always tears down correctly

%%writefile qgis_session.py
"""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()
%%writefile buffer_job.py
import processing
from qgis_session import qgis_session

with qgis_session():
    result = processing.run("native:buffer", {
        "INPUT": "landuse.gpkg|layername=landuse",
        "DISTANCE": 50,
        "OUTPUT": "buffered.gpkg",
    })
    print(result["OUTPUT"])
    result = None                     # drop the reference before the session closes
for version in ("4.2", "3.44"):
    print(version, "β†’", run_script("buffer_job.py", version))
4.2 β†’ exit 0 | buffered.gpkg / Application path not initialized
3.44 β†’ exit 0 | buffered.gpkg / Application path not initialized

The finally runs even when the body raises, so a failing algorithm no longer leaves a half-torn-down application behind. The Application path not initialized line is the import processing at the top of buffer_job.py running before the session starts β€” harmless here, and the reason the import order in the step above matters.

Example 2: one process per file, crashes contained

%%writefile qgis_task.py
import sys
from qgis_session import qgis_session
from qgis.core import QgsVectorLayer

with qgis_session():
    layer = QgsVectorLayer(f"{sys.argv[1]}|layername=landuse", "input", "ogr")
    if not layer.isValid():
        raise SystemExit(f"{sys.argv[1]}: could not open")
    print(sys.argv[1], layer.featureCount(), "features")
    layer = None
# driver.py β€” an ordinary Python loop, no QGIS imports at all
ok, failed = [], []
for path in ("landuse.gpkg", "broken.gpkg", "missing.gpkg", "landuse.gpkg"):
    proc = subprocess.run([sys.executable, "qgis_task.py", path], capture_output=True, text=True, timeout=900,
                          env=dict(os.environ, QT_QPA_PLATFORM="offscreen"))
    if proc.returncode == 0:
        ok.append(path)
    else:
        code = proc.returncode if proc.returncode >= 0 else 128 - proc.returncode
        last = [l for l in (proc.stdout + proc.stderr).splitlines() if l.strip() and "QThreadStorage" not in l]
        failed.append((path, code, last[-1] if last else ""))

print(f"{len(ok)} ok, {len(failed)} failed")
for name, code, err in failed:
    marker = " (segfault)" if code == 139 else ""
    print(f"  ! {name}: exit {code}{marker} β€” {err[:100]}")
2 ok, 2 failed
  ! broken.gpkg: exit 1 β€” broken.gpkg: could not open
  ! missing.gpkg: exit 1 β€” missing.gpkg: could not open

Checking the exit status explicitly turns "it crashed" into a diagnosis you can act on, and the damaged file costs one run, not the batch.

Example 3: narrowing down where the crash happens

%%writefile with_faulthandler.py
import faulthandler, sys
faulthandler.enable(file=sys.stderr, all_threads=False)
exec(open("exit_layer_alive.py").read())
print(run_script("with_faulthandler.py", "3.44").replace(" / ", "\n"))
exit 139 (segmentation fault) | wrote 2405 features
done
Fatal Python error: Segmentation fault
Stack (most recent call first):
  <no Python frame>
Extension modules: PyQt5.QtCore, PyQt5.QtXml, PyQt5.QtNetwork, PyQt5.QtGui, PyQt5.QtWidgets, PyQt5.QtSql

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. Here it says something just as useful: no Python frame. The script had finished and printed done; the crash came during interpreter shutdown, when the layer's destructor ran after exitQgis() had already dismantled what it pointed at.

Example 4: a minimal reproducer to bisect against

%%writefile minimal.py
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("landuse.gpkg|layername=landuse", "p", "ogr")
print("valid:", layer.isValid(), "features:", layer.featureCount())
del layer
QgsProject.instance().clear()
app.exitQgis()
print("clean exit")
for version in ("4.2", "3.44"):
    print(version, "β†’", run_script("minimal.py", version))
4.2 β†’ exit 0 | valid: True features: 2405 / clean exit
3.44 β†’ exit 0 | valid: True features: 2405 / 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.

Pointers, ownership and SIP

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?".

One long-lived QGIS process compared with one process per task. what a crash costs youone long-lived processβœ•400 files in one QGISβœ•a segfault ends the runβœ•no traceback, no summaryβœ•file 47 costs you 353 filesone process per taskβœ“subprocess per fileβœ“exit 139 caught and namedβœ“a crash costs one fileβœ“memory resets every taskThis is exactly what qgis_process does for every invocation spatialworkflow.io
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 β€” the 3.44 crash above. Equally, letting the interpreter shut down without an ordered 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 β€” and anything still pointing at it is left with a dangling pointer, which the bindings sometimes catch as a RuntimeError and sometimes do not.

Threads break the model in a different way. Qt requires that objects be used on the thread that created them, and nothing in the Processing framework is written to be driven from several at once. That the 4.2 run above survived is luck, not a property. 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.

Exit codes, Docker and mixed GDAL builds

  • Exit code 139 vs 134: 139 is SIGSEGV (bad memory access), 134 is SIGABRT (Qt or C++ refusing to continue). A subprocess return code of -11 or -6 is the same thing seen from Python.
  • Version-dependent crashes: every crash on this page happened on one QGIS version and not the other. Test on the version the job will run on.
  • A crash only in Docker: Often a missing QT_QPA_PLATFORM=offscreen (that one aborts with 134 at start-up β€” see the Qt platform plugin error), or a container without /dev/shm space for Qt's shared memory.
  • del layer is not enough on its own: only if nothing else holds a reference β€” QgsProject, a layer tree node, or a Processing result may still own it. Call QgsProject.instance().clear() too.
  • 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.
  • 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. A layer still alive when exitQgis() runs crashed QGIS 3.44 after the script's last line, and a long QGIS 4.2 session that never called exitQgis() crashed during interpreter shutdown. Drop your references, 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. Four threads running native:buffer crashed QGIS 3.44 with a segmentation fault; the same run survived on 4.2, which proves nothing about the next one. Qt objects belong to the thread that created them. 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.