usdata

NOAA · HURDAT2 Atlantic and Pacific Best Tracks

Tropical cyclone best tracks

National Hurricane Center best-track database: six-hourly position, intensity, pressure, and wind radii for Atlantic (since 1851) and northeast/north-central Pacific (since 1949) tropical cyclones.

HURDAT2 fixed-format textSince v0.12
The walkthrough's first look at tropical cyclone best tracks

At a glance

Spatial
Best-track positions in tenths of a degree
Time step
Six-hourly synoptic times, plus asynoptic records for landfalls and extremes
Updates
Annually, after each season's post-storm reanalysis; the 2025 season was added on 2026-02-27
Files
HURDAT2 fixed-format text
Selection
The newest or a named revision of one whole basin file; filter track points locally
You provide
Optional basin (atlantic or pacific) and revision date; no dates or geographic filters
Full description

National Hurricane Center best-track database: six-hourly position, intensity, pressure, and wind radii for Atlantic (since 1851) and northeast/north-central Pacific (since 1949) tropical cyclones. One fixed-format text file per basin, revised after each season; a basin parameter picks the file and the newest revision wins unless a revision date names another. There is no query interface, so dates and geographic filters are rejected and the local reader turns the whole file into one row per track point.

Quick start

Terminal

python -m pip install "usdata[pandas]"
usdata fetch noaa:hurdat2 -p basin=atlantic -p revision=2026-02-27

Python

from usdata import build_query, fetch, get

items = fetch(
    get("noaa:hurdat2"),
    build_query(
        basin="atlantic",
        revision="2026-02-27",
    ),
)
data = items[0].open()

Manifest

# dataset.yaml, then: usdata pull dataset.yaml
name: atlantic-best-tracks
sources:
  - dataset: noaa:hurdat2
    # One whole basin file. HURDAT2 has no query interface, so dates and
    # geographic filters are rejected; filter the parsed track table locally.
    params:
      basin: atlantic
      # The 2026-09-12 revision has two upstream typos the reader refuses to guess at.
      revision: 2026-02-27

The same query the walkthrough below ran. The pandas extra opens the files.

Saved results from a run against the live service; the notebook records when it ran and the checksums of what it read. Run it yourself.

HURDAT2 is the National Hurricane Center's best-track database: after each season, forecasters reanalyse every tropical and subtropical cyclone and record its centre position, maximum sustained wind, central pressure, and, since 2004, wind radii, every six hours plus extra points at landfalls and peaks. The Atlantic record starts in 1851 and the northeast and north-central Pacific in 1949. The NHC publishes each basin as one fixed-format text file and replaces it with a newly dated revision after each season; there is no query interface.

This walkthrough pulls the whole Atlantic file in its 2026-02-27 revision (1851 to 2025) and draws the 2024 season. It needs usdata[pandas] and matplotlib, and downloads one 7 MB file in a few seconds.

from datetime import UTC, datetime
from pathlib import Path

import matplotlib.pyplot as plt
import pandas as pd

import usdata
from usdata import cite_lockfile, pull, verify

# One figure style for every usdata notebook, so previews look alike.
plt.rcParams.update(
    {
        "figure.figsize": (8, 4.5),
        "figure.dpi": 120,
        "axes.spines.top": False,
        "axes.spines.right": False,
        "axes.grid": True,
        "grid.alpha": 0.3,
        "font.size": 10,
    }
)
manifest = Path("dataset.yaml")
print("Executed (UTC):", datetime.now(UTC).isoformat(timespec="seconds"))
print(f"usdata {usdata.__version__}; pandas {pd.__version__}")
Executed (UTC): 2026-09-24T06:07:13+00:00
usdata 0.26.0; pandas 3.0.6

Select

There is nothing to subset on the server, so the manifest names only a basin and a revision. basin is atlantic or pacific; dates, places, and variables are rejected rather than ignored, because none of them would change which bytes arrive. Filter the parsed table locally instead.

Without revision, a first pull takes the newest file. This manifest names the 2026-02-27 revision because the newest one, 2026-09-12, has two upstream typos the reader refuses to guess at. Once pulled, the lockfile pins the file either way.

print(manifest.read_text())
name: atlantic-best-tracks
sources:
  - dataset: noaa:hurdat2
    # One whole basin file. HURDAT2 has no query interface, so dates and
    # geographic filters are rejected; filter the parsed track table locally.
    params:
      basin: atlantic
      # The 2026-09-12 revision has two upstream typos the reader refuses to guess at.
      revision: 2026-02-27

What arrives

The first pull downloads one text file and writes dataset.lock.json beside the manifest, pinning its checksum. The filename carries the data span and the revision date (02272026 is 27 February 2026, written month first).

result = pull(manifest)
(item,) = result.fetched
print("File:", item.path.name)
print("Bytes:", item.provenance.size)
print("Source:", item.provenance.source_url)
print("Retrieved (UTC):", item.provenance.retrieved_at.isoformat(timespec="seconds"))
print("Checksum:", item.provenance.checksum)
File: hurdat2-1851-2025-02272026.txt
Bytes: 7082381
Source: https://www.nhc.noaa.gov/data/hurdat/hurdat2-1851-2025-02272026.txt
Retrieved (UTC): 2026-09-24T06:07:14+00:00
Checksum: sha256:1b9b0c7beed5b4505838658b1d30e159fc84330c60891a58cfcf43ae55c37202

Open

item.open() picks the HURDAT2 reader from the dataset and returns one row per best-track point. time is UTC. latitude and longitude are signed decimal degrees, the source's hemisphere letters turned into signs. max_wind_kt is the maximum sustained one-minute wind in knots and min_pressure_mb the central pressure in millibars. status is the system's type at that point (HU hurricane, TS tropical storm, EX extratropical, and so on), and record_identifier marks special points, L for landfall. The documented missing values (-999, and -99 for an unassigned wind) become NaN.

tracks = item.open()
print(f"{len(tracks):,} track points, {tracks['storm_id'].nunique():,} systems")
print(f"{tracks['time'].min():%Y-%m-%d} to {tracks['time'].max():%Y-%m-%d}")
print("Record identifiers:", tracks["record_identifier"].value_counts().to_dict())
columns = ["storm_id", "name", "time", "record_identifier", "status", "latitude", "longitude"]
tracks[[*columns, "max_wind_kt", "min_pressure_mb"]].tail()
55,605 track points, 2,004 systems
1851-06-25 to 2025-11-01
Record identifiers: {'L': 1175, 'I': 33, 'R': 11, 'P': 10, 'T': 9, 'S': 8, 'C': 5, 'W': 4, 'G': 1}
storm_id name time record_identifier status latitude longitude max_wind_kt min_pressure_mb
55600 AL132025 MELISSA 2025-10-31 06:00:00+00:00 <NA> HU 34.5 -65.5 75.0 972.0
55601 AL132025 MELISSA 2025-10-31 12:00:00+00:00 <NA> EX 37.7 -62.1 75.0 972.0
55602 AL132025 MELISSA 2025-10-31 18:00:00+00:00 <NA> EX 40.6 -58.9 70.0 972.0
55603 AL132025 MELISSA 2025-11-01 00:00:00+00:00 <NA> EX 43.7 -56.0 70.0 971.0
55604 AL132025 MELISSA 2025-11-01 06:00:00+00:00 <NA> EX 46.6 -51.3 65.0 968.0

Wind radii exist only from 2004, and the radius of maximum wind is mostly missing before 2021:

has_radii = tracks.groupby(tracks["time"].dt.year)[["r34_ne_nm", "max_wind_radius_nm"]].count()
print(has_radii.loc[[2003, 2004, 2020, 2021]].to_string())
      r34_ne_nm  max_wind_radius_nm
time                               
2003          0                   2
2004        587                   9
2020        883                   7
2021        602                 602

A first look

The 2024 season, each track point coloured by its Saffir-Simpson category. The file has no coastline, but its 1,175 landfall points from 1851 onward, drawn in grey, trace one.

season = tracks[tracks["time"].dt.year == 2024]
landfalls = tracks[tracks["record_identifier"] == "L"]
scale = [
    (0, 64, "#5b8db8", "Below hurricane"),
    (64, 96, "#f2b134", "Category 1-2"),
    (96, 137, "#d9622b", "Category 3-4"),
    (137, 999, "#8c1d40", "Category 5"),
]
fig, ax = plt.subplots(layout="constrained")
ax.scatter(
    landfalls["longitude"], landfalls["latitude"], s=2, color="#d4d4d4", label="Landfall, 1851-2025"
)
for _, storm in season.groupby("storm_id"):
    ax.plot(storm["longitude"], storm["latitude"], color="#555555", linewidth=0.6)
for low, high, color, label in scale:
    points = season[season["max_wind_kt"].ge(low) & season["max_wind_kt"].lt(high)]
    ax.scatter(points["longitude"], points["latitude"], s=9, color=color, label=label, zorder=3)
labels = {"BERYL": (6, 6), "HELENE": (6, 6), "KIRK": (8, 4), "MILTON": (-8, -20)}
for name, offset in labels.items():
    peak = season.loc[season.loc[season["name"] == name, "max_wind_kt"].idxmax()]
    where = (peak["longitude"], peak["latitude"])
    box = {"boxstyle": "round,pad=0.15", "fc": "white", "ec": "none", "alpha": 0.8}
    ax.annotate(name.title(), where, xytext=offset, textcoords="offset points", bbox=box)
ax.set_xlim(-100, -10)
ax.set_ylim(5, 55)
ax.set_aspect("equal")
ax.set_xlabel("Longitude (degrees east)")
ax.set_ylabel("Latitude (degrees north)")
ax.set_title("HURDAT2 Atlantic best tracks, 2024 season")
ax.legend(loc="lower right", fontsize=8, markerscale=1.5)
plt.show()
Saved plot from Tropical cyclone best tracks
strongest = season.groupby("name").agg(
    max_wind_kt=("max_wind_kt", "max"),
    min_pressure_mb=("min_pressure_mb", "min"),
    landfall_points=("record_identifier", lambda s: (s == "L").sum()),
)
hurricanes = (strongest["max_wind_kt"] >= 64).sum()
print(f"{season['storm_id'].nunique()} systems in 2024; {hurricanes} reached hurricane strength")
beryl = season[(season["name"] == "BERYL") & (season["max_wind_kt"] >= 137)].iloc[0]
where = f"{beryl['latitude']}° N, {-beryl['longitude']}° W"
print(f"Beryl first at category 5: {beryl['time']:%d %b %H:%M} UTC at {where}")
helene = season[(season["name"] == "HELENE") & (season["record_identifier"] == "L")].iloc[0]
print(f"Helene landfall: {helene['time']:%d %b %H:%M} UTC, {helene['max_wind_kt']:.0f} kt")
strongest.sort_values("max_wind_kt", ascending=False).head(6)
18 systems in 2024; 11 reached hurricane strength
Beryl first at category 5: 02 Jul 00:00 UTC at 13.5° N, 64.1° W
Helene landfall: 27 Sep 03:10 UTC, 120 kt
max_wind_kt min_pressure_mb landfall_points
name
MILTON 155.0 895.0 1
BERYL 145.0 932.0 3
KIRK 130.0 928.0 0
HELENE 120.0 939.0 1
RAFAEL 105.0 954.0 1
LESLIE 90.0 970.0 0

Beryl reached category 5 in the Caribbean on 2 July, the earliest in the year any Atlantic hurricane has; Milton reached 155 kt and 895 mb over the Gulf of Mexico in October. Kirk, a category 4, has no landfall point. Helene's single landfall point is its category 4 landfall in Florida's Big Bend, which the storm surge study times against the tide gauge at Cedar Key.

Pin and cite

verify checks the cached file against the lockfile's checksum. Keep the manifest, lockfile, and cached bytes: the NHC replaces this file with a new revision each season, and a checksum cannot recreate a file that is no longer published. pull(manifest, force=True) moves to the current revision on purpose. The citation below is what a methods section needs, and usdata cite dataset.yaml prints the same.

assert verify(manifest) == []
for citation in cite_lockfile(manifest):
    print(citation.as_text())
noaa:hurdat2
  Landsea, C. W. and J. L. Franklin, 2013: Atlantic Hurricane Database Uncertainty and Presentation of a New Database Format. Mon. Wea. Rev., 141, 3576-3592
  homepage: https://www.nhc.noaa.gov/data/#hurdat
  license: US Government Work (public domain)
  terms: https://www.weather.gov/disclaimer
  retrieved: 2026-09-24; 1 checksummed asset (7,082,381 bytes) pinned by usdata 0.26.0
  sources: 1

What was awkward

  • The newest revision, 2026-09-12, has two upstream typos the reader will not guess at, so this manifest has to name the previous revision.
  • There is no way to ask for one season or one storm. Every pull is the whole 7 MB basin file, and all filtering happens after parsing.
  • A landfall row is not one landfall event per storm: a storm crossing several coasts has several, and a landfall point's category is the intensity at that point.
  • The record is not uniform in time. Wind radii start in 2004, the radius of maximum wind in 2021 (with a few hundred earlier Atlantic points back-filled), and the early decades undercount storms and underestimate intensities, so counts and extremes across eras are not directly comparable.
  • The file has no coastline or map, so a map needs another source or, as here, the file's own landfall points.
  • Revision dates in filenames are month first (02272026), and the HURDAT2 guide notes that sorting them as text puts some revisions in the wrong order.

Used in these studies

Reference

Cite as Landsea, C. W. and J. L. Franklin, 2013: Atlantic Hurricane Database Uncertainty and Presentation of a New Database Format. Mon. Wea. Rev., 141, 3576-3592