georest.usgs.waterdata

USGS Water Data OGC API client - the modern replacement for NWIS Water Services.

Base URL https://api.waterdata.usgs.gov/ogcapi/v1. Every function returns a GeoJSON FeatureCollection dict (one Feature per observation for the time-series collections, one per site for monitoring-locations), matching the rest of georest. to_rows() flattens one into plain dicts for CSV, pandas, or plotting.

Legacy NWIS endpoint → collection (from the USGS migration guide):

/nwis/site                       monitoring-locations
/nwis/site?seriesCatalogOutput   time-series-metadata
/nwis/dv                         daily
/nwis/iv                         continuous / latest-continuous
/nwis/gwlevels, /measurements    field-measurements
/nwis/peak                       peaks

Things verified against the live API (2026-09-14) that shape this module:

  • The API does not sort. The time-series functions sort their output by (time_series_id, time) client-side.

  • value is a string (precision). The FeatureCollection is returned as sent; to_rows() converts to float.

  • There is no numberMatched. max_items bounds every fetch and a truncated: True member says when it bit; max_items=None means unlimited. Fifteen-minute continuous data is roughly 35 000 rows per year per parameter, so the default of 10 000 covers about 100 days.

  • Anonymous use works but is rate limited to “a few queries per hour”. Get a key at https://api.waterdata.usgs.gov/signup/ and set USGS_API_KEY (or call set_api_key()). The key is sent only as the X-Api-Key header.

API-key resolution, per call: an explicit api_key= argument wins ("" forces anonymous); else the value given to set_api_key(); else the first non-empty of the USGS_API_KEY / USGS_WATERDATA_API_KEY environment variables; else anonymous. Reading a .env file is the application’s job, not this library’s.

Copyright 2026 Ryan Rock and Ian Housman

Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at

Module Attributes

COLLECTIONS

The data collections, for reference.

PARAMETER_CODES

The parameter codes most queries want.

STATISTIC_CODES

Statistic codes for the daily / latest-daily collections.

Functions

get_continuous_values(monitoring_location_id)

Sensor readings, typically every 15 minutes - the continuous collection (old /nwis/iv, "instantaneous values").

get_daily_values(monitoring_location_id[, ...])

Daily statistics for a site - the daily collection (old /nwis/dv).

get_field_measurements([...])

Manually collected site-visit readings - the field-measurements collection (old /nwis/gwlevels and /measurements): groundwater levels, gage height and discharge measurements used to calibrate the sensors.

get_items(collection, *[, bbox, start, end, ...])

Fetch Features from any collection as a GeoJSON FeatureCollection.

get_latest_values([monitoring_location_id, ...])

The most recent observation per time series - latest-daily or latest-continuous (source="daily" / "continuous").

get_monitoring_location(...[, api_key, timeout])

One site as a single GeoJSON Feature (not a collection).

get_peaks(monitoring_location_id[, start, ...])

Annual peak streamflow - the peaks collection (old /nwis/peak).

get_queryables(collection, *[, api_key, timeout])

{property_name: json_schema} for a collection.

get_time_series_metadata(*[, ...])

What time series exist - the time-series-metadata collection (the old /nwis/site?seriesCatalogOutput=true).

list_collections(*[, api_key, timeout])

The live collection catalog: [{"id", "title", "description", "links", ...}].

search_monitoring_locations(*[, bbox, ...])

Find sites in the monitoring-locations collection.

set_api_key(key)

Set the process-wide API key, e.g. from a secret store at server start.

to_rows(fc[, numeric])

Flatten a FeatureCollection into one plain dict per Feature.

georest.usgs.waterdata.COLLECTIONS = ('monitoring-locations', 'daily', 'latest-daily', 'continuous', 'latest-continuous', 'field-measurements', 'latest-field-measurements', 'channel-measurements', 'peaks', 'time-series-metadata', 'field-measurements-metadata', 'combined-metadata', 'time-series-revisions', 'time-series-methods')

The data collections, for reference. list_collections() is the authoritative, live list (it also includes the small code tables such as agency-codes and aquifer-codes).

georest.usgs.waterdata.PARAMETER_CODES = {'00010': 'Temperature, water, degrees Celsius', '00045': 'Precipitation, total, inches', '00060': 'Discharge, cubic feet per second', '00065': 'Gage height, feet', '72019': 'Depth to water level, feet below land surface'}

The parameter codes most queries want. Any 5-digit USGS parameter code is valid; these are just the common ones, described for readability.

georest.usgs.waterdata.STATISTIC_CODES = {'00001': 'Maximum', '00002': 'Minimum', '00003': 'Mean', '00008': 'Median'}

Statistic codes for the daily / latest-daily collections.

georest.usgs.waterdata.set_api_key(key: str | None) → None[source]

Set the process-wide API key, e.g. from a secret store at server start.

Takes precedence over the environment variables; an explicit api_key= argument on a call still wins. None (or "") clears it, so resolution falls back to the environment again.

Example:

import os
from georest.usgs import waterdata

# Once at startup, from wherever you keep secrets; every call then uses it
waterdata.set_api_key(os.environ.get("USGS_API_KEY"))
georest.usgs.waterdata.list_collections(*, api_key: str | None = None, timeout: int | None = None) → list[dict][source]

The live collection catalog: [{"id", "title", "description", "links", ...}].

Raises:

RuntimeError – On an HTTP or connection failure (RateLimitError on 429). ValueError if the body isn’t JSON.

Example:

from georest.usgs import waterdata

[c["id"] for c in waterdata.list_collections()]
georest.usgs.waterdata.get_queryables(collection: str, *, api_key: str | None = None, timeout: int | None = None) → dict[source]

{property_name: json_schema} for a collection. Every key is a valid direct filter for get_items() and the helpers built on it - daily alone has ~55, including every site attribute.

Example:

from georest.usgs import waterdata

sorted(waterdata.get_queryables("daily"))   # every property you can filter daily values on
georest.usgs.waterdata.get_items(collection: str, *, bbox: str | dict | list | tuple | None = None, start: str | date | datetime | None = None, end: str | date | datetime | None = None, properties: str | Iterable[str] | None = None, skip_geometry: bool = False, filter: str | None = None, max_items: int | None = 10000, page_size: int = 1000, api_key: str | None = None, timeout: int | None = None, **filters: object) → dict[source]

Fetch Features from any collection as a GeoJSON FeatureCollection.

The escape hatch under every helper below; use it directly for a collection without one (channel-measurements, time-series-methods, the code tables) or for a filter the helpers don’t spell out.

Parameters:
  • collection – A collection id - see COLLECTIONS / list_collections().

  • bbox – minx,miny,maxx,maxy as a string, 4-tuple, or Esri-style dict.

  • start – The datetime window - strings, date or datetime objects; either side may be open. start may also be a ready interval ("2020-01-01/2020-01-31", "../2020-12-31") or an ISO 8601 duration such as "P7D" or "PT6H" (the last 7 days / 6 hours). The server rejects durations, so they are resolved client-side to <now minus duration>/..; years and months ("P1Y", "P3M") raise ValueError.

  • end – The datetime window - strings, date or datetime objects; either side may be open. start may also be a ready interval ("2020-01-01/2020-01-31", "../2020-12-31") or an ISO 8601 duration such as "P7D" or "PT6H" (the last 7 days / 6 hours). The server rejects durations, so they are resolved client-side to <now minus duration>/..; years and months ("P1Y", "P3M") raise ValueError.

  • properties – Property names to keep; the response is trimmed to exactly these (verified). Note this generic function does not add the time-series essentials back - the helpers do.

  • skip_geometry – Return geometry: null on every Feature. Large observation pulls don’t need a point repeated per row.

  • filter – A CQL2 text expression ("statistic_id IN ('00001','00003')").

  • max_items – Stop after this many Features; None for unlimited. When the server had more, the result carries truncated: True.

  • page_size – Rows per request, 1..50_000.

  • api_key – Overrides the process/environment key for this call; "" forces anonymous.

  • timeout – Seconds per request (default 60).

  • **filters – Any queryable property as an equality filter, e.g. parameter_code="00060", state_code="49".

Returns:

A FeatureCollection dict, plus truncated and rate_limit members when applicable (see georest.usgs._ogc.get_items()).

Raises:
  • TypeError – If f, limit, cursor or offset is passed in **filters.

  • ValueError – For a bad bbox/page_size/max_items, an interval start combined with end, a non-JSON body, or a page without features.

  • RuntimeError – On an HTTP error status or connection failure; the message includes the API’s own description when it sent one. A 429 the server won’t let us wait out raises the RateLimitError subclass, whose retry_after says how long it asked for.

Example:

from georest.usgs import waterdata

# Any collection with any filter: stream sites in Arizona's Coconino County
sites = waterdata.get_items("monitoring-locations", state_code="04",
                            county_code="005", site_type_code="ST", max_items=20)
len(sites["features"])
georest.usgs.waterdata.search_monitoring_locations(*, bbox: str | dict | list | tuple | None = None, state_code: str | None = None, county_code: str | None = None, site_type_code: str | list[str] | None = None, hydrologic_unit_code: str | None = None, monitoring_location_id: str | int | list | None = None, agency_code: str | None = None, properties: str | Iterable[str] | None = None, filter: str | None = None, max_items: int | None = 1000, page_size: int = 1000, api_key: str | None = None, timeout: int | None = None, **filters: object) → dict[source]

Find sites in the monitoring-locations collection.

Every filter is optional and they combine with AND. Common ones:

Parameters:
  • bbox – Spatial window, minx,miny,maxx,maxy (WGS84 lon/lat).

  • state_code – Two-digit ANSI/FIPS state code, e.g. "49" for Utah.

  • county_code – County FIPS code within the state.

  • site_type_code – "ST" stream, "LK" lake, "GW" well, "SP" spring, "ES" estuary… A list becomes a CQL2 IN.

  • hydrologic_unit_code – A HUC; the API prefix-matches, so "1407" finds every HUC8 under that HUC4.

  • monitoring_location_id – One id or a list; bare numbers get the USGS- prefix.

  • agency_code – "USGS", "USEPA", …

  • properties – As for get_items(). max_items defaults to 1 000 here.

  • filter – As for get_items(). max_items defaults to 1 000 here.

  • max_items – As for get_items(). max_items defaults to 1 000 here.

  • page_size – As for get_items(). max_items defaults to 1 000 here.

  • api_key – As for get_items(). max_items defaults to 1 000 here.

  • timeout – As for get_items(). max_items defaults to 1 000 here.

  • **filters –

    As for get_items(). max_items defaults to 1 000 here.

Returns:

A FeatureCollection of site Points; each Feature’s id is the monitoring_location_id ("USGS-09380000").

Example:

from georest.usgs import waterdata

# Stream gages in a box (west, south, east, north) around Lees Ferry, Arizona
sites = waterdata.search_monitoring_locations(bbox=(-111.7, 36.8, -111.5, 37.0),
                                              site_type_code="ST")
[(f["id"], f["properties"]["monitoring_location_name"]) for f in sites["features"]]
georest.usgs.waterdata.get_monitoring_location(monitoring_location_id: str | int, *, api_key: str | None = None, timeout: int | None = None) → dict[source]

One site as a single GeoJSON Feature (not a collection).

monitoring_location_id may be bare ("09380000") or prefixed ("USGS-09380000").

Raises:

RuntimeError – A 404 for an unknown id surfaces as Request failed (404): ….

Example:

from georest.usgs import waterdata

site = waterdata.get_monitoring_location("USGS-09380000")
site["properties"]["monitoring_location_name"], site["geometry"]["coordinates"]
georest.usgs.waterdata.get_time_series_metadata(*, monitoring_location_id: str | int | list | None = None, parameter_code: str | list[str] | None = None, statistic_id: str | None = None, bbox: str | dict | list | tuple | None = None, properties: str | Iterable[str] | None = None, filter: str | None = None, max_items: int | None = 10000, page_size: int = 10000, api_key: str | None = None, timeout: int | None = None, **filters: object) → dict[source]

What time series exist - the time-series-metadata collection (the old /nwis/site?seriesCatalogOutput=true).

One Feature per (site, parameter, statistic, computation) with its begin/end dates, unit and time_series_id. Use it to discover which parameter_code values a site actually reports before pulling values.

Example:

from georest.usgs import waterdata

# What this gage measures, and over which dates
series = waterdata.get_time_series_metadata(monitoring_location_id="USGS-09380000")
sorted({f["properties"]["parameter_name"] for f in series["features"]})
georest.usgs.waterdata.get_daily_values(monitoring_location_id: str | int | list, parameter_code: str | list[str] = '00060', statistic_id: str | list[str] = '00003', start: str | date | datetime | None = None, end: str | date | datetime | None = None, *, properties: str | Iterable[str] | None = None, skip_geometry: bool = True, filter: str | None = None, max_items: int | None = 10000, page_size: int = 10000, api_key: str | None = None, timeout: int | None = None, **filters: object) → dict[source]

Daily statistics for a site - the daily collection (old /nwis/dv).

One Feature per day, sorted by (time_series_id, time). time is a YYYY-MM-DD string; value is a string - see to_rows().

Parameters:
  • monitoring_location_id – "09380000" or "USGS-09380000"; a list of ids is allowed and becomes a CQL2 IN.

  • parameter_code – Default "00060" (discharge). See PARAMETER_CODES.

  • statistic_id – Default "00003" (daily mean). See STATISTIC_CODES.

  • start – Date window (inclusive), open on either side.

  • end – Date window (inclusive), open on either side.

  • skip_geometry – Default True - the site’s point is the same on every row; use get_monitoring_location() for it.

  • properties – Trim the response; time_series_id, monitoring_location_id, time and value are always kept.

  • max_items – Default 10 000 (~27 years of one daily series). None for unlimited; truncated: True in the result when it bit.

  • filter – As for get_items().

  • page_size – As for get_items().

  • api_key – As for get_items().

  • timeout – As for get_items().

  • **filters –

    As for get_items().

Example:

from georest.usgs import waterdata

fc = waterdata.get_daily_values("USGS-09380000", "00060",
                                start="2024-01-01", end="2024-01-31")
rows = waterdata.to_rows(fc)   # [{"time": "2024-01-01", "value": 9870.0, ...}, ...]
georest.usgs.waterdata.get_continuous_values(monitoring_location_id: str | int | list, parameter_code: str | list[str] = '00060', start: str | date | datetime | None = None, end: str | date | datetime | None = None, *, properties: str | Iterable[str] | None = None, skip_geometry: bool = True, filter: str | None = None, max_items: int | None = 10000, page_size: int = 10000, api_key: str | None = None, timeout: int | None = None, **filters: object) → dict[source]

Sensor readings, typically every 15 minutes - the continuous collection (old /nwis/iv, “instantaneous values”).

time is an RFC 3339 timestamp with offset. Volume warning: one parameter at one site is ~35 000 rows per year, so the default max_items of 10 000 covers roughly 100 days; a long pull needs a narrow start/end window or max_items=None plus an API key. start="P7D" asks for the last seven days.

Sorted by (time_series_id, time). Other arguments as for get_daily_values() (there is no statistic_id here).

Example:

from georest.usgs import waterdata

# Fifteen-minute discharge readings for one day
fc = waterdata.get_continuous_values("USGS-09380000", "00060",
                                     start="2024-06-01", end="2024-06-02")
rows = waterdata.to_rows(fc)
len(rows), rows[0]["time"], rows[0]["value"]
georest.usgs.waterdata.get_latest_values(monitoring_location_id: str | int | list | None = None, parameter_code: str | list[str] | None = None, *, source: str = 'daily', statistic_id: str | list[str] | None = None, bbox: str | dict | list | tuple | None = None, properties: str | Iterable[str] | None = None, skip_geometry: bool = False, filter: str | None = None, max_items: int | None = 10000, page_size: int = 10000, api_key: str | None = None, timeout: int | None = None, **filters: object) → dict[source]

The most recent observation per time series - latest-daily or latest-continuous (source="daily" / "continuous").

Unlike the history functions, no site is required: a bbox with parameter_code="00060" gives the current discharge at every gage in an area, which is why skip_geometry defaults to False here.

Raises:

ValueError – For a source other than "daily" or "continuous".

Example:

from georest.usgs import waterdata

latest = waterdata.to_rows(waterdata.get_latest_values("USGS-09380000", "00060"))
latest[0]["time"], latest[0]["value"], latest[0]["unit_of_measure"]
georest.usgs.waterdata.get_field_measurements(monitoring_location_id: str | int | list | None = None, parameter_code: str | list[str] | None = None, start: str | date | datetime | None = None, end: str | date | datetime | None = None, *, bbox: str | dict | list | tuple | None = None, properties: str | Iterable[str] | None = None, skip_geometry: bool = True, filter: str | None = None, max_items: int | None = 10000, page_size: int = 10000, api_key: str | None = None, timeout: int | None = None, **filters: object) → dict[source]

Manually collected site-visit readings - the field-measurements collection (old /nwis/gwlevels and /measurements): groundwater levels, gage height and discharge measurements used to calibrate the sensors.

time is the visit date (YYYY-MM-DD); time_of_day carries the clock time when known. Sorted by (monitoring_location_id, time) (these rows have no time_series_id).

Example:

from georest.usgs import waterdata

# Discharge measured in the river by USGS hydrographers
visits = waterdata.to_rows(waterdata.get_field_measurements(
    "USGS-09380000", "00060", start="2024-01-01", end="2024-12-31"))
[(v["time"], v["value"], v["observing_procedure"]) for v in visits]
georest.usgs.waterdata.get_peaks(monitoring_location_id: str | int | list, start: str | date | datetime | None = None, end: str | date | datetime | None = None, *, properties: str | Iterable[str] | None = None, skip_geometry: bool = True, filter: str | None = None, max_items: int | None = 10000, page_size: int = 10000, api_key: str | None = None, timeout: int | None = None, **filters: object) → dict[source]

Annual peak streamflow - the peaks collection (old /nwis/peak).

One Feature per water year with the peak value (ft³/s), its date in time, water_year, and a qualifier list (e.g. ["REGULATED"]). Sorted by time.

start/end work as everywhere else, but peaks rejects the OGC datetime parameter (“datetime query not supported”, verified 2026-09-14), so they are sent as a CQL2 time filter instead.

Example:

from georest.usgs import waterdata

# The largest flow of each year since 2000
peaks = waterdata.to_rows(waterdata.get_peaks("USGS-09380000", start="2000-01-01"))
max(peaks, key=lambda p: p["value"] or 0)
georest.usgs.waterdata.to_rows(fc: dict, numeric: Iterable[str] = ('value',)) → list[dict][source]

Flatten a FeatureCollection into one plain dict per Feature.

Each row is the Feature’s properties (copied), with the Feature’s id added under "id" when the properties don’t already carry one, and every key named in numeric converted with float() - None when the value is missing or non-numeric (the API sends value as a string, and a qualifier such as "Ice" can stand in for a number). Rows are sorted by (time_series_id, time) like the series functions.

A flat list of dicts feeds csv.DictWriter, pandas.DataFrame(rows) and plotting directly, and keeps qualifier / approval_status alongside the value. Group by time_series_id with itertools.groupby if a per-series shape is wanted.

Example:

import pandas as pd
from georest.usgs import waterdata

fc = waterdata.get_daily_values("USGS-09380000", "00060",
                                start="2024-01-01", end="2024-01-31")
flow = pd.DataFrame(waterdata.to_rows(fc))   # one row per day, values as numbers
flow[["time", "value"]]