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.

Where an empty value comes fromA database or file stores a missing value, such as SQL NULL in GeoPackage or PostGIS, an empty cell in a CSV read with type detection, or a blank shapefile field. The data provider turns it into a QVariant with no value. On QGIS 3 the Python bindings hand that to your code as the NULL object, which is falsy and not equal to None. On QGIS 4 the bindings convert it to Python None.One missing value, two Python facesdata sourceSQL NULLempty CSV cellblank DBF fieldproviderQVariant()no valueQGIS 3NULLfalsy, ≠ NoneQGIS 4Noneplain Pythoncode that must run on both has to accept either

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.

Field type to Python typeInteger and integer64 fields arrive as Python int, double as float, string as str and boolean as bool. Date fields arrive as QDate and convert with toPyDate; datetime fields arrive as QDateTime and convert with toPyDateTime; time fields arrive as QTime and convert with toPyTime; binary fields arrive as QByteArray and convert with bytes or data. A NULL in any of them needs the is_null check before conversion.Most values are plain Python — dates are notalready Pythoninteger, int8 → intdouble → floatstring → strboolean → boolQt objectsQDate.toPyDate()QDateTime.toPyDateTime()QTime.toPyTime()bytes(QByteArray)check is_null() first — a NULL date has no toPyDate()

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.

Same empty value, different formatsAn empty value written to three formats and read back. GeoPackage and PostGIS return NULL for every field type. A shapefile returns NULL for blank numeric fields, but an empty text field may come back as an empty string, and dates vary with the writer. Treat empty strings as missing when reading shapefiles.Round trip of an empty valueGeoPackage · PostGISnumber → NULLtext → NULLdate → NULLtrue SQL NULLShapefile (DBF)number → NULLtext → "" or NULLdate → depends on writernormalise on read

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 with is_null first.
  • The text "NULL" appears in the attribute table. A script wrote the string; repair with an update setting the field to the NULL constant where it equals 'NULL'.
  • AttributeError: 'QDate' object has no attribute 'year' in older code. Convert with toPyDate() or use QDate.year() with parentheses.
  • JSON export fails with "not serializable". A QDate or NULL reached json.dumps; run values through to_python first.

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.