Python SDK
The official Python client for the OpenData API. Install it, load a dataset, get a DataFrame. That's the whole loop.
Installation
# With pandas (recommended for most users)pip install tryopendata[pandas]# With polarspip install tryopendata[polars]# Base install (works without any DataFrame library)pip install tryopendata
Source and issues: github.com/tryopendata/opendata-python
Notebooks
The SDK works in Jupyter, JupyterLab, Google Colab, and VS Code notebooks. Results render as styled HTML tables automatically. See the Jupyter Notebooks guide for local setup, or the Google Colab guide to start in your browser with nothing to install.
Loading Data
There are four entry points, each for a different situation. Most of the time, client.load() is what you want.
| Method | Returns | When to use |
|---|---|---|
client.load(path) | All rows, paginated automatically | Most cases -- exploration, analysis, notebooks |
client.query(path) | First page only (up to 10,000 rows) | Sampling data, building dashboards, when you control pagination |
client.datasets.query_iter(path) | One page at a time (iterator) | Very large datasets that don't fit in memory |
client.sql.execute(sql) | Query results with execution metadata | Complex queries, JOINs across datasets, window functions, GROUP BY |
from opendata_sdk import OpenDataclient = OpenData()# Load everything into a DataFrame -- the simplest pathdf = client.load("bls/cpi-u").to_pandas()# Sample the first 1,000 rows for quick explorationsample = client.query("bls/cpi-u").to_pandas()# Process a huge dataset without loading it all at oncefor page in client.datasets.query_iter("census/acs5", page_size=5000):df = page.to_pandas()process(df) # handle each chunk
Filtering with the Query Builder
The Query class gives you a clean way to filter, sort, and select columns. Every method returns a new immutable query, so you can build a base query and branch from it without conflicts.
from opendata_sdk import OpenData, Queryclient = OpenData()q = (Query().fields("date", "value", "series_id") # select only these columns.eq("series_id", "CUUR0000SA0") # exact match.gte("year", 2020) # range filter.sort("date", desc=True) # newest first.limit(500))df = client.load("bls/cpi-u", q).to_pandas()print(df.head())
Full list of filter methods:
| Method | Operator | Example |
|---|---|---|
.eq(col, val) | equals | .eq("state", "CA") |
.ne(col, val) | not equals | .ne("status", "error") |
.gt(col, val) | greater than | .gt("year", 2020) |
.gte(col, val) | greater or equal | .gte("population", 1000000) |
.lt(col, val) | less than | .lt("price", 50) |
.lte(col, val) | less or equal | .lte("year", 2024) |
.like(col, pattern) | SQL LIKE | .like("name", "%California%") |
.isin(col, values) | in list | .isin("state", ["CA", "TX", "NY"]) |
Query objects are immutable -- chaining always creates a new object, so your original is never modified. This means you can reuse a base query across multiple calls safely.
# Base query you'll reusebase = Query().fields("year", "state", "value").gte("year", 2000)# Branch for different states -- base stays untouchedca = client.load("census/population", base.eq("state", "CA")).to_pandas()tx = client.load("census/population", base.eq("state", "TX")).to_pandas()
Aggregation
Use .group_by() and .aggregate() to summarize data server-side. Aggregations are computed before the response is returned, so you only download the grouped result -- not all the raw rows.
from opendata_sdk import OpenData, Queryclient = OpenData()# Total population and year count, grouped by stateq = (Query().group_by("state").aggregate("sum:population", "count:year"))df = client.load("census/population", q).to_pandas()print(df.head())
Dataset Metadata and Schema Discovery
Before loading data, you can inspect what a dataset contains without fetching any rows.
# Full metadata (renders as HTML in Jupyter)meta = client.meta("bls/cpi-u")print(meta.name) # dataset nameprint(meta.description) # what it containsprint(meta.rows) # total rowsprint(meta.available_views) # alternate views like 'timeseries', 'by_state'# Column stats -- types, distinct values, nulls, sample valuescolumns = client.datasets.columns("bls/cpi-u")for col in columns:print(f"{col.name} ({col.type}): {col.distinct_count} distinct")# Result schema -- works without fetching all dataresult = client.query("bls/cpi-u")print(result.dtypes) # {'year': 'BIGINT', 'value': 'DOUBLE', ...}print(result.schema) # [{'name': 'year', 'type': 'BIGINT'}, ...]
Views
Many datasets have alternate views that reshape or enrich the data. A dataset with raw monthly values might have a timeseries view that normalizes dates or a by_state view that pivots the data.
# See what views are availableviews = client.datasets.views("bls/cpi-u")for v in views:print(f"{v.name}: {v.description}")# Use a specific viewdf = client.load("bls/cpi-u", Query().view("timeseries")).to_pandas()
SQL Queries
The client.sql.execute() method lets you run DuckDB-compatible SQL directly against any dataset. Use it when you need JOINs across datasets, window functions, GROUP BY, or anything the Query Builder can't express. Results convert to pandas or polars the same way as load() and query().
SQL queries require an API key. Public unauthenticated access isn't supported for the SQL endpoint.
Basic Query
from opendata_sdk import OpenDataclient = OpenData(api_key="od_live_...")result = client.sql.execute("SELECT year, value FROM fred/gdp WHERE year > 2020 ORDER BY year")df = result.to_pandas()print(df)
Cross-Dataset JOIN
Reference datasets by their provider/slug path directly in SQL. You can JOIN across providers as long as they share a common key column.
result = client.sql.execute("""SELECT a.year, a.value AS gdp, b.populationFROM fred/gdp aJOIN census/population b ON a.year = b.yearWHERE a.year > 2015""")df = result.to_pandas()print(df.head())
Bind Parameters
Use ? placeholders and pass values via params. This keeps your SQL clean and avoids string interpolation bugs.
result = client.sql.execute("SELECT * FROM fred/gdp WHERE year > ? AND value < ?",params=[2020, 100.0],)df = result.to_pandas()
Options and Result Metadata
# Limit execution time and cap the number of rows returnedresult = client.sql.execute("SELECT * FROM fred/gdp",timeout_ms=3000,row_limit=500,)# Result metadataprint(result.execution_time_ms) # 125.0print(result.truncated) # False -- True if row_limit was hitprint(result.row_count) # 5# Convert to DataFrame the same way as load() / query()df = result.to_pandas()df = result.to_polars() # if you installed tryopendata[polars]
Search
Find datasets without leaving your code. client.search() runs a full-text search against the catalog. client.suggest() is faster and designed for autocomplete -- it returns lightweight results as you type.
# Full-text search -- returns rich metadata for each matchresults = client.search("inflation")for ds in results.results:print(f"{ds.provider}/{ds.slug}: {ds.name}")print(f" {(ds.description or '')[:80]}...")print()# Autocomplete -- fast, minimal results, good for search-as-you-typesuggestions = client.suggest("inf")for s in suggestions:print(s.slug)
Authentication
Public datasets work without a key. For higher rate limits or write access, pass your API key when creating the client.
# Option 1: pass directlyclient = OpenData(api_key="od_live_...")# Option 2: environment variable (SDK reads it automatically)# export OPENDATA_API_KEY=od_live_...client = OpenData()
The environment variable approach is cleaner for notebooks -- set it once in your environment and every script picks it up without hardcoding keys in code.
Async Usage
The async client is a drop-in replacement for async applications. Import from opendata_sdk.aio and add await.
from opendata_sdk.aio import OpenData as AsyncOpenDataasync with AsyncOpenData(api_key="od_live_...") as client:# Load a datasetdf = await client.load("bls/cpi-u")df = df.to_pandas()# Search the catalogresults = await client.search("unemployment rate")# Run a SQL queryresult = await client.sql.execute("SELECT * FROM fred/gdp LIMIT 10")df = result.to_pandas()
Error Handling
Errors are typed, so you can catch exactly what you need. Each exception includes the original API message, the HTTP status code, and a request_id you can share with support to trace a specific request.
When an error is printed or lands in a traceback, it includes a hint about what went wrong and how to fix it -- not just an error code.
from opendata_sdk import (OpenData,AuthenticationError,NotFoundError,RateLimitError,InvalidRequestError,APIConnectionError,)client = OpenData()try:df = client.load("bls/cpi-u", Query().eq("bad_column", "x")).to_pandas()except AuthenticationError:# Your API key is missing or invalid.# The error message includes instructions for setting OPENDATA_API_KEY.raiseexcept NotFoundError as e:# Dataset path doesn't exist. The error suggests how to search for the right path.print(f"Not found: {e.message}")results = client.search("cpi") # find what you were looking forexcept InvalidRequestError as e:# Bad filter, wrong column name, etc.# The error message names the column naming rules and points to client.meta().print(e) # prints the API message + guidance on valid column namesexcept RateLimitError as e:# The SDK retries automatically, but if retries are exhausted:print(f"Still rate limited after retries. Wait {e.retry_after}s")except APIConnectionError:# Network issue or timeout. Increase timeout if needed:slow_client = OpenData(timeout=60.0)df = slow_client.load("bls/cpi-u").to_pandas()
| Exception | HTTP Status | When |
|---|---|---|
AuthenticationError | 401 | Missing or invalid API key |
ForbiddenError | 403 | Key doesn't have access to this resource |
NotFoundError | 404 | Dataset or provider path doesn't exist |
RateLimitError | 429 | Too many requests (SDK retries automatically) |
InvalidRequestError | 400/422 | Bad filter values, wrong column names |
APIError | 5xx | Server error (SDK retries GET requests automatically) |
APIConnectionError | n/a | Network failure or timeout |
The SDK retries GET requests automatically on 429 and 5xx responses, using exponential backoff. POST/PATCH/DELETE requests are never retried automatically.
Configuration
client = OpenData(api_key="od_live_...", # or OPENDATA_API_KEY env varbase_url="https://custom.api/v1", # default: https://api.tryopendata.ai/v1timeout=60.0, # request timeout in seconds (default: 30)max_retries=5, # retry count for GET requests (default: 3))# Use as a context manager to clean up connections automaticallywith OpenData() as client:df = client.load("census/state-populations").to_pandas()
Next Steps
- Querying Guide -- Full details on filtering, aggregation, and views
- Authentication -- API key management and scopes
- Rate Limits -- Usage limits and how automatic retries work
- API Reference -- Every endpoint, if you want to go lower-level