Global Surface Temperature Anomalies#

Map of global surface temperature anomalies

The NOAA Global Surface Temperature Dataset provides monthly global land-ocean surface temperature data, derived from:

  • The Extended Reconstructed Sea Surface Temperature (ERSST) analysis.

  • The Global Historical Climatology Network - Monthly (GHCN-M) land surface air temperature analysis. It supports global climate monitoring by combining sea surface temperatures with land surface air temperatures.

Data characteristics#

  • Spatial resolution: 5° latitude × 5° longitude.

  • Temporal coverage: Monthly values from 1850 to the present.

  • Reference period: Anomalies calculated relative to the 1971–2000 climatology.

Note: To adjust anomalies for other climatology periods, calculate the average anomaly for the desired period and subtract it from the original anomalies.

Crediting the Data Providers#

When using this dataset in publications or presentations, please provide the following citation:

Huang, Boyin; Yin, Xungang; Menne, Matthew J.; Vose, Russell S.; and Zhang, Huai-Min. 2024.
NOAA Global Surface Temperature Dataset (NOAAGlobalTemp), Version 6.0. [indicate subset used].
NOAA National Centers for Environmental Information. https://doi.org/10.25921/rzxg-p717.
Accessed [date].

Exploring the data in Python#

from IPython.display import YouTubeVideo
YouTubeVideo('czLlSVQgXB0')

Importing modules#

import xarray as xr # For reading data from a NetCDF file
import matplotlib.pyplot as plt # For plotting the data
import cartopy.crs as ccrs # For plotting maps
from siphon.catalog import TDSCatalog # For extracting OPeNDAP URLs from the THREDDs catalogue

Opening and understanding the data#

The data have been published in a CF-NetCDF file. Whilst it is possible to directly download these data, we are not going to do that. The data are served over a THREDDS catalogue:

https://www.ncei.noaa.gov/thredds/catalog/noaa-global-temp-v6/catalog.html

The file path to the dataset is currently (at the time of writing) updated each time the dataset is updated. However, we can click on latest to direct us to the most recent version. https://www.ncei.noaa.gov/thredds/catalog/noaa-global-temp-v6/latest.html

If you click through to access the file, you will find the OPeNDAP Data Access Form. The current URL is below: https://www.ncei.noaa.gov/thredds/dodsC/noaa-global-temp-v6/NOAAGlobalTemp_v6.0.0_gridded_s185001_e202412_c20250106T150253.nc.html

This provides a way of streaming data over the internet so you don’t have to download them to your own computer. Simply copy the Data URL, or remove .html from the URL above. Unfortunately, this URL will change through time, which makes things more difficult. Ideally, this should be fixed so that people can easily build services upon the URL.

Fortunately there is a way to obtain the latest URL in Python. We can use the machine interface to the catalog. This is in XML format. You can paste this into your web browser to view it yourself. https://www.ncei.noaa.gov/thredds/catalog/noaa-global-temp-v6/latest.xml

catalog_url = 'https://www.ncei.noaa.gov/thredds/catalog/noaa-global-temp-v6/latest.xml'
catalog = TDSCatalog(catalog_url)
dataset = list(catalog.datasets.values())[0]
opendap_url = dataset.access_urls['OPENDAP']
opendap_url
'https://www.ncei.noaa.gov/thredds/dodsC/noaa-global-temp-v6/NOAAGlobalTemp_v6.0.0_gridded_s185001_e202501_c20250206T150119.nc'

Now let’s access the data using xarray.

xrds = xr.open_dataset(opendap_url)
xrds
<xarray.Dataset> Size: 22MB
Dimensions:  (time: 2101, lat: 36, lon: 72, z: 1)
Coordinates:
  * time     (time) datetime64[ns] 17kB 1850-01-01 1850-02-01 ... 2025-01-01
  * lat      (lat) float32 144B -87.5 -82.5 -77.5 -72.5 ... 72.5 77.5 82.5 87.5
  * lon      (lon) float32 288B 2.5 7.5 12.5 17.5 ... 342.5 347.5 352.5 357.5
  * z        (z) float32 4B 0.0
Data variables:
    anom     (time, z, lat, lon) float32 22MB ...
Attributes: (12/66)
    Conventions:                     CF-1.6, ACDD-1.3
    title:                           NOAA Merged Land Ocean Global Surface Te...
    summary:                         NOAAGlobalTemp is a merged land-ocean su...
    institution:                     DOC/NOAA/NESDIS/National Centers for Env...
    id:                               gov.noaa.ncdc:C00934 
    naming_authority:                 gov.noaa.ncei 
    ...                              ...
    time_coverage_duration:          P175Y1M
    references:                      Vose, R. S., et al., 2012: NOAAs merged ...
    climatology:                     Climatology is based on 1971-2000 monthl...
    acknowledgment:                  The NOAA Global Surface Temperature Data...
    date_modified:                   2025-02-06T20:01:21Z
    date_issued:                     2025-02-06T20:01:21Z

The data have 4 dimensions, time, lat, lon and z. However, z has a length of 1, simply indicating that the data are at the surface. So the anom variable is essentially a 3D array of values. Each variable has metadata associated it, and the dataset as a whole has 66 global attributes.

Let’s have a look at the variable attributes for the anom variable for example.

xrds['anom'].attrs
{'long_name': 'Global Temperature Anomalies',
 'standard_name': 'surface_temperature_anomaly',
 'coverage_content_type': 'physicalMeasurement',
 'units': 'degrees C',
 'valid_min': -40.0,
 'valid_max': 40.0}

The standard_name is taken from the CF standard name table, which can be found at:

https://cfconventions.org/Data/cf-standard-names/current/build/cf-standard-name-table.html

Standard names are accompanied by a description for the data. The data provider should have carefully read the description upon selecting the standard name, and the data user can also read the description, so the data user and provider share some common understanding about what the data are. This also makes the variable machine-readable. The description for surface_temperature_anomaly is:

The surface called “surface” means the lower boundary of the atmosphere. “anomaly” means difference from climatology. The surface temperature is the (skin) temperature at the interface, not the bulk temperature of the medium above or below. It is strongly recommended that a variable with this standard name should have the attribute units_metadata=”temperature: difference”, meaning that it refers to temperature differences and implying that the origin of the temperature scale is irrelevant, because it is essential to know whether a temperature is on-scale or a difference in order to convert the units correctly (cf. https://cfconventions.org/cf-conventions/cf-conventions.html#temperature-units).

Writing the data to a CSV file#

You can write all or a subset of the data to a pandas dataframe that you can export as a CSV file.

df = xrds['anom'].to_dataframe()
df.head()
anom
time z lat lon
1850-01-01 0.0 -87.5 2.5 -0.890190
7.5 -1.231461
12.5 0.498334
17.5 -0.632899
22.5 1.512975
df.to_csv('global_surface_temperature_anomalies.csv')

Plotting the data for one month#

Firstly, we need to isolate a single month of data for a desired date. The time coordinate has values for the 1st day of each month.

xrds['time'].values
array(['1850-01-01T00:00:00.000000000', '1850-02-01T00:00:00.000000000',
       '1850-03-01T00:00:00.000000000', ...,
       '2024-11-01T00:00:00.000000000', '2024-12-01T00:00:00.000000000',
       '2025-01-01T00:00:00.000000000'], dtype='datetime64[ns]')

So to select data from one month:

desired_date = '2022-11-01'
data_for_desired_date = xrds.sel(time=desired_date)

If you accidentally select a date that isn’t present in the time series, you can use method = 'nearest' to select the data from the nearest timestamp to the date you provide. Other methods can also be used, you can read about this here:

https://docs.xarray.dev/en/latest/generated/xarray.Dataset.sel.html#xarray-dataset-sel

data_for_desired_date = xrds.sel(time=desired_date, method='nearest')

We can very quickly plot the data.

data_for_desired_date['anom'].plot()
<matplotlib.collections.QuadMesh at 0x7f2b2e66b790>
_images/67a2f64976cc11188f782eb65944a7f52678388e1290fffe409adaea21d16ca7.png

Without the coastlines these data are difficult to interpret. Let’s now explore how to improve this plot and perhaps use some different projections.

Full working example#

Below is a full working example you can copy and play with.

import xarray as xr
import matplotlib.pyplot as plt
import cartopy.crs as ccrs

desired_date = '2024-11-01'  # Date of interest
projection = ccrs.Mollweide()  # Map projection for visualisation https://scitools.org.uk/cartopy/docs/v0.15/crs/projections.html
transform = ccrs.PlateCarree()  # Data coordinate reference system 
cmap = 'seismic'  # Select from diverging colour maps at https://matplotlib.org/stable/users/explain/colors/colormaps.html#diverging
missing_values_colour = 'black'  # Colour to fill areas outside the data extent

# Set up the figure and axes for plotting
fig = plt.figure(figsize=(16, 8)) 
ax = plt.axes(projection=projection)

# Load the data from the remote THREDDS server
catalog_url = 'https://www.ncei.noaa.gov/thredds/catalog/noaa-global-temp-v6/latest.xml'
catalog = TDSCatalog(catalog_url)
dataset = list(catalog.datasets.values())[0]
opendap_url = dataset.access_urls['OPENDAP']
xrds = xr.open_dataset(opendap_url)

# Select the data for the desired date (nearest available date if exact match isn't found)
data_for_desired_date = xrds.sel(time=desired_date, method='nearest')

# Compute the colour map range (vmin, vmax) by ignoring NaN values
vmin = xrds['anom'].min(skipna=True).compute()
vmax = xrds['anom'].max(skipna=True).compute()
abs_max = max(abs(vmin), abs(vmax))  # Use the maximum absolute value for the colour map range

# Plot the temperature anomaly data for the selected date
data_for_desired_date['anom'].plot(
    ax=ax,
    transform=transform,  # Reproject the data from Plate Carree to the map projection
    vmin=-abs_max,
    vmax=abs_max, 
    cmap=cmap
)

# Add background to areas outside the data extent (areas without data)
xmin, xmax, ymin, ymax = ax.get_extent(transform)
ax.add_patch(plt.Rectangle(
    (xmin, ymin), xmax - xmin, ymax - ymin,  # Create a rectangle to cover the areas outside the data
    facecolor=missing_values_colour,  # Fill the rectangle with the chosen colour
    transform=transform,  # Apply the correct projection to the rectangle
    zorder=-1  # Ensure the rectangle is drawn behind the data
))

ax.coastlines()  # Draw coastlines on the map

plt.savefig('Global_surface_temperature_anomalies.png')
plt.show()
_images/bb498d4f779dc9cd39cd19884de99d411c427d011c86b7823b8f94cccb7fd65e.png

Zooming in on area of interest#

Let’s now provide a latitude and longitude range to zoom in on.

If you have an xarray object, for example:

xrds = xr.open_dataset(opendap_url)
desired_date = '2024-11-01'
data_for_desired_date = xrds.sel(time=desired_date, method='nearest')
data_for_desired_date
<xarray.Dataset> Size: 11kB
Dimensions:  (lat: 36, lon: 72, z: 1)
Coordinates:
    time     datetime64[ns] 8B 2024-11-01
  * lat      (lat) float32 144B -87.5 -82.5 -77.5 -72.5 ... 72.5 77.5 82.5 87.5
  * lon      (lon) float32 288B 2.5 7.5 12.5 17.5 ... 342.5 347.5 352.5 357.5
  * z        (z) float32 4B 0.0
Data variables:
    anom     (z, lat, lon) float32 10kB ...
Attributes: (12/66)
    Conventions:                     CF-1.6, ACDD-1.3
    title:                           NOAA Merged Land Ocean Global Surface Te...
    summary:                         NOAAGlobalTemp is a merged land-ocean su...
    institution:                     DOC/NOAA/NESDIS/National Centers for Env...
    id:                               gov.noaa.ncdc:C00934 
    naming_authority:                 gov.noaa.ncei 
    ...                              ...
    time_coverage_duration:          P175Y1M
    references:                      Vose, R. S., et al., 2012: NOAAs merged ...
    climatology:                     Climatology is based on 1971-2000 monthl...
    acknowledgment:                  The NOAA Global Surface Temperature Data...
    date_modified:                   2025-02-06T20:01:21Z
    date_issued:                     2025-02-06T20:01:21Z

Let’s first take a subset of that for our desired range

lat_range = slice(36, 74)
lon_range = slice(0, 45)
data_aoi = data_for_desired_date.sel(lat=lat_range, lon=lon_range)
data_aoi
<xarray.Dataset> Size: 368B
Dimensions:  (lat: 8, lon: 9, z: 1)
Coordinates:
    time     datetime64[ns] 8B 2024-11-01
  * lat      (lat) float32 32B 37.5 42.5 47.5 52.5 57.5 62.5 67.5 72.5
  * lon      (lon) float32 36B 2.5 7.5 12.5 17.5 22.5 27.5 32.5 37.5 42.5
  * z        (z) float32 4B 0.0
Data variables:
    anom     (z, lat, lon) float32 288B ...
Attributes: (12/66)
    Conventions:                     CF-1.6, ACDD-1.3
    title:                           NOAA Merged Land Ocean Global Surface Te...
    summary:                         NOAAGlobalTemp is a merged land-ocean su...
    institution:                     DOC/NOAA/NESDIS/National Centers for Env...
    id:                               gov.noaa.ncdc:C00934 
    naming_authority:                 gov.noaa.ncei 
    ...                              ...
    time_coverage_duration:          P175Y1M
    references:                      Vose, R. S., et al., 2012: NOAAs merged ...
    climatology:                     Climatology is based on 1971-2000 monthl...
    acknowledgment:                  The NOAA Global Surface Temperature Data...
    date_modified:                   2025-02-06T20:01:21Z
    date_issued:                     2025-02-06T20:01:21Z

This can be more complicated if you need to handled wrapped longitude ranges. You can do this instead.

data_aoi = data_for_desired_date.sel(
    lat=slice(36, 74), lon=((data_for_desired_date.lon >= 335) | (data_for_desired_date.lon <= 45))
)
data_aoi['lon'].values
array([  2.5,   7.5,  12.5,  17.5,  22.5,  27.5,  32.5,  37.5,  42.5,
       337.5, 342.5, 347.5, 352.5, 357.5], dtype=float32)

We can then proceed to plotting the data as normal.

fig = plt.figure(figsize=(16, 8))
projection = ccrs.EuroPP()
ax = plt.axes(projection=projection)

data_aoi['anom'].plot(
    ax=ax,
    transform=transform,
    vmin=-abs_max,
    vmax=abs_max, 
    cmap=cmap
)

ax.coastlines()  # Draw coastlines on the map
plt.show()
_images/559235e7e120575981beb5c480eca2c83ef4899e4646dc4cfcaf5b81329079e7.png

Full working example#

import xarray as xr
import matplotlib.pyplot as plt
import cartopy.crs as ccrs

desired_date = '2024-11-01'  # Date of interest
projection = ccrs.EuroPP()  # Map projection for visualisation https://scitools.org.uk/cartopy/docs/v0.15/crs/projections.html
transform = ccrs.PlateCarree()  # Data coordinate reference system 
cmap = 'seismic'  # Select from diverging colour maps at https://matplotlib.org/stable/users/explain/colors/colormaps.html#diverging
missing_values_colour = 'black'  # Colour to fill areas outside the data extent

# Set up the figure and axes for plotting
fig = plt.figure(figsize=(16, 8)) 
ax = plt.axes(projection=projection)

# Load the data from the remote THREDDS server
catalog_url = 'https://www.ncei.noaa.gov/thredds/catalog/noaa-global-temp-v6/latest.xml'
catalog = TDSCatalog(catalog_url)
dataset = list(catalog.datasets.values())[0]
opendap_url = dataset.access_urls['OPENDAP']
xrds = xr.open_dataset(opendap_url)

# Select the data for the desired date (nearest available date if exact match isn't found)
data_for_desired_date = xrds.sel(time=desired_date, method='nearest')

# Selecting data around Europe
data_aoi = data_for_desired_date.sel(
    lat=slice(36, 74), lon=((data_for_desired_date.lon >= 335) | (data_for_desired_date.lon <= 45))
)
data_aoi['lon'].values

# Compute the colour map range (vmin, vmax) by ignoring NaN values
vmin = xrds['anom'].min(skipna=True).compute()
vmax = xrds['anom'].max(skipna=True).compute()
abs_max = max(abs(vmin), abs(vmax))  # Use the maximum absolute value for the colour map range

# Plot the temperature anomaly data for the selected date
data_aoi['anom'].plot(
    ax=ax,
    transform=transform,  # Reproject the data from Plate Carree to the map projection
    vmin=-abs_max,
    vmax=abs_max, 
    cmap=cmap
)

# Add background to areas outside the data extent (areas without data)
xmin, xmax, ymin, ymax = ax.get_extent(transform)
ax.add_patch(plt.Rectangle(
    (xmin, ymin), xmax - xmin, ymax - ymin,  # Create a rectangle to cover the areas outside the data
    facecolor=missing_values_colour,  # Fill the rectangle with the chosen colour
    transform=transform,  # Apply the correct projection to the rectangle
    zorder=-1  # Ensure the rectangle is drawn behind the data
))

ax.coastlines()  # Draw coastlines on the map
plt.savefig(f'surface_temperature_anomalies_europe.png', transparent=True)
plt.show()
_images/0c96d6ec29da4f6fdc6cd19c78e5ebc3ee936a4b890de289de96e79152cf07bf.png

Mean annual temperature anomaly#

We can compute the mean annual temperature from the monthly averages. Let’s first select a year, then compute the mean anomalies for that year.

# Select one year of data and then find the mean annual temperature anomalies
year = 2019
data_for_desired_year = xrds.sel(time=slice(f'{year}-01-01', f'{year}-12-31'))
mean_annual_anom = data_for_desired_year['anom'].mean(dim='time')
mean_annual_anom
<xarray.DataArray 'anom' (z: 1, lat: 36, lon: 72)> Size: 10kB
array([[[-0.6685675 , -0.32531917, -0.7419405 , ..., -0.03427446,
         -0.6027041 , -0.41491255],
        [-0.45025483, -0.75348425, -0.6525995 , ..., -0.3694527 ,
         -0.5254295 , -0.7343363 ],
        [-0.60521007, -0.5783654 , -0.5029815 , ..., -0.56523937,
         -0.93570787, -0.9395005 ],
        ...,
        [ 1.4957752 ,  1.5261837 ,  2.3272908 , ...,  1.0400597 ,
          0.9404378 ,  1.0355312 ],
        [ 1.6025678 ,  1.7646564 ,  1.5928353 , ...,  2.3586018 ,
          1.9159775 ,  1.82998   ],
        [ 2.1685798 ,  2.2421184 ,  2.2288802 , ...,  2.7987478 ,
          2.5091991 ,  2.3652189 ]]], dtype=float32)
Coordinates:
  * lat      (lat) float32 144B -87.5 -82.5 -77.5 -72.5 ... 72.5 77.5 82.5 87.5
  * lon      (lon) float32 288B 2.5 7.5 12.5 17.5 ... 342.5 347.5 352.5 357.5
  * z        (z) float32 4B 0.0

We can then plot the data as done previously. Let’s try a different projection to mix things up a bit.

fig = plt.figure(figsize=(16, 8))
projection = ccrs.InterruptedGoodeHomolosine()
ax = plt.axes(projection=projection)

mean_annual_anom.plot(
    ax=ax,
    transform=transform,
    vmin=-abs_max,
    vmax=abs_max, 
    cmap=cmap
)

ax.coastlines()  # Draw coastlines on the map
plt.show()
_images/551d03194a3f6dff09d0c1f66fbe07e5b34a04b7067fed1ca799bcbcbb1f84c9.png

Full working example#

import xarray as xr
import matplotlib.pyplot as plt
import cartopy.crs as ccrs

year = 2024
projection = ccrs.InterruptedGoodeHomolosine()  # Map projection for visualisation https://scitools.org.uk/cartopy/docs/v0.15/crs/projections.html
transform = ccrs.PlateCarree()  # Data coordinate reference system 
cmap = 'seismic'  # Select from diverging colour maps at https://matplotlib.org/stable/users/explain/colors/colormaps.html#diverging
missing_values_colour = 'black'  # Colour to fill areas outside the data extent

# Set up the figure and axes for plotting
fig = plt.figure(figsize=(16, 8)) 
ax = plt.axes(projection=projection)

# Load the data from the remote THREDDS server
catalog_url = 'https://www.ncei.noaa.gov/thredds/catalog/noaa-global-temp-v6/latest.xml'
catalog = TDSCatalog(catalog_url)
dataset = list(catalog.datasets.values())[0]
opendap_url = dataset.access_urls['OPENDAP']
xrds = xr.open_dataset(opendap_url)

year = 2019
data_for_desired_year = xrds.sel(time=slice(f'{year}-01-01', f'{year}-12-31'))
mean_annual_anom = data_for_desired_year['anom'].mean(dim='time')

# Compute the colour map range (vmin, vmax) by ignoring NaN values
vmin = xrds['anom'].min(skipna=True).compute()
vmax = xrds['anom'].max(skipna=True).compute()
abs_max = max(abs(vmin), abs(vmax))  # Use the maximum absolute value for the colour map range

# Plot the temperature anomaly data for the selected date
mean_annual_anom.plot(
    ax=ax,
    transform=transform,  # Reproject the data from Plate Carree to the map projection
    vmin=-abs_max,
    vmax=abs_max, 
    cmap=cmap
)

# Add background to areas outside the data extent (areas without data)
xmin, xmax, ymin, ymax = ax.get_extent(transform)
ax.add_patch(plt.Rectangle(
    (xmin, ymin), xmax - xmin, ymax - ymin,  # Create a rectangle to cover the areas outside the data
    facecolor=missing_values_colour,  # Fill the rectangle with the chosen colour
    transform=transform,  # Apply the correct projection to the rectangle
    zorder=-1  # Ensure the rectangle is drawn behind the data
))

ax.coastlines()  # Draw coastlines on the map

plt.show()
_images/79b6206fd916163f7de98787c625a6c5113f0ba343b629dbbdc4d61e4aca38b1.png