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.
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%.
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.
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.
Internal links
- NDVI stops responding in a dense canopy β what to do about it
- Crop phenology and growing seasons explained β the seasonal curve these indices trace
- How to extract phenology metrics from an NDVI time series β using the curve
- Spectral indices explained β the wider family
- How to calculate NDVI in Python β the basic computation
- How to mask clouds in Sentinel-2 imagery in Python β the SCL mask
- NDVI values are out of range β scaling and nodata problems
- How to classify crop types from a satellite time series β where band choice decides the result
- How to monitor crops with Sentinel-1 in Python β the radar alternative when the sky is cloudy
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.