Google Earth Engine NDVI Time Series: A Sentinel-2 Script with Cloud Score+
Copy-paste Google Earth Engine script for a Sentinel-2 NDVI time series with Cloud Score+ masking, monthly composites, a chart and CSV export.

In brief
- This script charts NDVI for any area from 2019 to 2025 using Sentinel-2 surface reflectance in Google Earth Engine.
- It masks clouds with Cloud Score+, Google’s current recommended cloud mask for Sentinel-2, instead of the older QA60 band most tutorials still use.
- Monthly median composites remove most of the remaining noise, and the chart can be downloaded as a CSV from the Code Editor.
- Earth Engine is free for non-commercial and research use once you register a Google Cloud project.
An NDVI time series shows how vegetation in one place changes through the seasons and across years: a crop cycle, a drought, a forest recovering after fire. Earth Engine is the quickest way to build one, because the whole Sentinel-2 archive is already there. The weak point in most tutorials is cloud masking. They use the QA60 band, which misses thin cloud and shadow, so the chart fills with false dips. The script below uses Cloud Score+ instead.
Before you start
You need a Google account and an Earth Engine-enabled Google Cloud project; registration for non-commercial use is free at code.earthengine.google.com. Paste each block into the Code Editor in order. Replace the area with your own: draw a polygon with the geometry tools, or edit the coordinates.
Step 1: the area and the cloud-masked collection
// Area of interest: a 500 m circle around a point (farmland near Seville, Spain).
// Replace with your own point or a drawn polygon.
var aoi = ee.Geometry.Point([-5.97, 37.38]).buffer(500);
var s2 = ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED');
var csPlus = ee.ImageCollection('GOOGLE/CLOUD_SCORE_PLUS/V1/S2_HARMONIZED');
var QA_BAND = 'cs_cdf';
var CLEAR_THRESHOLD = 0.60; // 0.50-0.65 works for most areas
var ndvi = s2
.filterBounds(aoi)
.filterDate('2019-01-01', '2026-01-01')
.linkCollection(csPlus, [QA_BAND])
.map(function (img) {
var clear = img.updateMask(img.select(QA_BAND).gte(CLEAR_THRESHOLD));
return clear.normalizedDifference(['B8', 'B4']).rename('NDVI')
.set('system:time_start', img.get('system:time_start'));
});COPERNICUS/S2_SR_HARMONIZED already removes the 2022 radiometric offset, so you can use the bands directly. linkCollection joins each Sentinel-2 image to its Cloud Score+ image, and pixels with a clear-sky score below 0.6 are masked.
Step 2: monthly composites
var start = ee.Date('2019-01-01');
var nMonths = 7 * 12;
var monthly = ee.ImageCollection.fromImages(
ee.List.sequence(0, nMonths - 1).map(function (m) {
var from = start.advance(m, 'month');
var subset = ndvi.filterDate(from, from.advance(1, 'month'));
return subset.median()
.set('system:time_start', from.millis())
.set('n', subset.size());
})
).filter(ee.Filter.gt('n', 0)); // drop months with no imagesStep 3: chart it
var chart = ui.Chart.image.series({
imageCollection: monthly,
region: aoi,
reducer: ee.Reducer.mean(),
scale: 10
}).setOptions({
title: 'Monthly mean NDVI',
vAxis: {title: 'NDVI', viewWindow: {min: 0, max: 1}},
lineWidth: 1,
pointSize: 3
});
print(chart);
Map.centerObject(aoi, 15);
Map.addLayer(monthly.filterDate('2025-06-01', '2025-07-01').first(),
{min: 0, max: 0.9, palette: ['brown', 'yellow', 'green']}, 'NDVI June 2025');Run the script. The chart appears in the Console. Click the arrow at its top right to open it full size, then Download CSV to take the numbers into Excel, R or Python.
Reading the chart
A regular wave each year is the growing season. A lower peak in one year can mean drought, a different crop or earlier harvest. A sudden, lasting drop suggests clearing, fire or building. Single sharp dips that recover the next month are often leftover cloud or haze; if you see many, raise the threshold to 0.65. For land under persistent cloud, Sentinel-1 radar is the alternative, because it sees through cloud.
| Problem | Likely cause | Fix |
|---|---|---|
| Chart is empty | Area outside your filter dates or too much cloud | Check the area and dates; lower the threshold slightly |
| “Too many pixels” or memory error | Area too large at 10 m | Use scale: 20 or 30, or a smaller area |
| Many sharp dips | Thin cloud or haze left in | Raise CLEAR_THRESHOLD to 0.65 |
| Values near zero all year | Area is water, urban or bare | Check the area on the map layer |
Frequently asked questions
What is the best cloud mask for Sentinel-2 in Earth Engine?
Cloud Score+ (GOOGLE/CLOUD_SCORE_PLUS/V1/S2_HARMONIZED), linked to Sentinel-2 with linkCollection and a threshold of about 0.6 on the cs or cs_cdf band.
Do I need to remove the Sentinel-2 offset in Earth Engine?
Not with COPERNICUS/S2_SR_HARMONIZED, which already shifts newer scenes to match older ones.
How do I export an NDVI time series from Earth Engine?
Open the chart in the Console full size and click Download CSV, or use Export.table.toDrive with values from reduceRegion.
Is Google Earth Engine free?
It is free for non-commercial and research use with a registered Google Cloud project. Commercial use requires a paid licence.
Get the climate stories that matter, explained, every Sunday.
One email a week: new explainers, data and free tools. No spam, unsubscribe any time.
More from climaRevolt

How to Calculate NDVI in QGIS from Sentinel-2, and the Offset Most Tutorials Miss

EUDR 2026: What the EU Deforestation Regulation Requires from 30 December
