How to Read MRMS Rainfall Estimates from GRIB2 in Python

On this page

Problem statement

NOAA's Multi-Radar Multi-Sensor system (MRMS) merges every radar in the United States with rain gauges, satellite and model data into rainfall grids at 1 km every hour, and publishes them free on AWS. It is the quickest way to get a defensible rainfall map for any US storm. The files are GRIB2, gzipped, on a 7,000 × 3,500 grid with longitudes from 230° to 300°; xarray opens them only to call the variable "unknown"; negative values are codes; and the product names hide differences — radar-only against gauge-corrected, which 24 hours — that change the answer by a factor of two.

This page reads the MRMS 24-hour estimates for Hurricane Beryl's landfall in Texas on 8 July 2024 and shows how to get the field, the units and the time window right.

Quick answer

Download the gzipped GRIB2 from the public bucket, decompress, open with cfgrib, mask the negative codes and convert longitudes:

key = "CONUS/MultiSensor_QPE_24H_Pass2_00.00/20240709/MRMS_MultiSensor_QPE_24H_Pass2_00.00_20240709-000000.grib2.gz"
grib = fetch_mrms(key)
ds = xr.open_dataset(grib, engine="cfgrib", decode_timedelta=True, backend_kwargs={"indexpath": ""})
qpe = ds["unknown"].where(ds["unknown"] >= 0)                       # -3 = no coverage
qpe = qpe.assign_coords(longitude=qpe.longitude - 360)
tx = qpe.sel(latitude=slice(31.6, 27.8), longitude=slice(-98, -93.5))
top = tx.where(tx == tx.max(), drop=True)
print(ds["unknown"].shape, "grid;", f"24 h ending {str(ds.valid_time.values)[:16]} UTC: max {float(tx.max()):.0f} mm at "
      f"{float(top.latitude[0]):.2f} N, {float(top.longitude[0]):.2f} E")
(3500, 7000) grid; 24 h ending 2024-07-09T00:00 UTC: max 369 mm at 28.44 N, -94.87 E
Map of south-east Texas with MRMS multi-sensor 24-hour rainfall ending 12 UTC on 9 July 2024, heaviest over Houston's northern suburbs and a band offshore, Beryl's track dashed, and about 100 rain gauges coloured on the same scale. Map of south-east Texas with MRMS multi-sensor 24-hour rainfall ending 12 UTC on 9 July 2024, heaviest over Houston's northern suburbs and a band offshore, Beryl's track dashed, and about 100 rain gauges coloured on the same scale.
MRMS MultiSensor QPE Pass2 via NOAA's AWS archive; gauges GHCN-Daily (CoCoRaHS and COOP); track IBTrACS v04r01.

Find the product, open it, mask the codes, pick the window

From an MRMS file on AWS to a rainfall map in millimetres. from a .grib2.gz to millimetrespick productPass2, RadarOnly…gunzip +cfgribvariable "unknown"mask −3no coveragelon − 360230–300 → −130…−60check thewindowstamp = end timeThe file carries the numbers; the product name carries their meaning spatialworkflow.io
Five steps from the file to millimetres.

1. Find the product

The bucket noaa-mrms-pds is organised as CONUS/<product>/<YYYYMMDD>/MRMS_<product>_<YYYYMMDD-HHMMSS>.grib2.gz. For rainfall, the main choices are:

listing = requests.get("https://noaa-mrms-pds.s3.amazonaws.com/", params={"list-type": 2, "prefix": "CONUS/", "delimiter": "/"}, timeout=60).text
products = re.findall(r"<Prefix>CONUS/([^<]*QPE[^<]*)/</Prefix>", listing)
print(len(products), "QPE products, e.g.", [p for p in products if "24H" in p])
34 QPE products, e.g. ['FLASH_QPE_ARI24H_00.00', 'MultiSensor_QPE_24H_Pass1_00.00', 'MultiSensor_QPE_24H_Pass2_00.00', 'RadarOnly_QPE_24H_00.00']

RadarOnly is radar with no gauge correction; MultiSensor Pass1 adds gauges available about an hour after the fact, Pass2 those available after two. The hour in the file name is the end of the accumulation window.

2. Open it, and know why the variable is "unknown"

MRMS uses a local GRIB2 parameter table that eccodes does not ship, so the variable name comes out as unknown. The metadata still tells you what it is:

a = ds["unknown"]
print({"centre": ds.attrs.get("GRIB_centre"), "grid": a.attrs.get("GRIB_gridType"), "step": a.attrs.get("GRIB_iDirectionIncrementInDegrees")})
print(f"latitude {float(a.latitude.min()):.3f}-{float(a.latitude.max()):.3f}, longitude {float(a.longitude.min()):.3f}-{float(a.longitude.max()):.3f}")
{'centre': '161', 'grid': 'regular_ll', 'step': 0.01}
latitude 20.005-54.995, longitude 230.005-299.995

Centre 161 is NOAA/OAR; the grid is regular latitude–longitude at 0.01°, longitudes 0–360. Units are millimetres for the QPE products — the file says "unknown" for those too.

3. Mask the codes

Negative values are not rain:

vals, counts = np.unique(a.values[a.values < 0], return_counts=True)
print(dict(zip(vals.tolist(), (counts / a.size).round(3).tolist())), "of the grid")
{-3.0: 0.062} of the grid

−3 marks cells with no radar coverage; left in, they turn a total into a deficit wherever they fall in an average.

4. Get the window right

A "24-hour" total ending at 00 UTC and one ending at 12 UTC on the same date are different storms' worth of rain when the heaviest falls cross either boundary. For Beryl, which made landfall at about 08:40 UTC on 8 July:

for stamp in ("20240709-000000", "20240709-120000"):
    f = fetch_mrms(f"CONUS/MultiSensor_QPE_24H_Pass2_00.00/{stamp[:8]}/MRMS_MultiSensor_QPE_24H_Pass2_00.00_{stamp}.grib2.gz")
    q = xr.open_dataset(f, engine="cfgrib", decode_timedelta=True, backend_kwargs={"indexpath": ""})["unknown"]
    q = q.where(q >= 0).sel(latitude=slice(31.6, 27.8), longitude=slice(262, 266.5))
    print(f"24 h ending {stamp[:8]} {stamp[9:11]} UTC: max {float(q.max()):5.0f} mm, cells at 150 mm or more: {int((q >= 150).sum()):6,}")
24 h ending 20240709 00 UTC: max   369 mm, cells at 150 mm or more: 12,345
24 h ending 20240709 12 UTC: max   182 mm, cells at 150 mm or more:    262
Two MRMS 24-hour rainfall maps for the same date: ending 00 UTC 9 July, with up to 369 mm along and east of Beryl's landfall; ending 12 UTC, with a maximum of 182 mm and the landfall rain missing. Two MRMS 24-hour rainfall maps for the same date: ending 00 UTC 9 July, with up to 369 mm along and east of Beryl's landfall; ending 12 UTC, with a maximum of 182 mm and the landfall rain missing.
Same product, same date, different 24 hours. The red dot is the landfall at 08:40 UTC.

The window ending at 00 UTC holds the landfall; the one ending at 12 UTC begins three hours after it and holds almost none of the heaviest totals — 262 cells at 150 mm or more against 12,345. Match the window to the question — and to the gauges you compare with, which usually report a day ending at 7 am local time.

5. Radar-only or gauge-corrected?

ro = xr.open_dataset(fetch_mrms("CONUS/RadarOnly_QPE_24H_00.00/20240709/MRMS_RadarOnly_QPE_24H_00.00_20240709-000000.grib2.gz"),
                     engine="cfgrib", decode_timedelta=True, backend_kwargs={"indexpath": ""})["unknown"]
ro = ro.where(ro >= 0).sel(latitude=slice(31.6, 27.8), longitude=slice(262, 266.5))
ms = qpe.sel(latitude=slice(31.6, 27.8), longitude=slice(-98, -93.5))
top = ro.where(ro == ro.max(), drop=True)
print(f"radar-only max {float(ro.max()):.0f} mm at {float(top.latitude[0]):.2f} N, {float(top.longitude[0]) - 360:.2f} E; "
      f"multi-sensor max {float(ms.max()):.0f} mm")
radar-only max 551 mm at 28.02 N, -97.75 E; multi-sensor max 369 mm

Radar-only places its maximum far from the storm's centre, at a value the gauge-corrected product does not support — the kind of artefact (bright band, hail, anomalous propagation) that the gauge correction exists to remove. For totals, use MultiSensor Pass2 unless you need radar alone.

A clipped GeoTIFF and area above a threshold

The data used on this page

NOAA Multi-Radar Multi-Sensor (MRMS) 24-hour QPE, MultiSensor Pass2 and RadarOnly, ending 00 and 12 UTC on 9 July 2024, from the NOAA MRMS archive on AWS; Beryl's landfall time from IBTrACS v04r01.

import gzip
import re
import shutil
from pathlib import Path

import numpy as np
import requests
import xarray as xr

Path("data").mkdir(exist_ok=True)

def fetch_mrms(key):
    """Download a gzipped MRMS GRIB2 from AWS and return the path of the decompressed file."""
    gz = Path("data") / key.split("/")[-1]
    out = gz.with_suffix("")
    if not out.exists():
        gz.write_bytes(requests.get("https://noaa-mrms-pds.s3.amazonaws.com/" + key, timeout=300).content)
        with gzip.open(gz) as f, open(out, "wb") as o:
            shutil.copyfileobj(f, o)
    return str(out)

print("ready")
ready

Example 1: save a clipped GeoTIFF

import rioxarray  # noqa: F401  (registers .rio)
clip = tx.rename({"longitude": "x", "latitude": "y"}).rio.write_crs("EPSG:4326")
clip.rio.to_raster("data/beryl_mrms_24h_20240709_00z.tif", compress="deflate")
print(clip.shape, round(Path("data/beryl_mrms_24h_20240709_00z.tif").stat().st_size / 1e6, 2), "MB")
(380, 450) 0.32 MB

Example 2: area above a threshold

On a 0.01° grid, cell area shrinks with latitude; for area statistics, weight by it:

cell_km2 = (111.32 * 0.01) ** 2 * np.cos(np.radians(tx.latitude))
area = float((cell_km2 * (tx >= 150)).sum())
print(f"area with 150 mm or more in the 24 h ending 00 UTC 9 July: {area:,.0f} km²")
area with 150 mm or more in the 24 h ending 00 UTC 9 July: 13,373 km²

MRMS is a mosaic of every radar

MRMS is not one radar's rainfall estimate but a mosaic: every radar's reflectivity, quality-controlled and merged onto one grid, converted to rain rate with relationships chosen by precipitation type, accumulated over time and — in the MultiSensor products — adjusted towards rain gauges. The GRIB2 files carry the result with minimal metadata, because the parameter definitions live in a local table rather than the WMO one.

MRMS 24-hour rainfall products for Beryl’s landfall compared. three products, one stormproduct, window endmax (SE Texas)cells ≥ 150 mmMultiSensor Pass2, 00 UTC 9 Jul369 mm12,345MultiSensor Pass2, 12 UTC 9 Jul182 mm262RadarOnly, 00 UTC 9 Jul551 mm, far inland17,870Window and gauge correction each change the answer by a factor of two spatialworkflow.io
Window and gauge correction both matter.

That leaves three things for the user. Identify the field from the product name, because the file calls it "unknown". Treat negative values as codes, because −3 is "no coverage", not "minus three millimetres". And read the time stamp as the end of the window, choosing the window to match the event and any data it will be compared with. Beryl shows how much the last one matters: the same "24-hour" product gave a maximum of 369 mm or 182 mm depending only on when the 24 hours ended.

Unknown variable names, coverage codes and 0-360 longitudes

Checks before using an MRMS rainfall grid. before trusting an MRMS totalProduct namednot "unknown"Negative codes masked−3 = no coverageLongitudes converted0–360 to ±180Window end time checkedagainst the eventGauge-corrected if possiblePass2 over RadarOnlyEach of these changed a number on this page spatialworkflow.io
Five checks before using an MRMS total.
  • The variable is called unknown; it is the product named in the file.
  • −3 means no coverage; mask negatives.
  • Longitudes run 230–300; subtract 360.
  • The time stamp is the window's end.
  • Pass2 over Pass1 over RadarOnly for totals; radar-only can carry large artefacts.
  • Cell area varies with latitude on the 0.01° grid.

FAQ

How do I open MRMS GRIB2 files in Python?

Download the .grib2.gz from the noaa-mrms-pds bucket, decompress it, and open with xarray.open_dataset(path, engine="cfgrib"); the field appears as unknown.

Why is the MRMS variable called unknown?

MRMS uses a local GRIB2 parameter table that eccodes does not include, so cfgrib cannot name it. The product name in the file name tells you what it is.

What does -3 mean in MRMS data?

No radar coverage. Mask all negative values before computing statistics.

Which MRMS rainfall product should I use?

MultiSensor QPE Pass2 for gauge-corrected totals; RadarOnly when you need a radar-only estimate; and the accumulation window whose end time matches your question.