How to Read the National Inventory of Dams in Python

On this page

Problem statement

The US Army Corps of Engineers keeps the National Inventory of Dams, the federal list of more than ninety thousand dams that are tall enough, hold enough water or would do enough harm if they failed to be tracked. Each row has the dam's location, owner, purpose, height, length, reservoir storage, the year it was finished, its hazard potential and, for many, a condition assessment and an emergency plan. The whole inventory downloads as one GeoPackage from the NID's API. Read as it comes, every measurement is text, several dams share one ID, and the units are American. This page reads the inventory and shows what is in it.

Quick answer

Read the GeoPackage with GeoPandas and convert the measurement columns to numbers; the setup below does it:

print(nid[["nidId", "name", "state", "county", "primaryPurposeId", "nidHeight", "nidStorage", "yearCompleted", "publicHazardId"]].head(3).to_string(index=False))
print(f"{len(nid):,} rows; {len(nid.columns)} columns; CRS {nid.crs.to_epsg()}")
  nidId                name    state   county primaryPurposeId  nidHeight  nidStorage  yearCompleted publicHazardId
OK20998           Dod Usa 4 Oklahoma Muskogee       Recreation         15        50.0         1940.0            Low
OK20994           Dod Usa 2 Oklahoma Muskogee       Recreation         18        50.0         1939.0            Low
KY00728 Aichi Forge Usa Dam Kentucky    Scott            Other         15        86.8         1974.0            Low
92,626 rows; 98 columns; CRS 4269

The inventory has 92,626 rows and 98 columns in NAD83 (EPSG:4269). The first rows are small Oklahoma recreation dams owned by the Defence Department, 15 and 18 feet high, holding 50 acre-feet each, finished in 1939 and 1940. Most of the 98 columns are coded categories with names ending in Id, which in this file hold readable labels such as "Recreation" or "Low", not numeric codes.

Map of the lower 48 with a dot for each dam coloured by hazard potential: grey low-hazard dams densest in the plains and Southeast, red high-hazard dams concentrated in the East, the Southeast and California. Map of the lower 48 with a dot for each dam coloured by hazard potential: grey low-hazard dams densest in the plains and Southeast, red high-hazard dams concentrated in the East, the Southeast and California.
Every dam in the inventory, lower 48, by hazard potential.

Rows, ids and units

1. One row per structure

print(nid.isAssociatedStructureId.value_counts().to_string())
print(f"NID ids used by more than one row: {nid.nidId.duplicated(keep=False).groupby(nid.nidId).any().sum()}")
isAssociatedStructureId
No     91616
Yes     1010
NID ids used by more than one row: 437

A row is a structure, not a dam project. 1,010 rows are associated structures, such as saddle dikes, spillway dams and levees, that share the NID id of a main dam. 437 ids are used by more than one row. Count dams with nidId.nunique() or after dropping the associated structures, never with len().

2. Which height and which storage

h = nid[["damHeight", "hydraulicHeight", "structuralHeight"]]
print(f"nidHeight equals the largest of the three heights: {(nid.nidHeight == h.max(axis=1)).mean():.1%}")
print(f"nidStorage equals the larger of max and normal storage: {(nid.nidStorage == nid[['maxStorage', 'normalStorage']].max(axis=1)).mean():.1%}")
print(nid[["nidHeight", "nidStorage", "surfaceArea", "drainageArea"]].describe().loc[["50%", "max"]].round(1).to_string())
nidHeight equals the largest of the three heights: 99.5%
nidStorage equals the larger of max and normal storage: 99.4%
     nidHeight   nidStorage  surfaceArea  drainageArea
50%       24.0        132.0         12.0           1.3
max      789.0  277540000.0   20255000.0     8300000.0

The inventory has three heights: the dam's height, the hydraulic height (the depth of water it holds) and the structural height. nidHeight is the largest of them for 99.5% of rows. Likewise, nidStorage is the larger of maximum and normal storage for 99.4%. The median dam is 24 feet high and holds 132 acre-feet, a farm-scale dam. The maximum storage, 277.5 million acre-feet, is not a reservoir at all, as the storage pages show. Areas are in acres and drainage areas in square miles.

3. How fresh each row is

print(nid.dataUpdated.dt.year.value_counts().sort_index().tail(4).to_string())
print(f"inspected 2021-2026: {nid.inspectionDate.dt.year.between(2021, 2026).mean():.1%}; no inspection date: {nid.inspectionDate.isna().mean():.1%}")
print("inspection dates in the future:", nid.loc[nid.inspectionDate.dt.year > 2026, ["nidId", "inspectionDate"]].to_dict("records"))
dataUpdated
2023      349
2024     2037
2025     4993
2026    68087
inspected 2021-2026: 30.7%; no inspection date: 37.2%
inspection dates in the future: [{'nidId': 'KS02157', 'inspectionDate': Timestamp('2041-07-16 00:00:00')}, {'nidId': 'IL00855', 'inspectionDate': Timestamp('5023-05-25 00:00:00')}]

68,087 rows were last updated in 2026, so most of the file is recent. Inspections are another matter: only 30.7% of dams have an inspection dated 2021 to 2026, and 37.2% have none, mostly low-hazard dams whose states don't publish one. Two inspection dates are in the future, 2041 and 5023, typing errors that would break any "years since inspection" calculation unless they are caught.

Bars of dams by the year of their last inspection from 1990 to 2026, rising steeply after 2015 to over 6,000 in 2025. Bars of dams by the year of their last inspection from 1990 to 2026, rising steeply after 2015 to over 6,000 in 2025.
When each dam was last inspected, according to the file.

States and a map

The data used on this page

The US Army Corps of Engineers' National Inventory of Dams (NID), the whole inventory as a GeoPackage from its API, downloaded on 19 October 2026 and saved on this site, because the inventory is updated as states and agencies send revisions. Heights are in feet, storage in acre-feet and areas in acres, as the NID gives them.

from pathlib import Path
import zipfile

import geopandas as gpd
import numpy as np
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
# US Army Corps of Engineers, National Inventory of Dams: the whole inventory as a GeoPackage, and the Census
# Bureau's 2024 cartographic boundary counties. Saved 19 October 2026, because the inventory is updated continuously.
# Live: https://nid.sec.usace.army.mil/api/nation/gpkg
ARCHIVE = "usace-nid-2026-10-19.zip"
if not Path(f"data/{ARCHIVE}").exists():
    Path(f"data/{ARCHIVE}").write_bytes(requests.get(f"https://data.spatialworkflow.io/{ARCHIVE}", timeout=900).content)
Z = zipfile.ZipFile(f"data/{ARCHIVE}")
if not Path("data/nid.gpkg").exists():
    Z.extract("nid.gpkg", "data")
NUMERIC = ["damHeight", "hydraulicHeight", "structuralHeight", "nidHeight", "damLength", "volume", "yearCompleted",
           "nidStorage", "maxStorage", "normalStorage", "surfaceArea", "drainageArea", "maxDischarge"]
nid = gpd.read_file("data/nid.gpkg")
nid[NUMERIC] = nid[NUMERIC].apply(pd.to_numeric, errors="coerce")       # the GeoPackage stores them as text
print(f"National Inventory of Dams: {len(nid):,} rows, {nid.nidId.nunique():,} NID ids")
National Inventory of Dams: 92,626 rows, 91,840 NID ids

Example 1: dams by state

print(nid.state.value_counts().head(6).to_string())
print(f"states and territories: {nid.state.nunique()}")
state
Texas          7421
Kansas         6477
Mississippi    6092
Georgia        5388
Missouri       5385
Oklahoma       5008
states and territories: 52

Texas has the most dams, 7,421, then Kansas, Mississippi, Georgia, Missouri and Oklahoma, each with 5,000 to 6,500. Most of them are small earthen dams on farms and ranches. The state column holds full names, and 52 values include Puerto Rico and Guam.

Horizontal bars of dams by state: Texas 7,421, Kansas 6,477, Mississippi 6,092, Georgia 5,388, Missouri 5,385 and Oklahoma 5,008. Horizontal bars of dams by state: Texas 7,421, Kansas 6,477, Mississippi 6,092, Georgia 5,388, Missouri 5,385 and Oklahoma 5,008.
Dams by state.

Example 2: every dam on a map

null_island = (nid.geometry.x == 0) & (nid.geometry.y == 0)
print("at longitude 0, latitude 0:", nid.loc[null_island, ["nidId", "name", "state"]].to_dict("records"))
lower48 = nid[~nid.state.isin(["Alaska", "Hawaii", "Puerto Rico", "Guam"]) & ~null_island].to_crs(5070)
ax = lower48.plot(markersize=0.2, figsize=(11, 7))
ax.set_axis_off()
print(f"dams drawn: {len(lower48):,}; without coordinates: {nid.geometry.is_empty.sum()}")
at longitude 0, latitude 0: [{'nidId': 'WI01242', 'name': 'Milwaukee County Grounds', 'state': 'Wisconsin'}, {'nidId': 'WI20048', 'name': 'Little Turtle Sub-Impoundment', 'state': 'Wisconsin'}]
dams drawn: 92,352; without coordinates: 0

Every row has coordinates, but two Wisconsin dams are at longitude 0, latitude 0, in the Gulf of Guinea, where a missing position was written as zero. Left in, they stretch the map's extent across the Atlantic. The 92,352 dams drawn in the lower 48 crowd the plains from Texas to the Dakotas and the Southeast, where farm ponds and small flood-control dams are densest.