Prism Data Lab

Every analysis here ships its dataset and the code that produced every figure. Nothing on this site is investment advice.

Data Sources1,117 words, 5 minutes

Pulling FRED series without an API key, cleanly, in Python

FRED serves every series as a plain CSV with no key. A standard-library script that downloads, merges, and coverage-checks any list of series ids, with the gotchas.

The FRED web service needs a key. The documentation says so in one sentence: "All web service requests require an API key to identify requests." What most tutorials skip is that the CSV behind the Download button on every series page does not. It is a stable URL with the series id as its only parameter, it returns the full history in two columns, and it is the endpoint every analysis on this site uses. This article is the tool we use to pull it, the three problems the raw file gives you, and a coverage check that catches the fourth.

The endpoint

https://fred.stlouisfed.org/graph/fredgraph.csv?id=DGS10

That is the whole interface. The response is a text file with a header row (observation_date,DGS10) and one row per observation. Daily series carry a row for every weekday including market holidays; monthly series carry the first of the month; quarterly series carry the first day of the quarter. A missing value is written as a single period, ., not as an empty cell, which is the first thing to fix.

The script fetches each id with urllib.request, parses it with the csv module, replaces . with an empty string, and returns a dictionary from date to value. Nothing outside the standard library is needed for the download; pandas (3.0.2 here) is imported only for the coverage table at the end.

BASE = "https://fred.stlouisfed.org/graph/fredgraph.csv?id="

def fetch(series_id: str) -> dict[str, str]:
    req = urllib.request.Request(BASE + series_id, headers={"User-Agent": "prism-data-lab/1.0 (research)"})
    with urllib.request.urlopen(req, timeout=60) as r:
        text = r.read().decode("utf-8")
    rows = list(csv.reader(io.StringIO(text)))
    header = rows[0]
    if len(header) < 2:
        raise SystemExit(f"{series_id}: unexpected header {header!r} (bad id?)")
    return {row[0]: ("" if row[1] == "." else row[1]) for row in rows[1:] if row}

A wrong series id does not return an HTTP error. It returns a page that is not a two-column CSV, which is why the header check is there: without it a typo produces an empty column and a script that runs to completion with nothing in it.

Merging series that do not share a calendar

Two series pulled this way rarely line up. DGS10 is daily from the Federal Reserve's H.15 release; T10Y2Y is a daily spread FRED computes; CPIAUCSL is monthly. The script takes the union of all dates across the requested ids, sorts it, and writes one wide CSV with a blank cell wherever a series has no observation on that date. It does not forward-fill. Forward-filling a monthly CPI onto daily Treasury dates is a modelling decision, and it belongs in the analysis script where it can be seen, not in the download step where it cannot.

The --since flag trims the union of dates to a start date. It is applied after the download, so the full history is fetched regardless; FRED's CSV endpoint has no date parameters, and the files are small enough (a daily series back to 1962 is under 400 KB) that trimming client-side costs nothing.

Running it

python code/fred-series-python-no-api-key.py DGS10 DGS2 T10Y2Y \
    --out datasets/fred-series-python-no-api-key.csv --since 2000-01-01

Output on 2026-09-06:

series first date last date observations empty cells in merged file
DGS10 2000-01-03 2026-09-03 6672 288
DGS2 2000-01-03 2026-09-03 6672 288
T10Y2Y 2000-01-03 2026-09-04 6673 287

The merged file has 6,960 rows. The 288 empty cells in DGS10 are the market holidays FRED lists as ., plus one more: on 2026-09-06 the two constant-maturity yields ended on 2026-09-03 while the spread had a 2026-09-04 observation of 0.41. The H.15 yields and the spread FRED derives from Treasury's own curve are posted on different schedules, and for a day or two the derived series can be ahead of its components. Nobody warns you about this; the coverage table is what surfaces it.

The last rows of the file make the point concretely:

date DGS10 DGS2 T10Y2Y
2026-09-02 4.79 4.39 0.40
2026-09-03 4.77 4.34 0.43
2026-09-04 0.41

A script that computed DGS10 - DGS2 and compared it to T10Y2Y on the last row would compare a blank to a number. A script that dropped rows with any blank would silently lose the newest spread observation. Neither is wrong; both should be chosen on purpose.

The four problems, named

  1. The missing marker. FRED writes . for a missing observation. pandas.read_csv(..., na_values=".") handles it; a plain float() does not. The script converts it to an empty cell so downstream code sees a standard blank.
  2. The calendar. Daily series include holidays as rows with .; monthly series are dated on the first of the month even when the release describes the whole month; weekly series such as MORTGAGE30US are dated on the survey's Thursday. Joining on date without knowing this produces a file that is mostly blanks and looks broken.
  3. Units. DGS10 is in percent; T10Y2Y is in percentage points; SP500 is an index level; PAYEMS is in thousands of persons. The CSV header carries none of this. The series page does, and the citation in an article should carry the series id and the retrieval date so that the units can be checked.
  4. Coverage and licensing. Some series are truncated by agreement with their owner. The SP500 series notes state that FRED "will include 10 years of daily history" for S&P Dow Jones Indices series, and that "since this is a price index and not a total return index, the S&P 500 index here does not contain dividends." A ten-year window that rolls forward every day means the first row of your file changes with each download, and a study that quotes "since 2016" today will not reproduce next year.

Why the retrieval date matters

FRED revises. Monthly payrolls are revised twice after the first print and benchmarked annually; GDP has three estimates and then annual and comprehensive revisions; even the H.15 yields are occasionally corrected. The CSV endpoint serves the current vintage only. If a figure in an article needs to be reproducible, the article must ship the file it used, dated, because the same URL will serve different numbers later. Every dataset on this site carries its retrieval date in the article's Sources list for that reason, and the --since trimming keeps the shipped file to the window the analysis uses.

FRED also has a vintage archive (ALFRED) for those who need the number as it was known on a given day. It is a different tool for a different question and it is the subject of a later article; for "what does the series say now", the plain CSV is enough.

What this does not cover

The script does not authenticate, so it cannot use the search, categories, or release-calendar features of the keyed API, and it cannot ask for a specific vintage. It does not retry: a network failure raises and stops, which is the right behaviour for a download step that another script depends on. It does not resample, fill, or convert frequency, on purpose. And it does not verify that the series you asked for is the series you meant: CPIAUCSL and CPIAUCNS are the same index seasonally adjusted and not, and the endpoint will hand you either without comment. Read the series page once; then automate.

This article is analysis and education, not investment, tax, or legal advice. Figures are cited to their source and dated; check them before relying on them.