Is the river running high right now?

A USGS Water Data walkthrough with georest.usgs.waterdata

The Colorado River at Lees Ferry, Arizona, is where the Grand Canyon begins and one of the most watched stream gages in the country. In this notebook you will:

  1. find the USGS gages near a place and see them on a map,

  2. pull three years of daily streamflow and explore it,

  3. judge whether today’s flow is high or low for the time of year, and

  4. see how USGS checks its record with measurements made in the river.

Everything comes from the public USGS Water Data API. No key is needed for a notebook like this one; see the end for when you would want one.

pip install georest plotly folium
import datetime as dt

import folium
import pandas as pd
import plotly.express as px
import plotly.io as pio

from georest.usgs import waterdata as wd

pd.set_option("display.max_colwidth", None)   # show text in full, never cut with "..."
# Interactive charts that also display on georest.io, not only in Jupyter.
pio.renderers.default = "notebook_connected"

1. Find the gages

search_monitoring_locations takes a bounding box (west, south, east, north in degrees) and returns a GeoJSON FeatureCollection of sites. site_type_code="ST" keeps streams, leaving out wells, springs and weather stations.

LEES_FERRY_BOX = (-111.7, 36.8, -111.5, 37.0)

sites = wd.search_monitoring_locations(bbox=LEES_FERRY_BOX, site_type_code="ST")

table = pd.DataFrame([{
    "id": f["id"],
    "name": f["properties"]["monitoring_location_name"],
    "drainage_area_sq_mi": f["properties"]["drainage_area"],
    "lon": f["geometry"]["coordinates"][0],
    "lat": f["geometry"]["coordinates"][1],
} for f in sites["features"]])
print(f"{len(table)} stream sites in the box")
table[["id", "name", "drainage_area_sq_mi"]]
14 stream sites in the box
id name drainage_area_sq_mi
0 USGS-09379950 R200-01 11 MI ABV LEES FERRY IN GCNRA, AZ NaN
1 USGS-09379965 R200-02 6.5 MI ABV LEES FERRY IN GCNRA, AZ. NaN
2 USGS-09380000 COLORADO RIVER AT LEES FERRY, AZ 111800.0
3 USGS-09380002 COLORADO R AT LEES FERRY - UNIT VALUES 111800.0
4 USGS-09381950 PARIA R. BLW. WATER POCKETS GULCH NR PAGE,AZ NaN
5 USGS-09382000 PARIA RIVER AT LEES FERRY, AZ 1410.0
6 USGS-09383000 COLORADO RIVER AT COMPACT POINT NR LEES FERRY, AZ 112000.0
7 USGS-09383003 R200-03 AT RM5 BELOW 5 MILE WASH IN GCNP, AZ NaN
8 USGS-365041111323801 Tailwater -03 NaN
9 USGS-365058111322200 WATER HOLES CANYON NaN
10 USGS-365200111340001 Tailwater -06 NaN
11 USGS-365248111304801 Tailwater -09 at spring NaN
12 USGS-365250111304801 Tailwater -09 above spring NaN
13 USGS-365354111312801 Tailwater -11 NaN

Click a marker for its name and id. The orange one is the main-stem gage this notebook follows, USGS-09380000, just below the mouth of the Paria River and about 15 river miles downstream of Glen Canyon Dam.

GAGE = "USGS-09380000"

m = folium.Map(location=[36.87, -111.6], zoom_start=12, tiles="https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}",
               attr="Tiles © Esri")
for row in table.itertuples():
    main = row.id == GAGE
    folium.CircleMarker(
        [row.lat, row.lon], radius=9 if main else 6,
        color="#d9480f" if main else "#1d5c8f", fill=True, fill_opacity=0.85,
        popup=folium.Popup(f"<b>{row.name}</b><br>{row.id}", max_width=260),
        tooltip=row.name,
    ).add_to(m)
m
Make this Notebook Trusted to load map: File -> Trust Notebook

2. Three years of daily flow

get_daily_values returns one value per day. Parameter 00060 is discharge in cubic feet per second and statistic 00003 is the daily mean, both defaults. to_rows flattens the result into plain dicts that pandas takes directly.

Hover over the line to read any day; drag to zoom, double-click to reset.

today = dt.date.today()
start = dt.date(today.year - 3, 10, 1)   # three water years, which start on October 1

daily = pd.DataFrame(wd.to_rows(wd.get_daily_values(GAGE, "00060", start=start, end=today)))
daily["time"] = pd.to_datetime(daily["time"])
print(f"{len(daily):,} days, {daily['time'].min():%Y-%m-%d} to {daily['time'].max():%Y-%m-%d}")

fig = px.line(daily, x="time", y="value",
              labels={"time": "", "value": "Discharge (cubic feet per second)"},
              title="Colorado River at Lees Ferry, daily mean discharge")
fig.update_traces(line_color="#1d5c8f",
                  hovertemplate="%{x|%b %d, %Y}<br>%{y:,.0f} cfs<extra></extra>")
fig.show()
1,119 days, 2023-10-01 to 2026-10-26

Two things stand out. Flow follows a weekly and seasonal rhythm rather than rain or snowmelt, because Glen Canyon Dam upstream decides how much water comes through. And the occasional sharp spike is a deliberate release, not a flood.

The most recent days are usually provisional: USGS has not finished reviewing them, and they can still change.

daily.groupby("approval_status")["time"].agg(["count", "min", "max"])
count min max
approval_status
Approved 991 2023-10-01 2026-06-17
Provisional 128 2026-06-18 2026-10-26

3. Is today high or low?

A flow means little on its own. Compare the latest daily value with every day in the same calendar month over the record you just fetched.

latest = wd.to_rows(wd.get_latest_values(GAGE, "00060"))[0]
latest_day = pd.Timestamp(latest["time"])
same_month = daily[daily["time"].dt.month == latest_day.month]["value"]
pct = (same_month < latest["value"]).mean() * 100

verdict = ("unusually low" if pct < 10 else "unusually high" if pct > 90
           else "about typical")
print(f"Latest: {latest['value']:,.0f} cfs on {latest_day:%B %d, %Y} ({latest['approval_status']})")
print(f"{pct:.0f}% of {latest_day:%B} days since {start:%Y} ran lower, "
      f"so this is {verdict} for the month.")

fig = px.histogram(same_month, nbins=30,
                   labels={"value": "Daily mean discharge (cfs)"},
                   title=f"{latest_day:%B} daily flows since {start:%Y}, and the latest day")
fig.update_traces(marker_color="#9fc3e0", hovertemplate="%{x:,.0f} cfs: %{y} days<extra></extra>")
fig.add_vline(x=latest["value"], line_color="#d9480f", line_width=3,
              annotation_text="latest", annotation_position="top")
fig.update_layout(showlegend=False, yaxis_title="Days")
fig.show()
Latest: 6,480 cfs on October 26, 2026 (Provisional)
1% of October days since 2023 ran lower, so this is unusually low for the month.

4. How the record is checked

A gage measures water level. Discharge comes from a rating curve that converts level to flow, and USGS hydrographers keep that curve honest by measuring discharge directly in the river, here with an acoustic Doppler current profiler. get_field_measurements returns those visits. Each orange point should sit close to the blue line on the same day.

visits = pd.DataFrame(wd.to_rows(wd.get_field_measurements(GAGE, "00060", start=start, end=today)))
visits["time"] = pd.to_datetime(visits["time"])

fig = px.line(daily, x="time", y="value",
              labels={"time": "", "value": "Discharge (cfs)"},
              title="Daily record (line) and direct measurements in the river (points)")
fig.update_traces(line_color="#1d5c8f", hovertemplate="%{x|%b %d, %Y}<br>%{y:,.0f} cfs<extra></extra>")
fig.add_scatter(x=visits["time"], y=visits["value"], mode="markers", name="field measurement",
                marker=dict(color="#d9480f", size=9, line=dict(color="white", width=1)),
                customdata=visits[["observing_procedure", "measurement_rated"]],
                hovertemplate=("%{x|%b %d, %Y}<br>%{y:,.0f} cfs measured<br>"
                               "%{customdata[0]}, rated %{customdata[1]}<extra></extra>"))
fig.show()

visits[["time", "value", "observing_procedure", "measurement_rated", "control_condition"]]
time value observing_procedure measurement_rated control_condition
0 2023-12-06 11400.0 Acoustic Doppler Current Profiler Good Submerged
1 2023-12-06 10800.0 Acoustic Doppler Current Profiler Good Submerged
2 2024-02-27 12000.0 Acoustic Doppler Current Profiler Poor Submerged
3 2024-04-24 10600.0 Acoustic Doppler Current Profiler Good Submerged
4 2024-06-04 12200.0 Acoustic Doppler Current Profiler Good Submerged
5 2024-06-05 13100.0 Acoustic Doppler Current Profiler Good Submerged
6 2024-08-06 15500.0 Acoustic Doppler Current Profiler Good Submerged
7 2024-10-08 8570.0 Acoustic Doppler Current Profiler Good Submerged
8 2024-12-03 9560.0 Acoustic Doppler Current Profiler Good Submerged
9 2024-12-03 9190.0 Acoustic Doppler Current Profiler Good Submerged
10 2025-02-04 11200.0 Acoustic Doppler Current Profiler Good Submerged
11 2025-02-04 11500.0 Acoustic Doppler Current Profiler Good Submerged
12 2025-04-15 9320.0 Acoustic Doppler Current Profiler Fair NaN
13 2025-04-15 10100.0 Acoustic Doppler Current Profiler Fair NaN
14 2025-06-17 11400.0 Acoustic Doppler Current Profiler Good Clear
15 2025-08-13 11900.0 Acoustic Doppler Current Profiler Good Clear
16 2025-11-24 9210.0 Acoustic Doppler Current Profiler Good Clear
17 2026-01-12 11300.0 Acoustic Doppler Current Profiler Good Clear
18 2026-01-12 11600.0 Acoustic Doppler Current Profiler Good Clear
19 2026-02-10 8570.0 Acoustic Doppler Current Profiler Fair NaN
20 2026-04-14 8440.0 Acoustic Doppler Current Profiler Good Clear
21 2026-04-14 8450.0 Acoustic Doppler Current Profiler Good Clear
22 2026-04-14 8400.0 Acoustic Doppler Current Profiler Good Clear
23 2026-04-14 8340.0 Acoustic Doppler Current Profiler Good Clear
24 2026-06-18 8200.0 Acoustic Doppler Current Profiler Good Vegetation - Light
25 2026-06-18 8230.0 Acoustic Doppler Current Profiler Good Vegetation - Light
26 2026-07-12 7670.0 Acoustic Doppler Current Profiler Good Vegetation - Light
27 2026-07-12 7560.0 Acoustic Doppler Current Profiler Good Vegetation - Light
28 2026-10-05 7450.0 Acoustic Doppler Current Profiler Good Vegetation - Light
29 2026-10-05 7420.0 Acoustic Doppler Current Profiler Good Vegetation - Light

Where to go next

  • Other places. Swap the bounding box, or search by state_code and county_code.

  • More history. Move start back; daily values at Lees Ferry go back to 1921.

  • Instantaneous readings. get_continuous_values returns the 15-minute sensor data behind the daily means.

  • Annual peaks. get_peaks gives the largest flow of each year.

  • Rate limits. Anonymous use allows a few queries per hour. For more, get a free key and set USGS_API_KEY.

Every function used here is documented in the waterdata reference.