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 polars
pip 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.

MethodReturnsWhen to use
client.load(path)All rows, paginated automaticallyMost 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 metadataComplex queries, JOINs across datasets, window functions, GROUP BY
from opendata_sdk import OpenData
client = OpenData()
# Load everything into a DataFrame -- the simplest path
df = client.load("bls/cpi-u").to_pandas()
# Sample the first 1,000 rows for quick exploration
sample = client.query("bls/cpi-u").to_pandas()
# Process a huge dataset without loading it all at once
for 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, Query
client = 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:

MethodOperatorExample
.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 reuse
base = Query().fields("year", "state", "value").gte("year", 2000)
# Branch for different states -- base stays untouched
ca = 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, Query
client = OpenData()
# Total population and year count, grouped by state
q = (
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 name
print(meta.description) # what it contains
print(meta.rows) # total rows
print(meta.available_views) # alternate views like 'timeseries', 'by_state'
# Column stats -- types, distinct values, nulls, sample values
columns = 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 data
result = 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 available
views = client.datasets.views("bls/cpi-u")
for v in views:
print(f"{v.name}: {v.description}")
# Use a specific view
df = 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 OpenData
client = 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.population
FROM fred/gdp a
JOIN census/population b ON a.year = b.year
WHERE 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 returned
result = client.sql.execute(
"SELECT * FROM fred/gdp",
timeout_ms=3000,
row_limit=500,
)
# Result metadata
print(result.execution_time_ms) # 125.0
print(result.truncated) # False -- True if row_limit was hit
print(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 match
results = 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-type
suggestions = 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 directly
client = 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 AsyncOpenData
async with AsyncOpenData(api_key="od_live_...") as client:
# Load a dataset
df = await client.load("bls/cpi-u")
df = df.to_pandas()
# Search the catalog
results = await client.search("unemployment rate")
# Run a SQL query
result = 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.
raise
except 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 for
except 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 names
except 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()
ExceptionHTTP StatusWhen
AuthenticationError401Missing or invalid API key
ForbiddenError403Key doesn't have access to this resource
NotFoundError404Dataset or provider path doesn't exist
RateLimitError429Too many requests (SDK retries automatically)
InvalidRequestError400/422Bad filter values, wrong column names
APIError5xxServer error (SDK retries GET requests automatically)
APIConnectionErrorn/aNetwork 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 var
base_url="https://custom.api/v1", # default: https://api.tryopendata.ai/v1
timeout=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 automatically
with OpenData() as client:
df = client.load("census/state-populations").to_pandas()

Next Steps

Search datasets

Search for datasets or run quick actions