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.
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.
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 version | Python | Notes |
|---|---|---|
| 3.16 LTR | 3.7 | Both reclassify algorithms present with the same parameter names. |
| 3.22 LTR | 3.9 | Infinite bounds accepted in the table parameter. |
| 3.28 LTR | 3.9 | DATA_TYPE codes stable across the raster algorithms. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40+ | 3.12 | Improved 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_BOUNDARIESis2, which excludes both bounds. Use0or1. - Raw values appear alongside classes.
NODATA_FOR_MISSINGisFalse, 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_DATAdoes not fitDATA_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.