Register a Custom Expression Function in PyQGIS

The QGIS expression library is broad, but it is not your domain. When the same twelve-line CASE WHEN block keeps reappearing in your labelling rules, or the calculation you need genuinely cannot be expressed — a lookup against a reference table, a checksum, a call to a local library — you can add a Python function to the engine. From the moment it is registered it behaves like any built-in: it appears in the expression builder dialog, works in the field calculator, and can be used in a data-defined property.

This page is a focused recipe within Working with QGIS Expressions in PyQGIS. It covers the @qgsfunction decorator, the mandatory parameters, declaring which columns the function reads, and the unregistration discipline that keeps a plugin reloadable.

Where a registered function becomes availableA Python function decorated with qgsfunction is added to the QGIS expression function registry. Once registered, four consumers can call it: the field calculator, rule-based labelling, feature filters, and data-defined symbol properties. A dashed return arrow marks unregisterFunction, which must run on plugin unload.Register once, callable from everywhere an expression is accepted@qgsfunctiondef utm_zone(lon,feature, parent):function registryprocess-wide, sharednames must be uniquefield calculatorrule-based labellingfeature filtersdata-defined propertiesunregisterFunction() on unload

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • The Python Console for experimentation, or a plugin skeleton for anything permanent — see Plugin Boilerplate & Structure.
  • An understanding of how the engine resolves names, since a custom function participates in the same scope stack.

Write and register a function

The decorator does the registration. Two trailing parameters are mandatory and are supplied by the engine rather than the caller.

from qgis.core import qgsfunction


@qgsfunction(args="auto", group="Custom", referenced_columns=[])
def utm_zone(longitude, feature, parent):
    """
    Return the UTM zone number containing a longitude.

    <h4>Syntax</h4>
    <p>utm_zone(<i>longitude</i>)</p>
    <h4>Example</h4>
    <p>utm_zone(12.49) &rarr; 33</p>
    """
    return int((longitude + 180) / 6) + 1

Breakdown: args="auto" tells QGIS to count the arguments from the signature, excluding the two trailing ones — so this function takes exactly one argument in an expression. group decides which folder it appears under in the expression builder. referenced_columns=[] declares that the function reads no fields itself, which lets the provider keep optimising attribute fetching; omit it and QGIS conservatively assumes every column might be needed. The docstring becomes the help panel in the dialog and accepts a little HTML, which is worth using — a function with no help text is one nobody else will adopt.

The feature and parent parameters are always last. feature is the feature currently being evaluated, which lets a function reach attributes the caller did not pass. parent is the expression node, used to report errors:

@qgsfunction(args="auto", group="Custom", referenced_columns=[])
def safe_ratio(numerator, denominator, feature, parent):
    """Divide two numbers, reporting a clear error instead of raising."""
    if not denominator:
        parent.setEvalErrorString("safe_ratio: denominator is zero or NULL")
        return None
    return numerator / denominator

Breakdown: parent.setEvalErrorString() routes the message through the engine's normal evaluation-error channel, so expression.evalErrorString() picks it up exactly as it would for a built-in failure. Returning None alongside it yields NULL in the expression, which is the engine's convention for "no answer". Raising a Python exception here instead would surface as an opaque error with no context about which feature caused it.

Read fields inside the function

When a function needs a column the caller did not pass, take it from feature — and declare it, so the provider still knows to fetch it.

@qgsfunction(args="auto", group="Custom", referenced_columns=["zoning", "assessed_value"])
def rate_band(feature, parent):
    """Classify a parcel into a rating band from its zoning and value."""
    zoning = feature["zoning"]
    value = feature["assessed_value"] or 0
    if zoning == "commercial":
        return "A" if value > 1_000_000 else "B"
    return "C" if value > 400_000 else "D"

Breakdown: This function takes no expression arguments at all — rate_band() is how it is called — so feature and parent are the entire signature. referenced_columns now lists the two fields it reads. Getting that list wrong is subtle: an under-declared column may still work in the field calculator, which fetches everything, and then return NULL in a labelling rule, which fetches only what was declared. The or 0 guard is the usual NULL defence.

Register without the decorator

Inside a plugin, registering at import time is the wrong moment — it happens before the plugin is properly initialised and cannot be undone cleanly. Register explicitly in initGui() instead.

from qgis.core import QgsExpression, qgsfunction


@qgsfunction(args="auto", group="Custom", referenced_columns=[], register=False)
def utm_zone(longitude, feature, parent):
    """Return the UTM zone number containing a longitude."""
    return int((longitude + 180) / 6) + 1


class MyPlugin:
    def initGui(self):
        QgsExpression.registerFunction(utm_zone)

    def unload(self):
        QgsExpression.unregisterFunction("utm_zone")

Breakdown: register=False builds the function object without adding it to the registry, so the decorator becomes a description rather than a side effect at import time. registerFunction() and unregisterFunction() then bracket the plugin's lifetime exactly. unregisterFunction() takes the name as a string, not the object. This pairing is what makes the plugin reloadable — see Reload a QGIS Plugin Without Restarting for why that matters during development.

The registry is process-wide and names must be unique. Registering a name that already exists fails, which is precisely the error a developer hits on the second reload after forgetting to unregister. Prefixing your functions — acme_utm_zone rather than utm_zone — also avoids colliding with someone else's plugin or a future built-in.

Use it and confirm it is available

Once registered, the function is ordinary. It can be evaluated from Python, typed into the field calculator, or used in a data-defined property.

The registration lifecycle across a plugin reloadA horizontal timeline with four moments. At import the decorated function is defined but not registered. At initGui registerFunction adds it to the registry. During the session it is callable from expressions. At unload unregisterFunction removes it. A branch below shows that skipping unload causes the next initGui to fail with a duplicate name error.Four moments — and one of them is easy to skipimportdefined,register=FalseinitGui()registerFunction()sessioncallable in everyexpression fieldunload()unregisterFunction()skip it and the next initGui() fails: name already registered

from qgis.core import QgsExpression, QgsExpressionContext

print("utm_zone" in [f.name() for f in QgsExpression.Functions()])

context = QgsExpressionContext()
print(QgsExpression("utm_zone(12.49)").evaluate(context))   # 33

Breakdown: QgsExpression.Functions() returns every function the engine knows about, which is the definitive check that registration succeeded — far more reliable than opening the dialog and scrolling. Evaluating with a bare context works here because utm_zone() references neither fields nor variables.

Deciding whether a custom function is the right answer

A custom function is powerful and slightly costly: it makes every project that uses it depend on your plugin. Three cheaper alternatives cover a surprising share of the cases people reach for it.

Four ways to reuse expression logicFour options are laid out from most portable to most powerful. A built-in expression works everywhere with no dependency. A project variable stores a value inside the project file. A stored expression preset travels with the user profile. A custom Python function is the most capable but requires the plugin to be installed wherever the project is opened.Reach for a custom function last, not firstbuilt-in expressionCASE, coalesce,scale_exp, aggregatesno dependencyopens anywhereproject variable@survey_yearset once, used oftenstored in the projecttravels with the filestored expressionsaved preset in theexpression builderper user profilenot shared automaticallycustom functionarbitrary Python,external librariesneeds the plugininstalled everywheremost portablemost powerful

A project variable is often all that is needed when the "function" is really a constant that changes per project:

from qgis.core import QgsExpressionContextUtils, QgsProject

QgsExpressionContextUtils.setProjectVariable(
    QgsProject.instance(), "survey_year", 2026
)
# now usable anywhere as @survey_year

Breakdown: setProjectVariable() writes into the project file, so the variable travels with the .qgz and needs no plugin at all. Any label, filter or data-defined property can then reference @survey_year. Reserve a custom function for logic that genuinely cannot be written as an expression — an external lookup, a library call, or a computation with real branching.

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.28 LTR3.9Identical API. @qgsfunction and register=False both available.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12usesGeometry and handlesNull decorator arguments gained clearer defaults; behaviour unchanged for the patterns here.

The decorator's signature has been stable throughout 3.x. Functions registered by one plugin are visible to all, so name collisions are a cross-plugin concern rather than a version one.

Troubleshooting

  • Function is already registered. A previous instance never unregistered. Call QgsExpression.unregisterFunction("name") in unload(), and use register=False on the decorator so registration only happens where you control it.
  • The function returns NULL in labels but works in the field calculator. A column it reads is missing from referenced_columns. The calculator fetches all attributes; the labelling engine fetches only the declared ones.
  • TypeError about argument count. The trailing feature and parent parameters are missing, or args was set to a number that does not match the signature. Use args="auto" and let QGIS count.
  • The function does not appear in the builder dialog. It registered into a group that is collapsed, or it never registered at all. Check with QgsExpression.Functions() rather than by eye.
  • Errors are opaque. A raised Python exception loses the feature context. Use parent.setEvalErrorString() and return None instead.
  • It works for you and not for a colleague. The function only exists while the plugin providing it is enabled. A project whose labels depend on a custom function is not portable without that plugin.

Conclusion

@qgsfunction turns a Python function into part of the expression language: reachable from the field calculator, labelling rules, filters and data-defined properties alike. The discipline that keeps it maintainable is small — declare referenced_columns honestly, report errors through parent, register with register=False in initGui(), and always unregister in unload().

Frequently Asked Questions

What are the feature and parent arguments for? They are supplied by the engine, not the caller. feature is the feature being evaluated, letting a function read attributes that were not passed as arguments. parent is the expression node, used to report an evaluation error through setEvalErrorString().

Why does my function fail on the second plugin reload? The name is still registered from the first load. The expression function registry is process-wide, so every registerFunction() needs a matching unregisterFunction() in the plugin's unload().

Do I have to set referenced_columns? You should. It tells QGIS which fields to fetch. Leaving it unset makes QGIS conservatively load every column, and setting it incompletely causes fields to come back NULL in contexts that honour the declaration, such as labelling.

Can other people use a project that depends on my custom function? Only if they have the plugin that registers it installed and enabled. Without it, the expression fails to resolve the function name. For portable projects, keep the logic in built-in functions.