BEA API Python Tutorial: GDP and Income Analysis
Download state GDP and personal income data, discover metadata, clean BEA JSON with pandas, compare growth, build charts and a map, and create a reproducible state animation.
BEA API with Python: Download GDP and Income Data
The Bureau of Economic Analysis provides an API for downloading official U.S. economic data directly into Python.
In this tutorial, you will use the BEA API to download state personal income and GDP data, turn the response into a pandas DataFrame, discover valid tables and line codes, calculate growth rates, and build a workflow that can be reused when BEA updates its data.
The examples use Python's requests library so you can see how the BEA API works directly.
If you prefer a package that handles more of the API work for you, BEA also maintains an official Python library called beaapi. We will look at that option later in the tutorial.
Table of contents
- Quick BEA API example
- What is the BEA API?
- Use Regional, not RegionalData
- Get a BEA API UserID
- Install Python packages
- Understand Regional parameters
- Build a reusable API function
- Discover tables and line codes
- Convert BEA data to pandas
- Download personal income
- Download real GDP
- Calculate growth rates
- Join GDP and income data
- Plot the results
- Save notes and metadata
- Handle rate limits
- Direct requests or beaapi?
- Common errors
- Extend the workflow to counties
- Validation checklist
- Frequently asked questions
Quick example: get BEA data with Python
If you already have a BEA UserID, this example downloads per capita personal income for all states.
import os
import requests
import pandas as pd
url = "https://apps.bea.gov/api/data"
params = {
"UserID": os.getenv("BEA_API_KEY"),
"method": "GetData",
"DataSetName": "Regional",
"TableName": "SAINC1",
"LineCode": "3",
"GeoFips": "STATE",
"Year": "LAST5",
"ResultFormat": "JSON",
}
response = requests.get(
url,
params=params,
timeout=30,
)
response.raise_for_status()
payload = response.json()
records = payload["BEAAPI"]["Results"]["Data"]
df = pd.DataFrame(records)
print(df.head())
The request uses the Regional dataset and the SAINC1 table. In the current BEA documentation, line code 3 in SAINC1 represents per capita personal income. GeoFips=STATE requests state data, while LAST5 asks for the latest five available years.
The basic BEA API workflow is:
- Choose a dataset.
- Choose a table.
- Choose a statistic inside that table.
- Choose a geography and time period.
- Send the request.
- Convert the result into a DataFrame.
The rest of this tutorial makes that process safer and easier to reuse.
What is the BEA API?
BEA is the U.S. Bureau of Economic Analysis. Its data cover areas such as national income, GDP, industries, international transactions, regional economies, personal income, and consumer spending.
The BEA Data API gives programs direct access to published statistics and metadata. The API can return data in JSON or XML format.
For this tutorial, we mainly use the Regional dataset because it contains detailed state and county economic statistics, including GDP and personal income.
The base API address is:
https://apps.bea.gov/api/data
All direct API requests in this tutorial use that endpoint.
Use Regional, not RegionalData
Older tutorials may refer to a dataset called:
RegionalData
Do not use it.
The current BEA API guide marks RegionalData as obsolete. Use:
Regional
This is one reason it is useful to check current API metadata instead of copying table names and codes from an old example.
Get a BEA API UserID
BEA requires registered API users to obtain a unique 36-character UserID.
You can register through the official BEA API signup page.
Do not place your real UserID directly inside code that may be shared on GitHub, in a public notebook, or in an article. Store it in an environment variable instead.
macOS or Linux
export BEA_API_KEY="YOUR_36_CHARACTER_USER_ID"
Windows PowerShell
$env:BEA_API_KEY="YOUR_36_CHARACTER_USER_ID"
Then read it in Python:
import os
BEA_USER_ID = os.getenv("BEA_API_KEY")
if not BEA_USER_ID:
raise RuntimeError(
"BEA_API_KEY is not set."
)
This keeps the credential outside your Python source file.
Install the Python packages
python -m pip install requests pandas matplotlib
| Package | Purpose |
|---|---|
requests |
Sends requests to the BEA API |
pandas |
Cleans and analyzes the returned data |
matplotlib |
Creates charts |
You do not need a special package to call the BEA API directly.
Understand the Regional API parameters
The current BEA documentation lists four dataset-specific parameters for the Regional dataset: GeoFips, LineCode, TableName, and Year.
| Parameter | Meaning | Example |
|---|---|---|
DataSetName |
Dataset to use | Regional |
TableName |
Economic table | SAINC1 |
LineCode |
Statistic inside the table | 3 |
GeoFips |
Geography | STATE |
Year |
Requested years | LAST5 |
UserID |
Your BEA API identifier | Environment variable |
method |
API operation | GetData |
ResultFormat |
Response format | JSON |
For Regional data, Year supports individual years, comma-separated years, LAST5, LAST10, and ALL.
Avoid requesting ALL unless you really need every available year. Smaller requests are easier to inspect and faster to process.
Do not guess table names or line codes
A Regional table can contain several statistics, and LineCode identifies which statistic you want.
For example, the current SAINC1 metadata contains:
| LineCode | Statistic |
|---|---|
1 |
Personal income |
2 |
Population |
3 |
Per capita personal income |
For other tables, ask the API for valid values instead of assuming that a code found in an old notebook is still correct.
Build a reusable BEA API function
import os
from typing import Any
import requests
BEA_URL = "https://apps.bea.gov/api/data"
def bea_request(
params: dict[str, Any],
) -> dict[str, Any]:
user_id = os.getenv("BEA_API_KEY")
if not user_id:
raise RuntimeError(
"BEA_API_KEY is not set."
)
request_params = {
"UserID": user_id,
"ResultFormat": "JSON",
**params,
}
response = requests.get(
BEA_URL,
params=request_params,
timeout=30,
)
response.raise_for_status()
payload = response.json()
results = (
payload
.get("BEAAPI", {})
.get("Results", {})
)
error = results.get("Error")
if error:
raise RuntimeError(
"BEA API error "
f"{error.get('APIErrorCode')}: "
f"{error.get('APIErrorDescription')}"
)
return payload
Checking only the HTTP status is not enough. A BEA response can also contain an API error object inside the JSON result.
Discover the available BEA datasets
datasets_payload = bea_request({
"method": "GetDatasetList",
})
datasets = (
datasets_payload["BEAAPI"]
["Results"]["Dataset"]
)
for dataset in datasets:
print(
dataset["DatasetName"],
dataset["DatasetDescription"],
)
This metadata-first approach makes your code easier to maintain.
Discover Regional parameters
parameter_payload = bea_request({
"method": "GetParameterList",
"DataSetName": "Regional",
})
parameters = (
parameter_payload["BEAAPI"]
["Results"]["Parameter"]
)
for item in parameters:
print(
item["ParameterName"],
item.get("ParameterDescription"),
)
The current Regional result describes GeoFips, LineCode, TableName, and Year.
Find valid table names
Some useful state and county tables include:
| Table | Description |
|---|---|
SAGDP1 |
Annual GDP by state summary |
SAGDP9 |
Annual real GDP by state |
SQGDP1 |
Quarterly GDP by state summary |
SQGDP9 |
Quarterly real GDP by state |
SAINC1 |
Annual state personal income summary |
SQINC1 |
Quarterly state personal income summary |
CAGDP1 |
County GDP summary |
CAINC1 |
County personal income summary |
Do not treat this table as a permanent replacement for API metadata. BEA can change published tables, so your code should still be able to inspect current values.
Find the valid LineCode for a table
import pandas as pd
def get_line_codes(
table_name: str,
) -> pd.DataFrame:
payload = bea_request({
"method": "GetParameterValuesFiltered",
"DataSetName": "Regional",
"TargetParameter": "LineCode",
"TableName": table_name,
})
values = (
payload["BEAAPI"]["Results"]
.get("ParamValue", [])
)
return pd.DataFrame(values)
Now inspect a table:
line_codes = get_line_codes("SAGDP9")
print(
line_codes[
["Key", "Desc"]
].to_string(index=False)
)
Read the returned descriptions and choose the line that matches the measure you actually need. This is safer than copying an unexplained number from another tutorial.
Make a Regional data request
def get_regional_data(
table_name: str,
line_code: str,
geo_fips: str = "STATE",
year: str = "LAST5",
) -> dict:
return bea_request({
"method": "GetData",
"DataSetName": "Regional",
"TableName": table_name,
"LineCode": line_code,
"GeoFips": geo_fips,
"Year": year,
})
For example, download per capita personal income:
payload = get_regional_data(
table_name="SAINC1",
line_code="3",
geo_fips="STATE",
year="LAST5",
)
Convert the BEA response to pandas
def parse_bea_data(
payload: dict,
) -> tuple[pd.DataFrame, pd.DataFrame]:
results = payload["BEAAPI"]["Results"]
data = pd.DataFrame(
results.get("Data", [])
)
notes = pd.DataFrame(
results.get("Notes", [])
)
if data.empty:
return data, notes
data["GeoFips"] = (
data["GeoFips"]
.astype("string")
)
data["GeoName"] = (
data["GeoName"]
.astype("string")
.str.replace(
r"\s*\*$",
"",
regex=True,
)
.str.strip()
)
cleaned = (
data["DataValue"]
.astype("string")
.str.replace(
",",
"",
regex=False,
)
.replace({
"(D)": pd.NA,
"(NA)": pd.NA,
"--": pd.NA,
})
)
data["value"] = pd.to_numeric(
cleaned,
errors="coerce",
)
return data, notes
Then:
income, income_notes = (
parse_bea_data(payload)
)
print(income.head())
Keep GeoFips as text
GeoFips is an identifier, not a measurement. A code such as 01000 must keep its leading zero.
If you convert it to an integer, it becomes 1000, which can cause problems when you join BEA data with maps or other geographic datasets.
Do not ignore units
Important fields can include:
CL_UNIT
UNIT_MULT
TimePeriod
Code
GeoFips
GeoName
NoteRef
CL_UNIT describes the unit, and UNIT_MULT tells you the base-10 scale that applies to the value.
If you need the fully scaled numeric amount, you can calculate:
data["unit_mult"] = pd.to_numeric(
data["UNIT_MULT"],
errors="coerce",
)
data["scaled_value"] = (
data["value"]
* (10 ** data["unit_mult"])
)
For percentage growth calculations on the same series, the scale factor cancels out. For level comparisons, the unit multiplier matters.
Download state personal income
Personal income is available in the SAINC1 table. Line code 1 is personal income.
income_payload = get_regional_data(
table_name="SAINC1",
line_code="1",
geo_fips="STATE",
year="LAST5",
)
personal_income, income_notes = (
parse_bea_data(income_payload)
)
You can get per capita personal income with line code 3:
pcpi_payload = get_regional_data(
table_name="SAINC1",
line_code="3",
geo_fips="STATE",
year="LAST5",
)
pcpi, pcpi_notes = (
parse_bea_data(pcpi_payload)
)
BEA defines per capita personal income as personal income divided by population.
Download real GDP by state
The current BEA Regional documentation lists SAGDP9 as the annual real GDP by state table.
Instead of hard-coding its line code, inspect the current metadata:
gdp_lines = get_line_codes(
"SAGDP9"
)
print(
gdp_lines[
["Key", "Desc"]
].to_string(index=False)
)
Select the line that clearly describes the real GDP measure you need.
Then request the data:
gdp_payload = get_regional_data(
table_name="SAGDP9",
line_code="YOUR_LINE_CODE",
geo_fips="STATE",
year="LAST5",
)
real_gdp, gdp_notes = (
parse_bea_data(gdp_payload)
)
This extra metadata step makes the tutorial more reliable when BEA tables change.
Real GDP and current-dollar GDP are different
Current-dollar GDP reflects both changes in production and changes in prices.
Real GDP adjusts for price changes and is usually more useful when the question is how the volume of production changed over time.
BEA's regional tables provide several GDP measures, so always read the table and line descriptions before calculating growth.
Calculate annual growth by state
real_gdp["year"] = pd.to_numeric(
real_gdp["TimePeriod"],
errors="coerce",
)
real_gdp = real_gdp.sort_values(
["GeoFips", "year"]
)
real_gdp["gdp_growth"] = (
real_gdp
.groupby("GeoFips")["value"]
.pct_change(fill_method=None)
* 100
)
The calculation is:
Growth = 100 × ((current value / previous value) - 1)
Grouping by GeoFips is essential. Without it, pandas could compare the final observation for one state with the first observation for the next state.
Calculate personal income growth
personal_income["year"] = pd.to_numeric(
personal_income["TimePeriod"],
errors="coerce",
)
personal_income = (
personal_income
.sort_values(
["GeoFips", "year"]
)
)
personal_income["income_growth"] = (
personal_income
.groupby("GeoFips")["value"]
.pct_change(fill_method=None)
* 100
)
Do not replace missing observations with zero before doing this calculation. A missing economic observation and a true value of zero are not the same thing.
Join GDP and personal income
gdp_ready = real_gdp[
[
"GeoFips",
"GeoName",
"TimePeriod",
"gdp_growth",
]
].copy()
income_ready = personal_income[
[
"GeoFips",
"GeoName",
"TimePeriod",
"income_growth",
]
].copy()
Then merge them:
state_panel = gdp_ready.merge(
income_ready,
on=[
"GeoFips",
"GeoName",
"TimePeriod",
],
how="inner",
)
Inspect the result:
print(
state_panel
.sort_values(
[
"TimePeriod",
"gdp_growth",
],
ascending=[
False,
False,
],
)
.head(10)
)
This gives you a simple state-level dataset for comparing GDP growth with personal income growth.
Plot GDP growth against income growth
import matplotlib.pyplot as plt
latest_period = (
state_panel["TimePeriod"]
.dropna()
.max()
)
plot_data = (
state_panel[
state_panel["TimePeriod"]
== latest_period
]
.dropna(
subset=[
"gdp_growth",
"income_growth",
]
)
)
fig, ax = plt.subplots(
figsize=(9, 6)
)
ax.scatter(
plot_data["gdp_growth"],
plot_data["income_growth"],
)
ax.axhline(
0,
linewidth=0.8,
)
ax.axvline(
0,
linewidth=0.8,
)
ax.set_title(
f"State GDP Growth vs Personal Income Growth, "
f"{latest_period}"
)
ax.set_xlabel(
"Real GDP growth, percent"
)
ax.set_ylabel(
"Personal income growth, percent"
)
ax.grid(alpha=0.2)
fig.tight_layout()
plt.show()
The chart is descriptive. A state with faster GDP growth may also have faster income growth, but the chart does not show that GDP growth caused the income change.
Save the source notes
Do not throw away the Notes part of the BEA response. BEA data can include notes about definitions, revisions, units, and special observations.
personal_income.to_csv(
"bea_personal_income.csv",
index=False,
)
income_notes.to_csv(
"bea_personal_income_notes.csv",
index=False,
)
Also record the date when you downloaded the data. BEA periodically revises economic estimates as new source data and updated methods become available.
Handle BEA API rate limits
The current BEA API guide documents standard limits of 100 requests per minute, 100 MB of data per minute, and 30 errors per minute. BEA notes that these limits can change.
When the API throttles a request, it can return HTTP status 429 with a Retry-After header.
For small research projects:
- Request only the years you need.
- Reuse downloaded data during analysis.
- Avoid repeatedly requesting the same metadata.
- Stop and inspect errors instead of sending the same invalid request again.
For larger workflows, add retry and caching logic.
Direct requests or the official beaapi package?
BEA also provides an official open-source Python package called beaapi.
python -m pip install beaapi
import beaapi
The package includes functions for listing datasets, reading parameters, finding parameter values, and downloading data into pandas-friendly objects.
Use direct requests when
- You want to learn how the BEA API works.
- You want to inspect the raw response.
- You want complete control over each request.
- You want to avoid an extra dependency.
Use beaapi when
- You want a shorter Python interface.
- You prefer BEA's official helper package.
- You want convenient metadata and data-loading functions.
Both approaches use the same underlying BEA economic data.
Common BEA API errors
Missing UserID
import os
print(
bool(os.getenv("BEA_API_KEY"))
)
Invalid UserID
BEA can return an API error describing an invalid UserID. Check the Results.Error object instead of assuming every HTTP 200 response contains valid data.
Invalid table or LineCode
Do not guess. Use GetParameterValues or GetParameterValuesFiltered to discover current values.
Empty data
- Check the table name.
- Check the line code.
- Check the geography.
- Check the requested years.
- Check the API error object.
- Check the returned notes.
Lost leading zeros
If geographic codes look too short, you probably converted GeoFips to numbers. Store them as strings.
Numbers look too large or too small
Check CL_UNIT and UNIT_MULT before interpreting the values.
Extend the workflow to counties
The same Regional API can retrieve county data. The current BEA guide lists CAGDP1 for county GDP summary data and CAINC1 for county personal income summary data.
For all counties, use:
county_payload = get_regional_data(
table_name="CAINC1",
line_code="3",
geo_fips="COUNTY",
year="LAST5",
)
A state postal abbreviation can also be used to request counties within one state.
california_payload = get_regional_data(
table_name="CAINC1",
line_code="3",
geo_fips="CA",
year="LAST5",
)
County datasets are much larger than state datasets, so start with one state when you are testing your code.
A simple validation checklist
- Confirm the dataset, table, and LineCode.
- Check
CL_UNITandUNIT_MULT. - Keep
GeoFipsas text. - Inspect the returned notes.
- Confirm the requested years.
- Check for missing values.
- Sort the data before calculating growth.
- Calculate growth separately for each geography.
- Save the retrieval date.
- Keep the raw response or original downloaded data when reproducibility matters.
These checks are simple, but they prevent many avoidable mistakes.
Frequently asked questions
Do I need a BEA API key?
Yes. The BEA API is available to registered users, and registration provides a unique 36-character UserID.
What is the BEA API URL?
https://apps.bea.gov/api/data
Which BEA dataset should I use for state GDP?
Use the Regional dataset. The current Regional documentation includes annual and quarterly state GDP tables.
Which table contains state personal income?
SAINC1 contains annual personal income, population, and per capita personal income for states.
What does LineCode mean?
A LineCode identifies a specific statistic within a Regional table. Discover the valid line codes for the selected table before downloading data.
How do I request all states?
GeoFips=STATE
How do I request all counties?
GeoFips=COUNTY
What does LAST5 mean?
For the Regional dataset, LAST5 requests the latest five available years.
Should I use requests or beaapi?
Use requests if you want to understand and control the raw API request. Use BEA's official beaapi package if you want a more convenient Python interface.
Why can BEA historical data change?
BEA updates its estimates when new source information or improved methods become available. Keep the retrieval date and notes when reproducibility matters.
Final workflow
- Get a BEA UserID.
- Use the current
Regionaldataset. - Discover the available metadata.
- Choose the correct table.
- Discover the correct LineCode.
- Request only the geography and years you need.
- Convert the JSON data to pandas.
- Keep geographic codes as text.
- Check units and source notes.
- Calculate changes within each geography.
- Save enough metadata to reproduce the analysis later.
The most important habit is not memorizing BEA table codes. Let the API tell you what is currently available.
That approach takes a few extra lines of Python, but it makes your analysis easier to understand, easier to update, and much less likely to break when the underlying data change.
Official references
- BEA API for Data Retrieval User Guide
- BEA API registration
- BEA resources for developers
- Official BEA Python package, beaapi
- BEA methodologies
