Handle NULL Values and QVariant in PyQGIS
Sooner or later every PyQGIS script meets an attribute that is empty, and on QGIS 3 that empty value is not Python's None. It is NULL — a Qt QVariant that prints as NULL, is falsy, compares unequal to None, and makes arithmetic raise a TypeError. Dates arrive as QDate, timestamps as QDateTime, and some numeric types as Qt values too. Code written as if attributes were plain Python values works on clean test data and fails on the first real table.
This recipe belongs to Features, Geometries & Memory Layers. It explains where NULL comes from, how to test for it on both QGIS 3 and QGIS 4, how to convert Qt types into Python ones, and how to write a NULL back to a field.
Prerequisites
- QGIS 3.34 LTR, 3.40 LTR, or the QGIS 4 series.
- A layer with some empty attributes. A GeoPackage with nullable columns is the most realistic test; shapefiles behave differently, as explained below.
Test for NULL reliably
The tests that work depend on the QGIS version. A helper that accepts both forms removes the question from the rest of the code.
from qgis.core import NULL, QgsProject
layer = QgsProject.instance().mapLayersByName("inspections")[0]
def is_null(value):
"""True for QGIS 3 NULL QVariants and for QGIS 4 None."""
if value is None:
return True
is_null_method = getattr(value, "isNull", None)
return bool(is_null_method and is_null_method())
empty, filled = 0, 0
for f in layer.getFeatures():
if is_null(f["inspected_on"]):
empty += 1
else:
filled += 1
print(f"{empty} never inspected, {filled} inspected")
Breakdown: On QGIS 3, f["inspected_on"] for an empty value is a QVariant whose isNull() returns True; value == NULL also works there. value is None does not. On QGIS 4, the bindings return None and there is no NULL QVariant to compare against. Checking for an isNull method also catches empty QDate and QDateTime objects, which some providers return for blank date fields instead of a null variant. Putting the logic in one function means one place to change when the minimum supported version moves.
Avoid if not value: as a NULL test. It is true for NULL, but also for 0, 0.0, "" and False — so a pipe with zero recorded leaks, or a flag field that is legitimately false, would be counted as missing.
Convert Qt types into Python values
Non-null values usually arrive as native Python types — int, float, str, bool. Dates, times and some binary values do not: they are QDate, QTime, QDateTime and QByteArray. Each has a conversion method.
from datetime import date, datetime
from qgis.PyQt.QtCore import QDate, QDateTime, QTime, QByteArray
def to_python(value):
if is_null(value):
return None
if isinstance(value, QDateTime):
return value.toPyDateTime()
if isinstance(value, QDate):
return value.toPyDate()
if isinstance(value, QTime):
return value.toPyTime()
if isinstance(value, QByteArray):
return bytes(value)
return value
names = layer.fields().names()
rows = [{n: to_python(v) for n, v in zip(names, f.attributes())}
for f in layer.getFeatures()]
overdue = [r for r in rows if r["inspected_on"] and r["inspected_on"] < date(2025, 1, 1)]
print(len(overdue), "inspections older than 2025")
Breakdown: Converting once at the boundary — when rows leave QGIS for Python logic, JSON, pandas or a report — means the rest of the program can use ordinary comparisons and datetime arithmetic. toPyDateTime() returns a naive datetime when the QGIS value has no time zone, and an aware one when it does; PostGIS timestamptz columns arrive as UTC-aware values. Checking QDateTime before QDate is not required for correctness here, but keeps the order of the most specific type first, which matters if you extend the function with subclasses. The same to_python function is the right preprocessing step before building a DataFrame, as in analysing an attribute table with pandas.
Write NULL back to a field
Clearing a value is where many scripts go wrong in the other direction: assigning None on QGIS 3, or the string "NULL", which some providers store literally.
from qgis.core import edit, NULL
field_idx = layer.fields().indexOf("inspected_on")
try:
null_value = NULL # QGIS 3
except NameError:
null_value = None # QGIS 4
with edit(layer):
for f in layer.getFeatures('"status" = \'cancelled\''):
layer.changeAttributeValue(f.id(), field_idx, null_value)
# via the provider, no edit session
layer.dataProvider().changeAttributeValues({17: {field_idx: null_value}})
Breakdown: On QGIS 3, NULL is what the provider recognises as "no value" for every field type. Assigning Python None works for many providers on recent 3.x releases, but older releases and some providers convert it to an empty string or zero, so the explicit constant is the safe choice. On Qt6-based QGIS 4 builds, PyQt6 has no Python-visible QVariant, so None is the null value; the try block picks whichever form the running version provides. Never write the string "NULL": a text field stores those four characters, and a later is_null test reports a value.
Shapefiles have no true NULL
The dBASE files behind shapefiles cannot store NULL for most types. A blank numeric field is read as NULL by recent GDAL versions, but a blank text field may be an empty string and a date field may be NULL or a zero date depending on how the file was written.
def is_missing(value):
"""NULL, None, or an empty/blank string — for shapefile and CSV sources."""
return is_null(value) or (isinstance(value, str) and not value.strip())
missing_names = sum(1 for f in layer.getFeatures() if is_missing(f["owner_name"]))
print(missing_names, "features without an owner name")
Breakdown: For sources without a reliable NULL — shapefiles and delimited text files — treating blank strings as missing is usually what the data means. Keep this separate from is_null so that a GeoPackage, where an empty string and NULL genuinely differ, is not flattened by accident. Converting shapefiles to GeoPackage early in a workflow, as in writing a vector layer to GeoPackage, removes the ambiguity for everything downstream.
NULL inside expressions
Expressions have their own NULL semantics, which follow SQL rather than Python: any comparison with NULL is NULL, and a filter that evaluates to NULL excludes the feature.
from qgis.core import QgsFeatureRequest
# misses NULLs: NULL <> 'closed' is NULL, not true
open_a = len(list(layer.getFeatures('"status" <> \'closed\'')))
# includes NULLs explicitly
open_b = len(list(layer.getFeatures('"status" IS NULL OR "status" <> \'closed\'')))
print(open_a, "vs", open_b)
# replace NULL with a default inside the expression
req = QgsFeatureRequest().setFilterExpression('coalesce("priority", 3) <= 2')
Breakdown: The two counts differ by exactly the number of features with a NULL status — the most common silent error in expression filters. IS NULL and IS NOT NULL are the only correct tests inside an expression; = NULL never matches. coalesce substitutes a default and is the expression equivalent of the Python helper. Evaluating QGIS expressions in PyQGIS covers the evaluation side; the NULL rules are the same everywhere expressions are used, including labels and data-defined styling.
NULL in statistics and aggregates
Summaries are where NULL handling decides the answer rather than causing a crash. An average computed in Python over values that include NULL either fails or, if the NULLs were turned into zeros, comes out too low. QGIS's own aggregate functions skip NULLs, which is usually right — but you should know it is happening and how many values were skipped.
from qgis.core import QgsAggregateCalculator
values = [f["flow_lps"] for f in layer.getFeatures()]
present = [v for v in values if not is_null(v)]
print(f"{len(values) - len(present)} of {len(values)} readings missing")
print("python mean:", sum(present) / len(present) if present else None)
mean, ok = layer.aggregate(QgsAggregateCalculator.Mean, "flow_lps")
missing, _ = layer.aggregate(QgsAggregateCalculator.CountMissing, "flow_lps")
print("aggregate mean:", mean, "| missing:", missing)
Breakdown: Reporting the number of missing values next to every statistic is cheap and prevents the most common misreading of a summary: a mean over 40 readings presented as if it described all 400 pipes. layer.aggregate runs inside the provider where it can — a SQL AVG on PostGIS — and ignores NULLs in the same way SQL does. CountMissing counts NULLs and, for text fields, empty strings, which suits the shapefile case above. If a NULL should count as zero for a particular statistic — no recorded leaks meaning zero leaks — make that substitution explicitly with coalesce or a Python default, and say so in the output, rather than letting it happen as a side effect of a falsy test.
QGIS version compatibility
QGIS 3.x returns NULL attributes as QVariant objects exported as qgis.core.NULL. QGIS 4, built on Qt6 and PyQt6, returns None for empty attributes because PyQt6 converts null variants automatically; do not rely on a NULL constant existing there. The is_null helper and the NULL/None fallback shown above run unchanged on both. Field type constants also change: QGIS 4 uses QMetaType.Type values, which 3.38 and later accept too.
Troubleshooting
TypeError: '<' not supported between instances of 'QVariant' and 'int'. A NULL reached a comparison; filter withis_nullfirst.- The text "NULL" appears in the attribute table. A script wrote the string; repair with an update setting the field to the
NULLconstant where it equals'NULL'. AttributeError: 'QDate' object has no attribute 'year'in older code. Convert withtoPyDate()or useQDate.year()with parentheses.- JSON export fails with "not serializable". A
QDateor NULL reachedjson.dumps; run values throughto_pythonfirst.
Conclusion
Empty attributes are NULL on QGIS 3 and None on QGIS 4, so test with a helper that accepts both, convert Qt date and binary types at the boundary to Python, write NULL with the constant rather than a string, treat blank strings as missing for shapefile and CSV sources, and use IS NULL inside expressions.
Frequently Asked Questions
Why does print(value) show NULL if it is not None?NULL is a QVariant with a custom string representation. It prints as NULL but is a different object from None.
Is bool(NULL) always False?
Yes, which is why if value: treats NULL as missing — along with every other falsy value.
Can I make QGIS 3 return None instead? Not globally. Convert at the boundary with a helper, which also prepares the code for QGIS 4.
How do I count NULLs per field quickly?
Use layer.aggregate(QgsAggregateCalculator.CountMissing, "field"), which runs in the provider.