Export a Graphical Model to a Python Script in PyQGIS

The Model Designer is a quick way to chain algorithms: drag in inputs, connect a buffer to a clip to a dissolve, set parameters, and run. Sooner or later a model needs something boxes and arrows cannot express — a loop over a folder, a condition on a feature count, a call to an external API, a retry when a service is slow. At that point the model can be exported as Python: QGIS writes a complete Processing script algorithm that reproduces the model, which you can then read, edit and extend like any other code.

This recipe belongs to Chaining Processing Algorithms. It exports a model to a script, explains the structure of the generated code, cleans it up, adds logic beyond the model's reach, registers the result in the toolbox, and discusses when to stay with the model.

From boxes to a script algorithmA model in the Model Designer has inputs, algorithm steps and outputs connected by arrows. Exporting it as a Python script produces a QgsProcessingAlgorithm subclass: initAlgorithm declares the same inputs and outputs, and processAlgorithm calls each step with processing.run in dependency order, passing child results along. The script can then be edited and saved in the scripts folder to appear in the toolbox.Same steps, now editable codemodel (.model3)inputsbuffer → clip→ dissolveoutputsscript algorithm (.py)initAlgorithm(): parametersprocessAlgorithm(): processing.run × 3results passed step to stepedit, extend, version

Prerequisites

Export the model

In the Model Designer, "Export as Script Algorithm" writes the Python and opens it in the script editor. The same export is available from Python, which is useful for converting many models at once.

from qgis.core import QgsProcessingModelAlgorithm, QgsProcessing

model = QgsProcessingModelAlgorithm()
if not model.fromFile("/data/models/flood_exposure.model3"):
    raise RuntimeError("could not read model")
model.setName("flood_exposure_script")

lines = model.asPythonCode(QgsProcessing.PythonOutputType.PythonQgsProcessingAlgorithmSubclass, 4)
code = "\n".join(lines)
with open("/data/scripts/flood_exposure.py", "w", encoding="utf-8") as fh:
    fh.write(code)
print(code[:600])

Breakdown: fromFile loads the model definition. asPythonCode with the algorithm-subclass output type produces a complete script algorithm — a class with initAlgorithm, processAlgorithm, a name and display name — indented with the given number of spaces. Changing the model's name before export gives the script its own id, so it does not clash with the model in the toolbox. On QGIS releases before 3.30 the enum is QgsProcessing.PythonQgsProcessingAlgorithmSubclass.

Read the generated code

The generated script mirrors the model step by step. Knowing its shape makes it easy to edit.

Anatomy of the exported scriptinitAlgorithm adds one parameter definition per model input and one per model output. processAlgorithm creates a multi-step feedback object, then for each child algorithm builds an alg_params dictionary, calls processing.run with is_child_algorithm set, stores the output in outputs and results, and checks for cancellation between steps. The final results dictionary maps output names to layers or files.One block per model stepinitAlgorithmaddParameter(…)per inputand per outputper stepalg_params = {…}processing.run(…)is_child_algorithmbetween stepsfeedback.setCurrentStepisCanceled() → {}results dict

# excerpt of a generated processAlgorithm
def processAlgorithm(self, parameters, context, model_feedback):
    feedback = QgsProcessingMultiStepFeedback(3, model_feedback)
    results = {}
    outputs = {}

    # Buffer
    alg_params = {
        'DISTANCE': parameters['buffer_distance'],
        'INPUT': parameters['rivers'],
        'OUTPUT': QgsProcessing.TEMPORARY_OUTPUT
    }
    outputs['Buffer'] = processing.run('native:buffer', alg_params, context=context,
                                       feedback=feedback, is_child_algorithm=True)

    feedback.setCurrentStep(1)
    if feedback.isCanceled():
        return {}

    # Clip
    alg_params = {
        'INPUT': parameters['buildings'],
        'OVERLAY': outputs['Buffer']['OUTPUT'],
        'OUTPUT': parameters['Exposed']
    }
    outputs['Clip'] = processing.run('native:clip', alg_params, context=context,
                                     feedback=feedback, is_child_algorithm=True)
    results['Exposed'] = outputs['Clip']['OUTPUT']
    return results

Breakdown: QgsProcessingMultiStepFeedback divides the progress bar among the steps. Each child algorithm gets a parameter dictionary built from model inputs and earlier outputs, runs with is_child_algorithm=True — which lets intermediate layers stay in the processing context instead of being loaded into the project — and stores its result under the step's name. Cancellation is checked between steps. Final outputs go into results. The structure matches the patterns in chaining buffer and clip, written for you.

Clean up the generated code

Generated code is correct but verbose: step names from the model, every default parameter spelled out, comments with model-specific ids. A short cleanup makes it maintainable.

# before: every default listed
alg_params = {'DISSOLVE': False, 'DISTANCE': parameters['buffer_distance'],
              'END_CAP_STYLE': 0, 'INPUT': parameters['rivers'], 'JOIN_STYLE': 0,
              'MITER_LIMIT': 2, 'SEGMENTS': 5, 'OUTPUT': QgsProcessing.TEMPORARY_OUTPUT}

# after: only what differs from defaults, with a meaningful name
buffer_params = {'INPUT': parameters['rivers'],
                 'DISTANCE': parameters['buffer_distance'],
                 'OUTPUT': QgsProcessing.TEMPORARY_OUTPUT}
river_buffer = processing.run('native:buffer', buffer_params, context=context,
                              feedback=feedback, is_child_algorithm=True)['OUTPUT']

Breakdown: Removing parameters that equal the algorithm's defaults shortens each step to what actually matters, which makes the next reader's job — often you, months later — much easier. Naming results after what they contain (river_buffer) rather than the step (outputs['Buffer']) makes the data flow obvious. Keep is_child_algorithm=True and the feedback and context arguments: they are what make the script behave correctly inside the Processing framework, including in batch mode and when called from other models.

Add what the model could not do

The point of exporting is usually to add logic. Common additions are early exits, conditions and loops.

Logic beyond the modelThree typical additions after export. An early exit stops with a clear message when an input layer is empty. A condition chooses a different algorithm path depending on a feature count or a parameter value. A loop repeats a step for each value in a list, such as several buffer distances, collecting the results. Each is a few lines of Python and impossible or awkward in the Model Designer.What boxes and arrows cannot sayearly exitempty input?raise a clear errorconditioncount > N?choose a pathloopfor each distancecollect results

from qgis.core import QgsProcessingException

source = self.parameterAsSource(parameters, 'buildings', context)
if source.featureCount() == 0:
    raise QgsProcessingException("The buildings layer is empty - nothing to assess.")

exposure = {}
for distance in (50, 100, 200):
    buf = processing.run('native:buffer', {
        'INPUT': parameters['rivers'], 'DISTANCE': distance,
        'OUTPUT': QgsProcessing.TEMPORARY_OUTPUT},
        context=context, feedback=feedback, is_child_algorithm=True)['OUTPUT']
    clipped = processing.run('native:clip', {
        'INPUT': parameters['buildings'], 'OVERLAY': buf,
        'OUTPUT': QgsProcessing.TEMPORARY_OUTPUT},
        context=context, feedback=feedback, is_child_algorithm=True)['OUTPUT']
    layer = context.getMapLayer(clipped)
    exposure[distance] = layer.featureCount()
    feedback.pushInfo(f"{distance} m: {exposure[distance]} buildings exposed")

Breakdown: Raising QgsProcessingException stops the algorithm with a message shown in the Processing dialog and log, far clearer than an empty output. The loop runs buffer and clip for several distances — something a model can only do by duplicating the steps — and context.getMapLayer turns a child result's layer id into a layer to inspect. pushInfo writes progress lines to the log. Handling processing feedback and errors covers messages and exceptions in detail.

Register the script in the toolbox

Saved in the Processing scripts folder, the script appears in the toolbox under Scripts, usable from the dialog, in batch mode, in other models and from processing.run.

from pathlib import Path
from qgis.core import QgsApplication

scripts_dir = Path(QgsApplication.qgisSettingsDirPath()) / "processing" / "scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
(scripts_dir / "flood_exposure.py").write_text(code, encoding="utf-8")

QgsApplication.processingRegistry().providerById("script").refreshAlgorithms()
print([a.id() for a in QgsApplication.processingRegistry().providerById("script").algorithms()])

Breakdown: The profile's processing/scripts folder is where QGIS looks for script algorithms by default; refreshing the script provider picks up the new file without restarting. The algorithm id becomes script: plus the name set in the script. For distribution to other users, scripts belong in a plugin with a processing provider, as in registering a processing provider in a plugin.

Test the script against the model

Before retiring the model, prove the script does the same thing. Running both on the same small inputs and comparing outputs catches anything lost in export or cleanup.

import processing

params = {"rivers": "/data/tests/rivers_sample.gpkg",
          "buildings": "/data/tests/buildings_sample.gpkg",
          "buffer_distance": 100}
from_model = processing.run("model:flood_exposure", {**params, "Exposed": "memory:"})["Exposed"]
from_script = processing.run("script:flood_exposure_script", {**params, "Exposed": "memory:"})["Exposed"]

a, b = from_model.featureCount(), from_script.featureCount()
ids_a = sorted(f["building_id"] for f in from_model.getFeatures())
ids_b = sorted(f["building_id"] for f in from_script.getFeatures())
print(f"model {a}, script {b}, identical ids: {ids_a == ids_b}")

Breakdown: Running both with identical parameters on a small sample makes the comparison fast; comparing feature counts and a sorted list of business keys is a strong check without having to compare geometries. Keeping a sample dataset and this comparison as an automated test — see testing processing algorithms with pytest — protects the script against regressions as it grows beyond what the model did.

Model or script?

Models remain the better choice when the workflow is a straightforward chain, when colleagues who do not write Python need to understand and modify it, and when the visual diagram is itself documentation. Scripts win when logic is needed, when the workflow is versioned and reviewed like code, and when it must be tested automatically. A useful middle path is to keep the model as the readable specification and export to a script only once, at the point where code becomes necessary — then maintain the script and retire the model, rather than trying to keep both in sync.

QGIS version compatibility

Model export with asPythonCode works on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. The Python output type enum moved to QgsProcessing.PythonOutputType in 3.30; QGIS 4 uses only the scoped form. Generated code targets the running QGIS version; a script exported from a newer release may use APIs unavailable in older ones.

Troubleshooting

  • The exported script has the same id as the model. Rename the model before exporting or edit name() in the script.
  • Intermediate layers appear in the project. is_child_algorithm=True was removed; restore it.
  • The script is missing from the toolbox. It is not in a scripts folder, or the provider was not refreshed; check the log for syntax errors.
  • Model inputs became oddly named parameters. The generated names follow the model's input names; rename them in both initAlgorithm and processAlgorithm.

Conclusion

Export a model as a script algorithm with asPythonCode, read its per-step structure, trim default parameters and name results after their content, add early exits, conditions and loops the model could not express, save it to the scripts folder or a plugin provider, and choose models for simple shared chains and scripts once logic or testing is needed.

Frequently Asked Questions

Can I convert a script back into a model? No. Export is one way; keep the model if you may want to edit it visually.

Does the script run faster than the model? About the same — the same algorithms run. Gains come from logic you add, such as skipping steps.

Can scripts call models? Yes: processing.run("model:name", params).

Is the @alg decorator an alternative? Yes, for short scripts; see writing a processing script with the @alg decorator.