Read NHANES DEMO_L.xpt in Python

Health
Python
Data I/O
How to open DEMO_L.xpt, the NHANES August 2021–August 2023 demographics file, in Python with svy: read the SAS transport file, keep its variable labels, pick the right weight and get design-based estimates.
Author
Published

October 15, 2026

Modified

October 15, 2026

Keywords

DEMO_L.xpt, NHANES DEMO_L, read xpt file Python, NHANES Python, SAS transport file, NHANES survey weights, complex survey analysis Python

DEMO_L.xpt is the demographics file of the NHANES August 2021–August 2023 cycle. It holds one row per participant (11,933 people) with age, sex, race and ethnicity, income, the survey weights and the design variables. Every other module of the cycle joins onto it by SEQN.

The .xpt extension means SAS transport format. You do not need SAS to read it. With svy it takes one line.

Get the file

CDC publishes it on the NHANES 2021–2023 demographics page. Download it once:

from urllib.request import urlretrieve

urlretrieve(
    "https://wwwn.cdc.gov/Nchs/Data/Nhanes/Public/2021/DataFiles/DEMO_L.xpt",
    "DEMO_L.xpt",
)

Read it

pip install svy

svy.read_xpt returns a polars DataFrame:

import svy

demo = svy.read_xpt("DEMO_L.xpt")
print(demo.shape)
demo.select("SEQN", "RIAGENDR", "RIDAGEYR", "SDMVSTRA", "SDMVPSU", "WTINT2YR", "WTMEC2YR").head(3)
(11933, 27)
shape: (3, 7)
SEQN RIAGENDR RIDAGEYR SDMVSTRA SDMVPSU WTINT2YR WTMEC2YR
f64 f64 f64 f64 f64 f64 f64
130378.0 1.0 43.0 173.0 2.0 50055.450807 54374.463898
130379.0 1.0 66.0 173.0 2.0 29087.450605 34084.721548
130380.0 2.0 44.0 174.0 1.0 80062.674301 81196.277992

Pass columns= to read only what you need:

svy.read_xpt("DEMO_L.xpt", columns=["SEQN", "RIDAGEYR"]).shape
(11933, 2)

svy.read_sas is the same reader. It also opens SAS .sas7bdat datasets.

Keep the labels

Variable names like RIDAGEYR are hard to read. The file carries a label for each one. svy.create_from_xpt reads the data and the labels together into a Sample:

sample = svy.create_from_xpt("DEMO_L.xpt")
for var in ["RIAGENDR", "RIDAGEYR", "INDFMPIR", "WTMEC2YR"]:
    print(f"{var:9} {sample.meta.var_labels[var]}")
RIAGENDR  Gender
RIDAGEYR  Age in years at screening
INDFMPIR  Ratio of family income to poverty
WTMEC2YR  Full sample 2-year MEC exam weight

Value labels are another matter. The XPT file does not contain them, so RIAGENDR arrives as 1 and 2. The codes are in the DEMO_L codebook. Add the ones you need:

sample = sample.set_value_labels("RIAGENDR", {1: "Male", 2: "Female"})

Set the design and the weight

NHANES is a stratified, clustered sample, so plain means and counts are wrong. The design uses the masked strata and PSUs from this file:

sample = sample.set_design(
    svy.Design(stratum="SDMVSTRA", psu="SDMVPSU", wgt="WTINT2YR")
)
print(sample.n_strata, "strata,", sample.n_psus, "PSUs")
15 strata, 30 PSUs

The file has two weights, and choosing the wrong one is easy:

  • WTINT2YR is for variables from the interview, which everyone did.
  • WTMEC2YR is for variables from the exam at the mobile examination centre (MEC). It is zero for the people who were only interviewed:
print("exam weight > 0:", (demo["WTMEC2YR"] > 0).sum())
print("exam weight = 0:", (demo["WTMEC2YR"] == 0).sum())
exam weight > 0: 8860
exam weight = 0: 3073

Here everything comes from the interview, so WTINT2YR is the right weight. Estimates now carry design-based standard errors:

print(sample.estimation.prop("RIAGENDR"))
╭─────────────── Estimate: PROP (TAYLOR) ───────────────╮
│                                                       │
│  Gender      est       se      lci      uci   cv (%)  │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  │
│  Female   0.5085   0.0068   0.4941   0.5230     1.33  │
│  Male     0.4915   0.0068   0.4770   0.5059     1.38  │
│                                                       │
╰───────────────────────────────────────────────────────╯
print(sample.estimation.mean("RIDAGEYR"))
╭──────────── Estimate: MEAN (TAYLOR) ────────────╮
│ y: Age in years at screening                    │
│                                                 │
│      est       se       lci       uci   cv (%)  │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  │
│  38.9893   0.5149   37.8918   40.0868     1.32  │
│                                                 │
╰─────────────────────────────────────────────────╯

To estimate for a subgroup, say adults, use where=. Do not filter rows first, because that understates the variance:

print(sample.estimation.mean("RIDAGEYR", where=svy.col("RIDAGEYR") >= 20))
╭──────────── Estimate: MEAN (TAYLOR) ────────────╮
│ y: Age in years at screening                    │
│ where: RIDAGEYR >= 20                           │
│                                                 │
│      est       se       lci       uci   cv (%)  │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  │
│  48.7565   0.3993   47.9053   49.6076     0.82  │
│                                                 │
╰─────────────────────────────────────────────────╯

Next

DEMO_L.xpt alone answers demographic questions. For health outcomes, join the questionnaire, exam and lab modules onto it by SEQN. Then deal with the refusal codes, the skip patterns and the choice of weight, which matter more than the estimator.

The case study svy on NHANES, August 2021–August 2023 does all of it on eight modules and reproduces published NCHS estimates at published precision.

Other formats

The same steps work for the other two formats. Only the reader changes, along with what each format keeps:

Back to top