The QGIS Processing Framework Explained
On this page
Problem statement
The Processing Toolbox holds hundreds of algorithms, contributed by half a dozen different engines, and from Python they all reduce to one call:
processing.run("native:buffer", {"INPUT": layer, "DISTANCE": 25, "OUTPUT": "TEMPORARY_OUTPUT"})
That uniformity is not an accident, and understanding the machinery behind it answers most of the questions people have when they start automating QGIS:
- why an algorithm id has a prefix, and why
qgis:andnative:both exist - why
TEMPORARY_OUTPUTsometimes gives you a layer and sometimes an id - why the algorithm works in the GUI and reports "not found" in a script
- how GRASS algorithms appear alongside QGIS's own
- what the Modeler is actually producing, and why it can be run headless
Processing is a plugin-based framework: a registry, a parameter system and a runner. Everything else β the Toolbox, the Modeler, qgis_process, batch mode β is a front end onto it. Everything below was run headless under QGIS 4.2 on Rutland's land use, schools and Land Registry parcels.
Quick answer
Five concepts explain the whole framework:
- Providers contribute algorithms and own an id prefix:
native:,qgis:,gdal:,grass:,pdal:,3d:,model:,script: - Algorithms declare typed parameters and typed outputs β that declaration is what generates the dialog, the CLI help and the validation
- A context carries the project, the invalid-geometry policy and the temporary layer store; feedback carries progress and messages
processing.run()validates parameters, executes, and returns a results dictionary- The registry is empty until
Processing.initialize()runs β which the GUI does for you and a script must do itself
registry = QgsApplication.processingRegistry()
print("providers :", [p.id() for p in registry.providers()])
print("algorithms:", len(registry.algorithms()))
alg = registry.algorithmById("native:buffer")
print(alg.displayName(), "->", [p.name() for p in alg.parameterDefinitions()])
providers : ['3d', 'gdal', 'grass', 'model', 'native', 'pdal', 'project', 'qgis', 'script']
algorithms: 772
Buffer -> ['INPUT', 'DISTANCE', 'SEGMENTS', 'END_CAP_STYLE', 'JOIN_STYLE', 'MITER_LIMIT', 'DISSOLVE', 'SEPARATE_DISJOINT', 'OUTPUT']
Asking the registry beats guessing every time: it reflects your installation, not the documentation for some other version.
The shape of the framework
Providers, algorithms, parameters, outputs and context
Providers: where algorithms come from
for provider in registry.providers():
print(f"{provider.id():8} {len(provider.algorithms()):>4} algorithms {provider.name()}")
3d 1 algorithms QGIS (3D)
gdal 59 algorithms GDAL
grass 307 algorithms GRASS
model 0 algorithms Models
native 350 algorithms QGIS (native c++)
pdal 24 algorithms QGIS (PDAL)
project 0 algorithms Project models
qgis 31 algorithms QGIS
script 0 algorithms Scripts
Each provider is a plugin that registers its algorithms under a prefix. native: is the C++ implementation; qgis: is the Python one, and the ids of algorithms that were ported to C++ still resolve as aliases; gdal: builds command lines for the GDAL tools; grass: runs GRASS modules and was registered here because GRASS is installed. model: and script: are empty until a profile holds models or scripts.
That model is why a missing algorithm is usually a missing provider, or an id from another release β the algorithm-not-found guide diffs 3.44 against 4.2.
Algorithms declare their own interface
An algorithm is a class that describes its parameters and outputs. Everything else is generated from that description.
import contextlib, io
buffer_help = io.StringIO()
with contextlib.redirect_stdout(buffer_help):
processing.algorithmHelp("native:buffer")
lines = buffer_help.getvalue().splitlines()
start = lines.index("END_CAP_STYLE: End cap style")
print("\n".join(lines[start:start + 8]))
END_CAP_STYLE: End cap style
Parameter type: QgsProcessingParameterEnum
Available values:
- 0: Round
- 1: Flat
- 2: Square
The same declaration produces the Toolbox dialog, the batch-mode table, the qgis_process help output, and the validation that rejects a bad parameter before anything runs:
for params in ({"INPUT": "data/rutland.gpkg|layername=schools", "DISTANCE": 25, "END_CAP_STYLE": "Flat",
"OUTPUT": "TEMPORARY_OUTPUT"},
{"INPUT": "data/rutland.gpkg|layername=schools", "DISTANCE": 25, "END_CAPSTYLE": 1,
"OUTPUT": "TEMPORARY_OUTPUT"}):
try:
out = processing.run("native:buffer", params)["OUTPUT"]
print("ran:", out.featureCount(), "features β the misspelt key was ignored")
except Exception as exc:
print(type(exc).__name__ + ":", " ".join(str(exc).split())[:100])
QgsProcessingException: Unable to execute algorithm Incorrect parameter value for END_CAP_STYLE
ran: 26 features β the misspelt key was ignored
An enum given as its label was refused before anything ran. A misspelt parameter name was not: END_CAPSTYLE is not a parameter of native:buffer, so it was ignored, and the buffer ran with the default cap. Validation checks the values of the parameters it knows about; it does not reject keys it has never heard of. Writing your own algorithm means writing that declaration β which is why a script algorithm gets a dialog for free.
Parameter types, and what they accept
schools = QgsVectorLayer("data/rutland.gpkg|layername=schools", "schools", "ogr")
result = processing.run("native:buffer", {
"INPUT": schools, # a source: layer object, path, or path|layername=
"DISTANCE": 25, # a distance, in the layer's units
"SEGMENTS": 8, # a number
"END_CAP_STYLE": 0, # an enum - the integer, not the label
"DISSOLVE": False, # a boolean
"OUTPUT": "out/buffered.gpkg", # a sink: a path, or TEMPORARY_OUTPUT
})
print(result)
{'OUTPUT': 'out/buffered.gpkg'}
The types worth knowing: source (an input layer), sink (an output layer), enum (an integer), distance and number, field (a column name), extent (xmin,xmax,ymin,ymax [CRS]), crs, matrix, and multiple layers. A source accepts a QgsVectorLayer, a file path, or a URI string β which is what lets the same call work in a script and in the Modeler.
Outputs: files, memory, or the context
from qgis.core import QgsProcessingContext, QgsProcessingFeedback
params = {"INPUT": schools, "DISTANCE": 25, "OUTPUT": "TEMPORARY_OUTPUT"}
plain = processing.run("native:buffer", params)
print("processing.run: ", type(plain["OUTPUT"]).__name__)
context = QgsProcessingContext()
feedback = QgsProcessingFeedback()
own = processing.run("native:buffer", params, context=context, feedback=feedback)
print("with your own context: ", type(own["OUTPUT"]).__name__)
child = processing.run("native:buffer", params, context=context, feedback=feedback, is_child_algorithm=True)
held = context.getMapLayer(child["OUTPUT"])
print("is_child_algorithm=True: ", type(child["OUTPUT"]).__name__, child["OUTPUT"][:17] + "...",
"->", held.featureCount(), "features in the context")
processing.run: QgsVectorLayer
with your own context: QgsVectorLayer
is_child_algorithm=True: str Buffered_6a6e9319... -> 26 features in the context
That is the "sometimes a layer, sometimes an id" question answered, and passing a context is not what decides it. A temporary output is created in the context's layer store; by default processing.run then takes it out and hands you the layer object, whether the context was yours or its own. With is_child_algorithm=True β what a script algorithm or model uses for its inner steps β the layer stays in the context and you get its id, which is only good for as long as that context lives. Temporary outputs are the reason chained algorithms are fast: intermediate results stay in memory rather than being written and re-read.
Context and feedback: the two objects to pass explicitly
from qgis.core import QgsFeatureRequest, QgsProject
class Verbose(QgsProcessingFeedback):
def __init__(self):
super().__init__()
self.messages = []
def pushInfo(self, info): self.messages.append(("INFO", info))
def pushWarning(self, warning): self.messages.append(("WARN", warning))
def reportError(self, error, fatalError=False): self.messages.append(("ERROR", error))
context = QgsProcessingContext()
context.setProject(QgsProject.instance()) # so @project variables resolve
listening = Verbose()
processing.run("native:clip", {"INPUT": "data/rutland.gpkg|layername=parcels",
"OVERLAY": "data/rutland.gpkg|layername=schools",
"OUTPUT": "TEMPORARY_OUTPUT"}, context=context, feedback=listening)
for level, text in listening.messages[:4]:
print(f"{level:5} {text[:95]}")
INFO Results: {'OUTPUT': 'Clipped_ac2cdc17_26b8_4eb9_a827_8b159a52b8e0'}
Without a feedback object, those messages β here, the algorithm's report of what it did, and in other runs skipped features or CRS notes β go to a default object nobody reads. That is the usual reason an empty result seems to have no explanation.
The invalid-geometry policy
The context also decides what happens when an input geometry is invalid. Rutland's parcels, OSM land use and buildings were all valid, so this test makes a bow-tie from one parcel's corners, joined in the wrong order so the outline crosses itself:
parcel = next(QgsVectorLayer("data/rutland.gpkg|layername=parcels", "p", "ogr").getFeatures())
box = parcel.geometry().boundingBox() # its corners, joined in the wrong order
ring = [QgsPointXY(box.xMinimum(), box.yMinimum()), QgsPointXY(box.xMaximum(), box.yMaximum()),
QgsPointXY(box.xMaximum(), box.yMinimum()), QgsPointXY(box.xMinimum(), box.yMaximum()),
QgsPointXY(box.xMinimum(), box.yMinimum())]
broken = QgsVectorLayer("Polygon?crs=EPSG:27700&field=id:integer", "broken", "memory")
feature = QgsFeature(broken.fields())
feature.setGeometry(QgsGeometry.fromPolygonXY([ring]))
feature["id"] = 1
broken.dataProvider().addFeatures([feature])
print("valid:", feature.geometry().isGeosValid())
def outcome(alg_id, context=None):
params = {"INPUT": broken, "OUTPUT": "TEMPORARY_OUTPUT", **({"DISTANCE": 5} if alg_id == "native:buffer" else {})}
try:
run = processing.run(alg_id, params, context=context) if context else processing.run(alg_id, params)
return f"{run['OUTPUT'].featureCount()} out"
except Exception:
return "stopped"
contexts = {"processing.run, no context": None}
for policy in (Qgis.InvalidGeometryCheck.NoCheck, Qgis.InvalidGeometryCheck.SkipInvalid,
Qgis.InvalidGeometryCheck.AbortOnInvalid):
contexts[f"own context, {policy.name}"] = QgsProcessingContext()
contexts[f"own context, {policy.name}"].setInvalidGeometryCheck(policy)
print(f"{'':32}{'native:centroids':>18}{'native:buffer':>15}{'native:fixgeometries':>22}")
for label, ctx in contexts.items():
print(f"{label:32}{outcome('native:centroids', ctx):>18}{outcome('native:buffer', ctx):>15}"
f"{outcome('native:fixgeometries', ctx):>22}")
print("a new QgsProcessingContext() defaults to:", QgsProcessingContext().invalidGeometryCheck().name)
valid: False
native:centroids native:buffer native:fixgeometries
processing.run, no context stopped 1 out 1 out
own context, NoCheck 1 out 1 out 1 out
own context, SkipInvalid 0 out 1 out 1 out
own context, AbortOnInvalid stopped 1 out 1 out
a new QgsProcessingContext() defaults to: NoCheck
This setting explains a class of "the GUI and my script disagree" reports, and the direction is not the one usually assumed. processing.run() without a context builds one from the Processing settings, whose default β the same as the GUI's β is to stop on an invalid geometry. A QgsProcessingContext() you create yourself defaults to no check at all, so the same bow-tie that stops native:centroids in the GUI sails through a script that passes its own context. And the policy is not universal: native:buffer and native:fixgeometries produced their feature under every setting, because algorithms that can cope with invalid input switch the check off for themselves. Set the policy explicitly whenever you create a context, and do not assume it covers every algorithm.
Running algorithms from every front end
QgsProject.instance().clear()
loaded = processing.runAndLoadResults("native:centroids", {"INPUT": schools, "OUTPUT": "TEMPORARY_OUTPUT"})
print("runAndLoadResults, headless:", [l.name() for l in QgsProject.instance().mapLayers().values()])
cli = subprocess.run(["qgis_process", "run", "native:centroids", "--", "INPUT=data/rutland.gpkg|layername=schools",
"OUTPUT=out/centroids_cli.gpkg"], capture_output=True, text=True, env=dict(os.environ))
print("qgis_process:", sorted({l for l in cli.stdout.splitlines() if l.startswith("OUTPUT")}))
runAndLoadResults, headless: ['Centroids']
qgis_process: ['OUTPUT:\tout/centroids_cli.gpkg']
runAndLoadResults is not GUI-only: headless, it added its result to QgsProject.instance(), where nothing will draw it. The uniformity is the payoff: anything the Toolbox can run, a script can run, the CLI can run, and a model can contain β because all of them resolve the same id against the same registry.
Exploring and chaining algorithms on Rutland's data
The data used on this page
Rutland's land use and schools from Geofabrik's OpenStreetMap extract of 1 January 2026 (Β© OpenStreetMap contributors, ODbL), and HM Land Registry's INSPIRE Index Polygons for the county (Β© Crown copyright and database rights, HM Land Registry; OGL), in one GeoPackage in British National Grid.
import os
import subprocess
import sys
from pathlib import Path
import requests
from osgeo import gdal
gdal.UseExceptions()
for folder in ("data", "out"):
Path(folder).mkdir(exist_ok=True)
session = requests.Session()
if not Path("data/rutland.gpkg").exists():
Path("data/rutland.shp.zip").write_bytes(session.get(
"https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip", timeout=300).content)
osm = "/vsizip/data/rutland.shp.zip/"
gdal.VectorTranslate("data/rutland.gpkg", osm + "gis_osm_landuse_a_free_1.shp", layerName="landuse",
dstSRS="EPSG:27700", reproject=True, selectFields=["fclass"])
gdal.VectorTranslate("data/rutland.gpkg", osm + "gis_osm_pois_a_free_1.shp", accessMode="append",
layerName="schools", dstSRS="EPSG:27700", reproject=True,
SQLStatement="SELECT name FROM gis_osm_pois_a_free_1 WHERE fclass = 'school'")
Path("data/Rutland.zip").write_bytes(session.get(
"https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip", timeout=300).content)
gdal.VectorTranslate("data/rutland.gpkg", "/vsizip/data/Rutland.zip/Land_Registry_Cadastral_Parcels.gml",
accessMode="append", layerName="parcels", SQLStatement="SELECT INSPIREID FROM PREDEFINED")
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import Qgis, QgsApplication, QgsFeature, QgsGeometry, QgsPointXY, QgsVectorLayer
qgs = QgsApplication([], False)
qgs.initQgis()
print("before Processing.initialize():", len(QgsApplication.processingRegistry().algorithms()), "algorithms")
from processing.core.Processing import Processing
import processing
Processing.initialize()
print("after:", len(QgsApplication.processingRegistry().algorithms()), "| QGIS", Qgis.QGIS_VERSION.split("-")[0])
before Processing.initialize(): 0 algorithms
after: 772 | QGIS 4.2.2
Example 1: Explore the registry properly
import csv
def dump_algorithms(path="out/algorithms.csv"):
with open(path, "w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
writer.writerow(["id", "display_name", "provider", "group", "parameters", "outputs"])
for alg in sorted(registry.algorithms(), key=lambda a: a.id()):
writer.writerow([
alg.id(), alg.displayName(), alg.provider().id(), alg.group(),
"|".join(p.name() for p in alg.parameterDefinitions()),
"|".join(o.name() for o in alg.outputDefinitions()),
])
return sum(1 for _ in open(path)) - 1
def find(term: str):
for alg in registry.algorithms():
if term.lower() in alg.id().lower() or term.lower() in alg.displayName().lower():
print(f" {alg.id():28} {alg.displayName()}")
find("dissolve")
print(dump_algorithms(), "algorithms written to out/algorithms.csv")
gdal:dissolve Dissolve
grass:v.dissolve v.dissolve
native:coverageunion Dissolve coverage
native:dissolve Dissolve
772 algorithms written to out/algorithms.csv
Example 2: Inspect parameters programmatically
from qgis.core import QgsProcessingParameterEnum, QgsProcessingParameterField, QgsProcessingParameterNumber
def describe(alg_id: str) -> None:
alg = registry.algorithmById(alg_id)
if alg is None:
raise ValueError(f"{alg_id} is not registered")
print(f"{alg.displayName()} ({alg.id()})")
for param in alg.parameterDefinitions():
line = f" {param.name():20} {type(param).__name__.replace('QgsProcessingParameter', ''):14}"
if isinstance(param, QgsProcessingParameterEnum):
line += " " + ", ".join(f"{i}={v}" for i, v in enumerate(param.options()))
elif isinstance(param, QgsProcessingParameterNumber):
line += f" default={param.defaultValue()}"
elif isinstance(param, QgsProcessingParameterField):
line += f" field of {param.parentLayerParameterName()}"
if param.flags() & Qgis.ProcessingParameterFlag.Optional:
line += " [optional]"
print(line[:108])
describe("native:joinattributesbylocation")
Join attributes by location (native:joinattributesbylocation)
INPUT FeatureSource
PREDICATE Enum 0=intersect, 1=contain, 2=equal, 3=touch, 4=overlap, 5=are within, 6=c
JOIN FeatureSource
JOIN_FIELDS Field field of JOIN [optional]
METHOD Enum 0=Create separate feature for each matching feature (one-to-many), 1=T
DISCARD_NONMATCHING Boolean
PREFIX String [optional]
OUTPUT FeatureSink [optional]
NON_MATCHING FeatureSink [optional]
This is algorithmHelp() with structure, and it is what you want when generating configuration or validating a model programmatically.
Example 3: Chain algorithms through one context
def chain(source, steps, final_output):
"""Run a list of (alg_id, params) with temporary outputs between them."""
context, feedback = QgsProcessingContext(), QgsProcessingFeedback()
context.setInvalidGeometryCheck(Qgis.InvalidGeometryCheck.AbortOnInvalid)
current = source
for i, (alg_id, params) in enumerate(steps):
is_last = i == len(steps) - 1
run_params = {**params, "INPUT": current,
"OUTPUT": final_output if is_last else "TEMPORARY_OUTPUT"}
result = processing.run(alg_id, run_params, context=context, feedback=feedback)
current = result["OUTPUT"]
print(f"{i + 1}. {alg_id:24} -> {'file' if is_last else 'memory'}")
return current, context # the context holds the settings the chain ran with
output, chain_context = chain(
"data/rutland.gpkg|layername=landuse",
[("native:fixgeometries", {}),
("native:reprojectlayer", {"TARGET_CRS": "EPSG:27700"}),
("native:buffer", {"DISTANCE": 25, "SEGMENTS": 8, "DISSOLVE": False}),
("native:dissolve", {"FIELD": ["fclass"]})],
"out/landuse_zones.gpkg",
)
print(output, "->", QgsVectorLayer(output, "zones", "ogr").featureCount(), "zones")
1. native:fixgeometries -> memory
2. native:reprojectlayer -> memory
3. native:buffer -> memory
4. native:dissolve -> file
out/landuse_zones.gpkg -> 19 zones
Each intermediate comes back as a layer object that the next step takes as its INPUT, so nothing touches the disk until the last step. Sharing one context is what makes every step run under the same settings β and setting its invalid-geometry policy is the detail that makes the chain behave like the GUI.
Example 4: Initialise Processing correctly in a script
Path("boot_and_buffer.py").write_text('''
import os, sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import QgsApplication, QgsProject
def boot(plugins="/usr/share/qgis/python/plugins"):
app = QgsApplication([], False)
app.initQgis() # 1. the application
if plugins not in sys.path:
sys.path.append(plugins)
from processing.core.Processing import Processing
Processing.initialize() # 2. THEN the registry
n = len(QgsApplication.processingRegistry().algorithms())
if not n:
raise RuntimeError("Processing registry is empty after initialize()")
print(f"{n} algorithms available")
return app
app = boot()
try:
import processing
print(processing.run("native:buffer", {"INPUT": "data/rutland.gpkg|layername=schools", "DISTANCE": 25,
"OUTPUT": "out/boot_buffered.gpkg"})["OUTPUT"])
finally:
QgsProject.instance().clear()
app.exitQgis()
''')
run = subprocess.run([sys.executable, "boot_and_buffer.py"], capture_output=True, text=True)
print("exit", run.returncode, "|", " | ".join(run.stdout.split("\n")[:2]))
exit 0 | 772 algorithms available | out/boot_buffered.gpkg
The order β initQgis() before Processing.initialize() β is the most common mistake in headless PyQGIS. Reversed, it raises nothing: it prints "Application path not initialized" dozens of times and leaves a registry that later reports "algorithm not found", pointing at the wrong thing entirely.
The page's QGIS session ends here:
QgsProject.instance().clear()
qgs.exitQgis()
print("QGIS closed")
QGIS closed
From SEXTANTE to a general framework
Processing began life as the SEXTANTE plugin and was absorbed into QGIS as its general-purpose analysis framework. Its central design decision is that an algorithm declares its interface rather than implementing one, and everything else follows from that.
native: is there without Python and without anything else installed.Because parameters are typed objects with names, defaults, ranges and optionality, one description generates every front end. The Toolbox builds a dialog from it. Batch mode builds a table. qgis_process help prints it. The Modeler uses it to know which outputs can feed which inputs. And processing.run() uses it to validate the values in your dictionary before anything executes β though, as the misspelt key showed, not to reject names it does not know.
The provider model is what makes hundreds of algorithms possible without QGIS implementing them all. The GRASS provider runs GRASS modules, translating parameters in and results back; the GDAL provider builds ogr2ogr and gdalwarp command lines; native: calls straight into C++. From the caller's side they are identical, which is a genuinely impressive abstraction and also the reason availability varies between installations: no GRASS on the machine, no grass: algorithms in the registry.
Context and feedback are the two objects that carry everything an algorithm needs that is not a parameter. The context holds the project, the transform context, the invalid-geometry policy and β importantly β a temporary layer store, which is what TEMPORARY_OUTPUT writes into. Feedback carries progress and messages back out. Passing your own instances of both is what turns a script from a black box into something you can watch and control β as long as you also give the context the policy you meant, since a new one checks nothing.
Finally, the registry's emptiness at start-up explains the most common headless failure. Inside QGIS, the application and Processing were initialised long before your code ran. In a standalone script the page's own setup printed 0 algorithms after initQgis() and the full set only after Processing.initialize(). Get the order wrong and the error you eventually see is "algorithm not found", pointing at the wrong thing entirely.
Ids, enums and parameters that are silently ignored
native:andqgis:ids: algorithms ported to C++ keep answering to their oldqgis:ids as aliases on newer releases; the newnative:ids do not exist on older ones.- Enum parameters take integers: the labels in the dialog are display text, and a label was refused.
algorithmHelp()lists the mapping. - Unknown parameter names are ignored: a typo in a key runs the algorithm with that parameter's default.
TEMPORARY_OUTPUTneeds a living context: keep a reference, or fetch the layer before the context goes out of scope.runAndLoadResultsworks headless: it adds the result toQgsProject.instance()β useful only if something reads the project afterwards.- Invalid-geometry policy differs by who made the context:
processing.run()without one stops on invalid input, like the GUI; a bareQgsProcessingContext()checks nothing. - Provider availability is per installation: check
registry.providers()rather than assuming GRASS exists. - Models and scripts are algorithms too: they register under
model:andscript:from the profile β and a standalone script has a profile of its own, which is why a headless run may need the.model3file path instead.
Internal links
- How to Run QGIS Processing Algorithms from Python
- QGIS Processing "Algorithm Not Found" Error in Python: How to Fix It
- How to Build a QGIS Processing Model and Run It from Python
- How to Write a Custom QGIS Processing Script Algorithm in Python
- How to Automate QGIS from the Command Line with qgis_process
- PyQGIS Processing Output Is Empty: How to Fix It
FAQ
What is the difference between native: and qgis: algorithms?
native: are the C++ implementations; qgis: are Python ones. When an algorithm is ported, its old qgis: id keeps working as an alias on newer releases β but the new native: id does not exist on older ones. Use whichever the QGIS that runs the script has, and confirm against its registry.
Why does my script say the algorithm is not found?
Almost always because Processing.initialize() has not run, or ran before QgsApplication.initQgis(). The registry is empty until both have happened in that order.
What does TEMPORARY_OUTPUT actually create?
A layer in the QgsProcessingContext, held in memory. If you passed your own context, the result is an id: retrieve the layer with context.getMapLayer(result["OUTPUT"]) and keep the context alive. If you did not, processing.run returns the layer object itself.
How do I find an algorithm's parameter names?
processing.algorithmHelp("id"), qgis_process help id, or iterate alg.parameterDefinitions() for structured access. The Processing History panel also records the exact call the GUI made.
Why do GRASS or SAGA algorithms not appear?
GRASS algorithms need GRASS installed; where it was, Processing.initialize() registered all 307 of them with no extra code. SAGA comes from a separate plugin, SAGA NextGen, which has to be installed and loaded.
What is the context for?
It carries the project, the coordinate transform context, the invalid-geometry policy and the temporary layer store. Passing your own gives you control over all four and keeps temporary outputs alive β but a new context checks no geometries, where processing.run without one stops on invalid input, so set the policy you want.
Can I write my own algorithm?
Yes β subclass QgsProcessingAlgorithm, declare parameters and outputs, and implement processAlgorithm. It then appears in the Toolbox, in batch mode, in the Modeler and in qgis_process with no extra work.