FMR dataset download: the fair market rent CSV that loads clean
The short version
HUD publishes FY2026 fair market rents as two Excel workbooks on huduser.gov. They hold every number you need, and they put up a fight on the way into your code. This page is about the other deliverable: the same data as one CSV where the load is a single pd.read_csv and the columns already have the right types.
You can test that claim in ten seconds, no download step. This URL is live:
https://jayjex.github.io/data-vault/data/hud-fmr-2026/sample.csv
It returns a 22-row sample of the ZIP-level file, 1.7 KB. The full dataset behind it is fmr-by-zip-2026.csv: 51,895 rows, one per ZIP code, plus a county file (3,229 rows) and a state summary (52 rows) in the same shape. Everything below comes from real runs against these files, pandas 3.0.5, September 2026.
Why the official HUD workbooks resist code
We built the CSV in this pack by parsing HUD's two source workbooks with openpyxl. The parse works now. The first pass hit four problems worth knowing about, because they will bite you if you download the workbooks directly.
Hard line breaks inside the header. The SAFMR workbook is one sheet, SAFMRs, 51,896 rows by 18 columns. Its header cells contain raw newline characters: the first column is not called ZIP Code but ZIP\nCode, and the rent columns read like SAFMR\n0BR -\n90%\nPayment\nStan.... Any code that matches headers by name has to strip the line breaks first or nothing matches.
Three columns per number you want. Those 18 columns carry 6 values per bedroom size: the FMR itself plus 90% and 110% payment-standard variants, for five sizes. If you want the FMRs, you walk the row with a stride of three and take every third cell. That layout is sensible in a spreadsheet built for program staff. In a dataframe, the twelve payment-standard columns are noise.
A workbook that refuses to open. The county-level file FY26_FMRs_revised.xlsx ships with a malformed timestamp in its document properties: 2026- 2-19T18:17:31Z, a space where the month padding should be. openpyxl dies on load with Unable to read workbook: could not read properties before you see a single row. Our build unzips the xlsx, patches the two property files with a regex, and rezips before openpyxl will touch it. Same file, two sheets, and the sheet dimension reports 1,048,576 rows because the grid runs to the Excel maximum; real data is a few thousand rows, so you cannot trust max_row.
Display strings instead of numbers. Rent values come out of the cells as whatever the spreadsheet decided to display: strings like '850' in the SAFMR file, floats like 1090.0 or dollar-formatted strings with commas in the county file. Every value needs normalizing to a bare integer before it lands in a column typed as money. And the two files disagree on column names for the same concept: the county file calls the state column stusps, the SAFMR file calls the ZIP column ZIP\nCode, and neither file marks metro status at ZIP level, so we derived it from the area code prefix and the area name.
None of this is criticism of HUD. The workbooks are built for humans reading tables. It is just the wrong shape for a dataframe, and the cleanup is exactly what the CSV in this pack has already done.
What the clean dataset looks like
One table, ten columns, one row per ZIP code. The full column-by-column reference lives in the HUD FMR data dictionary; the shape is:
- zip: 5-character text, one per row, 51,895 rows total
- hud_area_code, metro, area_name, state: the four geography columns
- fmr_0br through fmr_4br: monthly dollar amounts as bare integers, all five present in every row
Verified against the full file: zero rows with an empty fmr value, so no NA handling on load. Plain UTF-8. Only area_name is quoted, because names like "Abilene, TX MSA" contain a comma. Every other cell is bare.
pd.read_csv against the live sample
Real session, pandas 3.0.5. The sample URL loads straight into a dataframe, no download step:
import pandas as pd
url = "https://jayjex.github.io/data-vault/data/hud-fmr-2026/sample.csv"
df = pd.read_csv(url)
print(df.shape)
print(df.dtypes)
print(df.head(3))
Output, verbatim:
(22, 10)
zip int64
hud_area_code str
metro str
area_name str
state str
fmr_0br int64
fmr_1br int64
fmr_2br int64
fmr_3br int64
fmr_4br int64
zip hud_area_code metro area_name state fmr_0br fmr_1br fmr_2br fmr_3br fmr_4br
0 76437 METRO10180M10180 metro Abilene, TX MSA TX 850 880 1090 1420 1710
1 76443 METRO10180M10180 metro Abilene, TX MSA TX 850 860 1090 1420 1710
2 76464 METRO10180M10180 metro Abilene, TX MSA TX 850 860 1090 1420 1710
Three things to notice. The five fmr columns arrive as int64 with zero coercion, because the values are bare integers. The one comma-carrying column parses fine, because the quote marks are where they belong. And zip loaded as a number here, which is the one thing to fix: pass dtype={'zip': str}.
The zero matters more than it looks. The full file has 3,533 rows whose ZIP starts with 0 (3,004 unique ZIPs, the 006xx Puerto Rico range and the 0xxxx New England range among them). Loaded as int, ZIP 00601 becomes 601 and stops matching any geography table. One dtype argument fixes it:
df = pd.read_csv(url, dtype={"zip": str})
df["zip"].iloc[0] # '76437'
df["zip"].str.len().unique() # [5]
The full 51,895-row file loads the same way
The full ZIP file is in the pack, and it behaves like a bigger version of the sample. Real load of fmr-by-zip-2026.csv:
- shape (51895, 10), about 15.3 MB in memory
- 52 states and territories, 27,296 metro and 24,599 nonmetro rows
- fmr_2br: mean 1,378, median 1,150, min 480, max 5,230, no missing values
From there, work starts immediately. Group by area code to compare ZIP-level variation inside one metro:
df.groupby(["hud_area_code", "area_name"])["fmr_2br"].agg(["median", "min", "max"])
On the sample, that shows the Abilene metro with a 2BR median of 1,090 and a ZIP-level max of 1,900, the spread HUD's Small Area FMRs create inside one metro. Or join the state summary, a 52-row aggregate that ships in the same pack:
ss = pd.read_csv("https://jayjex.github.io/data-vault/data/hud-fmr-2026/state-summary.csv")
df.merge(ss[["state", "fmr_2br_median"]], on="state")
The merge is clean on the first try because both files key on the same two-letter state codes. When a download is enough and when a query layer wins instead: CSV vs API for data.
Get the FMR dataset
The free sample and state summary carry the exact schema documented above. The full pack adds all 51,895 ZIP rows, the 3,229-row county file, source checksums, and the data dictionary.
Checkout and download run through Getly. Source: US Department of Housing and Urban Development, FY2026 Fair Market Rent release, huduser.gov. US government data, public domain.
FMR dataset questions
- Where can I download a fair market rent dataset as one CSV?
- HUD publishes its FY2026 FMRs as two Excel workbooks on huduser.gov: one for ZIP-level Small Area FMRs and one for county-level FMRs. If you want the whole dataset as a single CSV with typed columns, the Data Vault HUD Fair Market Rents FY2026 pack ships fmr-by-zip-2026.csv, one row per ZIP code, 51,895 rows, plus a free 22-row sample you can load straight into pandas from a URL.
- What does the FMR dataset contain?
- The ZIP-level file has ten columns: zip, hud_area_code, metro, area_name, state, and one fmr column for each bedroom size from 0br through 4br. Every row carries all five monthly dollar amounts, and the full file has no empty fmr cells, so there is nothing to special-case on load. The pack adds a 3,229-row county file and a 52-row state summary in the same style.
- How do I load the FMR dataset in pandas?
- pd.read_csv loads it in one line. Read the zip column as text so leading zeros survive: pd.read_csv(url, dtype={'zip': str}). 3,533 rows in the full 51,895-row file have ZIPs starting with 0, and any tool that treats them as integers turns 00601 into 601. The five fmr columns arrive as int64 on their own, so the rest of the file needs no dtype hints.