Skip to contents

brfssdata is an R package, but the files it distributes are not an R format. Every survey year is a plain parquet file on a public release, and the R functions are one client among several. This article shows how to read those files from Python, from the DuckDB command line, and from SAS and Stata, and how to export an extract from R when a collaborator needs a file they can open in their own software.

The hosted files

Forty survey years, 1985 through 2024, are published as one parquet file per year at a fixed URL. Substitute the survey year in both places:

https://github.com/muntasirmasum/brfssdata/releases/download/data-YYYY/brfss_YYYY.parquet

For 2023 that resolves to:

https://github.com/muntasirmasum/brfssdata/releases/download/data-2023/brfss_2023.parquet

There is no token and no API. The 2023 file is about 29 MB and holds 433,323 rows and 351 columns, compressed with zstd, which every parquet reader of the last few years handles. Columns keep CDC’s canonical uppercase names, leading underscores on calculated variables included (_LLCPWT, _STSTR, _PSU, _IMPRACE), so a codebook written against CDC’s public-use file applies unchanged. One column is added, year, which matters once you read several files at once.

Almost every survey variable is stored as a physical DOUBLE, mirroring the SAS numerics these files come from. The exceptions are stored as strings, and which columns those are varies by year: the date parts (IDATE, IMONTH, IDAY, IYEAR) in every year, SEQNO from 1999 on, INTVID through 2012, and a few text-coded columns in the middle years (MRACE and RCSRACE among them). The added year column is an integer. Storing the numerics uniformly is deliberate and keeps a code comparable across files, but it is not a guarantee about CDC’s own choices: a column published as text in one year and as a number in another still differs between files, which is what the brfssdata_type_conflict error reports when such a pair is read together. The cost of DOUBLE storage is cosmetic, since an integral code renders as 2.0 in a CSV extract, so cast in SQL (GENHLTH::INTEGER) when integers are wanted.

The metadata lives under a single data-meta tag, nine assets in all. manifest.json lists the published years and carries a per-asset sha256 and size. Four parquet catalogs sit beside it: brfss_variables.parquet is the variable catalog (2,128 distinct variables with their labels and the years each one appears in), brfss_labels.parquet is the value-label catalog covering 1998 onward, brfss_crosswalk.parquet holds CDC’s rename families, so _DRNKWK1 through _DRNKWK3 read as one concept with a review status and a comparability note, and brfss_year_info.parquet is the per-year inventory of respondents, variables, jurisdictions, file size, and codebook URL. Each of the four catalogs has a .sha256 companion at the same URL with .sha256 appended; manifest.json does not, since it is the record the hashes live in. An air-gapped mirror wants all nine. A mirror missing the crosswalk falls back to the snapshot bundled with the installed package, which is only as current as that package version, and a mirror missing the year inventory has no bundled fallback to reach for at all.

https://github.com/muntasirmasum/brfssdata/releases/download/data-meta/manifest.json
https://github.com/muntasirmasum/brfssdata/releases/download/data-meta/brfss_variables.parquet
https://github.com/muntasirmasum/brfssdata/releases/download/data-meta/brfss_labels.parquet
https://github.com/muntasirmasum/brfssdata/releases/download/data-meta/brfss_crosswalk.parquet
https://github.com/muntasirmasum/brfssdata/releases/download/data-meta/brfss_year_info.parquet

Python

pandas reads a parquet URL directly. Naming the columns you want keeps memory down, though on plain HTTP the whole file still arrives before anything is decoded.

import pandas as pd

url = (
    "https://github.com/muntasirmasum/brfssdata/releases/download/"
    "data-2023/brfss_2023.parquet"
)

df = pd.read_parquet(url, columns=["year", "GENHLTH", "_LLCPWT"])
df["GENHLTH"].value_counts().sort_index()

DuckDB is the better tool when you want a slice of a year rather than the year. It reads the file over HTTP range requests, so a query touching three columns transfers roughly those three columns instead of all 351. Quote the underscore-prefixed names in SQL. DuckDB itself accepts them bare, since its lexer follows PostgreSQL in allowing a leading underscore, so sum(_LLCPWT) runs; other engines do not, and quoting is what makes the query portable.

import duckdb

duckdb.sql(f"""
  SELECT GENHLTH,
         count(*) AS n,
         sum("_LLCPWT") AS weighted_n
  FROM read_parquet('{url}')
  GROUP BY GENHLTH
  ORDER BY GENHLTH
""")

Add .df() to hand the result back to pandas, or .arrow() for an Arrow table. Several years combine in one call. Variable sets drift across years, so union_by_name = true fills the gaps with nulls, and the year column tells the rows apart afterwards.

base = "https://github.com/muntasirmasum/brfssdata/releases/download"
files = [f"{base}/data-{y}/brfss_{y}.parquet" for y in (2021, 2022, 2023)]

duckdb.sql(f"""
  SELECT year, count(*) AS n
  FROM read_parquet({files}, union_by_name = true)
  GROUP BY year
  ORDER BY year
""").df()

polars works the same way through pl.read_parquet(), and pyarrow through pyarrow.parquet.read_table().

The DuckDB command line

Nothing about the above needs Python. The DuckDB CLI runs the same queries, and the httpfs extension is what teaches it to read an https path. Recent versions load that extension on demand, but installing it explicitly works on any version.

INSTALL httpfs;
LOAD httpfs;

SELECT year, GENHLTH, count(*) AS n
FROM read_parquet(
  'https://github.com/muntasirmasum/brfssdata/releases/download/data-2023/brfss_2023.parquet'
)
GROUP BY year, GENHLTH
ORDER BY GENHLTH;

DESCRIBE SELECT * FROM read_parquet(...) prints the schema without reading the rows, which is a quick way to check whether a variable exists in a given year. To hand a subset to software that cannot read parquet, COPY writes it out:

COPY (
  SELECT year, GENHLTH, SEXVAR, "_LLCPWT", "_STSTR", "_PSU"
  FROM read_parquet(
    'https://github.com/muntasirmasum/brfssdata/releases/download/data-2023/brfss_2023.parquet'
  )
) TO 'brfss_2023_extract.csv' (HEADER, FORMAT CSV);

SAS

SAS support for parquet depends on which SAS you run, and the honest summary is narrow. The SAS Viya platform provides a LIBNAME engine for parquet, introduced in Viya 2021.2.6, and SAS documents ZSTD, the codec these files use, among the compression types that engine reads. That engine is not part of SAS 9, and its reach has grown release by release, starting with directories local to the compute server and adding cloud object storage later. It also points at a directory rather than a URL, so you would download the file first. If you run Viya, check the LIBNAME engines for ORC and Parquet reference for your release before assuming a path works; the shape of the code is this:

libname brfss parquet "/path/to/downloaded/files";

proc surveyfreq data = brfss.brfss_2023;
  strata _STSTR;
  cluster _PSU;
  weight _LLCPWT;
  tables GENHLTH;
run;

The route that works on any SAS, including SAS 9, is to export a SAS Transport file from R. haven::write_xpt() writes one.

library(brfssdata)
library(dplyr)
library(haven)

extract <- read_brfss(
  2023,
  vars = c(
    "GENHLTH", "SEXVAR", "_AGE_G", "_IMPRACE",
    "_LLCPWT", "_STSTR", "_PSU"
  ),
  quiet = TRUE
) |>
  slice_head(n = 5000)

xpt <- tempfile(fileext = ".xpt")
write_xpt(extract, xpt)
file.size(xpt)
#> [1] 321840

Reading it back confirms the names survived:

names(read_xpt(xpt, n_max = 1))
#> [1] "GENHLTH"  "SEXVAR"   "_AGE_G"   "_IMPRACE" "_LLCPWT"  "_STSTR"   "_PSU"    
#> [8] "year"

Which transport version

Transport files come in two flavors, SAS reads them by different routes, and version is therefore the argument that decides whether your collaborator can open the file at all.

Version 8 is haven’s default and allows variable names up to 32 characters. SAS’s XPORT libname engine cannot read it, because that engine implements the Version 5 feature set; pointed at a V8 file it reports that the file is not a SAS data set. What reads it is the %XPT2LOC autocall macro, which has shipped with SAS since 9.4M2 and handles both flavors:

filename xptfile "/path/to/brfss23.xpt";
%xpt2loc(libref = work, filespec = xptfile);

Version 5 is the older specification and the one the XPORT engine reads directly, which is why submission workflows and older sites still ask for it:

libname xptfile xport "/path/to/brfss23.xpt";

proc copy in = xptfile out = work;
run;

Version 5 caps both the dataset member name and every variable name at 8 characters. The member name defaults to the file name without its extension, so a random temporary file name fails outright:

write_xpt(extract, tempfile(fileext = ".xpt"), version = 5)
#> Error in `write_xpt()`:
#> ! `name` must be 8 characters or fewer.

Pass name to fix that. Variable names are the subtler problem. Every one of the 2,128 variables in CDC’s catalog is 8 characters or fewer, _IMPRACE sitting exactly at the limit, so a straight CDC extract comes through version 5 unchanged. Derived variables do not, and the truncation is silent:

derived <- extract |>
  mutate(fairpoor_health = as.integer(GENHLTH %in% 4:5))

v5 <- tempfile(fileext = ".xpt")
write_xpt(derived, v5, version = 5, name = "brfss23")
names(read_xpt(v5, n_max = 1))
#> [1] "GENHLTH"  "SEXVAR"   "_AGE_G"   "_IMPRACE" "_LLCPWT"  "_STSTR"   "_PSU"    
#> [8] "year"     "fairpoor"

fairpoor_health arrives as fairpoor, with no warning, and two derived names sharing their first 8 characters would collide the same way. Choose by what the recipient will run: version 8 if they can call %XPT2LOC, version 5 if they will point the XPORT engine at the file, and in that second case rename your derived variables to 8 characters yourself so you pick the abbreviations instead of inheriting them.

Transport files carry the numeric codes, not the meanings. Ship the relevant slice of the value-label catalog next to the data so the recipient has a codebook:

brfss_labels(
  c("GENHLTH", "SEXVAR", "_AGE_G", "_IMPRACE"),
  years = 2023
) |>
  arrange(variable, code)
#> # A tibble: 21 × 5
#>     year variable  code label              complete
#>    <int> <chr>    <int> <chr>              <lgl>   
#>  1  2023 GENHLTH      1 Excellent          TRUE    
#>  2  2023 GENHLTH      2 Very good          TRUE    
#>  3  2023 GENHLTH      3 Good               TRUE    
#>  4  2023 GENHLTH      4 Fair               TRUE    
#>  5  2023 GENHLTH      5 Poor               TRUE    
#>  6  2023 GENHLTH      7 Dont know/Not Sure TRUE    
#>  7  2023 GENHLTH      9 Refused            TRUE    
#>  8  2023 SEXVAR       1 Male               TRUE    
#>  9  2023 SEXVAR       2 Female             TRUE    
#> 10  2023 _AGE_G       1 Age 18 to 24       TRUE    
#> # ℹ 11 more rows

Stata

haven::write_dta() writes a Stata file, defaulting to the Stata 14 format; pass version = 13 or lower for older Stata. The complication is naming. Stata permits a leading underscore but reserves such names for its own system variables (_n, _N, _b, _cons), and the manual advises against user variables that begin with one. haven does not enforce this, so write_dta() on a raw BRFSS extract succeeds and the trouble surfaces later, in Stata. Rename first:

for_stata <- extract |>
  rename_with(\(x) sub("^_", "x_", x))

names(for_stata)
#> [1] "GENHLTH"   "SEXVAR"    "x_AGE_G"   "x_IMPRACE" "x_LLCPWT"  "x_STSTR"  
#> [7] "x_PSU"     "year"

dta <- tempfile(fileext = ".dta")
write_dta(for_stata, dta)
file.size(dta)
#> [1] 305675

Prefixing is safer than deleting the underscore outright. CDC’s catalog contains both _RACE and RACE, and both _SEX and SEX, among other pairs, so stripping the character can quietly merge two different variables in a multi-year extract. Stata allows names up to 32 characters, which nothing in BRFSS comes near.

On the Stata side, declare the design before estimating anything:

use "brfss_2023_extract.dta", clear

svyset x_PSU [pweight = x_LLCPWT], strata(x_STSTR) singleunit(centered)
svy: proportion GENHLTH

Single-PSU strata are common in BRFSS, because the public-use files give each respondent their own primary sampling unit within a stratum, so any stratum holding one respondent holds one PSU. Software differs in what it does with them. In R, brfss_design() sets options(survey.lonely.psu = "adjust") when the design it just built actually contains such a stratum and the option is unset (any value other than survey’s own load-time "fail" counts as set by you), which centers those strata at the grand mean; singleunit(centered) is the Stata option with the same behavior. A year with no single-PSU stratum leaves the option alone, so an unrelated survey analysis later in the session keeps survey’s own default.

Value labels without losing CDC’s codes

read_brfss(labels = TRUE) returns factors for variables whose CDC format is a complete code-to-label map, and write_dta() does turn those factors into Stata value labels. What it cannot turn them into is CDC’s codes. A factor carries its levels and their positions, nothing else, so the export numbers the levels 1, 2, 3 in order and the original codes are gone:

labeled <- read_brfss(2023, vars = "GENHLTH", labels = TRUE, quiet = TRUE)

factor_dta <- tempfile(fileext = ".dta")
write_dta(labeled, factor_dta)

read_dta(factor_dta) |>
  count(value = as.numeric(GENHLTH), label = as_factor(GENHLTH))
#> # A tibble: 8 × 3
#>   value label                   n
#>   <dbl> <fct>               <int>
#> 1     1 Excellent           63410
#> 2     2 Very good          142115
#> 3     3 Good               144209
#> 4     4 Fair                61955
#> 5     5 Poor                20372
#> 6     6 Dont know/Not Sure    897
#> 7     7 Refused               361
#> 8    NA NA                      4

“Dont know/Not Sure” arrives on value 6 and “Refused” on 7, where CDC wrote 7 and 9. Reading with na = TRUE does not rescue this; it drops the missing-type levels and renumbers whatever is left, which moves substantive codes too. CHECKUP1’s code 8 is “Never”, a real answer from 2,747 respondents in 2023, and under na = TRUE it arrives on value 5.

The renumbering is silent, and Stata is where it does its damage, because the reflex there is a line like replace GENHLTH = . if inlist(GENHLTH, 7, 9). Against a renumbered file that line matches nothing, and every don’t-know answer stays in the estimation sample.

Two routes keep the codes. The plain one is to export the raw codes, as for_stata does above, and ship the relevant slice of brfss_labels() alongside as a codebook. The other builds Stata value labels from that same catalog, so the .dta carries the true codes and their meanings together:

codes <- brfss_labels(c("GENHLTH", "SEXVAR"), years = 2023)

label_from_catalog <- function(x, name) {
  map <- codes[codes$variable == name, ]
  map <- map[order(map$code), ]
  labelled(x, setNames(map$code, map$label))
}

labeled_codes <- for_stata |>
  mutate(
    GENHLTH = label_from_catalog(GENHLTH, "GENHLTH"),
    SEXVAR = label_from_catalog(SEXVAR, "SEXVAR")
  )

codes_dta <- tempfile(fileext = ".dta")
write_dta(labeled_codes, codes_dta)

read_dta(codes_dta) |>
  count(value = as.numeric(GENHLTH), label = as_factor(GENHLTH))
#> # A tibble: 7 × 3
#>   value label                  n
#>   <dbl> <fct>              <int>
#> 1     1 Excellent            582
#> 2     2 Very good           1442
#> 3     3 Good                1752
#> 4     4 Fair                 925
#> 5     5 Poor                 286
#> 6     7 Dont know/Not Sure     8
#> 7     9 Refused                5

CDC’s 7 and 9 survive, and label list in Stata shows the wording against them. Do this only for the variables the analysis touches. The catalog is per year and per variable, so a multi-year extract needs its labels checked for drift before one year’s map is applied to another.

CSV

CSV is the format nothing refuses, and when you do not know what the recipient will open the file with, it is the safe answer.

readr::write_csv(for_stata, "brfss_2023_extract.csv")

# base R only
utils::write.csv(for_stata, "brfss_2023_extract.csv", row.names = FALSE)

The cost is size and types. Written out in full, the 2023 file is about 447 MB as CSV against 29 MB as parquet, roughly fifteen times larger. None of the column types survive the trip either, so every reader on the other end re-guesses whether a column is integer, double, or text, and a leading-zero FIPS code or a variable that is numeric in one year and character in another is where that guessing goes wrong. Send a column-selected, row-filtered extract instead of a whole year, and if the recipient can read parquet at all, send parquet.

Citing and verifying

Whatever tool opens the file, the data are CDC’s and the citation is CDC’s:

Centers for Disease Control and Prevention (CDC). Behavioral Risk Factor Surveillance System Survey Data. Atlanta, Georgia: U.S. Department of Health and Human Services, Centers for Disease Control and Prevention, [appropriate year].

Each parquet file is built from CDC’s published SAS Transport release by the pipeline in data-raw/, and every published survey year has a .sha256 companion at the same URL with .sha256 appended. Anyone downloading outside R can check what they received:

base=https://github.com/muntasirmasum/brfssdata/releases/download/data-2023

curl -LO $base/brfss_2023.parquet
curl -LO $base/brfss_2023.parquet.sha256

shasum -a 256 -c brfss_2023.parquet.sha256   # sha256sum -c on Linux
#> brfss_2023.parquet: OK

Recording that checksum alongside the analysis is what lets someone else confirm years later that they are holding the same file you analyzed.