Vegetation indices explained: NDVI, EVI, NDRE and when each fails

On this page

Problem statement

NDVI is the default and it stops working exactly where agriculture gets interesting. Once a canopy closes, almost all the red light is absorbed, the numerator and denominator both flatten, and the index becomes nearly constant across a wide range of real biomass.

That is not a theoretical concern. On a nearly cloud-free Sentinel-2 scene over Flevoland farmland on 12 June 2025, 498,174 vegetation pixels β€” 50.7% of them β€” had an NDVI above 0.8. Across those pixels NDVI varied over a range of 0.199 with a coefficient of variation of 0.033; the red-edge index NDRE over the same pixels varied over a range of 0.521 with a coefficient of variation of 0.093 β€” 2.8 times the relative variation, on the same ground.

This guide covers the main indices, what each responds to, and where each stops responding.

Quick answer

import numpy as np

# Sentinel-2 L2A digital numbers to reflectance
r, n, re1, b, g = (a / 10_000 for a in (red, nir, rededge1, blue, green))

ndvi  = (n - r) / (n + r)
ndre  = (n - re1) / (n + re1)                    # Sentinel-2 B8 and B5
evi   = 2.5 * (n - r) / (n + 6 * r - 7.5 * b + 1)
evi2  = 2.5 * (n - r) / (n + 2.4 * r + 1)        # no blue band needed
gndvi = (n - g) / (n + g)
savi  = 1.5 * (n - r) / (n + r + 0.5)            # soil-adjusted, sparse canopies

Reflectance must be scaled first, which is the first line: Sentinel-2 L2A stores reflectance Γ— 10,000. Skipping that makes EVI, EVI2 and SAVI wrong, because their additive constants assume reflectance in 0–1.

Five vegetation indices against what each responds to and where each fails. five indicesNDVINDREEVISAVIdense canopyβœ•saturatesβœ“respondsβœ“respondsβœ•saturatessparse canopy!soil-affected!weakβœ“okβœ“bestneeds blue bandβœ“noβœ“noβœ•yesβœ“nocross-sensorβœ“30 years!red edge onlyβœ“goodβœ“goodGNDVI is the fifth: green instead of red, and it saturates later than NDVI spatialworkflow.io
Five indices, three different failure modes; only one of them is about the canopy.

What NDVI measures, red edge for closed canopies, choosing the index

1. Know what NDVI actually measures

The contrast between red absorption by chlorophyll and near-infrared scattering by leaf structure. That contrast grows quickly as leaf area increases from bare soil and then flattens, because there is no more red light left to absorb. The index saturates; the canopy does not.

2. Use a red-edge index where the canopy is closed

The red edge β€” Sentinel-2 bands 5, 6 and 7 between about 705 and 783 nm β€” sits on the steep part of the vegetation reflectance curve, so it keeps responding when red is exhausted. Across 379 arable parcels on the same scene whose median NDVI exceeded 0.8, NDVI spanned 0.123 between fields and NDRE spanned 0.231 (measured here).

3. Choose the right red-edge band

Not all of them work. Using band 7 at 783 nm gives an index near zero, because that band is already on the NIR plateau: over the scene's vegetation pixels the median NDRE was +0.569 with band 5 and +0.019 with band 7. Band 5 is the usual choice for crop work.

4. Use a soil-adjusted index on sparse canopies

At low cover, NDVI is strongly affected by the soil beneath β€” wet soil and dry soil give different values under identical vegetation. SAVI adds a soil brightness term; it matters in early season, in arid systems and on row crops before closure.

5. Use EVI where the atmosphere or the background is a problem

EVI adds a blue-band aerosol correction and a canopy background adjustment. It is more responsive at high biomass than NDVI β€” on the dense pixels above it varied over 0.771 against NDVI's 0.199 β€” at the cost of needing a blue band whose atmospheric correction is the least reliable of the three.

6. Match the index to the question, not to the convention

Cover and greenness: NDVI. Biomass or nitrogen at closure: NDRE. Early-season emergence on bare soil: SAVI. Cross-sensor consistency over decades: NDVI, because it is the one everybody else used.

7. Mask before you index

Cloud, shadow, water and cloud shadow all produce valid-looking index values. Sentinel-2's scene classification layer at 20 m is the cheapest mask available; on the reference scene, classes 4, 5 and 6 β€” vegetation, bare soil and water β€” covered 99.7% of the box, and class 4 alone 79.0%.

Range and coefficient of variation of three indices among dense canopy pixels. 498,174 pixels with NDVI above 0.8NDVI range0.199 Β· CV 0.033NDRE range0.521 Β· CV 0.093EVI range0.771 Β· CV 0.11300.8Sentinel-2, Flevoland, 12 June 2025; 50.7% of vegetation pixels are dense spatialworkflow.io
Among 64,302 pixels with NDVI above 0.8, NDVI varies by 0.128 and NDRE by 0.572.

The index family on one Sentinel-2 scene

The data used on this page

One whole Sentinel-2 L2A scene from Earth Search: S2A_31UFU_20250612_0_L2A, 12 June 2025, a nearly cloud-free pass over Flevoland farmland, cut to 5.55–5.75Β° E, 52.48–52.56Β° N. The setup reads the blue, green, red, NIR, bands 5, 6 and 7 and the scene classification (scl) onto the 10 m grid, nearest-neighbour for the 20 m bands, and keeps them as raw digital numbers so that the scaling in Example 1 has something to do.

import os

import geopandas as gpd
import numpy as np
import rasterio
import requests
from rasterio.windows import from_bounds

os.environ.update(AWS_NO_SIGN_REQUEST="YES", GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR",
                  CPL_VSIL_CURL_ALLOWED_EXTENSIONS=".tif")
SCENE = "S2A_31UFU_20250612_0_L2A"


def window(href, bounds, shape=None):
    with rasterio.open(href) as src:
        win = from_bounds(*bounds, transform=src.transform)
        extra = {"out_shape": shape} if shape else {}
        return src.read(1, window=win, boundless=True, **extra)


if not os.path.exists("scene_bands_20250612.npz"):
    item = requests.get("https://earth-search.aws.element84.com/v1/collections/sentinel-2-l2a/items/"
                        + SCENE, timeout=60).json()
    corners = gpd.GeoSeries.from_xy([5.55, 5.75], [52.48, 52.56], crs=4326).to_crs(item["properties"]["proj:epsg"])
    b = (corners.x.min(), corners.y.min(), corners.x.max(), corners.y.max())
    bands = {"red": window(item["assets"]["red"]["href"], b)}
    for name in ("blue", "green", "nir", "rededge1", "rededge2", "rededge3", "scl"):
        bands[name] = window(item["assets"][name]["href"], b, bands["red"].shape)
    np.savez_compressed("scene_bands_20250612.npz", **bands)

z = np.load("scene_bands_20250612.npz")
blue, green, red, nir, rededge1, rededge2, rededge3, scl = (z[k] for k in
    ("blue", "green", "red", "nir", "rededge1", "rededge2", "rededge3", "scl"))
print(f"{SCENE}: {red.shape[1]} x {red.shape[0]} pixels, dtype {red.dtype}, "
      f"red DN {np.percentile(red[scl == 4], 50):.0f} at the median vegetation pixel")
S2A_31UFU_20250612_0_L2A: 1324 x 939 pixels, dtype uint16, red DN 436 at the median vegetation pixel

Example 1 β€” compute the family, correctly scaled and masked

import numpy as np, rasterio

SCALE = 1e-4                      # Sentinel-2 L2A reflectance is stored as x10,000

def indices(red, nir, rededge1, blue, green, scl=None):
    red, nir, re1, blue, green = (a.astype("float32") * SCALE
                                  for a in (red, nir, rededge1, blue, green))
    with np.errstate(invalid="ignore", divide="ignore"):
        out = {
            "ndvi": (nir - red) / (nir + red),
            "ndre": (nir - re1) / (nir + re1),
            "gndvi": (nir - green) / (nir + green),
            "evi": 2.5 * (nir - red) / (nir + 6 * red - 7.5 * blue + 1),
            "evi2": 2.5 * (nir - red) / (nir + 2.4 * red + 1),
            "savi": 1.5 * (nir - red) / (nir + red + 0.5),
        }
    if scl is not None:
        keep = np.isin(scl, [4, 5, 6])           # vegetation, bare soil, water
        for k in out:
            out[k] = np.where(keep, out[k], np.nan)
    return out


idx = indices(red, nir, rededge1, blue, green, scl)
ndvi, ndre, evi = idx["ndvi"], idx["ndre"], idx["evi"]
shares = {c: (scl == c).mean() for c in (4, 5, 6)}
print("scene classes: " + ", ".join(f"{c} {v:.1%}" for c, v in shares.items()))
for name, arr in idx.items():
    print(f"{name:5} median over vegetation {np.nanmedian(arr[scl == 4]):+.3f}")
scene classes: 4 79.0%, 5 20.6%, 6 0.1%
ndvi  median over vegetation +0.803
ndre  median over vegetation +0.569
gndvi median over vegetation +0.698
evi   median over vegetation +0.650
evi2  median over vegetation +0.592
savi  median over vegetation +0.567

Example 2 β€” measure the saturation on your own scene

import numpy as np

veg = np.isin(scl, [4]) & np.isfinite(ndvi) & np.isfinite(ndre)
dense = veg & (ndvi > 0.8)
print(f"vegetation pixels {veg.sum():,}; dense canopy {dense.sum():,} "
      f"({dense.sum()/veg.sum():.1%})")

for name, arr in (("NDVI", ndvi), ("NDRE", ndre), ("EVI", evi)):
    v = arr[dense]
    print(f"{name:5} range {v.max()-v.min():.3f}  sd {v.std():.4f}  "
          f"CV {v.std()/abs(v.mean()):.3f}")
vegetation pixels 982,656; dense canopy 498,174 (50.7%)
NDVI  range 0.199  sd 0.0282  CV 0.033
NDRE  range 0.521  sd 0.0608  CV 0.093
EVI   range 0.771  sd 0.0849  CV 0.113

Run this on your own peak-season scene before choosing an index. If more than half your vegetation pixels are above 0.8, NDVI is not going to separate them.

Example 3 β€” choosing the red-edge band matters

import numpy as np

b5, b6, b7 = (a.astype("float32") for a in (rededge1, rededge2, rededge3))
nir = nir.astype("float32")
ndre_b5 = (nir - b5) / (nir + b5)          # 705 nm β€” on the red edge
ndre_b6 = (nir - b6) / (nir + b6)          # 740 nm
ndre_b7 = (nir - b7) / (nir + b7)          # 783 nm β€” already on the NIR plateau

for name, arr in (("NDRE B5", ndre_b5), ("NDRE B6", ndre_b6), ("NDRE B7", ndre_b7)):
    v = arr[veg]
    print(f"{name}: p5 {np.percentile(v,5):+.3f}  median {np.median(v):+.3f}  "
          f"p95 {np.percentile(v,95):+.3f}")
NDRE B5: p5 +0.308  median +0.569  p95 +0.739
NDRE B6: p5 +0.027  median +0.097  p95 +0.228
NDRE B7: p5 -0.044  median +0.019  p95 +0.081

Band 6 at 740 nm is already most of the way up the edge, with a median of +0.097. Band 7 is close enough to the NIR plateau that the normalised difference collapses towards zero. "Red edge" is three bands with quite different behaviour, and the band number belongs in the metadata.

Why NDVI saturates and reflectance must be scaled

Why NDVI saturates and a red-edge index does not

Chlorophyll absorbs red light strongly. Once the leaf area index passes about three, essentially all incident red is absorbed, so further leaves change red reflectance almost not at all while NIR keeps rising slowly. The normalised difference therefore asymptotes. The red edge sits where absorption is only partial, so additional chlorophyll still changes the reflectance measurably β€” which is why red-edge indices track nitrogen status and late-season biomass where NDVI is flat.

Why the additive constants mean reflectance must be scaled

EVI's +1, EVI2's +1 and SAVI's +0.5 are in reflectance units. Feeding raw digital numbers in the thousands makes those constants negligible, and the index silently becomes a different formula β€” usually close to a scaled NDVI. NDVI and NDRE are ratios and survive an unscaled input; the others do not.

Why SAVI exists

At low cover the soil contributes most of the signal, and soil brightness varies with moisture, tillage and organic matter. NDVI over 20% cover on wet dark soil and on dry bright soil differs measurably with identical vegetation. SAVI's L term compresses that, at the cost of a constant that is itself cover-dependent β€” which is why adaptive variants exist.

Why NDVI remains the right answer surprisingly often

Three decades of consistent measurement across AVHRR, MODIS, Landsat and Sentinel means NDVI has a comparability nothing else has. For trend work, for cross-sensor time series and for anything that has to line up with published literature, the saturation is a known limitation rather than a reason to switch.

The same normalised difference computed against three red-edge bands. "red edge" is three bandsbandcentremedian NDREuseful?B5705 nm+0.569yes, the crop standardB6740 nm+0.097mostly past the edgeB7783 nm+0.019already on the NIR plateauthe band number belongs in the metadata β€” "NDRE" alone is ambiguousVegetation pixels, Flevoland, 12 June 2025: same NIR band, three red-edge bands spatialworkflow.io
Band 7 sits on the NIR plateau, so the normalised difference collapses to zero.

Scaling, band resolutions and negative NDVI

  • Scale reflectance first. EVI, EVI2 and SAVI depend on it; NDVI and NDRE do not.
  • Band resolutions differ. Red and NIR are 10 m on Sentinel-2; the red edges are 20 m.
  • L2A includes an aerosol correction that the blue band, and so EVI, depends on most.
  • Negative NDVI is water, not an error.
  • Shadow looks like dense canopy to most indices; mask it.
  • Different sensors have different band centres, so indices are not directly comparable.
  • Sun and view angle matter more at high biomass than most people assume.
  • Record which bands you used. "NDRE" without a band number is ambiguous.

FAQ

Why does my NDVI stop changing in summer?

Because the canopy has closed and almost all red light is already absorbed. On a real June scene, 50.7% of vegetation pixels were above 0.8 and varied over a range of only 0.199.

What should I use instead of NDVI at high biomass?

A red-edge index such as NDRE, or EVI. On the same dense pixels, NDRE varied over 0.521 and EVI over 0.771.

Which red-edge band should I use?

Sentinel-2 band 5 at 705 nm for crops. Band 7 at 783 nm is already on the NIR plateau and gives an index near zero.

Do I need to scale reflectance before computing indices?

For EVI, EVI2 and SAVI, yes β€” their additive constants are in reflectance units. NDVI and NDRE are ratios and are unaffected.

When is SAVI worth using?

On sparse canopies, where soil brightness dominates the signal β€” early season, arid systems and row crops before closure.

Is NDVI obsolete?

No. Its cross-sensor consistency over three decades is unmatched, which is exactly what trend work and comparison with published literature need.