How to Map a Hurricane Track from IBTrACS in Python

On this page

Problem statement

IBTrACS, the International Best Track Archive for Climate Stewardship, collects every tropical cyclone track from every warning agency into one dataset: position, wind, pressure and much more every three hours, back to the 1840s. It is the starting point for any map of a storm's path, a count of landfalls or a climatology. Its CSV is also 170 columns wide, has a units row under the header, mixes official six-hourly fixes with interpolated three-hourly points, and carries several agencies' estimates of the same wind — so a careless read gets the wrong intensity, the wrong time of landfall or a track that zig-zags.

This page reads Hurricane Beryl (2024) from the North Atlantic file, maps it by Saffir–Simpson category, and extracts landfalls, peak intensity and rapid intensification.

Quick answer

Read the basin file skipping the units row, select the storm by name and season, parse the time, and use one agency's wind consistently:

d = pd.read_csv("data/ibtracs.NA.list.v04r01.csv", skiprows=[1], low_memory=False, keep_default_na=False, na_values=[" ", ""])
b = d[(d.NAME == "BERYL") & (d.SEASON == 2024)].copy()
b["time"] = pd.to_datetime(b.ISO_TIME)
for c in ("LAT", "LON", "USA_WIND", "USA_PRES", "USA_SSHS", "STORM_SPEED"):
    b[c] = pd.to_numeric(b[c])
peak = b.loc[b.USA_WIND.idxmax()]
print(f"{len(b)} points, {b.time.min():%d %b} to {b.time.max():%d %b %Y}; peak {peak.USA_WIND:.0f} kt / {peak.USA_PRES:.0f} hPa "
      f"at {peak.time:%d %b %H:%M} UTC, {peak.LAT:.1f} N {abs(peak.LON):.1f} W")
109 points, 28 Jun to 11 Jul 2024; peak 145 kt / 935 hPa at 02 Jul 06:00 UTC, 14.3 N 65.9 W
Map of the Caribbean, Gulf of Mexico and eastern United States with Hurricane Beryl's track coloured by category: forming east of the Windward Islands, peaking at category 5 south of Puerto Rico, weakening across the Caribbean, crossing Yucatán, and coming ashore in Texas before turning north-east as a post-tropical low; landfalls ringed and labelled. Map of the Caribbean, Gulf of Mexico and eastern United States with Hurricane Beryl's track coloured by category: forming east of the Windward Islands, peaking at category 5 south of Puerto Rico, weakening across the Caribbean, crossing Yucatán, and coming ashore in Texas before turning north-east as a post-tropical low; landfalls ringed and labelled.
IBTrACS v04r01, USA (NHC) winds; coastlines Natural Earth 1:50m.

The right file, the units row, official fixes against interpolation

From the IBTrACS CSV to a clean hurricane track. from 174 columns to one stormbasin CSVNA, 57 MBskip units rowblank = missingstorm by name+ seasonor by SIDone agency’swindUSA_WIND, 1-minfixes vsinterpolated53 of 109 pointsHurricane Beryl, 28 June – 11 July 2024 spatialworkflow.io
Five steps to a clean track.

1. Choose the file

IBTrACS comes per basin (NA, EP, WP, NI, SI, SP, SA) or as one global file. The North Atlantic CSV is about 57 MB; the global one several hundred. Columns fall into groups: common fields (SID, NAME, ISO_TIME, LAT, LON, NATURE), WMO-agency fields (WMO_WIND, WMO_PRES), and each agency's own estimates (USA_, TOKYO_, REUNION_*…).

2. Skip the units row and keep blanks as missing

The second line of the file holds units, not data. Missing values are blank strings, and some columns contain a single space. NCEI rewrites this file every week while storms are active, so the row count below is the copy downloaded on 1 October 2026 (127,390 rows; it had 128,125 by 11 October) — yours will be larger:

print(d.shape, "| units row skipped | first SID:", d.SID.iloc[0])
print("blank USA_WIND in the file:", d.USA_WIND.isna().mean().round(3))
(127390, 174) | units row skipped | first SID: 1851175N26270
blank USA_WIND in the file: 0.148

3. Separate official fixes from interpolated points

Best tracks are issued every six hours (00, 06, 12, 18 UTC), plus special points such as landfalls. IBTrACS interpolates to every three hours and marks which points were interpolated in the IFLAG column:

synoptic = b[b.time.dt.hour.isin([0, 6, 12, 18]) & (b.time.dt.minute == 0)]
special = b[b.USA_RECORD.astype(str).str.strip().isin(["L", "I", "P", "W"])]
print(f"{len(synoptic)} six-hourly fixes, {len(special)} special records, {len(b) - len(synoptic) - len(special)} interpolated 3-hourly points")
print("intervals between points (h):", b.time.diff().dt.total_seconds().div(3600).round(2).value_counts().head(4).to_dict())
53 six-hourly fixes, 4 special records, 52 interpolated 3-hourly points
intervals between points (h): {3.0: 100, 0.33: 2, 2.67: 2, 0.75: 1}

Statistics such as rapid intensification should use the six-hourly fixes, not the interpolated points.

4. Pick one agency's wind

WMO_WIND is the official agency's estimate, but agencies average winds over different periods (1, 3 or 10 minutes). For an Atlantic storm the US agency (NHC) is the official one and uses one-minute winds; keeping to USA_WIND avoids mixing definitions. Saffir–Simpson category (USA_SSHS) is derived from it:

print(b[["USA_WIND", "USA_SSHS"]].groupby("USA_SSHS").USA_WIND.agg(["min", "max", "count"]).to_string())
            min    max  count
USA_SSHS                     
-4         25.0   30.0     17
-1         25.0   33.0      8
 0         35.0   60.0     29
 1         65.0   80.0     11
 2         85.0   95.0      8
 3         98.0  110.0     11
 4        113.0  135.0     18
 5        138.0  145.0      7

5. Find landfalls and rapid intensification

Landfall records have USA_RECORD == "L". Rapid intensification is conventionally a 30-knot increase in 24 hours:

for _, r in b[b.USA_RECORD.astype(str).str.strip() == "L"].iterrows():
    print(f"landfall {r.time:%d %b %H:%M} UTC at {r.LAT:.1f} N {abs(r.LON):.1f} W, {r.USA_WIND:.0f} kt, category {r.USA_SSHS:.0f}")
w = synoptic.set_index("time").USA_WIND
gain = w - w.shift(4)
print(f"largest 24 h gain: {gain.max():.0f} kt ending {gain.idxmax():%d %b %H} UTC; 24 h periods at 30 kt or more: {(gain >= 30).sum()}")
landfall 01 Jul 15:20 UTC at 12.5 N 61.5 W, 120 kt, category 4
landfall 05 Jul 11:00 UTC at 20.3 N 87.4 W, 80 kt, category 1
landfall 08 Jul 08:40 UTC at 28.6 N 96.0 W, 80 kt, category 1
largest 24 h gain: 55 kt ending 30 Jun 18 UTC; 24 h periods at 30 kt or more: 8

Distance, forward speed and a GeoJSON track

The data used on this page

IBTrACS version 04r01, North Atlantic CSV, NOAA NCEI. Knapp, K. R., M. C. Kruk, D. H. Levinson, H. J. Diamond, and C. J. Neumann (2010). Coastlines: Natural Earth 1:50m land.

from pathlib import Path

import numpy as np
import pandas as pd
import requests
from pyproj import Geod

Path("data").mkdir(exist_ok=True)
URL = ("https://www.ncei.noaa.gov/data/international-best-track-archive-for-climate-stewardship-ibtracs/"
       "v04r01/access/csv/ibtracs.NA.list.v04r01.csv")
if not Path("data/ibtracs.NA.list.v04r01.csv").exists():
    Path("data/ibtracs.NA.list.v04r01.csv").write_bytes(requests.get(URL, timeout=300).content)
print(round(Path("data/ibtracs.NA.list.v04r01.csv").stat().st_size / 1e6), "MB")
57 MB

Example 1: distance and forward speed

g = Geod(ellps="WGS84")
step = [g.inv(*p, *q)[2] / 1000 for p, q in zip(zip(b.LON[:-1], b.LAT[:-1]), zip(b.LON[1:], b.LAT[1:]))]
print(f"track length {sum(step):,.0f} km; IBTrACS forward speed (STORM_SPEED, kt): median {b.STORM_SPEED.median():.0f}, "
      f"max {b.STORM_SPEED.max():.0f}")
track length 9,040 km; IBTrACS forward speed (STORM_SPEED, kt): median 16, max 31

Example 2: the track as GeoJSON for a web map

import geopandas as gpd
from shapely.geometry import LineString
seg = gpd.GeoDataFrame(
    {"time": b.time.iloc[:-1].astype(str).values, "wind_kt": b.USA_WIND.iloc[:-1].values, "sshs": b.USA_SSHS.iloc[:-1].values},
    geometry=[LineString([p, q]) for p, q in zip(zip(b.LON[:-1], b.LAT[:-1]), zip(b.LON[1:], b.LAT[1:]))], crs=4326)
seg.to_file("data/beryl_2024.geojson")
print(len(seg), "segments,", round(Path("data/beryl_2024.geojson").stat().st_size / 1000), "kB")
108 segments, 21 kB

Segments rather than one line let a web map colour each leg by category.

Example 3: how unusual was Beryl?

The whole basin file answers questions no single track can. When in the year has any Atlantic storm first reached category 5, and how quickly did Beryl get from tropical-storm strength to category 4?

all_ = d[["SID", "NAME", "ISO_TIME", "USA_SSHS"]].assign(time=pd.to_datetime(d.ISO_TIME),
                                                         USA_SSHS=pd.to_numeric(d.USA_SSHS, errors="coerce"))
first5 = all_[all_.USA_SSHS >= 5].groupby("SID").agg(name=("NAME", "first"), t=("time", "min"))
first5["doy"] = first5.t.dt.dayofyear
print(first5.sort_values("doy").head(3).assign(t=lambda x: x.t.dt.strftime("%Y-%m-%d %H UTC")).to_string())
ts = b[b.USA_WIND >= 34].time.min(); c4 = b[b.USA_SSHS >= 4].time.min()
print(f"Beryl: tropical-storm winds {ts:%d %b %H} UTC, category 4 {c4:%d %b %H} UTC - {(c4 - ts).total_seconds() / 3600:.0f} hours")
                name                  t  doy
SID                                         
2024181N09320  BERYL  2024-07-02 00 UTC  184
2005192N11318  EMILY  2005-07-16 21 UTC  197
1980214N11330  ALLEN  1980-08-05 00 UTC  218
Beryl: tropical-storm winds 29 Jun 00 UTC, category 4 30 Jun 18 UTC - 42 hours

A best track is a post-season reanalysis

A best track is a post-season reanalysis: forecasters revisit every observation of a storm — aircraft, satellites, buoys, radar — and issue a single consistent history. IBTrACS gathers these from all agencies and adds interpolation, derived fields and metadata. The richness is the point, and also the risk: the same row holds several agencies' winds, official and interpolated positions sit side by side, and the documentation matters as much as the data.

Kinds of record in Beryl’s IBTrACS track. what the 109 rows arekindhow to find itcountsix-hourly best-track fix00, 06, 12, 18 UTC53special record (landfall, peak)USA_RECORD L, I, P…4interpolated 3-hourly pointeverything else52Rates of change belong on the fixes, not the interpolations spatialworkflow.io
What the rows of one storm are.

For a single storm three choices keep the result clean. Use one agency's wind and its definition. Base rates of change on the official six-hourly fixes. And read special records — landfalls, peaks — from their flags, not by guessing from the nearest synoptic time. Beryl illustrates why the effort pays: Example 3 measures its rise from tropical-storm winds to category 4 in 42 hours and finds it the earliest category 5 in the Atlantic record by two weeks, and the landfall flags give three landfalls in a week.

Units rows, blank strings and repeated names

Beryl’s three landfalls and its peak from IBTrACS. Beryl in four recordstime (UTC)windcategoryCarriacou1 Jul 15:20120 kt4peak2 Jul 06:00✕145 kt, 935 hPa5Yucatán5 Jul 11:0080 kt1Texas8 Jul 08:4080 kt1Earliest Atlantic category 5 in the record: 2 July, day 184 spatialworkflow.io
Beryl's key moments from the flags.
  • Skip the units row (skiprows=[1]).
  • Blank strings are missing; set keep_default_na=False, na_values=[" ", ""].
  • IFLAG and synoptic times distinguish fixes from interpolations.
  • One agency's wind; WMO_WIND mixes averaging periods across basins.
  • Names repeat; select by SID or by name and season.
  • Longitude runs −180 to 180 in IBTrACS; tracks crossing the date line need care (see storm track crosses the date line).

FAQ

Where can I download hurricane tracks?

IBTrACS from NOAA NCEI, as CSV, netCDF or shapefile, by basin or globally, free.

How do I read IBTrACS in pandas?

pd.read_csv(path, skiprows=[1], keep_default_na=False, na_values=[" ", ""], low_memory=False), then filter by SID or by NAME and SEASON.

Which wind column should I use in IBTrACS?

One agency's, consistently — USA_WIND (one-minute sustained, knots) for Atlantic and East Pacific storms.

How strong was Hurricane Beryl?

In IBTrACS v04r01 Beryl peaked at 145 knots and 935 hPa on 2 July 2024 at 06 UTC, category 5, and made landfall in Texas on 8 July at 80 knots.