Skip to content
AI Agent
QGIS
Tutorial

QGIS Raster Calculator: Tested Formulas and 4 Silent Traps

Published

Open the QGIS Raster Calculator from Raster > Raster Calculator (or from the Processing Toolbox). For Sentinel-2 NDVI, use ("B08@1" - "B04@1") / ("B08@1" + "B04@1"): the menu calculator computes in Float32 automatically. Watch out for the GDAL calculator wrapping unsigned integers, * masks writing 0 instead of nodata, and slope tools breaking on degree grids.

I tested all of those on 7 November 2026 in QGIS 3.44.15 on a 5 km by 5 km window of hilly farmland south of Machado, Minas Gerais, Brazil (2,500 ha, 250,000 pixels at 10 m in UTM 23S, EPSG:32723). I used a Sentinel-2 L2A scene from 3 November 2026 and a 30 m Copernicus GLO-30 DEM, and checked every output against a 64-bit floating-point numpy reference outside QGIS.

What is the QGIS Raster Calculator, and which one are you opening?

A raster calculator evaluates a math or boolean expression pixel by pixel across one or more raster bands and writes a new grid. In QGIS, you refer to a band as "layername@band" (for example "B08@1" for band 1 of layer B08), combine bands with +, -, *, /, <, >, AND, OR or if(condition, true, false), and write the output to a GeoTIFF or a virtual layer in memory.

What trips people up is that QGIS ships five tools named "Raster calculator", and they don't all run the same engine. Four use the native QGIS expression parser and compute in floating point. The GDAL one (gdal_calc.py under the hood) uses A/B/C letter variables and evaluates numpy expressions in the input band's own data type.

Tool in QGIS 3.44Band syntaxOutput typePixels off by > 0.01 on uint16 NDVI (of 250,000)
Raster > Raster Calculator (QgsRasterCalculator)"B08@1"Float320
Processing: Raster calculator (native:rastercalc)"B08@1"Float320
Processing: Raster calculator (virtual) (native:virtualrastercalc)"B08@1"Float64 (in memory)0
Processing: Raster calculator (legacy) (qgis:rastercalculator)"B08@1"Float320
Processing: GDAL Raster calculator (gdal:rastercalculator)A, BFloat32 file, math in the input type703 (0.28%, values up to 126.7)

If you just want a formula that works without casting types, use Raster > Raster Calculator from the top menu, or the first Raster calculator under the native QGIS group in Processing.

Step by step: calculate NDVI in the QGIS Raster Calculator

If you need Sentinel-2 bands first, our guide on how to download Sentinel-2 imagery in QGIS covers the STAC catalog path. Once bands B04 (red, 10 m) and B08 (near-infrared, 10 m) are in your Layers panel, calculating what NDVI measures takes four steps.

Select the layer that sets your output grid

Click B08 in the Layers panel before opening the dialog. When Raster > Raster Calculator opens, it reads the output extent, coordinate system (CRS), width and height from whichever raster layer is currently selected in the Layers panel. Confirm the header says Extent (current: B08).

Enter the NDVI expression

In Raster > Raster Calculator, double-click B08@1 and B04@1 in the Raster Bands list (or type them with double quotes) to build:

("B08@1" - "B04@1") / ("B08@1" + "B04@1")

Check that the note directly under the expression box reads Expression valid. You don't need to cast the unsigned 16-bit Sentinel-2 bands to float in this dialog.

Pick an output file or virtual layer

Set Output layer to a .tif path and leave Output format on GeoTIFF. If you only want a quick preview and don't want a file on disk, tick Create on-the-fly raster instead of writing layer to disk (it stores a formula URI in the project file with absolute paths to the inputs, and matches the written file to 3e-8).

Run and style the result

Click OK. On our 500 by 500 pixel window, the calculation finished in 0.12 seconds, with values from -0.5507 to 0.9547 and a maximum difference of 2.98e-8 against 64-bit numpy. Open Layer Properties > Symbology, switch to Singleband pseudocolor, pick the RdYlGn ramp, and set min to 0 and max to 0.9.

Here is the dialog right before clicking OK, with the extent taken from B08:

QGIS Raster Calculator dialog with B04@1 and B08@1 in the Raster Bands list, Output layer path filled in, Extent (current: B08) with 500 columns and 500 rows in EPSG:32723, and the NDVI formula showing Expression valid.
The Raster > Raster Calculator dialog takes its initial extent, CRS and pixel dimensions from the layer selected in the Layers panel.

Once styled with RdYlGn, dense vegetation around Machado shows up in dark green, bare soil and roads in red, and the 703 pixels where red beats near-infrared (water and dark spots) go negative. The mean NDVI over the whole window is 0.727. If your canopy is dense enough to saturate NDVI or leaves bare soil exposed between rows, see how the formula changes in our comparison of NDVI vs EVI, SAVI and NDRE.

QGIS map canvas showing the 5 km by 5 km Machado farmland NDVI raster styled with a Red-Yellow-Green ramp from 0 to 0.9, with the Layers panel on the left.
The Float32 NDVI output over Machado, styled from 0 (red) to 0.9 (green). Every pixel matches the external 64-bit reference within 3e-8.

QGIS Raster Calculator formula cookbook

Every expression below was run in QGIS 3.44.15 (QgsRasterCalculator) on the Machado dataset and checked pixel by pixel against numpy. Copy the syntax with double quotes around "layer@band".

TaskTested expression (Raster > Raster Calculator)What it produced on the Machado test window
NDVI (Sentinel-2 10 m)("B08@1" - "B04@1") / ("B08@1" + "B04@1")Float32 from -0.5507 to 0.9547 across 250,000 pixels; max error 2.98e-8.
NDWI (McFeeters water index)("B03@1" - "B08@1") / ("B03@1" + "B08@1")Float32; "ndwi@1" > 0 isolated 637 water pixels (6.4 ha of ponds and streams).
Mask below threshold to nodata"ndvi@1" / ("ndvi@1" > 0.3)Divides by 0 where false, writing true nodata (11,343 pixels excluded; mean 0.753).
Conditional replace (if)if("ndvi@1" > 0.3, "ndvi@1", 0)Parses and keeps values above 0.3, but writes 0 elsewhere, like the * mask.
Reclass DEM into 5 classes (boolean sum)("dem@1" < 800) * 1 + ("dem@1" >= 800 AND "dem@1" < 850) * 2 + ("dem@1" >= 850 AND "dem@1" < 900) * 3 + ("dem@1" >= 900 AND "dem@1" < 1000) * 4 + ("dem@1" >= 1000) * 5Codes 1 to 5 across 54,901 DEM pixels; identical on every pixel to native:reclassifybytable.
Reclass DEM into 5 classes (nested if)if("dem@1" < 800, 1, if("dem@1" < 850, 2, if("dem@1" < 900, 3, if("dem@1" < 1000, 4, 5))))Same 5 classes (96.9 ha, 1,089.4 ha, 1,323.1 ha, 1,759.7 ha, 582.7 ha).
Slope threshold"slope@1" > 15Writes 1 above 15 degrees and 0 at or below (17,327 of 52,979 valid UTM DEM pixels, 32.61%).
Elevation metres to feet"dem@1" * 3.28084Max error 0.98 ft (0.026% at 1,168 m) if you use the QGIS manual's 3.28 instead of 3.28084.

Four silent traps that give wrong numbers (and how to spot them)

None of the four failures below raises a warning in QGIS. Each one finishes, adds a layer to your project, and hands you numbers that look plausible until you check them.

1. The GDAL Raster Calculator wraps unsigned integers on negative NDVI

The native QGIS Raster Calculator never hits an integer-division bug on Sentinel-2 bands, but the Processing GDAL Raster calculator (gdal:rastercalculator) evaluates (A-B)/(A+B) in the input file's own numpy data type and corrupts every pixel where Red is higher than NIR.

Sentinel-2 Level-2A bands ship as unsigned 16-bit integers (uint16). In our 250,000-pixel window, 703 pixels (0.28%, water and dark spots) have B04 larger than B08. When gdal:rastercalculator subtracts B04 from B08 in uint16, the negative result wraps around 65,536, and the tool writes those 703 pixels into a Float32 file with values between 0 and 126.7 instead of negative NDVI.

QGIS map showing the GDAL raster calculator NDVI output on uint16 bands, with magenta pixels marking values above 1 where red exceeds near-infrared.
The GDAL Raster Calculator on raw uint16 bands. Magenta marks values above 1: pixels that should be negative wrapped around 65,536 instead, up to 126.7.

If you use the GDAL calculator, either cast in the expression with (A.astype(numpy.float32)-B)/(A+B) (which brought wrong pixels down to 0 in our test), or use the native QGIS Raster Calculator instead. And never set the GDAL output type to Int16 or Byte on an index: forcing Int16 rounded 249,973 of our 250,000 pixels (99.99%) into just -1, 0 or 1.

2. Multiplying by a condition (*) writes 0, not nodata, and drags your mean down

Multiplying a raster by a boolean condition like ("ndvi@1" > 0.3) * "ndvi@1" sets every excluded pixel to 0 instead of nodata, which pulled the mean NDVI of the kept area down from 0.753 to 0.719.

The official QGIS manual teaches ("elevation@1" >= 0) * "elevation@1" as the standard way to mask a raster. On our Machado NDVI layer, 11,343 pixels sit at or below 0.3. When you multiply by ("ndvi@1" > 0.3), all 11,343 pixels are written as the valid number 0.0, so the layer statistics still count all 250,000 pixels and include 11,343 zeros in every mean, histogram and zonal statistic you run next.

Mask expressionWhat excluded pixels becomePixels counted in layer statsMean NDVI of kept area
("ndvi@1" > 0.3) * "ndvi@1"0.0 (counted as data)250,0000.719 (-0.034 error)
"ndvi@1" / ("ndvi@1" > 0.3)nodata (-3.40282e+38)238,6570.753 (exact)

To write true nodata in a single step, divide by the condition instead of multiplying: "ndvi@1" / ("ndvi@1" > 0.3). Where the condition is true (1), the value is unchanged; where it is false (0), division by zero produces nodata.

3. Running Slope on a degree DEM before the calculator marks 100% of your map as steep

Running the QGIS Slope tool with default settings on a geographic DEM (such as Copernicus GLO-30 in EPSG:4326) before thresholding "slope@1" > 15 marked 100% of our hilly farmland as steeper than 15 degrees, with a mean slope of 89.99 degrees, whereas the true figure after warping to UTM is 32.61%.

Copernicus GLO-30 and other free global DEMs (see our guide to free DEM downloads in QGIS) arrive in geographic degrees (EPSG:4326) with elevations in metres. With the default Z factor, the Slope tool reads a rise in metres against a horizontal step of 0.000278 (a pixel width in degrees), so every hillside looks like a vertical cliff.

How Slope was computed before "slope@1" > 15Pixels > 15 degreesMean slope
Native Slope on EPSG:4326 DEM, default Z factor 1100.00%89.99°
GDAL Slope on EPSG:4326 DEM, scale 1100.00%89.99°
GDAL Slope on EPSG:4326 DEM, scale 11112031.61%12.54°
Native Slope after warping DEM to UTM 23S 30 m (reference)32.61%12.68°
GDAL Slope after warping DEM to UTM 23S 30 m32.71%12.71°

Even the 111120 metres-per-degree scale shortcut lands a full percentage point low (31.61% vs 32.61%), because at 22°S a degree of longitude is about 7% shorter than a degree of latitude (cos 22° = 0.93). Always warp your DEM to its local UTM zone (Raster > Projections > Warp (Reproject), bilinear resampling, and set a -9999 nodata value so the edge doesn't ring with fake 87° slopes) before running Slope and the Raster Calculator.

4. Subtracting the Sentinel-2 +1000 offset without checking the scene breaks 90.6% of pixels

Subtracting 1000 from our Sentinel-2 L2A bands before computing NDVI pushed 226,413 of 250,000 pixels (90.6%) outside the valid [-1, 1] range, with values from -1273 to 1217 and a mean of 1.39 instead of 0.727.

ESA's Sentinel-2 processing baseline 04.00 and later added a +1000 digital-number offset so dark pixels don't clip at zero, and STAC metadata lists scale: 0.0001 and offset: -0.1. But when you pull Cloud-Optimized GeoTIFFs from the Earth Search STAC API (as we did for scene S2A_23KLR_20261103_0_L2A, baseline 05.13), the provider has already shifted the values and sets earthsearch:boa_offset_applied: true in the item metadata.

In our B04 file, 226,773 of 250,000 pixels (90.7%) had raw digital numbers below 1000 (minimum 59, median 491). Check the minimum value of B04 in Layer Properties > Information before you touch the formula: if dark pixels sit around 50 to 300, the offset is already removed and raw (B08 - B04) / (B08 + B04) is exact (dividing by the 10,000 scale factor cancels out in the ratio). Only subtract 1000 if your darkest clear-sky pixels sit above 1000.

Bonus: what "Expression valid" and the generic red error bar actually mean

Two other behaviours in Raster > Raster Calculator waste a lot of debugging time:

  • Mixed resolutions on a coarse grid do not average. When you combine a 10 m band (B08) and a 20 m band (B11), the native calculator resamples on the fly using nearest neighbour (the GDAL calculator stops immediately with Error! Dimensions of file ... (250, 250) are different from other files (500, 500). Cannot proceed). On a 10 m output grid, repeating each 20 m pixel 2 by 2 works cleanly. On a 20 m output grid, the calculator does not average the four 10 m pixels: in 62,250 of 62,500 cells it simply picked the top-right 10 m pixel of each 2 by 2 block, differing from a 4-pixel mean by up to 0.71 in (B08 - B11) / (B08 + B11).
  • "Expression valid" only checks syntax, not band names. If you leave a trailing operator like "B08@1" /, the dialog catches it (Expression invalid, OK greyed out).
QGIS Raster Calculator dialog with an incomplete formula ending in a slash, showing Expression invalid at the bottom left and the OK button disabled.
A syntax error disables the OK button right away.

But if you type a layer or band that isn't in the project (such as "B04@2" or "B02@1" when B02 isn't loaded), the label still says Expression valid. When you click OK, QGIS writes a 500 by 500 GeoTIFF filled 100% with nodata, adds nothing to the map, and shows one red banner: Raster calculator: An error occurred while performing the calculation. Whenever you see that banner, check every "layer@band" name against the Raster Bands list.

Red error bar at the top of the QGIS map canvas reading Raster calculator: An error occurred while performing the calculation.
A typo in a band name passes the syntax check, fails with this generic bar, and leaves an all-nodata GeoTIFF on disk.

Skipping the dialog: running raster math by asking an AI agent in QGIS

We build AI Agent, so this section is about our own plugin. It docks a chat panel inside QGIS that can search STAC catalogues, warp DEMs, run raster math in Python or Processing, and style the output on your map.

To see whether it avoids the traps above on the exact same Machado data, I drew a 120.07 ha farm polygon (E 405300-406300, N 7594400-7595600 in UTM 23S) over an empty OpenStreetMap project and sent three single-message prompts on Medium effort, Balanced permissions, one fresh chat per task.

Test 1: NDVI in 5 classes with hectares per class

I typed:

Compute NDVI for this field from the latest cloud-free Sentinel-2 and class it in 5 classes with hectares per class

The agent checked the STAC catalogue, noted that a 6 November 2026 scene existed but was heavily cloudy, and picked the same 3 November 2026 scene (1.65% tile cloud cover) I used for the manual test. It fetched B08 and B04 at 10 m, computed NDVI, classed it into five intervals, styled the layer, and reported the area per class on the 10 m metric grid.

NDVI classAI Agent reportMy independent numpy check (same 12,000 pixels)Difference
-1 to < 00.76 ha0.76 ha0.0%
0 to < 0.223.85 ha23.84 ha< 0.1%
0.2 to < 0.49.27 ha9.27 ha0.0%
0.4 to < 0.68.70 ha8.69 ha0.1%
0.6 to 177.42 ha77.44 ha< 0.1%
Total valid area120.00 ha120.00 ha (of 120.07 ha polygon)0.0%

Pixel by pixel on its exported NDVI raster, the mean absolute difference against my reference was 5e-5, and its class raster differed from mine on 4 pixels out of 12,000.

QGIS interface showing the 120 ha farm polygon styled in five NDVI classes from red to dark green, with the Layers panel showing hectare counts per class and the AI Agent panel on the right listing the 3 November 2026 Sentinel-2 source and class areas.
One message: the agent skipped the cloudy 6 November pass, used the 3 November scene, and matched our reference areas within 0.1%.

Test 2: Slope above 15 degrees (without a DEM in the project)

In a new chat with only the farm polygon loaded, I typed:

Show where slope is above 15 degrees on the farm layer

The agent fetched the Copernicus 30 m DEM, reprojected to a metric grid rather than running slope in degrees, and styled a two-class layer (15 degrees or less in pale yellow, above 15 degrees in red). It reported 331 of 1,320 farm pixels above 15 degrees (25.08%, max 29.15°). My own UTM-warped native Slope reference on the same rectangle gave 341 of 1,360 pixels (25.07%, max 29.29°), a 0.01 percentage-point difference.

QGIS map of the farm polygon showing slope above 15 degrees in red cells over OpenStreetMap, with the AI Agent chat panel on the right reporting 331 of 1,320 farm pixels above the threshold and a maximum of 29.1 degrees from the 30 m Copernicus DEM.
The agent warped the Copernicus DEM to a metric grid before thresholding, landing within 0.01 percentage points of our reference.

Test 3: Where you still have to check the agent

In a third chat, I asked:

Mask out everything with NDVI below 0.3 on the farm layer and tell me the mean NDVI of what is left

Note that I didn't write "cloud-free" or "latest" in this prompt. The agent avoided the 0 mask trap: it wrote -9999 nodata for every masked pixel and returned a mean NDVI of 0.552 across 2,277 retained pixels. The mean of its output file was 0.5515; mine on the same scene and rule was 0.5513.

However, it pulled the Sentinel-2 scene from 27 October 2026, and on that date 80.5% of the farm (9,656 of 12,000 pixels in the Scene Classification band) was covered by cloud, shadow or cirrus. It named the date in its answer, but it never mentioned that 0.552 described only the clear fifth of the field. On the clear 3 November scene, the same rule gives a mean of 0.766 over 8,785 pixels.

My rule of thumb: whether you run the Raster Calculator by hand or ask an agent in the QGIS AI ecosystem, always inspect the scene date and load the true-colour or SCL cloud band before trusting a summary number.

How we measured this. All tests ran on 7 November 2026 in QGIS 3.44.15-Solothurn on macOS (GDAL 3.13.3, numpy 2.5.2) over a 5 km by 5 km window south of Machado, Minas Gerais, Brazil (EPSG:32723, E 402000-407000, N 7591000-7596000), using Sentinel-2 L2A scene S2A_23KLR_20261103_0_L2A (and the 27 October 2026 scene for test 3) from Earth Search and Copernicus GLO-30 tile S22_00_W046_00. Success criteria for the manual tests and agent tests 1 and 2 were written before any raster calculation ran; the criteria for test 3 were written after seeing tests 1 and 2. Reference grids were computed in 64-bit float numpy outside QGIS. AI Agent 1.9.4 ran on Medium effort and Balanced permissions, one message per chat. One scene and one site is a single-case benchmark, not a statistical survey across baselines.

Frequently asked questions

Why is my NDVI all 0, -1 or 1 in QGIS?

You forced an integer output type (Int16 or Byte) in the GDAL raster calculator, which rounds every decimal between -1 and 1 to -1, 0 or 1 (99.99% of pixels in our test). Leave the output type on Float32, or use Raster > Raster Calculator from the top menu, which outputs Float32 automatically.

Why does the QGIS Raster Calculator fail with 'An error occurred while performing the calculation' and output nodata?

A "layer@band" name in your formula does not match a loaded layer or band number (for example typing "B04@2" on a single-band file, or referring to a layer you removed). The Expression valid message under the formula box only checks syntax, not whether the bands exist. Re-insert each band by double-clicking it in the Raster Bands list.

What is the difference between Raster > Raster Calculator and the GDAL Raster Calculator in Processing?

Raster > Raster Calculator (and the native Processing Raster calculator) uses QGIS's own parser with "layer@band" names and always computes in floating point. The GDAL Raster calculator (gdal_calc.py) uses letter variables (A, B) and evaluates numpy math in the input band's data type, so subtracting unsigned 16-bit integer bands wraps negative numbers around 65,536 unless you cast with A.astype(numpy.float32).

How do I write an IF condition or set values to nodata in the QGIS Raster Calculator?

You can write if("ndvi@1" > 0.3, "ndvi@1", 0) to replace values below 0.3 with 0. If you want excluded pixels to become true nodata so they don't drag down your layer mean, divide by the condition instead: "ndvi@1" / ("ndvi@1" > 0.3). Where the condition is false (0), dividing by zero writes nodata.

Try AI Agent for free

Do it in QGIS by asking for it

AI Agent carries out the task you describe, in your own QGIS project. Free, no card.

Try AI Agent for free