An asynchronous Python client for the NOAA National Centers for Environmental Information (NCEI) Climate Data Online (CDO) Web Services API v2. Features automatic rate limiting, connection pooling, and comprehensive type safety.
- β‘ Asynchronous API: Built with
aiohttpfor high-performance async I/O - π¦ Automatic Rate Limiting: Enforces NOAA's limits (5 req/sec, 10,000 req/day)
- π Connection Pooling: Efficient TCP connection reuse
- π Type Safety: Full type hints and runtime validation
- π¨ Beautiful Documentation: Color-formatted docstrings with pdoc
- π‘οΈ Resource Management: Proper async context management
- π Complete Coverage: All documented NOAA CDO v2 endpoints supported
Working with NOAA's CDO API in Python usually means one of three things. Here's how they compare:
noaa-cdo-api |
Other CDO Python wrappers | Roll your own (aiohttp/requests) |
|
|---|---|---|---|
| Async / concurrent requests | β
aiohttp-native |
Usually β (sync-only) | Depends |
| Rate limiting (5/sec, 10k/day) built in | β enforced automatically | β caller manages | β caller manages |
Typed responses (TypedDict per endpoint) |
β full editor autocomplete | β dict[str, Any] / DataFrame |
β |
Ships py.typed marker |
β | Rarely | N/A |
| Connection pooling | β
(aiohttp TCPConnector) |
Partial | You wire it |
| Loop-aware session reuse | β (auto rebuild on loop change) | N/A | Manual |
| All v2 endpoints covered | β | Varies | N/A |
The unique value: async + rate-limit-aware + typed in one package. You can asyncio.gather() a hundred requests without worrying about blacklisting, and your IDE can autocomplete response["results"][0]["value"] all the way down.
pip install noaa-cdo-apiFull API documentation with colored formatting is available at https://fxf8.github.io/noaa-cdo-api/.
import asyncio
from noaa_cdo_api import NOAAClient, Extent
async def main():
# Best Practice: Use async context manager for automatic cleanup
async with NOAAClient(token="YOUR_TOKEN_HERE") as client:
# Query available datasets
datasets = await client.get_datasets(limit=10)
# Query stations in a geographic region
stations = await client.get_stations(
extent=Extent(40.0, -80.0, 45.0, -75.0), # latitude_min, longitude_min, latitude_max, longitude_max
datasetid="GHCND",
limit=5
)
# Get climate data with unit conversion
data = await client.get_data(
datasetid="GHCND",
startdate="2022-01-01",
enddate="2022-01-31",
stationid="GHCND:USW00094728",
units="metric",
limit=100,
)
if __name__ == "__main__":
asyncio.run(main())# β BAD: Creating multiple event loops
client1 = await NOAAClient(token="TOKEN1")
client2 = await NOAAClient(token="TOKEN2")
results = [*asyncio.run(client1.get_datasets(...)), *asyncio.run(client2.get_datasets(...))]
# β
GOOD: Share the same event loop (note that rate limits apply **per token**)
async with NOAAClient(token="TOKEN1") as client1, \
NOAAClient(token="TOKEN2") as client2:
await asyncio.gather(
client1.get_datasets(),
client2.get_datasets()
)# β Less ideal but functional: Manual cleanup
client = NOAAClient(token="TOKEN")
try:
await client.get_datasets()
finally:
await client.close()
# β
Better: Use async context manager
async with NOAAClient(token="TOKEN") as client:
await client.get_datasets()# β
Good: Use only a single client
async def parallel_with():
async with NOAAClient(token="TOKEN") as client:
tasks = [client.get_datasets() for _ in range(20)]
return await asyncio.gather(*tasks) # Rate limits respected
# β Bad: Each client has separate rate limits
async def parallel_separate():
tasks = []
for i in range(20):
client = NOAAClient(token="TOKEN") # Each has separate limiter
tasks.append(client.get_datasets())
return await asyncio.gather(*tasks) # May exceed rate limitsThree questions shaped the architecture. The tradeoffs are called out honestly so you can decide if this library's opinions match yours.
Climate data pulls are I/O-bound and often bulk (many stations Γ many years). Doing them sequentially wastes real time β but naive concurrency hits NOAA's rate limits fast (5 req/sec, 10,000 per day), and repeated violations can get a token blacklisted.
The library uses two aiolimiter.AsyncLimiter instances β one per-second, one per-day β combined via async with on every request. Result:
await asyncio.gather(*many_requests)is safe; the limiter enforces spacing transparently- Both limits are respected simultaneously without user code
- Rate limiting is a property of the client, not a discipline the caller has to remember
Tradeoff: limits are enforced per client instance. Sharing one client across a task group is the correct pattern; spinning up a new client per request defeats the purpose. This is documented in the Rate Limiting section above.
Most Python API wrappers return dict[str, Any] (or a DataFrame that erases the schema). Both throw away everything the API contract tells us β field names, value types, which fields are optional.
Every endpoint here returns a TypedDict matching the CDO response shape (DatasetsJSON, StationsJSON, DataJSON, ...). In a Pyright/mypy-aware editor:
- Autocomplete works down to individual response fields
- Typos in field access are caught at edit time, not at runtime on a 3 AM cron job
- Refactoring across a codebase is safe
Tradeoff: NOAA returns a different shape on rate-limit-exceeded responses, so return types are SuccessJSON | RateLimitJSON unions. Callers narrow with isinstance or a key check before touching fields. The plan for a future release is to raise RateLimitError instead, so the happy path returns a single concrete type.
aiohttp.ClientSession is bound to the event loop that created it β reusing one across loops fails in confusing ways. Realistic scenarios where that happens: pytest with asyncio_mode, notebook re-runs, embedding in a larger app that owns its own loop.
The client tracks which loop owns its TCPConnector and ClientSession in a private _session_loop attribute set at session creation. When a request arrives on a different loop, the connector and session are rebuilt automatically. Critically, this is done without reading any aiohttp private attributes β earlier revisions of the code peeked at session._loop, which is not part of aiohttp's public API and would silently break on upgrades. The current implementation only uses asyncio.get_running_loop() and object identity.
Tradeoff: one identity comparison per request, and a bit of internal state. Worth it for correctness in the "reused across contexts" case.
-
Connection Pooling
- Reuse the same client instance
- Default connection limit is 10
- Adjust with
tcp_connector_limitparameter
-
Pagination
- Use
limitandoffsetfor large result sets - Process data in chunks for memory efficiency
- Use
-
Data Volume
- Limit date ranges (1 year for daily, 10 years for monthly)
- Use specific station IDs when possible
- Set
includemetadata=Falseif not needed
-
Caching
- Cache frequently accessed metadata
- Implement local caching for historical data
/datasets: Query available datasets/datacategories: Query data categories/datatypes: Query data types/locationcategories: Query location categories/locations: Query locations/stations: Query weather stations/data: Query actual climate data
The library provides comprehensive type checking through:
TypedDictschemas for all parameters- Runtime validation of parameter values
- Proper enum types for constrained fields
Example with type checking:
from noaa_cdo_api import parameter_schemas
params: parameter_schemas.StationsParameters = {
"extent": "42.0,-90.0,40.0,-88.0",
"datasetid": "GHCND",
"limit": 100
}This project is licensed under the MIT License - see the LICENSE file for details.
- NOAA's National Centers for Environmental Information (NCEI)
- The aiohttp team for their excellent HTTP client
- Contributors to the project