Sentinel-2 or Landsat? Choosing a Satellite Imagery Source

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
# the pixels, anonymously
rasterio.open(sentinel_item.assets["red"].href)     # works

with rasterio.Env(AWS_NO_SIGN_REQUEST="YES"):
    rasterio.open(landsat_item.assets["red"].href)  # AccessDenied
RasterioIOError: AccessDenied: Anonymous users cannot invoke requests
against Requester Pays buckets.
Sentinel-2 and Landsat compared on resolution, revisit, archive depth, thermal bands and access model.
Sentinel-2 wins on resolution and revisit; Landsat wins on archive depth and thermal. The access model decides more projects than either.

Step-by-step solution

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 5 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:

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 of βˆ’0.09 in every band, 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) & ~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.
Class codes and bit fields need different code. Applying `np.isin` to a bit field produces a mask-shaped array of wrong answers.

Code examples

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

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

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
        clear = (qa >> 6) & 1
        return clear.astype(bool) & ~fill.astype(bool)
    raise ValueError(f"unknown sensor {sensor!r}")


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:
        shape = window_shape(ds, aoi)

    with rasterio.open(assets[cfg["qa"]]) as ds:
        qa = ds.read(1, out_shape=shape, resampling=Resampling.nearest)

    bands = {}
    for name, asset in cfg["bands"].items():
        with rasterio.open(assets[asset]) as ds:
            raw = ds.read(1, out_shape=shape, resampling=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

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")
  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.

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 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.

Explanation

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. Sentinel-2A, 2B and 2C give 5 days at the equator and better at high latitudes where adjacent swaths overlap.

That predicts roughly a 2:1 ratio; the measurement was 201 to 79, about 2.5:1. The extra comes from swath overlap at 53Β° north, where a single location falls in more than one Sentinel-2 relative orbit.

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

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. The result is a mask that keeps everything, including all the cloud, with no error raised.

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 with 201 items and 21 under 20 percent cloud against Landsat with 79 and 11 over the same area in 2025.
Measured over one window for one year. In a cloudy climate the overpass count is what decides.

Edge cases or notes

  • 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 at mid-latitudes, measured here as 201 against 79 items over one 10 km window in a year.

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.