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:
find the USGS gages near a place and see them on a map,
pull three years of daily streamflow and explore it,
judge whether today’s flow is high or low for the time of year, and
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
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_codeandcounty_code.More history. Move
startback; daily values at Lees Ferry go back to 1921.Instantaneous readings.
get_continuous_valuesreturns the 15-minute sensor data behind the daily means.Annual peaks.
get_peaksgives 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.