Comparing L1C and L2A Sentinel-2 Products
Use L2A for anything compared across dates, and read both levels with the same scale and baseline-aware offset:
import numpy as np
def to_reflectance(dn: np.ndarray, baseline: str) -> np.ndarray:
offset = -1000 if baseline >= "04.00" else 0 # applies to L1C and L2A
out = (dn.astype("float32") + offset) / 10000
out[dn == 0] = np.nan # 0 is nodata in both levels
return out
The two levels share a format and a grid, which is exactly why they are easy to confuse and costly to mix. This page belongs to atmospheric correction and surface reflectance in Satellite Processing Workflows & Index Pipelines.
What Differs Between the Levels
The scene classification layer is a practical reason to prefer L2A on its own. It is produced during correction, it is aligned with the reflectance bands, and it is the cloud and shadow mask most Sentinel-2 pipelines use — as covered in masking clouds with the Sentinel-2 SCL band.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
pystac-client |
>=0.7 |
Finding matching L1C and L2A items |
rasterio |
>=1.3.0 |
Reading bands from both levels |
numpy |
>=1.23 |
Differences and statistics |
pip install "pystac-client>=0.7" "rasterio>=1.3.0" "numpy>=1.23"
Complete Working Example
import numpy as np
import pystac_client
import rasterio
API = "https://earth-search.aws.element84.com/v1"
def matching_pair(bbox, date: str):
"""The same acquisition at both processing levels."""
client = pystac_client.Client.open(API)
l2a = next(client.search(collections=["sentinel-2-l2a"], bbox=bbox,
datetime=date, max_items=1).items())
tile, when = l2a.properties["s2:mgrs_tile"], l2a.datetime.date().isoformat()
l1c = next(it for it in client.search(collections=["sentinel-2-l1c"], bbox=bbox,
datetime=when).items()
if it.properties.get("s2:mgrs_tile") == tile)
return l1c, l2a
def reflectance(item, asset: str, window) -> np.ndarray:
baseline = str(item.properties.get("s2:processing_baseline", "00.00"))
with rasterio.open(item.assets[asset].href) as src:
dn = src.read(1, window=window)
offset = -1000 if baseline >= "04.00" else 0
out = (dn.astype("float32") + offset) / 10000
out[dn == 0] = np.nan
return out
def level_difference(bbox, date, window=((0, 1024), (0, 1024))) -> dict[str, float]:
l1c, l2a = matching_pair(bbox, date)
out = {}
for asset in ("blue", "red", "nir"):
toa = reflectance(l1c, asset, window)
boa = reflectance(l2a, asset, window)
out[asset] = float(np.nanmedian(toa - boa))
return out # positive: atmosphere added brightness
Pulling the same acquisition at both levels and differencing them over your own area turns an abstract distinction into a number. Over a typical humid scene the blue band’s top-of-atmosphere value exceeds surface reflectance by several hundredths — larger than many of the index differences analyses look for — while the near-infrared difference is small and can go either way.
The Processing Baseline Offset
The offset applies to both levels from the same baseline, which is why the conversion function above keys on the baseline rather than the level. An archive spanning the change mixes scenes with and without it, and decoding them with one global rule produces a false step in every time series at the switch date. Reading the baseline per scene removes the problem entirely; the general principle is set out in converting int16 reflectance to float safely.
Never Mix Levels in One Analysis
The most expensive mistake with these products is using both in one time series — L2A where it exists and L1C to fill gaps, say. The two differ by an amount that varies with the atmosphere on each date, so the mixed series contains steps that look like change and are not. If L2A is missing for part of a period, it is better to leave the gap, or to produce your own correction for the whole period from L1C, than to splice the levels together.
Recording the processing level in every derived product’s tags, and asserting that inputs agree before combining them, makes the rule enforceable rather than aspirational. The tagging pattern is described in reading and writing GDAL tags and band descriptions.
Verification
diff = level_difference([34.6, 0.25, 34.9, 0.55], "2026-06-14")
print(diff)
assert diff["blue"] > diff["red"] > abs(diff["nir"]) - 0.01, "unexpected spectral pattern"
assert all(abs(v - 0.1) > 0.02 for v in diff.values()), "looks like an offset mismatch, not atmosphere"
A difference of almost exactly 0.1 in every band is the fingerprint of the offset being applied to one level and not the other. Real atmospheric differences follow the spectral pattern — largest in blue, smallest in the infrared — and never produce a flat offset across all bands.
Common Errors
L2A values are all 0.1 too high
The baseline offset was not subtracted. Decode per scene using s2:processing_baseline.
The cirrus band is missing from L2A
B10 is not distributed at L2A because it carries no surface information. Use L1C’s B10 if cirrus detection needs it.
A time series steps at one date
Levels were mixed, or a baseline change was decoded with one global rule. Use one level and per-scene decoding.
SCL classes look shifted relative to the bands
SCL is at 20 m. Resample it to 10 m with nearest-neighbour, never bilinear.
Frequently Asked Questions
Q: When would I use L1C instead of L2A? When L2A is unavailable for the period, when you need your own atmospheric correction for consistency with another dataset, or for applications such as cloud detection research that model the atmosphere explicitly. For most land analysis, L2A is the right choice.
Q: What is the processing baseline offset? From baseline 04.00, both L1C and L2A add 1000 to every digital number so that negative reflectance can be stored. Converting back therefore subtracts 1000 before dividing by 10000 — equivalently, an offset of minus 0.1 in reflectance.
Q: Does L2A include a cloud mask? It includes a scene classification layer with classes for cloud, cloud shadow, cirrus, snow, water and vegetation, among others. It is a good first-pass mask, though thin cirrus and cloud edges often need additional handling.
Q: Are reprocessed archives consistent with recent acquisitions? Reprocessing campaigns bring older scenes up to a newer baseline, which is exactly what makes long time series consistent. Where an archive mixes original and reprocessed scenes, prefer the reprocessed versions throughout and record which baseline each scene used.
Q: Is L2A always available for every L1C scene? Very nearly for recent years, but not universally for the early archive, and occasionally a scene fails processing at L2A while its L1C exists. A search that falls back to L1C silently would mix levels, so treat a missing L2A as a gap in the series rather than as a cue to substitute, and let the observation count record it.
Related
- Atmospheric Correction and Surface Reflectance — the parent topic.
- Harmonising Landsat and Sentinel-2 Reflectance — the cross-sensor counterpart to this cross-level comparison.
- Using pystac-client to Filter Sentinel-2 Imagery by Date — finding matching items.
- Resampling Sentinel-2 20m Bands to 10m — bringing SCL onto the 10 m grid.