All articles

Getting Started with the BLS Public Data API

First pull, series IDs, and what to expect when building pipelines on the Bureau of Labor Statistics Public Data API.

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 IDDescriptionFrequencyRows (2015–2026)History
LNS14000000Unemployment rate, SAMonthly~132Back to 1948
CES0000000001Total nonfarm payrollsMonthly~132Back to 1939
CUUR0000SA0CPI-U, all items, NSAMonthly~132Back to 1913
PRS85006032Nonfarm business productivityQuarterly~44Back to 1947
LES1252881600QMedian usual weekly earningsQuarterly~44Back 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, SA
  • LNS11000000 — labor force participation rate, SA
  • LNU04000000 — unemployment level (thousands), NSA

CES (payrolls):

  • CES0000000001 — total nonfarm, SA
  • CES3000000001 — manufacturing, SA
  • CES9000000001 — government, SA
  • CEU0000000008 — average hourly earnings, all private, SA

CPI:

  • CUUR0000SA0 — CPI-U all items, NSA (use this for official CPI)
  • CUSR0000SA0 — CPI-U all items, SA
  • CUUR0000SA0L1E — Core CPI (less food & energy), NSA

JOLTS (job openings):

  • JTS000000000000000JOL — job openings level
  • JTS000000000000000HIL — hires level
  • JTS000000000000000QUL — 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: M01M12 = months Jan–Dec, Q01Q04 = 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=True for 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

Learn

Recent guides

View all →