Read Stata .dta files in Python: MEPS 2023

Health
Python
Data I/O
How to open a Stata .dta file in Python with svy, using the MEPS 2023 full-year consolidated file (h251.dta): read it, even straight from the zip, keep its variable and value labels, clear the reserve codes and get design-based estimates.
Author
Published

October 15, 2026

Modified

October 15, 2026

Keywords

read dta file Python, Stata dta Python, MEPS Python, h251.dta, MEPS 2023 full year consolidated, Stata value labels Python, complex survey analysis Python

A Stata .dta file carries more than the data: a label for each variable and value labels that map codes to text. Many readers drop the labels or force you to pick between codes and text. With svy you get the data and the labels together, ready for survey estimation.

The example is the 2023 full-year consolidated file of the Medical Expenditure Panel Survey (MEPS HC-251, h251.dta): 18,919 people, 1,374 variables.

Get the file

AHRQ publishes it on the HC-251 page as a zipped Stata file:

from urllib.request import urlretrieve

urlretrieve(
    "https://meps.ahrq.gov/mepsweb/data_files/pufs/h251/h251dta.zip",
    "data/h251dta.zip",
)

Read it

pip install svy

svy.read_stata returns a polars DataFrame. It reads the .dta inside the zip, so there is nothing to unpack:

import svy

meps = svy.read_stata("data/h251dta.zip")
print(meps.shape)
meps.select("DUPERSID", "AGE23X", "SEX", "INSCOV23", "TOTEXP23", "VARSTR", "VARPSU", "PERWT23F").head(3)
(18919, 1374)
shape: (3, 8)
DUPERSID AGE23X SEX INSCOV23 TOTEXP23 VARSTR VARPSU PERWT23F
str i64 i64 i64 i64 i64 i64 f64
"2790002101" 58 2 1 646 2019 1 11664.426815
"2790002102" 27 1 1 1894 2019 1 32212.113596
"2790004101" 49 2 1 986 2084 1 21944.142826

With 1,374 columns, read only what you need:

svy.read_stata("data/h251dta.zip", columns=["DUPERSID", "TOTEXP23"]).shape
(18919, 2)

svy.read_dta is the same function under another name.

Keep the labels

svy.create_from_stata reads the data and both kinds of labels into a Sample:

sample = svy.create_from_stata("data/h251dta.zip")
print(sample.meta.var_labels["INSCOV23"])
sample.meta.value_labels["INSCOV23"]
HEALTH INSURANCE COVERAGE INDICATOR 2023
{'-1': '-1 INAPPLICABLE',
 '1': '1 ANY PRIVATE',
 '2': '2 PUBLIC ONLY',
 '3': '3 UNINSURED'}

The data keep the codes. The labels go along with them and show up in every estimate, as you will see below.

Clear the reserve codes

MEPS stores “inapplicable”, “refused”, “don’t know” and “cannot be computed” as ordinary numbers: -1, -7, -8 and -15. The value labels name them, but to Stata and to svy they are just numbers. Leave them in and they count as answers:

sample.data["HAVEUS42"].value_counts().sort("HAVEUS42")
shape: (5, 2)
HAVEUS42 count
i64 u32
-8 452
-7 304
-1 248
1 13469
2 4446

Turn them into nulls before estimating. recode maps each new value to the old values it replaces:

sample = sample.wrangling.recode(
    ["HAVEUS42"],
    {None: [-1, -7, -8, -15]},
    replace=True,
)

Set the design

MEPS is a stratified, clustered sample. The variance strata, PSUs and person weight are in the file:

sample = sample.set_design(
    svy.Design(stratum="VARSTR", psu="VARPSU", wgt="PERWT23F")
)
print(sample.n_strata, "strata,", sample.n_psus, "PSUs")
105 strata, 262 PSUs

Some people have a person weight of zero because they were out of scope for 2023. Keep them: the zero weight already takes them out of the estimates.

Estimates now carry design-based standard errors and the value labels:

print(sample.estimation.prop("INSCOV23"))
╭──────────────────────────────── Estimate: PROP (TAYLOR) ────────────────────────────────╮
│                                                                                         │
│  HEALTH INSURANCE COVERAGE INDICATOR 2023      est       se      lci      uci   cv (%)  │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  │
│  1 ANY PRIVATE                              0.6454   0.0095   0.6264   0.6640     1.48  │
│  2 PUBLIC ONLY                              0.2912   0.0080   0.2757   0.3072     2.74  │
│  3 UNINSURED                                0.0634   0.0042   0.0555   0.0723     6.69  │
│                                                                                         │
╰─────────────────────────────────────────────────────────────────────────────────────────╯
print(sample.estimation.prop("HAVEUS42", drop_nulls=True))
╭───────────────────────────── Estimate: PROP (TAYLOR) ─────────────────────────────╮
│                                                                                   │
│  DOES PERSON HAVE USC PROVIDER-R4/2      est       se      lci      uci   cv (%)  │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  │
│  1 YES                                0.7393   0.0059   0.7274   0.7509     0.80  │
│  2 NO                                 0.2607   0.0059   0.2491   0.2726     2.28  │
│                                                                                   │
╰───────────────────────────────────────────────────────────────────────────────────╯
print(sample.estimation.mean("TOTEXP23"))
╭───────────────── Estimate: MEAN (TAYLOR) ──────────────────╮
│ y: TOTAL HEALTH CARE EXP 23                                │
│                                                            │
│         est         se          lci          uci   cv (%)  │
│  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  │
│  7,487.2616   173.3692   7,144.8246   7,829.6986     2.32  │
│                                                            │
╰────────────────────────────────────────────────────────────╯

Other formats

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

Data: Medical Expenditure Panel Survey, HC-251, Agency for Healthcare Research and Quality.

Back to top