How to Download Free Sentinel-2 Data from Copernicus Hub Using Python

Download Sentinel-2 data Python

Introduction: The Data Problem Every Agricultural Researcher Faces

In Part 1 of this series, I showed you how to calculate NDVI from Sentinel-2 satellite data using Python. The most common question I received after that post was:

“Where do I actually get the Sentinel-2 data files?”

This is the practical gap that most remote sensing tutorials skip. They show you the analysis but assume you already have the data sitting on your hard drive.

In this post I fill that gap completely. By the end you will know how to:

  • Create a free Copernicus account and understand the data portal
  • Find Sentinel-2 images for any location in India — your study area, your district, your field
  • Filter images by date, cloud cover, and tile
  • Download data automatically using Python and the sentinelsat library
  • Understand the folder structure of downloaded Sentinel-2 data
  • Extract exactly the bands you need (Band 4 and Band 8 for NDVI)

Everything here is free. No paid software. No institutional login required. Just a free Copernicus account and Python.


What is Copernicus Hub and Why is the Data Free?

The Copernicus Programme is the European Union’s Earth observation programme. It operates a fleet of Sentinel satellites that continuously image the entire Earth’s surface. The data collected is made available free of charge to anyone in the world — researchers, students, government agencies, farmers, and developers.

For agricultural scientists in India this is a remarkable resource:

  • Sentinel-2 revisits any location every 5 days (with two satellites combined)
  • 10-metre resolution for key bands — fine enough to distinguish individual farm plots
  • 13 spectral bands covering visible, near-infrared, and shortwave infrared wavelengths
  • Free archive going back to 2015 — over 10 years of historical data for time series analysis
  • Covers all of India — every district, every state, every season

For context: equivalent commercial satellite data would cost ₹50,000–₹5,00,000 per image. You get it free.


Sentinel-2 Bands Quick Reference

Before downloading, it helps to know which bands you actually need:

BandNameWavelengthResolutionKey Use
Band 2Blue490 nm10 mTrue colour composite
Band 3Green560 nm10 mTrue colour composite
Band 4Red665 nm10 mNDVI, crop stress
Band 8NIR842 nm10 mNDVI, vegetation health
Band 8ARed Edge865 nm20 mCanopy chlorophyll
Band 11SWIR-11610 nm20 mSoil moisture, NDWI
Band 12SWIR-22190 nm20 mMineral mapping

For NDVI: You need Band 4 (Red) and Band 8 (NIR) — both at 10m resolution.
For soil moisture: You additionally need Band 11 (SWIR-1) at 20m.


Part 1 — Manual Download via Copernicus Browser (No Code Required)

Before learning the Python approach, let me show you the manual method. This is useful when you need one or two images quickly.

Step 1: Create Your Free Copernicus Account

  1. Go to https://browser.dataspace.copernicus.eu/
  2. Click Register in the top right
  3. Fill in your details — use your personal email
  4. Verify your email
  5. Log in

Note: The old Copernicus Open Access Hub (scihub.copernicus.eu) was retired in 2023. The new portal is Copernicus Data Space Ecosystem at dataspace.copernicus.eu. All links and API endpoints have changed. Make sure you are using the new portal.

Step 2: Find Your Study Area

  1. On the map, navigate to your study area in India
  2. Use the search box — type your district name (e.g., “Kamrup, Assam”)
  3. The map will zoom to your location
  4. You can draw a polygon around your exact area of interest using the draw tool

Step 3: Search for Images

On the left panel:

  • Data source: Select Sentinel-2 L2A (atmospherically corrected — use this, not L1C)
  • Date range: Enter your start and end date
  • Cloud cover: Set maximum to 20% (anything above 20% clouds makes agricultural analysis unreliable)
  • Click Search

A list of available images appears. Each entry shows:

  • Acquisition date and time
  • Cloud cover percentage
  • Tile ID (more on this below)
  • Thumbnail preview

Step 4: Download

Click on any image → click the download icon → select which files to download.

What to download:

  • For NDVI only: Download Band 4 and Band 8 files individually (saves bandwidth)
  • For full analysis: Download the complete product (2–8 GB per image)

Understanding Sentinel-2 Tile System

India is divided into Sentinel-2 Military Grid Reference System (MGRS) tiles. Each tile is 100km × 100km. Knowing your tile code saves a lot of search time.

Common tiles for India:

RegionTile Codes
Assam / Northeast IndiaT46RBN, T46RBM, T46QBL, T46QBK
West BengalT45RYL, T45RYK, T45QYL
Punjab / HaryanaT43RFQ, T43RGQ, T43SGQ
MaharashtraT43PBR, T43PCR, T44PMS
Andhra Pradesh / TelanganaT44QMF, T44QNF, T44QLF
Tamil Nadu / KarnatakaT43PGR, T44PKS, T43QHB
Uttar PradeshT44RKQ, T44RLQ, T44RMQ
RajasthanT43RGP, T43RHP, T43RFP
OdishaT45QXE, T45QYE, T44QQE
GujaratT42RUS, T43RBP, T43RCP

For Assam specifically: if your study area is in Kamrup or nearby districts, your tile is likely T46RBN.


Part 2 — Automated Download Using Python (sentinelsat)

The manual approach works for occasional downloads. But if you need:

  • Multiple dates for time series analysis
  • Multiple tiles for district or state-level analysis
  • Regular automated downloads (monthly crop monitoring)

…then Python automation is the right approach.

Step 1: Install Required Libraries

pip install sentinelsat geopandas shapely requests

Note on sentinelsat: The library has been updated to work with the new Copernicus Data Space API. Make sure you install version 1.0 or later.

pip install sentinelsat --upgrade

Step 2: Set Up Your Credentials

Never hardcode your password in a script. Use environment variables or a config file.

import os

# Option 1: Set as environment variables (recommended)
# Run these in your terminal before running the script:
# export COPERNICUS_USER="your_username"
# export COPERNICUS_PASSWORD="your_password"

username = os.environ.get('COPERNICUS_USER', 'your_username_here')
password = os.environ.get('COPERNICUS_PASSWORD', 'your_password_here')

Step 3: Connect to Copernicus Data Space

from sentinelsat import SentinelAPI, read_geojson, geojson_to_wkt
from datetime import date
import geopandas as gpd
from shapely.geometry import box
import pandas as pd
import os

# Connect to new Copernicus Data Space API
api = SentinelAPI(
    username,
    password,
    'https://apihub.copernicus.eu/apihub'  # Updated endpoint
)

print("Successfully connected to Copernicus Data Space")

Step 4: Define Your Area of Interest

You can define your study area in three ways:

Method A — Bounding Box (simplest)

# Define bounding box for your study area
# Format: (min_longitude, min_latitude, max_longitude, max_latitude)

# Example: Kamrup district, Assam
min_lon, min_lat = 91.2, 25.8   # Southwest corner
max_lon, max_lat = 91.9, 26.3   # Northeast corner

# Create footprint from bounding box
from shapely.geometry import box
footprint = box(min_lon, min_lat, max_lon, max_lat).wkt

print(f"Study area defined: {min_lon}°E to {max_lon}°E, "
      f"{min_lat}°N to {max_lat}°N")

Method B — From a Shapefile (for district/block boundaries)

# If you have a shapefile of your study area
import geopandas as gpd

# Load shapefile (e.g., district boundary)
study_area = gpd.read_file("kamrup_district.shp")

# Convert to WGS84 if needed
study_area = study_area.to_crs("EPSG:4326")

# Get footprint as WKT
footprint = study_area.geometry.union_all().wkt
print("Study area loaded from shapefile")

Method C — Draw on Map and Export GeoJSON

# If you drew your area in QGIS or geojson.io and saved as GeoJSON
from sentinelsat import read_geojson, geojson_to_wkt

footprint = geojson_to_wkt(read_geojson('my_study_area.geojson'))
print("Study area loaded from GeoJSON")

Step 5: Search for Available Images

# Search for Sentinel-2 images
products = api.query(
    footprint,
    date=('20231001', '20231115'),    # Date range: 1 Oct to 15 Nov 2023
    platformname='Sentinel-2',
    producttype='S2MSI2A',             # L2A = atmospherically corrected
    cloudcoverpercentage=(0, 20)       # Maximum 20% cloud cover
)

# Convert to DataFrame for easy viewing
products_df = api.to_dataframe(products)

print(f"\nFound {len(products_df)} images matching your criteria")
print("\nAvailable images:")
print(products_df[['title', 'cloudcoverpercentage',
                    'size', 'beginposition']].to_string())

Sample output:

Found 4 images matching your criteria

Available images:
                                    title  cloudcoverpercentage  size  beginposition
S2A_MSIL2A_20231005T043701_N0509...        3.24  1.2 GB  2023-10-05
S2B_MSIL2A_20231010T043659_N0509...        8.91  1.1 GB  2023-10-10
S2A_MSIL2A_20231020T043701_N0509...       12.45  1.3 GB  2023-10-20
S2B_MSIL2A_20231115T043659_N0509...        5.67  1.2 GB  2023-11-15

Step 6: Select the Best Image

# Sort by cloud cover — lowest first
products_df = products_df.sort_values('cloudcoverpercentage')

# Select the clearest image
best_product = products_df.iloc[0]
best_product_id = products_df.index[0]

print(f"\nBest image selected:")
print(f"  Date: {best_product['beginposition'].date()}")
print(f"  Cloud cover: {best_product['cloudcoverpercentage']:.1f}%")
print(f"  Size: {best_product['size']}")
print(f"  Product ID: {best_product_id}")

Step 7: Download the Image

import os

# Create download directory
download_dir = "sentinel2_data"
os.makedirs(download_dir, exist_ok=True)

print(f"\nDownloading image to '{download_dir}' folder...")
print("This may take 10–30 minutes depending on your internet speed.")
print("File size is approximately 1–1.5 GB\n")

# Download the selected product
api.download(best_product_id, directory_path=download_dir)

print(f"\n✓ Download complete!")
print(f"  Check the '{download_dir}' folder for your data.")

Understanding the Downloaded Data Structure

After downloading, you will find a .SAFE folder. Here is what is inside:

S2A_MSIL2A_20231005T043701_N0509_R031_T46RBN_20231005T221503.SAFE/
│
├── GRANULE/
│   └── L2A_T46RBN_A043082_20231005T044459/
│       └── IMG_DATA/
│           ├── R10m/          ← 10-metre resolution bands
│           │   ├── T46RBN_20231005T043701_B02_10m.tif  (Blue)
│           │   ├── T46RBN_20231005T043701_B03_10m.tif  (Green)
│           │   ├── T46RBN_20231005T043701_B04_10m.tif  ← RED (for NDVI)
│           │   └── T46RBN_20231005T043701_B08_10m.tif  ← NIR (for NDVI)
│           │
│           ├── R20m/          ← 20-metre resolution bands
│           │   ├── T46RBN_20231005T043701_B05_20m.tif
│           │   ├── T46RBN_20231005T043701_B06_20m.tif
│           │   ├── T46RBN_20231005T043701_B8A_20m.tif
│           │   ├── T46RBN_20231005T043701_B11_20m.tif
│           │   └── T46RBN_20231005T043701_B12_20m.tif
│           │
│           └── R60m/          ← 60-metre resolution bands
│               ├── T46RBN_20231005T043701_B01_60m.tif
│               └── T46RBN_20231005T043701_B09_60m.tif
│
├── MTD_MSIL2A.xml             ← Metadata file
└── INSPIRE.xml

For NDVI analysis, you need only these two files from R10m folder:

  • B04_10m.tif — Red band
  • B08_10m.tif — NIR band

Automatically Extract Band Paths

import glob
import os
import zipfile

# The download may come as a .zip file — extract it first
zip_files = glob.glob(os.path.join(download_dir, "*.zip"))

if zip_files:
    zip_path = zip_files[0]
    print(f"Extracting {os.path.basename(zip_path)}...")
    with zipfile.ZipFile(zip_path, 'r') as zip_ref:
        zip_ref.extractall(download_dir)
    print("Extraction complete.")

# Find the .SAFE folder
safe_folders = glob.glob(os.path.join(download_dir, "*.SAFE"))
safe_folder = safe_folders[0]
print(f"\nSAFE folder: {os.path.basename(safe_folder)}")

# Find Band 4 (Red) and Band 8 (NIR) at 10m resolution
red_band_path = glob.glob(
    os.path.join(safe_folder, "GRANULE/*/IMG_DATA/R10m/*B04_10m.tif")
)[0]

nir_band_path = glob.glob(
    os.path.join(safe_folder, "GRANULE/*/IMG_DATA/R10m/*B08_10m.tif")
)[0]

print(f"\nBand paths found:")
print(f"  Red (B04): {os.path.basename(red_band_path)}")
print(f"  NIR (B08): {os.path.basename(nir_band_path)}")
print("\n✓ Ready for NDVI calculation!")
print("  Feed these paths into the NDVI code from Part 1.")

Part 3 — Download Multiple Dates for Time Series

For crop monitoring, you often need images across an entire season. Here is how to download multiple dates efficiently:

# Search for entire Kharif season: June to November 2023
products_season = api.query(
    footprint,
    date=('20230601', '20231130'),
    platformname='Sentinel-2',
    producttype='S2MSI2A',
    cloudcoverpercentage=(0, 25)
)

products_season_df = api.to_dataframe(products_season)
products_season_df = products_season_df.sort_values('beginposition')

print(f"Found {len(products_season_df)} images for Kharif 2023 season")
print("\nImage dates available:")
for idx, row in products_season_df.iterrows():
    print(f"  {row['beginposition'].date()} — "
          f"Cloud cover: {row['cloudcoverpercentage']:.1f}%")

Sample output:

Found 18 images for Kharif 2023 season

Image dates available:
  2023-06-07 — Cloud cover: 45.2%   ← Too cloudy — skip
  2023-06-12 — Cloud cover: 18.9%   ← Acceptable
  2023-06-17 — Cloud cover: 67.3%   ← Too cloudy — skip
  2023-06-22 — Cloud cover:  8.1%   ← Good
  ...
  2023-10-05 — Cloud cover:  3.2%   ← Excellent
  2023-10-20 — Cloud cover: 12.4%   ← Good
  2023-11-15 — Cloud cover:  5.7%   ← Excellent
# Filter to only low-cloud images (under 20%)
clear_images = products_season_df[
    products_season_df['cloudcoverpercentage'] < 20
].copy()

print(f"\n{len(clear_images)} clear images selected for download")
print(f"Estimated total download size: "
      f"~{len(clear_images) * 1.2:.0f} GB")
print("\nConfirm before downloading large datasets!")

# Download all clear images
# WARNING: This can be 5–20 GB total — ensure you have disk space
confirm = input("\nProceed with download? (yes/no): ")

if confirm.lower() == 'yes':
    for i, (product_id, row) in enumerate(clear_images.iterrows(), 1):
        print(f"\nDownloading image {i}/{len(clear_images)}: "
              f"{row['beginposition'].date()}")
        try:
            api.download(product_id, directory_path=download_dir)
            print(f"  ✓ Downloaded successfully")
        except Exception as e:
            print(f"  ✗ Download failed: {e}")
            continue

    print(f"\n✓ All downloads complete!")
    print(f"  {len(clear_images)} images saved to '{download_dir}'")

Part 4 — Lightweight Alternative: Download Only Specific Bands

Full Sentinel-2 products are 1–1.5 GB each. If you only need Band 4 and Band 8 for NDVI, you can save significant bandwidth by downloading only those files.

import requests
from requests.auth import HTTPBasicAuth

def download_specific_band(product_id, band_name, output_dir,
                            username, password):
    """
    Download a specific band file from a Sentinel-2 product.

    Args:
        product_id: Product UUID from sentinelsat search
        band_name: e.g., 'B04_10m' or 'B08_10m'
        output_dir: Where to save the file
        username: Copernicus username
        password: Copernicus password
    """
    # Get product info
    product_info = api.get_product_odata(product_id)

    # Construct band file URL
    # Note: This requires knowing the internal file path
    # The full download approach above is more reliable for beginners
    pass  # Implementation varies by API version

# For most use cases, downloading the full product
# and extracting bands locally is more reliable.
# The full product approach in Part 2 is recommended.

Practical tip: If bandwidth is a concern, use Copernicus Browser (browser.dataspace.copernicus.eu) to visually browse and download individual band files manually. Select your product → click “Download individual files” → select only B04 and B08.


Alternative: Google Earth Engine (For Large Areas)

If your study covers an entire state or multiple districts, downloading Sentinel-2 data locally may not be practical. Google Earth Engine (GEE) is a better option for large-scale analysis — it processes data in the cloud without downloading anything.

GEE is free for researchers. Here is a preview of how simple it is:

// Google Earth Engine JavaScript API
// Run this in code.earthengine.google.com

// Load Sentinel-2 collection for Assam, October 2023
var collection = ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')
  .filterDate('2023-10-01', '2023-10-31')
  .filterBounds(ee.Geometry.Rectangle([90.2, 24.1, 96.0, 28.2]))
  .filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE', 20));

// Calculate NDVI for the least cloudy image
var image = collection.sort('CLOUDY_PIXEL_PERCENTAGE').first();
var ndvi = image.normalizedDifference(['B8', 'B4']).rename('NDVI');

// Visualise
Map.centerObject(ndvi, 8);
Map.addLayer(ndvi, {min: 0, max: 0.8, palette: ['red','yellow','green']},
             'NDVI Assam Oct 2023');

I will cover Google Earth Engine with Python in a separate post in this series.


Complete Script: Search, Select, and Download

Here is the complete workflow in one clean script:

"""
Sentinel-2 Data Download — Complete Workflow
Author: Data Science with DEB | dibyendudeb.com
Part 2 of Agricultural Data Science with Python Series

Downloads Sentinel-2 L2A data from Copernicus Data Space
for a specified study area and date range.

Requirements:
    pip install sentinelsat geopandas shapely
"""

import os
import glob
import zipfile
from datetime import datetime
from sentinelsat import SentinelAPI
from shapely.geometry import box

# ─── CONFIGURATION ─────────────────────────────────────────────────────────
USERNAME     = os.environ.get('COPERNICUS_USER', 'your_username')
PASSWORD     = os.environ.get('COPERNICUS_PASSWORD', 'your_password')
DOWNLOAD_DIR = "sentinel2_downloads"

# Study area: Kamrup district, Assam
# Replace with your study area coordinates
MIN_LON, MIN_LAT = 91.2, 25.8
MAX_LON, MAX_LAT = 91.9, 26.3

# Date range
START_DATE = '20231001'
END_DATE   = '20231115'

# Maximum acceptable cloud cover (%)
MAX_CLOUD  = 20

# ─── CONNECT ───────────────────────────────────────────────────────────────
print("Connecting to Copernicus Data Space...")
api = SentinelAPI(USERNAME, PASSWORD,
                  'https://apihub.copernicus.eu/apihub')
print("✓ Connected\n")

# ─── DEFINE STUDY AREA ─────────────────────────────────────────────────────
footprint = box(MIN_LON, MIN_LAT, MAX_LON, MAX_LAT).wkt
print(f"Study area: {MIN_LON}°E–{MAX_LON}°E, {MIN_LAT}°N–{MAX_LAT}°N")

# ─── SEARCH ────────────────────────────────────────────────────────────────
print(f"\nSearching for images: {START_DATE} to {END_DATE}")
print(f"Maximum cloud cover: {MAX_CLOUD}%\n")

products = api.query(
    footprint,
    date=(START_DATE, END_DATE),
    platformname='Sentinel-2',
    producttype='S2MSI2A',
    cloudcoverpercentage=(0, MAX_CLOUD)
)

df = api.to_dataframe(products).sort_values('cloudcoverpercentage')

print(f"Found {len(df)} suitable images:")
for _, row in df.iterrows():
    print(f"  {row['beginposition'].date()} | "
          f"Cloud: {row['cloudcoverpercentage']:.1f}% | "
          f"Size: {row['size']}")

# ─── SELECT BEST ───────────────────────────────────────────────────────────
if len(df) == 0:
    print("\n✗ No images found. Try:")
    print("  - Widening the date range")
    print("  - Increasing maximum cloud cover")
    print("  - Checking your bounding box coordinates")
    exit()

best_id  = df.index[0]
best_row = df.iloc[0]
print(f"\nBest image: {best_row['beginposition'].date()} "
      f"({best_row['cloudcoverpercentage']:.1f}% cloud)")

# ─── DOWNLOAD ──────────────────────────────────────────────────────────────
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
print(f"\nDownloading to '{DOWNLOAD_DIR}'...")
print("Please wait — this may take 10–30 minutes.\n")

api.download(best_id, directory_path=DOWNLOAD_DIR)

# ─── EXTRACT ───────────────────────────────────────────────────────────────
zip_files = glob.glob(os.path.join(DOWNLOAD_DIR, "*.zip"))
if zip_files:
    print("Extracting zip file...")
    with zipfile.ZipFile(zip_files[0], 'r') as z:
        z.extractall(DOWNLOAD_DIR)

# ─── FIND BAND PATHS ───────────────────────────────────────────────────────
safe_dir  = glob.glob(os.path.join(DOWNLOAD_DIR, "*.SAFE"))[0]
red_path  = glob.glob(
    os.path.join(safe_dir, "GRANULE/*/IMG_DATA/R10m/*B04_10m.tif"))[0]
nir_path  = glob.glob(
    os.path.join(safe_dir, "GRANULE/*/IMG_DATA/R10m/*B08_10m.tif"))[0]

print("\n" + "=" * 55)
print("✓ DOWNLOAD COMPLETE")
print("=" * 55)
print(f"Red band (B04): {os.path.basename(red_path)}")
print(f"NIR band (B08): {os.path.basename(nir_path)}")
print("\nNext step: Feed these paths into the NDVI")
print("calculation script from Part 1 of this series.")
print("=" * 55)

Troubleshooting Common Issues

Issue: “Authentication failed”

  • Double-check your Copernicus username and password
  • Make sure you are using the new dataspace.copernicus.eu account — old scihub accounts may need migration

Issue: “No products found”

  • Try widening your date range to 2–3 months
  • Increase maximum cloud cover to 30% temporarily
  • Verify your bounding box coordinates are correct (use Google Maps to check lat/lon)
  • Northeast India gets heavy cloud cover June–September — try October–November for Kharif monitoring

Issue: “Product is offline”

  • Older Sentinel-2 images (before 2020) may be in long-term archive storage
  • Request the product to be brought online — Copernicus usually processes this within 24 hours
  • Use the api.download() with checksum=False parameter if download is interrupted

Issue: Download very slow

  • Copernicus servers can be slow during peak hours (European business hours = IST 13:30–21:30)
  • Download early morning IST (before 13:00) for best speeds
  • File sizes are 1–1.5 GB — expect 20–60 minutes on a typical Indian broadband connection

Issue: Disk space

  • Each full Sentinel-2 product is 1–1.5 GB compressed, 3–5 GB extracted
  • For time series (10 images), plan for 30–50 GB storage
  • Delete the zip files after extraction to save space

What to Do With Your Downloaded Data

Once you have your Band 4 and Band 8 files, the next steps are:

  1. Calculate NDVI — use the complete Python script from Part 1 of this series
  2. Clip to your study area — use rasterio.mask to clip the 100km tile to your district or field
  3. Stack multiple dates — for time series NDVI analysis across the crop season
  4. Export maps — save as GeoTIFF for QGIS or as PNG for reports

Frequently Asked Questions

Q: Do I need to pay for Copernicus data? No. All Sentinel data is free to download and use for any purpose — research, commercial, educational.

Q: How often does Sentinel-2 revisit India? Every 5 days on average (combining Sentinel-2A and Sentinel-2B satellites). In practice you get 2–4 usable (low-cloud) images per month for most Indian locations.

Q: Can I download data for the entire state of Assam? Yes — but it will span multiple tiles and multiple gigabytes. For state-level analysis, Google Earth Engine (cloud processing) is more practical than local download.

Q: Is Sentinel-2 data available for the 1990s or 2000s? No. Sentinel-2 archive starts from 2015. For historical analysis before 2015, use Landsat data from USGS EarthExplorer (free, going back to 1972 but at 30m resolution).

Q: Can I use this data in my research publications? Yes — Copernicus data is freely usable for research. Cite as: “Copernicus Sentinel data [year], processed by [your name/institution].”


What’s Next in the Agricultural Data Science Series

  • Part 3: SAVI vs NDVI vs EVI — which vegetation index should you use and when?
  • Part 4: Random Forest for crop type classification using Sentinel-2 multispectral data
  • Part 5: Time series NDVI analysis for paddy crop monitoring — a complete case study

Conclusion

In this post you learned how to:

  • Navigate the Copernicus Data Space and find Sentinel-2 images for any location in India
  • Use Python and sentinelsat to search, filter, and download images automatically
  • Understand the downloaded folder structure and extract the exact bands you need
  • Download multiple dates for seasonal time series analysis
  • Troubleshoot the most common download problems

Combined with the NDVI calculation script from Part 1, you now have a complete free pipeline — from raw satellite to vegetation health map — entirely in Python.


Take the Next Step

📩 Get the next tutorial in your inbox — I publish one practical agricultural data science tutorial every week. Subscribe free below and never miss a post in this series.

👉 [Subscribe here — it’s free]

💬 Tell me your study area — drop a comment below with your district or state and I’ll confirm which Sentinel-2 tile code you need. I reply to every comment.

Want a quick reference guide?

Download the Remote Sensing Cheat Sheet (₹199)

Includes: Sentinel-2 bands, vegetation index formulas,
crop signatures, Python template, and common mistakes


Data Science with DEB — Practical tutorials on Python, remote sensing, and machine learning for agricultural researchers. Published weekly at dibyendudeb.com


How to Calculate NDVI with Python: A Practical Guide for Agricultural Scenario

NDVI with Python

Introduction: Why Every Agricultural Scientist Should Know NDVI

If you work in agriculture, chances are you’ve heard the term NDVI. Normalized Difference Vegetation Index — a mouthful, but one of the most powerful and widely-used tools in modern agricultural science.

Think of NDVI as a crop health report card, derived entirely from satellite images. In one number — ranging from -1 to +1 — it tells you whether your field is thriving, stressed, or bare.

But here’s the gap I see all the time among agricultural researchers in India: they understand NDVI conceptually, but they depend entirely on paid software like ERDAS Imagine or commercial platforms to compute it. That’s expensive, slow, and inflexible.

With Python, you can calculate NDVI for any field, anywhere in India, for free — in under 20 lines of code.

In this post, I’ll show you exactly how, using real Sentinel-2 satellite data. By the end, you’ll be able to:

  • Understand what NDVI measures and why it matters for agricultural research
  • Download and read multispectral satellite imagery in Python
  • Calculate NDVI using the rasterio and numpy libraries
  • Visualise NDVI maps with matplotlib
  • Apply NDVI analysis to real agricultural problems (crop monitoring, drought assessment, yield estimation)

I use this workflow regularly in my own research. Let’s get into it.


What is NDVI and Why Does It Matter?

NDVI stands for Normalized Difference Vegetation Index. It was developed by NASA scientists in the 1970s and remains the most widely used vegetation index in the world today.

The formula is simple:

NDVI = (NIR - Red) / (NIR + Red)

Where:

  • NIR = Near-Infrared band reflectance
  • Red = Red band reflectance

Healthy green vegetation absorbs red light for photosynthesis and strongly reflects near-infrared light. Stressed or sparse vegetation does the opposite. This contrast is what NDVI captures.

NDVI with Python

NDVI value interpretation:

NDVI RangeWhat It MeansAgricultural Implication
0.8 to 1.0Dense, healthy vegetationPeak crop canopy, excellent health
0.6 to 0.8Moderate-high vegetationGood crop growth
0.4 to 0.6Moderate vegetationAverage crop health, possible stress
0.2 to 0.4Sparse vegetationStress, early season, or thin canopy
0.0 to 0.2Very sparse/bare soilBare ground, very early stage
Below 0.0Non-vegetationWater bodies, built-up areas, clouds

In Indian agricultural contexts, NDVI is used for:

  1. Crop condition monitoring — Is the paddy crop healthy in July? Are wheat fields uniform in November?
  2. Drought and stress detection — Which districts show early signs of moisture stress?
  3. Yield forecasting — NDVI at critical crop growth stages correlates strongly with final yield
  4. Kharif/Rabi crop mapping — Identifying crop types across a region based on NDVI time series
  5. Assessment of PMFBY claims — Insurance companies and state governments use NDVI to validate crop loss claims

We use NDVI-based analyses to support everything from crop condition reports to state-level advisories. Once you know how to compute it in Python, you unlock all of this yourself.


What Satellite Data Will We Use?

We’ll use Sentinel-2 imagery from the European Space Agency (ESA). It’s free, it covers all of India, and it has a 10-metre spatial resolution for the bands we need — fine enough to see individual farm fields.

Key Sentinel-2 bands for NDVI:

BandNameWavelengthResolutionRole in NDVI
Band 4Red665 nm10 mDenominator + difference
Band 8NIR842 nm10 mNumerator + difference

You can download Sentinel-2 data freely from:

  • Copernicus Open Access Hub: https://scihub.copernicus.eu/
  • Google Earth Engine (for larger areas or time series)
  • AWS Open Data Registry: free access, no sign-up needed for many datasets
  • USGS EarthExplorer: also carries some ESA products

For this tutorial, I’ll walk you through reading a pre-downloaded GeoTIFF. If you’d like a separate post on how to download Sentinel-2 data from Copernicus Hub using Python, let me know in the comments.


Setting Up Your Python Environment

Before writing any code, let’s make sure the right libraries are installed.

pip install rasterio numpy matplotlib geopandas

If you’re working on a government server or a restricted environment, you can also run this in Google Colab for free — no installation needed.

Libraries we’ll use:

  • rasterio — read and write geospatial raster files (GeoTIFFs)
  • numpy — numerical operations, including the NDVI formula
  • matplotlib — visualisation and NDVI map plotting
  • geopandas — optional, for masking to a specific study area

Step 1: Import Libraries and Load the Satellite Bands

import rasterio
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
import warnings
warnings.filterwarnings('ignore')

# Define paths to your Sentinel-2 band files
# Replace these with your actual file paths
red_band_path = "T44QKF_20231015_B04_10m.tif"   # Band 4 (Red)
nir_band_path  = "T44QKF_20231015_B08_10m.tif"   # Band 8 (NIR)

# Open the Red band
with rasterio.open(red_band_path) as red_src:
    red_band = red_src.read(1).astype(float)    # Read as float for division
    profile = red_src.profile                    # Save metadata for output file
    print(f"Red band shape: {red_band.shape}")
    print(f"CRS: {red_src.crs}")
    print(f"Transform: {red_src.transform}")

# Open the NIR band
with rasterio.open(nir_band_path) as nir_src:
    nir_band = nir_src.read(1).astype(float)
    print(f"NIR band shape: {nir_band.shape}")

What this does: We read both bands as 2D numpy arrays. The .astype(float) is important — integer arrays would give wrong results during division.

Note: Sentinel-2 filenames follow the format T[tile]_[date]_[band]_[resolution].tif. The tile code for Assam and northeast India is typically T46RBN, T46RBM, or T46QBL depending on your study area.


Step 2: Calculate NDVI

This is the core calculation. Two lines of Python.

# Avoid division by zero — set to NaN where both bands are zero
denominator = nir_band + red_band
denominator[denominator == 0] = np.nan

# Calculate NDVI
ndvi = (nir_band - red_band) / denominator

# Quick summary statistics
print(f"NDVI Statistics:")
print(f"  Min:  {np.nanmin(ndvi):.4f}")
print(f"  Max:  {np.nanmax(ndvi):.4f}")
print(f"  Mean: {np.nanmean(ndvi):.4f}")
print(f"  Std:  {np.nanstd(ndvi):.4f}")

Sample output for an agricultural area in Assam (October, post-Kharif):

NDVI Statistics:
  Min:  -0.2341
  Max:   0.8923
  Mean:  0.4512
  Std:   0.1876

A mean NDVI of 0.45 in October for this area is consistent with late Kharif paddy — the crop is still standing but past its peak greenness.


Step 3: Visualise the NDVI Map

A raw array of numbers isn’t useful for agricultural interpretation. We need a colour-coded map.

# Set up a professional NDVI colour map
# RdYlGn: Red (low/stressed) → Yellow (moderate) → Green (healthy)
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
fig.suptitle("NDVI Analysis — Agricultural Area, Assam (October 2023)",
             fontsize=14, fontweight='bold')

# Panel 1: Red Band (visible light)
ax1 = axes[0]
im1 = ax1.imshow(red_band, cmap='Reds_r', vmin=0, vmax=3000)
ax1.set_title("Red Band (Band 4)", fontsize=11)
ax1.axis('off')
plt.colorbar(im1, ax=ax1, fraction=0.046, pad=0.04, label="Reflectance")

# Panel 2: NIR Band
ax2 = axes[1]
im2 = ax2.imshow(nir_band, cmap='YlOrRd', vmin=0, vmax=5000)
ax2.set_title("NIR Band (Band 8)", fontsize=11)
ax2.axis('off')
plt.colorbar(im2, ax=ax2, fraction=0.046, pad=0.04, label="Reflectance")

# Panel 3: NDVI Map
ax3 = axes[2]
im3 = ax3.imshow(ndvi, cmap='RdYlGn', vmin=-0.3, vmax=0.9)
ax3.set_title("NDVI — Vegetation Health Index", fontsize=11)
ax3.axis('off')
cbar = plt.colorbar(im3, ax=ax3, fraction=0.046, pad=0.04)
cbar.set_label("NDVI Value", rotation=270, labelpad=15)
cbar.set_ticks([-0.3, 0.0, 0.2, 0.4, 0.6, 0.8])
cbar.set_ticklabels(['Water/Cloud', 'Bare Soil', 'Sparse', 'Moderate', 'Good', 'Dense'])

plt.tight_layout()
plt.savefig("ndvi_analysis_assam.png", dpi=200, bbox_inches='tight')
plt.show()
print("Map saved as ndvi_analysis_assam.png")

Step 4: Classify NDVI into Agricultural Categories

Raw NDVI values are useful, but for field reports and policy briefs, classified maps are more interpretable. Let’s create a crop health classification.

NDVI with Python, classified image
# Create NDVI classification for agricultural interpretation
def classify_ndvi_agriculture(ndvi_array):
    """
    Classify NDVI into agricultural health categories.
    Suitable for Kharif and Rabi crop monitoring in India.
    """
    classified = np.full(ndvi_array.shape, np.nan)
    
    classified[ndvi_array < 0.0]                       = 0  # Water / Non-veg
    classified[(ndvi_array >= 0.0) & (ndvi_array < 0.2)] = 1  # Bare/Very sparse
    classified[(ndvi_array >= 0.2) & (ndvi_array < 0.4)] = 2  # Sparse / stressed crop
    classified[(ndvi_array >= 0.4) & (ndvi_array < 0.6)] = 3  # Moderate crop health
    classified[(ndvi_array >= 0.6) & (ndvi_array < 0.8)] = 4  # Good crop health
    classified[ndvi_array >= 0.8]                       = 5  # Excellent / dense canopy
    
    return classified

ndvi_classified = classify_ndvi_agriculture(ndvi)

# Create a custom colour map for the classified map
class_colors = ['#2166AC',  # 0 — Blue: Water/Non-veg
                '#D7191C',  # 1 — Red: Bare/Very sparse
                '#FDAE61',  # 2 — Orange: Stressed crop
                '#FEE08B',  # 3 — Yellow: Moderate
                '#A6D96A',  # 4 — Light green: Good
                '#1A9641']  # 5 — Dark green: Excellent

cmap_custom = mcolors.ListedColormap(class_colors)
bounds = [-0.5, 0.5, 1.5, 2.5, 3.5, 4.5, 5.5]
norm = mcolors.BoundaryNorm(bounds, cmap_custom.N)

fig, ax = plt.subplots(figsize=(10, 8))
im = ax.imshow(ndvi_classified, cmap=cmap_custom, norm=norm)
ax.set_title("NDVI Crop Health Classification\nAgricultural Area, Assam (October 2023)",
             fontsize=13, fontweight='bold')
ax.axis('off')

cbar = plt.colorbar(im, ax=ax, fraction=0.03, pad=0.04)
cbar.set_ticks([0, 1, 2, 3, 4, 5])
cbar.set_ticklabels(['Water / Non-veg',
                      'Bare / Very Sparse',
                      'Stressed Crop',
                      'Moderate Health',
                      'Good Health',
                      'Excellent / Dense'])
cbar.set_label("Crop Health Category", rotation=270, labelpad=20)

plt.tight_layout()
plt.savefig("ndvi_classified_map.png", dpi=200, bbox_inches='tight')
plt.show()

Step 5: Calculate Area Statistics by Category

For an agricultural report, you’ll want to know how many hectares fall in each category.

# Calculate pixel counts and area for each class
# Sentinel-2 at 10m resolution: each pixel = 10m × 10m = 0.01 hectares

pixel_area_ha = 0.01  # hectares per pixel (10m × 10m)

categories = {
    0: "Water / Non-veg",
    1: "Bare / Very Sparse",
    2: "Stressed Crop",
    3: "Moderate Health",
    4: "Good Health",
    5: "Excellent / Dense"
}

print("=" * 55)
print("NDVI AREA STATISTICS — Assam Agricultural Area")
print("=" * 55)
print(f"{'Category':<25} {'Pixels':>10} {'Area (ha)':>12} {'%':>8}")
print("-" * 55)

total_valid_pixels = np.sum(~np.isnan(ndvi_classified))

for class_val, class_name in categories.items():
    pixel_count = np.sum(ndvi_classified == class_val)
    area_ha = pixel_count * pixel_area_ha
    pct = (pixel_count / total_valid_pixels) * 100 if total_valid_pixels > 0 else 0
    print(f"{class_name:<25} {pixel_count:>10,} {area_ha:>12,.1f} {pct:>7.1f}%")

print("-" * 55)
print(f"{'TOTAL':<25} {total_valid_pixels:>10,} "
      f"{total_valid_pixels * pixel_area_ha:>12,.1f} {'100.0':>8}%")
print("=" * 55)

Sample output:

=======================================================
NDVI AREA STATISTICS — Assam Agricultural Area
=======================================================
Category                  Pixels      Area (ha)        %
-------------------------------------------------------
Water / Non-veg            12,450        124.5      5.2%
Bare / Very Sparse          8,230         82.3      3.4%
Stressed Crop              18,750        187.5      7.8%
Moderate Health            64,300        643.0     26.8%
Good Health               98,760        987.6     41.2%
Excellent / Dense          37,320        373.2     15.6%
-------------------------------------------------------
TOTAL                     239,810      2,398.1    100.0%
=======================================================

This kind of output goes directly into a district crop situation report or a field survey planning document.


NDVI with Python, moisture map of the study area

Step 6: Save the NDVI Raster as a GeoTIFF

For use in GIS software (QGIS, ArcGIS) or further analysis, save the NDVI output as a proper georeferenced GeoTIFF.

# Save NDVI as GeoTIFF with geospatial metadata preserved
output_path = "ndvi_output_assam.tif"

# Update profile for float32 single-band output
profile.update({
    'dtype': rasterio.float32,
    'count': 1,
    'nodata': -9999
})

# Replace NaN with the nodata value before writing
ndvi_to_save = ndvi.copy()
ndvi_to_save[np.isnan(ndvi_to_save)] = -9999

with rasterio.open(output_path, 'w', **profile) as dst:
    dst.write(ndvi_to_save.astype(rasterio.float32), 1)

print(f"NDVI GeoTIFF saved successfully: {output_path}")
print(f"File size: {os.path.getsize(output_path) / (1024*1024):.1f} MB")

You can now open ndvi_output_assam.tif directly in QGIS for further GIS analysis, or share it with colleagues who don’t use Python.


Real-World Application: Monitoring Paddy Crop Health in Assam

Let me show you how this workflow plays out in a real agricultural scenario.

Scenario: It’s late October. The Kharif paddy crop is in its grain-filling stage across Kamrup district, Assam. There were reports of drought stress in some blocks. The district agriculture officer wants to know which blocks are showing low NDVI before planning the relief package.

Using the workflow above, here’s what you can do in a single afternoon:

  1. Download Sentinel-2 images for October 2023 for Kamrup district (free from Copernicus Hub)
  2. Calculate NDVI for each 10m pixel across the district
  3. Clip the raster to administrative block boundaries using geopandas
  4. Compute mean NDVI per block
  5. Map the blocks by average NDVI — immediately showing which blocks are stressed
import geopandas as gpd
from rasterio.mask import mask
import json

# Load block-level shapefile for Kamrup district
# (Available from ICRISAT VDSA or state GIS portals)
blocks_gdf = gpd.read_file("kamrup_blocks.shp")

# Ensure same CRS as the raster
blocks_gdf = blocks_gdf.to_crs("EPSG:32646")  # UTM Zone 46N for Assam

# Compute mean NDVI per block
block_ndvi_results = []

with rasterio.open(nir_band_path) as src:
    for idx, row in blocks_gdf.iterrows():
        geom = [json.loads(row.geometry.json())]
        
        try:
            # Open raster and mask to block boundary
            with rasterio.open("ndvi_output_assam.tif") as ndvi_src:
                masked_ndvi, _ = mask(ndvi_src, geom, crop=True)
                valid_vals = masked_ndvi[masked_ndvi != -9999]
                
                if len(valid_vals) > 0:
                    mean_ndvi = float(np.nanmean(valid_vals))
                else:
                    mean_ndvi = np.nan
                    
        except Exception:
            mean_ndvi = np.nan
        
        block_ndvi_results.append({
            'block_name': row['BLOCK_NAME'],
            'mean_ndvi': mean_ndvi
        })

# Add results back to GeoDataFrame
import pandas as pd
ndvi_df = pd.DataFrame(block_ndvi_results)
blocks_gdf = blocks_gdf.merge(ndvi_df, left_on='BLOCK_NAME', right_on='block_name')

# Identify stressed blocks (mean NDVI < 0.4 in October = concern)
stressed_blocks = blocks_gdf[blocks_gdf['mean_ndvi'] < 0.4]
print(f"\nBlocks showing possible crop stress (NDVI < 0.40):")
print(stressed_blocks[['BLOCK_NAME', 'mean_ndvi']].sort_values('mean_ndvi'))

This analysis — which would take days in traditional software — runs in minutes with Python. And it’s fully reproducible, documented, and shareable.


NDVI Time Series: Tracking Crop Growth Through the Season

One of the most powerful applications of NDVI is monitoring how it changes across the crop season. Here’s a simple example using multi-date NDVI values:

import matplotlib.pyplot as plt
import numpy as np

# NDVI values for a paddy field in Assam — Kharif 2023
# (You would compute these from actual multi-date satellite images)
dates = ['June 15', 'July 01', 'July 15', 'Aug 01', 'Aug 15',
         'Sep 01', 'Sep 15', 'Oct 01', 'Oct 15', 'Nov 01']

# Healthy paddy field (good season)
ndvi_healthy = [0.12, 0.28, 0.45, 0.63, 0.72, 0.78, 0.74, 0.65, 0.52, 0.35]

# Stress-affected paddy field (drought mid-season)
ndvi_stressed = [0.10, 0.22, 0.38, 0.45, 0.42, 0.40, 0.37, 0.30, 0.22, 0.18]

fig, ax = plt.subplots(figsize=(12, 6))

ax.plot(dates, ndvi_healthy, 'g-o', linewidth=2.5, markersize=8,
        label='Healthy Paddy (Kamrup Block A)')
ax.plot(dates, ndvi_stressed, 'r--s', linewidth=2.5, markersize=8,
        label='Stress-Affected Paddy (Kamrup Block B)')

# Add reference lines
ax.axhline(y=0.6, color='gray', linestyle=':', alpha=0.7, label='Moderate-Good threshold (0.6)')
ax.axhline(y=0.4, color='orange', linestyle=':', alpha=0.7, label='Stress warning threshold (0.4)')

# Shade the growing season
ax.axvspan(2, 7, alpha=0.05, color='green', label='Active growing season')

ax.set_xlabel('Date (Kharif 2023)', fontsize=12)
ax.set_ylabel('Mean NDVI', fontsize=12)
ax.set_title('NDVI Time Series — Paddy Crop Monitoring, Kamrup District, Assam\n'
             'Kharif Season 2023', fontsize=13, fontweight='bold')
ax.legend(loc='lower right', fontsize=10)
ax.set_ylim(0, 0.95)
ax.grid(True, alpha=0.3)
plt.xticks(rotation=30)

plt.tight_layout()
plt.savefig("ndvi_time_series_paddy.png", dpi=200, bbox_inches='tight')
plt.show()

The divergence between the two curves — starting from August — clearly shows where drought stress began. This kind of analysis supports early warning systems for district agriculture offices.


Complete Code: All Steps in One Script

Here’s the full workflow in a clean, ready-to-run script:

"""
NDVI Calculation with Python — Complete Workflow
Author: Data Science with DEB
Website: https://dibyendudeb.com
Use case: Agricultural crop health monitoring using Sentinel-2 data

Requirements:
    pip install rasterio numpy matplotlib geopandas
"""

import os
import numpy as np
import rasterio
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
import warnings
warnings.filterwarnings('ignore')

# ─── CONFIG ────────────────────────────────────────────────────────────────
RED_BAND_PATH = "T44QKF_20231015_B04_10m.tif"   # Sentinel-2 Band 4 (Red)
NIR_BAND_PATH = "T44QKF_20231015_B08_10m.tif"   # Sentinel-2 Band 8 (NIR)
OUTPUT_NDVI   = "ndvi_output.tif"
PIXEL_AREA_HA = 0.01  # 10m x 10m pixel = 0.01 hectares

# ─── LOAD BANDS ────────────────────────────────────────────────────────────
print("Loading satellite bands...")
with rasterio.open(RED_BAND_PATH) as red_src:
    red = red_src.read(1).astype(float)
    profile = red_src.profile

with rasterio.open(NIR_BAND_PATH) as nir_src:
    nir = nir_src.read(1).astype(float)

# ─── CALCULATE NDVI ────────────────────────────────────────────────────────
print("Calculating NDVI...")
denom = nir + red
denom[denom == 0] = np.nan
ndvi = (nir - red) / denom

print(f"NDVI range: {np.nanmin(ndvi):.4f} to {np.nanmax(ndvi):.4f}")
print(f"Mean NDVI:  {np.nanmean(ndvi):.4f}")

# ─── VISUALISE ─────────────────────────────────────────────────────────────
print("Creating NDVI map...")
fig, ax = plt.subplots(figsize=(10, 8))
im = ax.imshow(ndvi, cmap='RdYlGn', vmin=-0.3, vmax=0.9)
ax.set_title("NDVI — Vegetation Health Index", fontsize=14, fontweight='bold')
ax.axis('off')
cbar = plt.colorbar(im, ax=ax, fraction=0.03, pad=0.04)
cbar.set_label("NDVI Value", rotation=270, labelpad=20)
plt.tight_layout()
plt.savefig("ndvi_map.png", dpi=200, bbox_inches='tight')
plt.close()

# ─── CLASSIFY ──────────────────────────────────────────────────────────────
print("Classifying NDVI into agricultural categories...")
classified = np.full(ndvi.shape, np.nan)
classified[ndvi < 0.0]                       = 0
classified[(ndvi >= 0.0) & (ndvi < 0.2)]    = 1
classified[(ndvi >= 0.2) & (ndvi < 0.4)]    = 2
classified[(ndvi >= 0.4) & (ndvi < 0.6)]    = 3
classified[(ndvi >= 0.6) & (ndvi < 0.8)]    = 4
classified[ndvi >= 0.8]                      = 5

# ─── AREA STATS ────────────────────────────────────────────────────────────
categories = {0: "Water/Non-veg", 1: "Bare/Sparse",
              2: "Stressed Crop", 3: "Moderate Health",
              4: "Good Health",   5: "Excellent/Dense"}

print("\n" + "=" * 55)
print("NDVI AREA SUMMARY")
print("=" * 55)
total = np.sum(~np.isnan(classified))
for k, v in categories.items():
    n = np.sum(classified == k)
    print(f"{v:<22} {n:>8,} px  {n*PIXEL_AREA_HA:>10,.1f} ha  "
          f"{100*n/total:>6.1f}%")

# ─── SAVE GEOTIFF ──────────────────────────────────────────────────────────
print(f"\nSaving NDVI GeoTIFF to {OUTPUT_NDVI}...")
profile.update({'dtype': rasterio.float32, 'count': 1, 'nodata': -9999})
ndvi_save = ndvi.copy()
ndvi_save[np.isnan(ndvi_save)] = -9999
with rasterio.open(OUTPUT_NDVI, 'w', **profile) as dst:
    dst.write(ndvi_save.astype(rasterio.float32), 1)

print("✓ All outputs saved. NDVI analysis complete.")

Frequently Asked Questions

Q: Can I use this code with Landsat data instead of Sentinel-2?
Yes. For Landsat 8/9, the Red band is Band 4 and NIR is Band 5. The formula and code remain the same — just update the file paths.

Q: What if I don’t have downloaded satellite files? Can I compute NDVI online?
Yes — Google Earth Engine is ideal for large-area or time-series analysis. I’ll cover GEE with Python in a future post.

Q: How do I download Sentinel-2 data for free for my study area in India?
Register at Copernicus Open Access Hub and use the map interface to select your area and date. Downloads are free. I’ll write a dedicated post on this.

Q: What’s the difference between NDVI and other indices like EVI, SAVI, NDWI?
NDVI is the most widely used but has limitations over dense canopies or bare soils. SAVI (Soil Adjusted Vegetation Index) is better for sparse vegetation and is common in semi-arid agricultural research. I’ll cover these in the next post in this series.


What’s Next in the Agricultural Data Science Series?

This post is Part 1 of my Agricultural Data Science with Python series. Here’s what’s coming:

  • Part 2: Downloading Sentinel-2 data from Copernicus Hub using Python
  • Part 3: SAVI, EVI, and NDWI — Which vegetation index should you use?
  • Part 4: Crop classification using Random Forest and Sentinel-2 data
  • Part 5: Time series NDVI analysis for yield forecasting

Conclusion

NDVI is one of the most powerful tools available to modern agricultural scientists — and with Python, it’s accessible, free, and fully customisable.

In this post, you learned how to:

  • Calculate NDVI from Sentinel-2 satellite data using rasterio and numpy
  • Visualise NDVI as a colour-coded map with matplotlib
  • Classify NDVI into agricultural health categories
  • Compute area statistics for field or district-level crop reporting
  • Build an NDVI time series to track crop health through the season
  • Save analysis results as a georeferenced GeoTIFF

The same workflow I’ve shown you is used in real crop monitoring projects. It’s not just an academic exercise — this is how modern scinece works.

Want a quick reference guide?

Download the Remote Sensing Cheat Sheet (₹199)

Includes: Sentinel-2 bands, vegetation index formulas,
crop signatures, Python template, and common mistakes


Want More Like This?

I write practical tutorials on data science, Python, and remote sensing for agricultural researchers — one post every week.

Next up in the Agricultural Data Science series:

  • How to download free Sentinel-2 data from Copernicus Hub using Python
  • SAVI vs NDVI vs EVI — which vegetation index should you use?
  • Random Forest for crop classification from satellite data

If this post helped you, the best next step is to subscribe by email so you don’t miss the next one in this series.

No spam. Just one practical tutorial per week.