Sentinel-2 or Landsat? Choosing a Satellite Imagery Source

On this page

Problem statement

Both are free, both are optical multispectral, both have red, near-infrared and shortwave-infrared bands, and both are indexed in the same STAC catalogues. Choosing between them looks like a preference.

It is not. The two differ in resolution, revisit rate, band scaling, mask format, archive depth and β€” the one that surprises people mid-project β€” how you are allowed to read the pixels.

Measured over one 10 km window in Snowdonia for calendar year 2025:

Sentinel-2 L2A items    201
Landsat C2 L2 items      79

Two and a half times as many looks, at three times the spatial resolution. That settles a lot of cases before any other consideration.

Quick answer

Sentinel-2 L2A Landsat 8/9 Collection 2 L2
resolution 10 m (VNIR), 20 m (red-edge, SWIR), 60 m 30 m, thermal at 100 m resampled to 30
items over one AOI in 2025 201 79
archive starts 2015 1980s
scale / offset 1e-4 / βˆ’0.1 declared 2.75e-5 / βˆ’0.2 declared
cloud mask SCL, class codes 0–11 QA_PIXEL, a bit field
thermal band none yes (lwir11)
anonymous S3 read yes no β€” requester-pays
import rasterio

# the pixels, anonymously
with rasterio.open(sentinel_item.assets["red"].href) as ds:            # works
    print("Sentinel-2:", ds.width, "x", ds.height, ds.dtypes[0])
try:
    with rasterio.Env(AWS_NO_SIGN_REQUEST="YES"):
        rasterio.open(landsat_item.assets["red"].href)                 # AccessDenied
except rasterio.errors.RasterioIOError as e:
    print(f"Landsat: {type(e).__name__}: {e}")
Sentinel-2: 10980 x 10980 uint16
Landsat: RasterioIOError: AccessDenied: Anonymous users cannot invoke requests against Requester Pays buckets. Please authenticate.
Sentinel-2 and Landsat compared on resolution, revisit, archive depth, thermal bands and access model. measured over one 10 km window, calendar year 2025items in 2025resolutionarchive fromanonymous readSentinel-2 L2Aβœ“201βœ“10 / 20 m2015βœ“yesLandsat C2 L2!7930 mβœ“1980sβœ•noLandsat Collection 2 is requester-pays: anonymous reads return AccessDenied spatialworkflow.io
Sentinel-2 wins on resolution and revisit; Landsat wins on archive depth and thermal. The access model decides more projects than either.

Time span, feature size, two different archives

1. Start with the question's time span

This is usually decisive and it needs no analysis.

  • Anything before 2015 is Landsat. Sentinel-2A did not launch until June 2015 and full two-satellite coverage came later. Landsat's usable multispectral record runs back to the 1980s.
  • Recent monitoring favours Sentinel-2, on both resolution and revisit β€” 201 opportunities against 79 in the window measured here.
  • A long series ending recently needs both, harmonised, which is a real piece of work: different band widths, different resolutions, different atmospheric correction.

2. Check the resolution against your features

Sentinel-2's 10 m red and near-infrared give 100 mΒ² pixels; Landsat's 30 m give 900 mΒ². A 0.5 ha field is 50 Sentinel-2 pixels and about 5.6 Landsat pixels.

Below about 1 ha, Landsat gives you a handful of pixels, most of them mixed with the boundary. Above about 10 ha the difference stops mattering for a mean, and Landsat's longer archive starts to win.

3. Handle the two scalings separately

Both declare a scale and an offset, and they are different numbers:

for name, item in (("Sentinel-2 L2A", sentinel_item), ("Landsat C2 L2", landsat_item)):
    declared = item.assets["red"].extra_fields["raster:bands"][0]
    print(f"{name:16} scale {declared['scale']:<9g} offset {declared['offset']}")
Sentinel-2 L2A   scale 0.0001    offset -0.1
Landsat C2 L2    scale 2.75e-05  offset -0.2

Neither should be applied without checking. For the Sentinel-2 COGs measured here, applying the declared offset gives water a reflectance between βˆ’0.090 and βˆ’0.098 in every band (Example 4), which means it was already applied β€” see Radiometric levels explained.

Landsat Collection 2 surface reflectance genuinely does need DN * 2.75e-5 - 0.2. Run the same water check on whichever copy you have, because the answer is a property of the archive, not of the mission.

4. Write two different mask functions

The mask formats are not variations on a theme:

# Sentinel-2: SCL holds a class code per pixel
usable = ~np.isin(scl, (0, 1, 3, 8, 9, 10))

# Landsat: QA_PIXEL packs flags into bits
fill        = (qa >> 0) & 1
dilated     = (qa >> 1) & 1
cirrus      = (qa >> 2) & 1
cloud       = (qa >> 3) & 1
shadow      = (qa >> 4) & 1
snow        = (qa >> 5) & 1
clear       = (qa >> 6) & 1
water       = (qa >> 7) & 1
usable = clear.astype(bool) & ~shadow.astype(bool) & ~fill.astype(bool)

np.isin on a bit field returns nonsense that looks like a mask. This is the single most common bug when a Sentinel-2 pipeline is pointed at Landsat.

5. Check the access model before you plan the compute

The Sentinel-2 L2A COGs on AWS are openly readable β€” a windowed read from a laptop needs no credentials. The USGS Landsat Collection 2 bucket is requester-pays, and anonymous reads fail outright, as shown above.

That is not a blocker; it is a configuration and a cost line. Set credentials and AWS_REQUEST_PAYER=requester, or use a mirror such as a commercial or institutional STAC that hosts open copies. But discover it now rather than when the batch job fails at three in the morning.

6. Use Landsat when you need the thermal band

Landsat carries a thermal infrared instrument; Sentinel-2 does not. Anything about land surface temperature, evapotranspiration, urban heat or thermal anomalies is Landsat, full stop β€” regardless of what the resolution comparison says.

Sentinel-2's SCL band as class codes against Landsat's QA_PIXEL as packed bit flags requiring bitwise tests. two mask formats, two pieces of codeSentinel-2 SCLβœ“one class code per pixelβœ“values 0–11βœ“np.isin(scl, drop)βœ“readable directlyLandsat QA_PIXELβœ•packed bit flagsβœ•values in the tens of thousandsβœ•(qa >> 6) & 1βœ•np.isin matches nothingnp.isin on a bit field returns a mask-shaped array that keeps every cloud spatialworkflow.io
Class codes and bit fields need different code. Applying np.isin to a bit field produces a mask-shaped array of wrong answers.

One loader for both sensors

The data used on this page

Every Sentinel-2 L2A and Landsat Collection 2 Level-2 item that Earth Search lists for 2025 over a 10 km window on Snowdon, 4.16–4.00Β° W, 53.03–53.12Β° N. Sentinel-2 pixels are read from Earth Search's open COGs. Landsat pixels cannot be read anonymously from the USGS bucket (step 5), so they come from the Microsoft Planetary Computer copy of the same scenes, whose URLs its client signs for free.

import planetary_computer
from pystac_client import Client

AOI = (-4.16, 53.03, -4.00, 53.12)
earth = Client.open("https://earth-search.aws.element84.com/v1")
s2_items = list(earth.search(collections=["sentinel-2-l2a"], bbox=list(AOI),
                             datetime="2025-01-01/2025-12-31").items())
ls_items = list(earth.search(collections=["landsat-c2-l2"], bbox=list(AOI),
                             datetime="2025-01-01/2025-12-31").items())
cloud = lambda i: i.properties["eo:cloud_cover"]
summer = [i for i in s2_items if "2025-06" <= i.properties["datetime"][:7] <= "2025-09"]
sentinel_item = min(summer, key=cloud)                     # least cloudy, June-September
landsat_item = min(ls_items, key=cloud)                    # least cloudy of the year

mirror = Client.open("https://planetarycomputer.microsoft.com/api/stac/v1",
                     modifier=planetary_computer.sign_inplace)
landsat_mirror = lambda item: mirror.get_collection("landsat-c2-l2").get_item(item.id)
print(len(s2_items), "Sentinel-2 items;", len(ls_items), "Landsat items")
print("Sentinel-2:", sentinel_item.id, f"{cloud(sentinel_item):.2f}% cloud")
print("Landsat:   ", landsat_item.id, f"{cloud(landsat_item):.2f}% cloud")
201 Sentinel-2 items; 79 Landsat items
Sentinel-2: S2C_30UVD_20250712_0_L2A 0.06% cloud
Landsat:    LC09_L2SP_204023_20250410_02_T1 0.03% cloud

Example 1 β€” one loader for both, with the differences isolated

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

SENSORS = {
    "sentinel-2-l2a": {
        "bands": {"blue": "blue", "green": "green", "red": "red", "nir": "nir",
                  "swir16": "swir16"},
        "qa": "scl", "scale": 1e-4, "offset": 0.0,     # verified against water
        "reference": "red",
    },
    "landsat-c2-l2": {
        "bands": {"blue": "blue", "green": "green", "red": "red",
                  "nir": "nir08", "swir16": "swir16"},
        "qa": "qa_pixel", "scale": 2.75e-5, "offset": -0.2,
        "reference": "red",
    },
}


def usable_mask(sensor, qa):
    """The one function that genuinely differs between the two."""
    if sensor == "sentinel-2-l2a":
        return ~np.isin(qa, (0, 1, 3, 8, 9, 10))
    if sensor == "landsat-c2-l2":
        fill = (qa >> 0) & 1
        shadow = (qa >> 4) & 1                      # "clear" means no cloud, not no shadow
        clear = (qa >> 6) & 1
        return clear.astype(bool) & ~shadow.astype(bool) & ~fill.astype(bool)
    raise ValueError(f"unknown sensor {sensor!r}")


def window_shape(ds, aoi):
    """The AOI's bounds in the dataset's CRS, and its shape on the dataset's grid."""
    bounds = transform_bounds("EPSG:4326", ds.crs, *aoi)
    win = rasterio.windows.from_bounds(*bounds, transform=ds.transform).round_offsets().round_lengths()
    return bounds, (int(win.height), int(win.width))


def read_window(href, bounds, shape, resampling):
    with rasterio.open(href) as ds:
        win = rasterio.windows.from_bounds(*bounds, transform=ds.transform)
        return ds.read(1, window=win, out_shape=shape, resampling=resampling)


def load(item, sensor, aoi):
    cfg = SENSORS[sensor]
    assets = {k: v.href for k, v in item.assets.items()}

    with rasterio.open(assets[cfg["bands"][cfg["reference"]]]) as ds:
        bounds, shape = window_shape(ds, aoi)

    qa = read_window(assets[cfg["qa"]], bounds, shape, Resampling.nearest)

    bands = {}
    for name, asset in cfg["bands"].items():
        raw = read_window(assets[asset], bounds, shape, Resampling.bilinear)
        bands[name] = raw.astype("float32") * cfg["scale"] + cfg["offset"]

    mask = usable_mask(sensor, qa)
    for name in bands:
        bands[name] = np.where(mask, bands[name], np.nan)

    print(f"  {sensor} {item.id}: {shape}, {mask.mean():.1%} usable")
    return bands, mask


s2_bands, s2_mask = load(sentinel_item, "sentinel-2-l2a", AOI)
ls_bands, ls_mask = load(landsat_mirror(landsat_item), "landsat-c2-l2", AOI)
  sentinel-2-l2a S2C_30UVD_20250712_0_L2A: (1017, 1087), 100.0% usable
  landsat-c2-l2 LC09_L2SP_204023_20250410_02_T1: (339, 362), 100.0% usable

Putting the scale, offset, band aliases and QA rule in one dictionary per sensor is what keeps a two-sensor pipeline honest. The moment those live inline in the processing code, one of them will be applied to the other sensor.

Example 2 β€” checking the revisit you will actually get

from collections import Counter


def revisit_report(client, aoi, year, collections=("sentinel-2-l2a",
                                                   "landsat-c2-l2")):
    """Items, and items you could actually use, per collection."""
    for collection in collections:
        items = list(client.search(collections=[collection], bbox=list(aoi),
                                   datetime=f"{year}-01-01/{year}-12-31").items())
        clouds = [i.properties.get("eo:cloud_cover", 100) for i in items]
        months = Counter(i.properties["datetime"][5:7] for i in items)
        under20 = sum(1 for c in clouds if c < 20)
        print(f"  {collection:18} {len(items):4d} items, "
              f"{under20:3d} under 20% scene cloud, "
              f"{len(months)} months represented")


revisit_report(earth, AOI, 2025)
  sentinel-2-l2a      201 items,  21 under 20% scene cloud, 12 months represented
  landsat-c2-l2        79 items,  11 under 20% scene cloud, 11 months represented

Item count is not the whole story. Run this for your own area before committing β€” in a persistently cloudy region the number that matters is not overpasses but usable overpasses, and that ratio differs by climate rather than by satellite.

Where the 2025 looks over one Snowdon window came from, for Sentinel-2 and Landsat. one 10 km window at 53Β° N, 2025Sentinel-2 L2ALandsat C2 L2satellites imaging3: 2A 62, 2B 72, 2C 672: L8 40, L9 39orbits over the windowrelative orbits 037, 080WRS-2 paths 204, 205items20179days with an item17279under 20% scene cloud2111both missions see this window from two orbits; the third satellite is the differenceA 5-day pair against an 8-day pair predicts 1.6:1; three satellites make it 2.5:1 spatialworkflow.io
Measured in Examples 2 and 4. Overlap doubles both missions; the extra Sentinel-2 satellite is what makes the ratio 2.5.

Example 3 β€” harmonising the two for one time series

import numpy as np


def harmonise_ndvi(value, sensor, coefficients=None):
    """Adjust one sensor's NDVI onto the other's scale.

    Band widths and centre wavelengths differ, so the same ground gives
    slightly different NDVI. Published cross-calibrations are linear.
    """
    coefficients = coefficients or {
        # NDVI_s2_equivalent = a + b * NDVI_landsat  (illustrative form only β€”
        # take the constants from a published calibration for your bands)
        "landsat-c2-l2": {"a": 0.0, "b": 1.0},
        "sentinel-2-l2a": {"a": 0.0, "b": 1.0},
    }
    c = coefficients[sensor]
    return c["a"] + c["b"] * np.asarray(value)

The difference is not hypothetical. Landsat 8 on 2 April 2025 and Sentinel-2 on 3 April, averaged to the same 30 m grid, gave mean NDVIs of 0.516 and 0.481 over 122,700 clear pixels of the window, with a correlation of only 0.812 (Example 4) β€” a day of change, a different view angle and sub-pixel misregistration as well as band widths. That one pair's fit is not a calibration, which is the point below.

The important part of this function is not the arithmetic, which is trivial. It is that the sensor is an explicit argument, so a merged series cannot silently mix two scales. Take the constants from a published cross-calibration for the exact band pair β€” do not fit them on your own cloudy overlap of a handful of dates.

Example 4 β€” the measurements behind the explanation

import re
from collections import Counter

# 1. Where the looks come from
orbit = lambda i: re.search(r"_R(\d{3})_", i.properties["s2:product_uri"]).group(1)
print("Sentinel-2:", len(s2_items), "items on", len({i.properties["datetime"][:10] for i in s2_items}), "days;",
      "tiles", dict(Counter(i.properties["grid:code"] for i in s2_items)), "relative orbits", dict(Counter(map(orbit, s2_items))),
      "platforms", dict(Counter(i.properties["platform"] for i in s2_items)))
print("Landsat:   ", len(ls_items), "items on", len({i.properties["datetime"][:10] for i in ls_items}), "days;",
      "WRS-2 path/row", dict(Counter(i.id.split("_")[2] for i in ls_items)),
      "platforms", dict(Counter(i.properties["platform"] for i in ls_items)))

# 2. Is Sentinel-2's declared offset already applied? Look at water.
water = s2_mask & (read_window(sentinel_item.assets["scl"].href, *window_shape(
    rasterio.open(sentinel_item.assets["red"].href), AOI), Resampling.nearest) == 6)
print(f"Sentinel-2 water pixels: {water.sum():,}")
for band in ("blue", "green", "red", "nir"):
    dn_scaled = np.nanmedian(s2_bands[band][water])
    print(f"  {band:5} DN x 1e-4 = {dn_scaled:.4f}   with the declared -0.1 = {dn_scaled - 0.1:.4f}")

# 3. What np.isin does to a Landsat bit field, on a cloudy scene
cloudy = min(ls_items, key=lambda i: abs(cloud(i) - 50))
signed = landsat_mirror(cloudy)
with rasterio.open(signed.assets["qa_pixel"].href) as ds:
    bounds, shape = window_shape(ds, AOI)
qa = read_window(signed.assets["qa_pixel"].href, bounds, shape, Resampling.nearest)
print(f"{cloudy.id}, {cloud(cloudy):.1f}% scene cloud; most common QA_PIXEL values in the window:")
for value, n in Counter(qa.ravel().tolist()).most_common(4):
    flags = [name for bit, name in ((0, "fill"), (1, "dilated"), (2, "cirrus"), (3, "cloud"), (4, "shadow"),
                                     (5, "snow"), (6, "clear"), (7, "water")) if value >> bit & 1]
    print(f"  {value:6d} = {value:016b}  {n / qa.size:6.1%}  {flags}")
wrong = ~np.isin(qa, (0, 1, 3, 8, 9, 10))
right = usable_mask("landsat-c2-l2", qa)
cloudy_px = ((qa >> 3) & 1).astype(bool)
clear_only = ((qa >> 6) & 1).astype(bool) & ~(qa & 1).astype(bool)
print(f"  Sentinel-2 rule keeps {wrong.mean():.1%}, including {(wrong & cloudy_px).sum() / cloudy_px.sum():.1%} "
      f"of the cloud pixels; clear-bit alone keeps {clear_only.mean():.1%}; clear without shadow {right.mean():.1%}")

# 4. The same ground one day apart: Landsat 2 April, Sentinel-2 3 April
ls_day = next(i for i in ls_items if i.properties["datetime"][:10] == "2025-04-02")
s2_day = min((i for i in s2_items if i.properties["datetime"][:10] == "2025-04-03"), key=cloud)
lb, lm = load(landsat_mirror(ls_day), "landsat-c2-l2", AOI)
with rasterio.open(landsat_mirror(ls_day).assets["red"].href) as ds:
    bounds, shape = window_shape(ds, AOI)
s2_on_30m = {b: read_window(s2_day.assets[b].href, bounds, shape, Resampling.average).astype("float32") * 1e-4
             for b in ("red", "nir")}
ndvi_ls = (lb["nir"] - lb["red"]) / (lb["nir"] + lb["red"])
ndvi_s2 = (s2_on_30m["nir"] - s2_on_30m["red"]) / (s2_on_30m["nir"] + s2_on_30m["red"])
ok = lm & np.isfinite(ndvi_ls) & np.isfinite(ndvi_s2)
b, a = np.polyfit(ndvi_ls[ok], ndvi_s2[ok], 1)
print(f"NDVI on {ok.sum():,} shared 30 m pixels: Landsat mean {ndvi_ls[ok].mean():.3f}, Sentinel-2 mean "
      f"{ndvi_s2[ok].mean():.3f}, r = {np.corrcoef(ndvi_ls[ok], ndvi_s2[ok])[0, 1]:.3f}, "
      f"fit S2 = {a:.3f} + {b:.3f} x Landsat")
Sentinel-2: 201 items on 172 days; tiles {'MGRS-30UVD': 201} relative orbits {'037': 100, '080': 101} platforms {'sentinel-2a': 62, 'sentinel-2c': 67, 'sentinel-2b': 72}
Landsat:    79 items on 79 days; WRS-2 path/row {'204023': 39, '205023': 40} platforms {'landsat-8': 40, 'landsat-9': 39}
Sentinel-2 water pixels: 23,380
  blue  DN x 1e-4 = 0.0096   with the declared -0.1 = -0.0904
  green DN x 1e-4 = 0.0092   with the declared -0.1 = -0.0908
  red   DN x 1e-4 = 0.0039   with the declared -0.1 = -0.0961
  nir   DN x 1e-4 = 0.0018   with the declared -0.1 = -0.0982
LC09_L2SP_204023_20250528_02_T1, 47.6% scene cloud; most common QA_PIXEL values in the window:
   22280 = 0101011100001000   87.1%  ['cloud']
   23888 = 0101110101010000    3.8%  ['shadow', 'clear']
   23826 = 0101110100010010    3.7%  ['dilated', 'shadow']
   21824 = 0101010101000000    2.0%  ['clear']
  Sentinel-2 rule keeps 100.0%, including 100.0% of the cloud pixels; clear-bit alone keeps 6.4%; clear without shadow 2.2%
  landsat-c2-l2 LC08_L2SP_204023_20250402_02_T1: (339, 362), 100.0% usable
NDVI on 122,700 shared 30 m pixels: Landsat mean 0.516, Sentinel-2 mean 0.481, r = 0.812, fit S2 = -0.229 + 1.377 x Landsat

Revisit and resolution in practice

Why the revisit difference is larger than the orbit difference

Landsat 8 and 9 each repeat every 16 days, staggered to give 8 days combined. A two-satellite Sentinel-2 constellation repeats every 5 days. That predicts about 1.6:1; the measurement was 201 to 79, about 2.5:1.

Swath overlap is not the difference, because both missions have it here. The window lies in two Sentinel-2 relative orbits (037 and 080, with 100 and 101 items) and in two Landsat paths (204 and 205, with 39 and 40), so each sees it twice per repeat cycle. The difference is a third satellite: in 2025 Sentinel-2A, 2B and 2C were all imaging, with 62, 72 and 67 items. Three satellites on a 10-day orbit is one pass per 3.3 days per relative orbit, which predicts 2.4:1 against Landsat β€” and on 29 days two of them imaged the window from different orbits, so the 201 items fell on 172 days.

In a cloudy climate the revisit rate matters more than anything else, because usable observations are a small fraction of overpasses. Two and a half times the looks is two and a half times the chance of catching a clear day.

Why 10 m is not always three times better

Sentinel-2's 10 m applies to four bands: blue, green, red and the broad near-infrared. The red-edge bands and both shortwave-infrared bands are 20 m.

So an analysis using SWIR β€” burn severity, moisture, snow, built-up indices β€” works at 20 m on Sentinel-2 against 30 m on Landsat. That is a 2.25Γ— difference in pixel area rather than 9Γ—, and Landsat's longer archive often outweighs it.

Why the mask difference causes silent bugs

Share of a cloudy Landsat window kept by three masking rules applied to QA_PIXEL. Landsat 9, 28 May 2025, 47.6% scene cloud: share of the window keptSentinel-2 rule, np.isin100.0% β€” every cloud pixel toobit 6Γ· 16clear bit only6.4% β€” shadow includedbit 4Γ· 2.9clear, not shadow, not fill2.2%Class codes and bit fields need different code; bit 6 alone is not a shadow test spatialworkflow.io
Measured in Example 4. The wrong rule raises no error; it just keeps the clouds.

An SCL band holds small integers 0–11. A QA_PIXEL band holds packed flags, with values in the tens of thousands.

Point Sentinel-2 code at Landsat and np.isin(qa, (0, 1, 3, 8, 9, 10)) will match almost nothing, because a QA_PIXEL value of 21824 (a clear land pixel) is not in the list β€” nor is 22280, cloud. On a Landsat 9 scene of 28 May 2025 with 47.6% scene cloud, the Sentinel-2 rule kept 100.0% of the window, including every cloud pixel, with no error raised. The bit rule kept 2.2%.

The bit field has its own trap. Bit 6, "clear", means no cloud and no dilated cloud β€” not no shadow. 23888 is a cloud-shadow pixel with the clear bit set, and it was 3.8% of that window, so testing the clear bit alone kept 6.4%. Test the shadow bit too, as usable_mask does, or the Landsat mask keeps shadow that the Sentinel-2 one removes.

Symptom: a suspiciously high usable fraction and a composite that looks like the cloud composite in How to build a cloud-free composite. Always print the usable fraction after masking.

Why requester-pays changes architecture

An openly readable COG means a windowed read from anywhere with no account, which is what makes the "read only the tiles you need" pattern so effective for Sentinel-2.

Requester-pays means every read is billed to your AWS account, including the reads that turn out to be cloud. That pushes the design towards pre-filtering on metadata, colocating compute in the same region, and caching aggressively β€” all things worth deciding at the start rather than discovering from a bill.

Sentinel-2 and Landsat item counts and low-cloud counts over one area of interest in 2025. opportunities over one 10 km window, 2025Sentinel-2, all items201Sentinel-2, under 20% cloud21Landsat, all items79Landsat, under 20% cloud1102202.5Γ— the overpasses, and about twice as many low-cloud ones spatialworkflow.io
Measured over one window for one year. In a cloudy climate the overpass count is what decides.

Landsat 7's failure and band naming

  • Landsat 7 has the scan-line corrector failure (post-2003): usable, striped, and needs gap handling.
  • Landsat 8 and 9 are interchangeable for most purposes; Landsat 5 and 7 need different band indices β€” the same physical band has a different number.
  • Sentinel-2's nir (B08) and nir08 (B8A) are different bands at 10 m and 20 m. Landsat's nir08 is closer to B8A than to B08.
  • Both declare a scale and offset; verify both against a dark target.
  • Landsat thermal has no Sentinel-2 equivalent.
  • Sentinel-2 tiles overlap, so one date can produce two items for one location.
  • Landsat's WRS-2 path/row and Sentinel-2's MGRS tiles are different grids; a mosaic needs reprojection either way.
  • Harmonised Landsat–Sentinel products exist and are usually a better idea than harmonising them yourself.

FAQ

Should I use Sentinel-2 or Landsat?

Sentinel-2 for anything since 2015 needing resolution or frequency β€” it gave 201 items against Landsat's 79 over the same area in 2025. Landsat for anything before 2015, anything thermal, and anything at scales where 30 m is enough.

How much more often does Sentinel-2 pass over?

About 2.5 times as often in 2025, measured here as 201 against 79 items over one 10 km window at 53Β° N, with three Sentinel-2 satellites imaging.

Do both need a scale and offset applied?

Both declare one. Landsat Collection 2 genuinely needs DN * 2.75e-5 - 0.2. Whether Sentinel-2 needs its declared βˆ’0.1 depends on the archive β€” check against water before applying either.

Why does my Landsat cloud mask keep everything?

Because QA_PIXEL is a bit field, not class codes. np.isin matches nothing; use bitwise shifts and tests instead.

Why can I not read Landsat pixels without credentials?

The USGS Landsat Collection 2 bucket is requester-pays. Anonymous reads return AccessDenied. Set AWS credentials and AWS_REQUEST_PAYER=requester, or use an open mirror.

Can I combine Landsat and Sentinel-2 in one time series?

Only with a cross-calibration. Band widths and centre wavelengths differ, so the same ground gives slightly different index values. Prefer a published harmonised product.

Does Sentinel-2 have a thermal band?

No. Anything about surface temperature needs Landsat.