How to Read Landsat Surface Temperature in Python

On this page

Problem statement

Landsat's Collection 2 Level-2 surface temperature product gives the temperature of the ground — roofs, roads, fields, water — at 30 m for every clear Landsat scene since the 1980s. It is the standard input for urban heat studies. It also arrives as scaled integers in Kelvin with a fill value of zero, a quality band whose bits have to be decoded, and assets named differently on different satellites. Skipping any of these steps produces temperatures of 40,000 degrees, clouds that look like cool parks, or no data at all.

Quick answer

Find a clear Landsat 8 or 9 scene, read a window of the thermal and QA bands, apply the scale and mask:

import numpy as np, rasterio, pystac_client, planetary_computer
from rasterio.windows import from_bounds
from rasterio.warp import transform_bounds

AOI = (-3.80, 40.35, -3.60, 40.50)                     # central Madrid
cat = pystac_client.Client.open("https://planetarycomputer.microsoft.com/api/stac/v1")
items = [i for i in cat.search(collections=["landsat-c2-l2"], bbox=list(AOI),
                               datetime="2023-07-01/2023-07-31").items()
         if i.properties["platform"] in ("landsat-8", "landsat-9")
         and i.properties["eo:cloud_cover"] < 10]
item = min(items, key=lambda i: i.properties["eo:cloud_cover"])
print(f"{item.id}: {item.properties['platform']}, {item.datetime:%Y-%m-%d %H:%M} UTC, "
      f"{item.properties['eo:cloud_cover']:.1f}% cloud")

def read(asset):
    with rasterio.open(planetary_computer.sign(item.assets[asset].href)) as src:
        win = from_bounds(*transform_bounds("EPSG:4326", src.crs, *AOI), src.transform)
        return src.read(1, window=win), src.window_transform(win), src.crs

dn, tf, crs = read("lwir11")                              # Landsat 8/9 band 10
qa, _, _ = read("qa_pixel")
lst = np.where(dn == 0, np.nan, dn * 0.00341802 + 149.0 - 273.15)      # → °C
cloudy = (qa & (1 << 1)) | (qa & (1 << 3)) | (qa & (1 << 4))           # dilated cloud, cloud, shadow
lst[cloudy > 0] = np.nan
print(f"window {lst.shape[1]} × {lst.shape[0]} pixels at {tf.a:.0f} m in {crs}")
print(f"surface temperature: min {np.nanmin(lst):.1f}, median {np.nanmedian(lst):.1f}, "
      f"max {np.nanmax(lst):.1f} °C; masked {np.isnan(lst).mean():.1%}")
LC08_L2SP_201032_20230729_02_T1: landsat-8, 2023-07-29 10:55 UTC, 0.1% cloud
window 570 × 559 pixels at 30 m in EPSG:32630
surface temperature: min 30.8, median 45.1, max 58.2 °C; masked 0.2%

A July late morning in Madrid: a median surface of 45 °C and the hottest pixels at 58 °C, while the air at Barajas airport around the overpass was 32 °C. That gap is the first thing to understand about surface temperature.

Land surface temperature map of central Madrid from one Landsat 8 scene: most of the city orange, with parks and the river in blue and the hottest areas in dark red. Land surface temperature map of central Madrid from one Landsat 8 scene: most of the city orange, with parks and the river in blue and the hottest areas in dark red.
The window from the code above: one Landsat 8 overpass at 10:55 UTC on 29 July 2023, 30.8 to 58.2 °C across central Madrid. The parks and the river are the cool patches and strips.

Right product, cloud filtering and masking, scale factors

1. Pick the right product and satellites

Collection 2 Level-2 (landsat-c2-l2) includes surface temperature. Landsat 8 and 9 carry band 10 (lwir11); Landsat 7 and 5 carry band 6 (lwir). Mixing them needs asset names per platform.

2. Filter scenes by cloud cover — then mask anyway

Scene-level cloud cover is for the whole 185 km scene. A scene with 5% cloud can have all of it over your city. The per-pixel QA band decides.

3. Read only your window

The assets are cloud-optimised GeoTIFFs in UTM. Transform your bounds into the scene CRS and read a window.

4. Apply the scale and offset

Surface temperature = DN × 0.00341802 + 149.0, in Kelvin. Subtract 273.15 for °C. DN 0 is fill.

5. Decode the QA bits

qa_pixel is a bit field: bit 0 fill, bit 1 dilated cloud, bit 3 cloud, bit 4 cloud shadow, bit 5 snow, bit 7 water. Mask clouds, their dilated edges and shadows.

6. Remember the native resolution

The thermal band is acquired at 100 m and delivered resampled to 30 m. Features smaller than about 100 m are blurred.

From a Landsat scene search through windowed reading, scale and offset and QA masking to surface temperature in degrees Celsius. four steps from integers to °CsearchL8/9, cloud filterwindowAOI in scene CRSscale + offset× 0.00341802 + 149QA maskbits 1, 3, 4°C− 273.15Madrid, 29 July 2023: median 45.1 °C, max 58.2 °C spatialworkflow.io
Search, window, scale, mask: four steps from integers to °C.

QA bits, raw values and a clean GeoTIFF

Example 1 — the QA bits, counted

bits = {0: "fill", 1: "dilated cloud", 3: "cloud", 4: "cloud shadow", 5: "snow", 7: "water"}
for b, name in bits.items():
    print(f"bit {b} ({name}): {((qa >> b) & 1).mean():.2%} of pixels")
bit 0 (fill): 0.00% of pixels
bit 1 (dilated cloud): 0.09% of pixels
bit 3 (cloud): 0.02% of pixels
bit 4 (cloud shadow): 0.11% of pixels
bit 5 (snow): 0.00% of pixels
bit 7 (water): 0.11% of pixels

On a clear summer scene most bits are rare; the water bit catches reservoirs and rivers, which are the coolest surfaces in the city and worth keeping separate rather than masking.

Example 2 — what the raw numbers look like

print(f"raw DN: min {dn[dn > 0].min()}, max {dn.max()}, dtype {dn.dtype}")
print(f"× 0.00341802 + 149 = {dn[dn > 0].min() * 0.00341802 + 149:.1f} to "
      f"{dn.max() * 0.00341802 + 149:.1f} K")
raw DN: min 43015, max 53437, dtype uint16
× 0.00341802 + 149 = 296.0 to 331.6 K

Integers in the tens of thousands, Kelvin after scaling, Celsius after the offset. Every step is a place a factor can be lost.

Example 3 — save a clean GeoTIFF

profile = {"driver": "GTiff", "height": lst.shape[0], "width": lst.shape[1], "count": 1,
           "dtype": "float32", "crs": crs, "transform": tf, "nodata": np.nan, "compress": "deflate"}
with rasterio.open(f"madrid_lst_{item.datetime:%Y%m%d}.tif", "w", **profile) as dst:
    dst.write(lst.astype("float32"), 1)
    dst.update_tags(1, units="degC", source=item.id, masked="cloud, dilated cloud, shadow")
print("saved with units and provenance in the tags")
saved with units and provenance in the tags

Recording the unit, the scene and the masking in the file saves the next reader from guessing.

Landsat Collection 2 QA_PIXEL bits used for surface temperature masking. qa_pixel bits that matterbitmeaningmask for LST?0fillyes1dilated cloudyes3cloudyes4cloud shadowyes5snowseasonal7waterdepends on the questionTest bits with shifts, never by comparing whole values spatialworkflow.io
The bits to decode before trusting a pixel.

Hotter than the air, and stored as integers

Why surface temperature is so much hotter than the air

A dark roof or road in full sun absorbs most of the incoming radiation and heats until it sheds that energy by convection and radiation. At midday in summer its surface can be 20–30 °C above the air a few metres above it. Surface temperature measures that skin, not the air people breathe.

Why the scale factor exists

Storing temperatures as 16-bit integers with a scale and offset keeps files small while preserving about 0.003 K precision. The factors are published in the product guide and repeated in the STAC metadata; they are the same for every Collection 2 Level-2 scene.

Why the overpass time matters

Landsat 8 and 9 cross the equator at about 10:00–10:15 local solar time. That is late morning: surfaces are heating but have not peaked. Afternoon maxima are higher, and night-time heat islands — often the ones that matter most for health — are invisible to Landsat.

The surface temperature conversion line, annotated. one line, three places to go wrongst = np.where(dn == 0, np.nan, dn * 0.00341802 + 149.0)st_c = st - 273.15①②③①fill first, before scaling②scale and offset → Kelvin③then CelsiusRaw DN 43,015–53,437 becomes 296.0–331.6 K spatialworkflow.io
Mask the fill, scale to Kelvin, then convert: in that order.

Landsat 7, water and tokens

  • Landsat 7 has a drifted orbit and scan-line gaps after 2003; treat it separately.
  • Emissivity is built into the Level-2 product; do not correct again.
  • Water: the product is valid over water, but water temperatures behave differently; keep the water bit as a class.
  • Tier 2 scenes have poorer geometric accuracy; prefer Tier 1 (_T1) for urban work.
  • Planetary Computer tokens expire; sign assets at read time.

FAQ

How do I convert Landsat surface temperature to Celsius?

DN × 0.00341802 + 149.0 gives Kelvin; subtract 273.15 for °C. DN 0 is fill.

Which band is surface temperature?

In Collection 2 Level-2, ST_B10 for Landsat 8/9 (lwir11 on the Planetary Computer) and ST_B6 for Landsat 5/7 (lwir).

How do I mask clouds?

Decode qa_pixel: exclude bits 1 (dilated cloud), 3 (cloud) and 4 (cloud shadow).

What resolution is Landsat thermal data?

Acquired at 100 m (Landsat 8/9), delivered resampled to 30 m.

What time of day is Landsat LST?

Late morning, around 10:00–10:30 local solar time.

Is LST the same as air temperature?

No. It is the temperature of the surface, often much hotter than the air in daytime.