Reclassify Raster Values in PyQGIS

Reclassification is how a continuous surface becomes a decision: slope becomes buildable or not, elevation becomes flood risk bands, land cover codes become a habitat suitability score. The algorithm is simple; the two things that go wrong are the boundary rule at each class edge and what happens to values the table does not mention.

This recipe belongs to Raster Analysis Workflows in PyQGIS. It covers building the flat parameter table, the four boundary conventions and which one you want, handling values outside the table, choosing an output data type that fits the classes, and the layer-driven variant when the table is itself data.

Boundaries, and the values the table forgotA continuous axis is divided into three ranges mapped to three class codes. Each boundary value must belong to exactly one range, decided by the range boundaries setting. Values below the first range or above the last are not mentioned by the table and become nodata unless the algorithm is told otherwise.Every boundary belongs to exactly one side0 – 5 → class 15 – 15 → class 215 – 30 → class 3exactly 5class 1 or class 2?01530nodatanodatavalues outside every range become nodataextend the first and last ranges to infinity, or accept the holes

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • A single-band raster. Multi-band inputs are handled one band at a time.
  • Knowledge of the raster's actual value range. layer.dataProvider().bandStatistics(1) reports it, and reclassifying against a guessed range is how holes appear.

Build the table and run it

The table is a flat list of triples, not a list of lists, which is the first thing to get right.

import processing

TABLE = [
    0,   5,  1,      # gentle
    5,  15,  2,      # moderate
    15, 30,  3,      # steep
    30, 90,  4,      # unbuildable
]

processing.run("native:reclassifybytable", {
    "INPUT_RASTER": "/data/output/slope_deg.tif",
    "RASTER_BAND": 1,
    "TABLE": TABLE,
    "NO_DATA": -9999,
    "RANGE_BOUNDARIES": 0,
    "NODATA_FOR_MISSING": True,
    "DATA_TYPE": 1,                     # Byte
    "OUTPUT": "/data/output/slope_class.tif",
})

Breakdown: Laying the table out three values per line with a comment is the difference between reviewable and unreviewable; QGIS reads it as a flat sequence regardless. NO_DATA sets the output's nodata value, which should be a number no class uses — with a Byte output, 255 is the conventional choice and -9999 will not fit, so the two settings must agree. DATA_TYPE: 1 is Byte, correct for four classes and eight times smaller than the Float32 default. NODATA_FOR_MISSING: True makes unmatched values nodata explicitly; with False they pass through unchanged, which mixes raw slope degrees into a class raster and is almost never what anyone wants.

The boundary rule

RANGE_BOUNDARIES takes four values, and picking the wrong one puts every boundary value in the neighbouring class.

0 is min < value ≤ max, 1 is min ≤ value < max, 2 is min < value < max, and 3 is min ≤ value ≤ max. The first two are the sensible choices because they tile the axis with no gaps and no overlaps. 2 leaves every boundary value unmatched, so a slope of exactly 5 degrees becomes nodata. 3 matches boundary values in both adjacent ranges, and the earlier one in the table wins — which works but makes the table order significant in a way that is easy to forget.

BOUNDARY_MIN_EXCLUSIVE = 0     # min <  v <= max
BOUNDARY_MAX_EXCLUSIVE = 1     # min <= v <  max

Breakdown: Naming the constants once, at the top of a script, removes the need to remember which integer is which every time a table is written. Convention in most classification work is 1 — lower bound inclusive — because it reads the way people describe classes out loud: "five to fifteen" usually means from five up to but not including fifteen.

Values the table does not mention

Anything outside every range is unmatched, and with NODATA_FOR_MISSING: True becomes nodata. On a raster whose real minimum is slightly below your first range — a slope raster with a few cells at −0.0001 from floating-point noise — that produces a scatter of holes.

The robust fix is to extend the outer ranges beyond the data.

TABLE = [
    float("-inf"),  5,  1,
    5,             15,  2,
    15,            30,  3,
    30, float("inf"),   4,
]

Breakdown: The algorithm accepts infinities in the table, which is much safer than picking a number you believe is beyond the data. It also documents intent: the first class is "everything up to 5", not "everything between an arbitrary lower bound and 5". Where an outer range genuinely should be bounded — because values beyond it are errors — leave it bounded and let them become nodata deliberately, then count them so the decision is visible.

The four boundary conventionsWith min exclusive and max inclusive, or min inclusive and max exclusive, the ranges tile the axis with no value falling in two classes or none. With both bounds exclusive, boundary values match no range and become nodata. With both inclusive, boundary values match two ranges and the earlier table entry wins.Only two of the four tile the axis cleanly0 — min < v ≤ max5 lands in the lower classno gaps, no overlaps1 — min ≤ v < max5 lands in the upper classmatches how classes are spoken2 — min < v < max5 matches nothinga hole at every boundary3 — min ≤ v ≤ max5 matches two rangestable order silently decides

Building a table from class breaks

Classification methods that produce breaks — quantiles, natural breaks, equal intervals — give a list of boundaries rather than a table of ranges. Converting between the two is three lines and worth having as a helper.

def table_from_breaks(breaks, first_class=1, unbounded=True):
    """[5, 15, 30] -> a flat reclassify table with 4 classes."""
    edges = list(breaks)
    lower = float("-inf") if unbounded else edges[0]
    table = []
    code = first_class
    for edge in edges:
        table += [lower, edge, code]
        lower, code = edge, code + 1
    table += [lower, float("inf") if unbounded else lower, code]
    return table

Breakdown: Producing one more class than there are breaks is the arithmetic people get wrong by hand — three breaks make four classes, not three. Defaulting unbounded to True gives the safe behaviour discussed above, with the option of a bounded table when values outside the range genuinely are errors. Feeding this from QgsClassificationQuantile().classes() on the equivalent vector data keeps a raster classification and a choropleth using literally the same breaks, which matters whenever the two appear on the same page.

Keeping the breaks and the class labels together in one structure is the other half of the discipline, because a class raster with no record of what 3 means is nearly useless six months later:

CLASSES = {1: "gentle (≤5°)", 2: "moderate (5–15°)", 3: "steep (15–30°)", 4: "unbuildable (>30°)"}

Breakdown: Writing this dictionary next to the breaks lets the same source drive the reclassification, the renderer's category labels and the legend text, so the three can never disagree. It is also what a .qmd metadata sidecar should contain, as described in reading and writing layer metadata.

When the table is data

native:reclassifybylayer takes the ranges from a table layer instead of a parameter, which is the right shape when the classification is maintained by somebody who does not edit Python.

processing.run("native:reclassifybylayer", {
    "INPUT_RASTER": "/data/landcover.tif",
    "RASTER_BAND": 1,
    "INPUT_TABLE": "/data/lookup/habitat_scores.csv",
    "MIN_FIELD": "code_min",
    "MAX_FIELD": "code_max",
    "VALUE_FIELD": "score",
    "RANGE_BOUNDARIES": 3,
    "NODATA_FOR_MISSING": True,
    "DATA_TYPE": 1,
    "OUTPUT": "/data/output/habitat.tif",
})

Breakdown: For discrete input codes — land cover classes rather than a continuous surface — the min and max fields hold the same value and RANGE_BOUNDARIES: 3 (both inclusive) is correct, because you are matching exact codes rather than ranges. A CSV read this way must have numeric columns; a text column of codes silently produces no matches, and the result is an entirely nodata raster. Keeping the lookup in version control alongside the script gives the best of both: reviewable history and an editable file.

Choosing the output type

The default Float32 is right for almost nothing after a reclassification.

Four classes fit in a Byte, which is a quarter the size of Int16 and an eighth of Float32 — meaningful on a national raster. Byte holds 0–255, so nodata must be a value inside that range and outside the class codes; 255 is conventional. Where scores are fractional — a suitability index from 0 to 1 — Float32 is correct and the size cost is unavoidable. What is never correct is an integer type holding fractional scores, which truncates every score to 0 or 1 and produces a map that looks decisive and is wrong.

from qgis.core import QgsRasterLayer

out = QgsRasterLayer("/data/output/slope_class.tif", "classes")
provider = out.dataProvider()
print(provider.dataType(1), provider.sourceNoDataValue(1))
print(provider.bandStatistics(1).minimumValue, provider.bandStatistics(1).maximumValue)

Breakdown: Checking the written file rather than trusting the parameters catches the mismatch between NO_DATA and DATA_TYPE — asking for -9999 on a Byte output silently stores something else, and the nodata mask then does not work. Comparing the statistics against the expected class codes is a two-line sanity check that catches an off-by-one in the table.

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.16 LTR3.7Both reclassify algorithms present with the same parameter names.
3.22 LTR3.9Infinite bounds accepted in the table parameter.
3.28 LTR3.9DATA_TYPE codes stable across the raster algorithms.
3.34 LTR3.12Baseline for this page.
3.40+3.12Improved reporting of unmatched value counts in the algorithm log.

Troubleshooting

  • The output is entirely nodata. No value matched. Check the table covers the raster's real range with bandStatistics(1).
  • Holes at every class boundary. RANGE_BOUNDARIES is 2, which excludes both bounds. Use 0 or 1.
  • Raw values appear alongside classes. NODATA_FOR_MISSING is False, so unmatched values pass through.
  • A scatter of holes at the extremes. The outer ranges do not extend far enough. Use infinities.
  • Nodata is not being honoured downstream. NO_DATA does not fit DATA_TYPE. Check the written file's actual nodata value.
  • All scores became 0 or 1. An integer output type truncated fractional values. Use Float32 for scores.

Conclusion

Write the table three values per line with comments, extend the outer ranges to infinity, choose 0 or 1 for the boundary rule and say which in a comment, set NODATA_FOR_MISSING to True, and pick the smallest data type that holds the classes with room for a nodata value. Then read the written file back and check its statistics.

Frequently Asked Questions

Can I reclassify with an expression instead? Yes — the raster calculator handles simple cases as sums of boolean products. Reclassification is clearer for more than two or three classes, and much clearer to review.

How do I reclassify several bands? One call per band, each writing its own output, then combine with gdal:merge using SEPARATE: True to stack them back into a multi-band file.

Does reclassification change the cell size or extent? No. It is a per-cell value mapping, so the grid is identical to the input. That makes reclassified rasters directly stackable with the original in cell statistics.

How do I get class areas afterwards? Report unique values with native:rasterlayeruniquevaluesreport, which gives a pixel count and area per class. For per-zone breakdowns use zonal statistics with the majority and variety statistics.