Source on EconIndx: Bureau of Labor Statistics — free, register for v2 key (500 series/day), covers CPI, payrolls, unemployment, wages, and more.
Access & Pricing
Free. Register for a v2 API key at data.bls.gov/registrationEngine — email + organization name, instant activation. No cost, no contracts, no enterprise tier. The key unlocks 500 series/day and 50 series/request (vs 25/day and 10 series unregistered).
Your First Data Pull
BLS uses a POST-based API with JSON bodies. Series IDs are structured strings that encode the survey, geography, and metric:
import requests
import json
import pandas as pd
BLS_KEY = "your_key_here"
BLS_URL = "https://api.bls.gov/publicAPI/v2/timeseries/data/"
def fetch_bls(series_ids: list[str], start_year: str, end_year: str) -> pd.DataFrame:
payload = {
"seriesid": series_ids,
"startyear": start_year,
"endyear": end_year,
"registrationkey": BLS_KEY,
}
r = requests.post(BLS_URL, data=json.dumps(payload),
headers={"Content-type": "application/json"})
results = r.json()["Results"]["series"]
rows = []
for series in results:
sid = series["seriesID"]
for obs in series["data"]:
rows.append({
"series_id": sid,
"year": int(obs["year"]),
"period": obs["period"], # M01-M12, Q01-Q04, or A01
"value": float(obs["value"]),
"footnotes": obs.get("footnotes", []),
})
return pd.DataFrame(rows)
# Core US labor market series
df = fetch_bls(
series_ids=[
"LNS14000000", # Unemployment rate, SA
"CES0000000001", # Total nonfarm payrolls (thousands)
"CUUR0000SA0", # CPI-U all items, not seasonally adjusted
],
start_year="2015",
end_year="2026"
)
print(df.groupby("series_id")[["value"]].agg(["count", "min", "max"]))
First Pull: What to Expect
| Series ID | Description | Frequency | Rows (2015–2026) | History |
|---|---|---|---|---|
LNS14000000 | Unemployment rate, SA | Monthly | ~132 | Back to 1948 |
CES0000000001 | Total nonfarm payrolls | Monthly | ~132 | Back to 1939 |
CUUR0000SA0 | CPI-U, all items, NSA | Monthly | ~132 | Back to 1913 |
PRS85006032 | Nonfarm business productivity | Quarterly | ~44 | Back to 1947 |
LES1252881600Q | Median usual weekly earnings | Quarterly | ~44 | Back to 1979 |
Batch of 3 series (10 years): returns in under 2 seconds. BLS API v2 maxes at 50 series per request and 20 years per request — for longer history, make multiple calls with different year ranges.
Footnote codes to track: BLS embeds revision flags in the footnotes array. Key codes: P = preliminary (will be revised), R = revised, C = corrected. Always store footnotes — payroll numbers carry P for 2 months after release.
Key Series IDs to Know
BLS series IDs encode metadata in their structure. The pattern for most surveys:
[Survey prefix][Seasonal][Area code][Measure][...]
CPS (unemployment):
LNS14000000— unemployment rate, national, SALNS11000000— labor force participation rate, SALNU04000000— unemployment level (thousands), NSA
CES (payrolls):
CES0000000001— total nonfarm, SACES3000000001— manufacturing, SACES9000000001— government, SACEU0000000008— average hourly earnings, all private, SA
CPI:
CUUR0000SA0— CPI-U all items, NSA (use this for official CPI)CUSR0000SA0— CPI-U all items, SACUUR0000SA0L1E— Core CPI (less food & energy), NSA
JOLTS (job openings):
JTS000000000000000JOL— job openings levelJTS000000000000000HIL— hires levelJTS000000000000000QUL— quits level
Data Tolerance & Validation
What’s normal:
- Payroll data (
CES) is revised in the two months following release. Build your pipeline to re-pull the last 3 months on every run. - CPI is rarely revised after initial release — treat as final.
- Footnote
P(preliminary) on payrolls is expected for the most recent 1–2 months. - Period codes:
M01–M12= months Jan–Dec,Q01–Q04= quarters,A01= annual average.
Validation checks:
def validate_bls_pull(df: pd.DataFrame) -> dict:
by_series = df.groupby("series_id").agg(
row_count=("value", "count"),
latest_year=("year", "max"),
has_preliminary=("footnotes", lambda x: any(
any(f.get("code") == "P" for f in row) for row in x if row
))
).reset_index()
for _, row in by_series.iterrows():
stale = (2026 - row["latest_year"]) > 1
print(f"{row['series_id']}: {row['row_count']} rows, "
f"latest={row['latest_year']}, stale={stale}, "
f"has_prelim={row['has_preliminary']}")
validate_bls_pull(df)
Alert thresholds:
- Monthly series: alert if latest period is more than 45 days old
- Quarterly series: alert if latest period is more than 120 days old
- Any series where
has_preliminary=Truefor a period older than 3 months: re-pull
Getting Flat Files (Bulk Alternative)
For initial full loads, BLS bulk flat files at download.bls.gov are faster than the API:
# All CPS data — tab-delimited flat file
url = "https://download.bls.gov/pub/time.series/ln/ln.data.1.AllData"
df_bulk = pd.read_csv(url, sep="\t", dtype=str)
df_bulk.columns = df_bulk.columns.str.strip()
print(f"Full CPS dataset: {len(df_bulk):,} rows")
# Expected: ~500,000–800,000 rows for the full CPS series
Flat files are updated after each release. Use them for initial backfills, then switch to the API for incremental updates.
Schema Stability
BLS series IDs are highly stable — they rarely retire a series. The API response envelope has been consistent for years. The only change pattern to watch: BLS occasionally reclassifies industries in CES (payrolls), which can shift historical values for industry breakdowns. Monitor the footnotes field for revision notices on any series you’re tracking closely.
Next Steps
- Full details on rate limits, bulk file paths, and seasonal adjustment flags at the BLS source page on EconIndx
- Python:
pip install blsorpandas_datareaderhas BLS support - BLS Series ID structure reference: bls.gov/help/hlpforma.htm