data·vault_
← Catalog

FMR dataset download: the fair market rent CSV that loads clean

Published 2026-09-10. Built from the HUD Fair Market Rents FY2026 pack. Source: HUD, public domain. Data Vault is not affiliated with HUD. This page documents the data; it is not housing or legal advice.

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:

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:

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.