NDVI Values Are Above 1 or Below −1: How to Fix It

Problem statement

NDVI is (nir − red) / (nir + red). When both bands are non-negative that expression is mathematically bounded by −1 and 1. There is no vegetation, no sensor and no season that produces 1.376.

So an out-of-range NDVI is never a data story. It is always one of four bugs, and all four are in the code between reading the file and doing the division:

NDVI over pixels the scene classification calls vegetation
  computed correctly        0.730
  with the offset applied   1.376

Both were computed from the same two GeoTIFFs, on the same 466,954 pixels, five lines apart.

Quick answer

Check the denominator, not the numerator:

import numpy as np

total = nir + red
print(f"denominator: {np.nanmin(total):.4f} to {np.nanmax(total):.4f}")
print(f"negative denominators: {(total < 0).sum():,} of {total.size:,}")
denominator: -0.1998 to 2.6600
negative denominators: 195,656 of 1,105,479

A negative denominator flips the sign of the whole fraction and removes the bound. Its cause is almost always a radiometric offset subtracted from bands that already had it subtracted.

Four causes of an out-of-range NDVI: double-applied offset, unsigned integer wraparound, mixed scaling between bands, and an unhandled zero denominator.
All four are code bugs upstream of the division. None is a property of the ground.

Step-by-step solution

1. Cause one: the offset was applied twice

Sentinel-2 L2A products from processing baseline 04.00 onwards carry a −1000 DN offset, and STAC metadata declares it:

raster:bands -> {"scale": 0.0001, "offset": -0.1, "nodata": 0}

Many archives, however, harmonise their COGs — they subtract the offset at ingest so the whole archive is on one scale — and leave the metadata describing the unharmonised product. Applying the declared offset then subtracts it a second time.

Test it against water, which must be dark and positive in every band:

water median reflectance     blue    green     red      nir
  DN * 1e-4                 0.015    0.013   0.008    0.006
  DN * 1e-4 - 0.1          -0.085   -0.087  -0.092   -0.094

Water at −0.09 reflectance in all four bands is not a dark lake. Drop the offset.

2. Cause two: unsigned integer wraparound

red = rasterio.open("B04.tif").read(1)
print(red.dtype, red.min())     # uint16 1
print(red.min() - 1000)         # 64537

uint16 has no negative numbers, so 1 - 1000 wraps to 64,537. After scaling that is a reflectance of 6.45, and any index built on it is nonsense — but a plausible-looking, finite nonsense.

red = red.astype("float32")     # then subtract

Cast before any subtraction, always. This includes differences between dates, dark-object corrections and offsets.

3. Cause three: the two bands are on different scales

red = red_ds.read(1).astype("float32") * 1e-4    # reflectance
nir = nir_ds.read(1).astype("float32")           # still DN

A mismatch of 10,000× drives NDVI to almost exactly 1 everywhere, which looks like an implausibly healthy landscape rather than an error. If your NDVI is above 0.98 across the whole scene, check that both bands went through the same conversion.

The safest structure is one function that reads and scales, used for every band, so the two cannot drift apart.

4. Cause four: an unhandled zero denominator

Where both bands are zero — no-data fill, scene edge — the division is 0/0, which is NaN with a warning. Where only one is zero it is finite but extreme.

with np.errstate(divide="ignore", invalid="ignore"):
    total = nir + red
    ndvi = np.where(np.abs(total) < 1e-6, np.nan, (nir - red) / total)

Do not substitute 0 for the undefined case; 0 is a real NDVI value meaning "equal red and near-infrared".

5. Add the assertion that would have caught it

finite = ndvi[np.isfinite(ndvi)]
assert finite.min() >= -1.001 and finite.max() <= 1.001, \
    f"NDVI out of range: {finite.min():.3f} to {finite.max():.3f}"
A normalised difference with a positive denominator staying inside minus one to one, and the same expression with a negative denominator escaping the bound.
The bound on a normalised difference is a property of the denominator's sign, not of the ground.

Code examples

Example 1 — diagnose which of the four it is

import numpy as np
import rasterio


def diagnose_index(red_path, nir_path, scale=1e-4, offset=0.0):
    """Work out why a normalised difference left [-1, 1]."""
    with rasterio.open(red_path) as ds:
        red_raw = ds.read(1)
    with rasterio.open(nir_path) as ds:
        nir_raw = ds.read(1)

    print(f"  dtypes      red {red_raw.dtype}, nir {nir_raw.dtype}")
    print(f"  shapes      red {red_raw.shape}, nir {nir_raw.shape}")
    print(f"  raw ranges  red {red_raw.min()}-{red_raw.max()}, "
          f"nir {nir_raw.min()}-{nir_raw.max()}")

    if red_raw.shape != nir_raw.shape:
        print("  -> shapes differ: resample onto one grid first")
        return

    red = red_raw.astype("float32") * scale + offset
    nir = nir_raw.astype("float32") * scale + offset
    total = nir + red

    negatives = int((total < 0).sum())
    zeros = int((np.abs(total) < 1e-6).sum())
    print(f"  denominator {np.nanmin(total):.4f} to {np.nanmax(total):.4f}")
    print(f"  negative    {negatives:,} ({negatives / total.size:.1%})")
    print(f"  ~zero       {zeros:,} ({zeros / total.size:.1%})")

    if negatives:
        print("  -> negative denominator: the offset is probably applied twice")
    if zeros:
        print("  -> zero denominator: mask nodata and guard the division")
    ratio = float(np.median(nir[nir > 0])) / max(float(np.median(red[red > 0])), 1e-9)
    if ratio > 100 or ratio < 0.01:
        print(f"  -> band medians differ by {ratio:.0f}x: mixed scaling")
  dtypes      red uint16, nir uint16
  shapes      red (1017, 1087), nir (1017, 1087)
  raw ranges  red 1-13184, nir 0-15528
  denominator -0.1998 to 2.6600
  negative    195,656 (17.7%)
  ~zero       138 (0.0%)
  -> negative denominator: the offset is probably applied twice
  -> zero denominator: mask nodata and guard the division

17.7% of denominators negative is decisive. A genuine dark-target artefact affects a fraction of a percent of pixels — here it is nearly a fifth of the scene, and it is exactly the fifth that is water, shadow and dark rock.

The 138 near-zero denominators are separate and real: pixels where both bands read zero at the scene edge. They need the guard whether or not the offset is right.

Example 2 — a scale-and-offset check that runs before the index

import numpy as np
import rasterio
from rasterio.enums import Resampling

# what physics says these surfaces must look like in surface reflectance
BOUNDS = {6: {"blue": (0.0, 0.10), "nir": (0.0, 0.06)},     # water
          4: {"red": (0.01, 0.12), "nir": (0.15, 0.60)}}    # vegetation


def verify_scaling(band_paths, scl_path, scale, offset):
    with rasterio.open(scl_path) as ds:
        scl = ds.read(1)

    failures = []
    for code, expectations in BOUNDS.items():
        mask = scl == code
        if mask.sum() < 500:
            continue
        for band, (lo, hi) in expectations.items():
            with rasterio.open(band_paths[band]) as ds:
                dn = ds.read(1, out_shape=scl.shape,
                             resampling=Resampling.nearest)
            value = float(np.median(dn[mask].astype("float32")) * scale + offset)
            if not lo <= value <= hi:
                failures.append(f"class {code} {band}={value:.3f} "
                                f"outside {lo}-{hi}")

    if failures:
        raise ValueError(f"scale={scale}, offset={offset} is wrong: "
                         + "; ".join(failures))
    return True

Run it once per data source, not once per scene. The answer is a property of the archive, and once you know it you can hard-code it with a comment saying how you found out.

Example 3 — a safe normalised difference for any pair of bands

import numpy as np


def normalised_difference(a, b, name="index", tolerance=1e-3):
    """(a - b) / (a + b) with the four failure modes handled."""
    a = np.asarray(a, dtype="float64")     # never integer arithmetic
    b = np.asarray(b, dtype="float64")

    if a.shape != b.shape:
        raise ValueError(f"{name}: shapes {a.shape} and {b.shape} differ")

    total = a + b
    if np.nanmin(total) < 0:
        share = float((total < 0).mean())
        raise ValueError(
            f"{name}: {share:.1%} of denominators are negative — "
            "the inputs are not both non-negative, so the result is unbounded"
        )

    with np.errstate(divide="ignore", invalid="ignore"):
        out = np.where(np.abs(total) < 1e-12, np.nan, (a - b) / total)

    finite = out[np.isfinite(out)]
    if finite.size and (finite.min() < -1 - tolerance or
                        finite.max() > 1 + tolerance):
        raise ValueError(f"{name}: range {finite.min():.3f} to "
                         f"{finite.max():.3f} escapes [-1, 1]")
    return out.astype("float32")

Raising on a negative denominator rather than silently producing an unbounded result is the whole point. The failure is upstream and the message should say so.

Explanation

Why the bound exists and how it breaks

For non-negative a and b, |a − b| ≤ a + b, so the ratio lies in [−1, 1]. That is the entire argument, and it depends on one thing: the denominator being positive.

Make one input negative and the inequality fails. If nir = 0.261 and red = −0.044, the numerator is 0.305 and the denominator is 0.217 — a ratio of 1.41. The formula is doing exactly what it was asked to do; the inputs stopped satisfying its precondition.

That is why "clip the result to [−1, 1]" is not a fix. It hides an input error behind a plausible output.

Why the offset trap is so persistent

The metadata is not lying. scale: 0.0001, offset: -0.1 correctly describes Sentinel-2 L2A products at baseline 04.00 and later, as delivered by the mission.

What it does not describe is what a particular archive did on ingest. Harmonising — subtracting the offset so scenes either side of the 2022 baseline change are comparable — is a sensible thing for an archive to do, and it changes the stored integers without necessarily changing the item-level metadata.

The consequence is that you cannot answer "should I apply the offset?" from documentation. You answer it from a dark target, once per archive.

Why NDVI near 1 everywhere is the same class of bug

Mixed scaling is the quiet version. If nir is in DN (thousands) and red is in reflectance (hundredths), then (nir − red)/(nir + red) ≈ 1 for every pixel, because red is negligible.

The output is in range, contains no NaN, and maps as a uniformly bright, healthy landscape. Only the histogram gives it away: real NDVI has a spread, and a scene at 0.999 ± 0.001 has none.

Why the check belongs in code, not in your memory

Every one of these produces a finite array of the right dtype, shape and CRS. There is no exception, no warning, and no visual signature — an out-of-range NDVI plotted with a default colour ramp looks like a map.

Two comparisons at the end of the function catch three of the four causes, and the fourth (reading the wrong band) is caught by the class-median check in Example 2. Both are cheaper than the meeting where someone asks why the NDVI is above 1.

Clipping an out-of-range index against fixing the input scaling, with clipping preserving the underlying bias.
Clipping makes the range look right and leaves every value wrong by the same amount.

Edge cases or notes

  • Clipping is not fixing. np.clip(ndvi, -1, 1) converts a loud error into a silent bias.
  • Surface reflectance can be slightly negative over very dark targets — around −0.01 from an imperfect atmospheric model. Two thirds of a scene at −0.09 is a double correction.
  • 0/0 is NaN, x/0 is ±inf. Guard on abs(total) < eps, not on total == 0.
  • Integer inputs truncate. In older NumPy (a - b) / (a + b) on integers gave integer division; cast explicitly rather than relying on the version.
  • The scale cancels, the offset does not. For a ratio index you can skip the multiplicative scale entirely — but never the offset question.
  • Other normalised indices have the same bound and the same failure: NDWI, NDBI, NDSI, NBR.
  • A whole-scene NDVI of 0.999 is mixed scaling, not a rainforest.
  • nodata is 0 and 0 is a legal reflectance. Mask from the classification band.

FAQ

Why is my NDVI greater than 1?

The denominator nir + red went negative. Almost always a radiometric offset applied to bands that already had it applied, which makes one or both reflectances negative.

Why is my NDVI exactly 1 everywhere?

The two bands are on different scales — one in reflectance and one in raw digital numbers, differing by about 10,000×, which makes the smaller term negligible.

Can NDVI legitimately be negative?

Yes, down to about −0.1 over open water, and more negative over snow and cloud. Measured over water in this scene: −0.096.

Should I clip NDVI to [−1, 1]?

No. Clipping hides an input error. Fix the scaling and the range takes care of itself.

Why does 1 − 1000 give 64537?

Because the array is uint16, which has no negative values, so the subtraction wraps. Cast to float before any subtraction on a raw band.

How do I know whether to apply the STAC offset?

Compute the median reflectance over water both ways. Water must be low and positive in every band. If applying the offset makes it around −0.09, it was already applied.

Does this affect other indices?

Yes — every normalised difference index shares the bound and the failure. NDWI, NDBI, NDSI and NBR all escape [−1, 1] when a band goes negative.