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.
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) → 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.
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.
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 version | Python | Notes |
|---|---|---|
| 3.28 LTR | 3.9 | Identical API. @qgsfunction and register=False both available. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | usesGeometry 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. CallQgsExpression.unregisterFunction("name")inunload(), and useregister=Falseon the decorator so registration only happens where you control it.- The function returns
NULLin labels but works in the field calculator. A column it reads is missing fromreferenced_columns. The calculator fetches all attributes; the labelling engine fetches only the declared ones. TypeErrorabout argument count. The trailingfeatureandparentparameters are missing, orargswas set to a number that does not match the signature. Useargs="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 returnNoneinstead. - 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.