#!/usr/bin/env python3 """Build The RX Index Medicare GLP-1 prescriptions-by-state dataset. Uses only the Python standard library. By default, source files are downloaded from CMS and verified against frozen SHA-256 hashes. Pass --source-dir to use already-downloaded CMS files. """ from __future__ import annotations import argparse import csv import hashlib import json import math import shutil import urllib.request import zipfile from collections import defaultdict from dataclasses import dataclass from decimal import Decimal, ROUND_HALF_UP from pathlib import Path from typing import Any, Iterable DATASET_VERSION = "1.1.0" ASSEMBLED_ON = "2026-07-31" # Complete standalone GLP-1 receptor-agonist generic names observed in the # 2019-2024 CMS files, plus tirzepatide (dual GIP/GLP-1 receptor agonist). INCLUDED_GENERICS = ( "Albiglutide", "Dulaglutide", "Exenatide", "Exenatide Microspheres", "Liraglutide", "Lixisenatide", "Semaglutide", "Tirzepatide", ) # Fixed-ratio insulin/GLP-1 combinations are deliberately excluded so the # class definition is reproducible and does not mix insulin combination claims # with standalone incretin products. EXCLUDED_FIXED_COMBINATIONS = ( "Insulin Degludec/Liraglutide", "Insulin Glargine/Lixisenatide", ) STATE_FIPS = { "Alabama": "01", "Alaska": "02", "Arizona": "04", "Arkansas": "05", "California": "06", "Colorado": "08", "Connecticut": "09", "Delaware": "10", "District of Columbia": "11", "Florida": "12", "Georgia": "13", "Hawaii": "15", "Idaho": "16", "Illinois": "17", "Indiana": "18", "Iowa": "19", "Kansas": "20", "Kentucky": "21", "Louisiana": "22", "Maine": "23", "Maryland": "24", "Massachusetts": "25", "Michigan": "26", "Minnesota": "27", "Mississippi": "28", "Missouri": "29", "Montana": "30", "Nebraska": "31", "Nevada": "32", "New Hampshire": "33", "New Jersey": "34", "New Mexico": "35", "New York": "36", "North Carolina": "37", "North Dakota": "38", "Ohio": "39", "Oklahoma": "40", "Oregon": "41", "Pennsylvania": "42", "Rhode Island": "44", "South Carolina": "45", "South Dakota": "46", "Tennessee": "47", "Texas": "48", "Utah": "49", "Vermont": "50", "Virginia": "51", "Washington": "53", "West Virginia": "54", "Wisconsin": "55", "Wyoming": "56", } SOURCE_FILES = { 2019: { "filename": "MUP_DPR_RY21_P04_V10_DY19_Geo.csv", "url": "https://data.cms.gov/sites/default/files/2021-08/MUP_DPR_RY21_P04_V10_DY19_Geo.csv", "sha256": "d87977e5aed2ba7c862165bba0a32e844823ecd1f8cbeebe52a8bcd4f1892e01", }, 2020: { "filename": "MUP_DPR_RY22_P04_V10_DY20_Geo.csv", "url": "https://data.cms.gov/sites/default/files/2022-07/ca71b7df-4d48-4c2d-aded-2ca22285739c/MUP_DPR_RY22_P04_V10_DY20_Geo.csv", "sha256": "28a7f5558435ac99cb98b113ad054e54cb4fa487b623ae55dc89f6b152a9f57b", }, 2021: { "filename": "MUP_DPR_RY23_P04_V10_DY21_Geo.csv", "url": "https://data.cms.gov/sites/default/files/2023-04/3d3ebd5b-b4bf-45b4-876d-afa7916d1b72/MUP_DPR_RY23_P04_V10_DY21_Geo.csv", "sha256": "e345008b016df5d1e7362f82a0139a57f2d42ceae8f37c3ddc981168c9286535", }, 2022: { "filename": "MUP_DPR_RY24_P04_V10_DY22_Geo.csv", "url": "https://data.cms.gov/sites/default/files/2024-05/410ed206-d782-4dba-b442-6bcd45ae2016/MUP_DPR_RY24_P04_V10_DY22_Geo.csv", "sha256": "a26588a60a1e6dbb1d39b83d750ed68029b76d35bb08bd72a418172eca867f87", }, 2023: { "filename": "MUP_DPR_RY25_P04_V10_DY23_Geo.csv", "url": "https://data.cms.gov/sites/default/files/2025-04/9fe6b8a6-0cb9-4b7c-9760-87800da010a8/MUP_DPR_RY25_P04_V10_DY23_Geo.csv", "sha256": "876ff262c8a3eb0b8fca312f2004f14d42a8bfd01829e0695d64e34b5191f4f8", }, 2024: { "filename": "MUP_DPR_RY26_P04_V10_DY24_Geo.csv", "url": "https://data.cms.gov/sites/default/files/2026-05/3e80b44e-5a87-4414-959a-b6a7c8c18720/MUP_DPR_RY26_P04_V10_DY24_Geo.csv", "sha256": "c84a834ffb34fa1e46c6b8566d1e89d87f40390120393342e050f7203ef3ed68", }, } CMS_CATALOG_URL = "https://catalog.data.gov/dataset/medicare-part-d-prescribers-by-geography-and-drug" CMS_DICTIONARY_URL = "https://data.cms.gov/sites/default/files/2026-05/3e80b44e-5a87-4414-959a-b6a7c8c18720/MUP_DPR_RY26_P04_V10_DY24_Geo.pdf" FDA_CLASSIFICATION_URLS = { "Albiglutide": "https://www.accessdata.fda.gov/drugsatfda_docs/label/2017/125431s019lbl.pdf", "Lixisenatide": "https://www.accessdata.fda.gov/drugsatfda_docs/label/2024/208471s009lbl.pdf", "Semaglutide": "https://www.accessdata.fda.gov/drugsatfda_docs/label/2025/209637s035%2C209637s037lbl.pdf", "Tirzepatide": "https://www.accessdata.fda.gov/drugsatfda_docs/label/2026/215866s009lbl.pdf", } D0 = Decimal("0") def d(value: str | None) -> Decimal: if value is None or value == "": return D0 return Decimal(value) def q(value: Decimal, places: str) -> Decimal: return value.quantize(Decimal(places), rounding=ROUND_HALF_UP) def fmt_decimal(value: Decimal, places: str) -> str: return format(q(value, places), "f") def sha256_file(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as fh: for block in iter(lambda: fh.read(1024 * 1024), b""): h.update(block) return h.hexdigest() def resolve_source_files(source_dir: Path, cache_dir: Path) -> dict[int, Path]: cache_dir.mkdir(parents=True, exist_ok=True) resolved: dict[int, Path] = {} for year, spec in SOURCE_FILES.items(): local = source_dir / spec["filename"] if source_dir else cache_dir / spec["filename"] if not local.exists(): if source_dir: raise FileNotFoundError(f"Missing {local}") print(f"Downloading {year} CMS file...") urllib.request.urlretrieve(spec["url"], local) actual = sha256_file(local) if actual != spec["sha256"]: raise RuntimeError(f"SHA-256 mismatch for {local.name}: {actual}") resolved[year] = local return resolved @dataclass class StateAgg: claims: int = 0 fills: Decimal = D0 cost: Decimal = D0 published_all_claims: int = 0 published_all_cost: Decimal = D0 @dataclass class NationalAgg: claims: int = 0 fills: Decimal = D0 cost: Decimal = D0 def aggregate_year(path: Path) -> dict[str, Any]: state = {name: StateAgg() for name in STATE_FIPS} state_brands: dict[str, dict[tuple[str, str], int]] = { name: defaultdict(int) for name in STATE_FIPS } national = NationalAgg() national_brands: dict[tuple[str, str], dict[str, Any]] = defaultdict( lambda: {"claims": 0, "fills": D0, "cost": D0} ) outside_51: dict[str, int] = defaultdict(int) all_state_level_glp1_claims = 0 with path.open(newline="", encoding="utf-8-sig") as fh: reader = csv.DictReader(fh) required = { "Prscrbr_Geo_Lvl", "Prscrbr_Geo_Desc", "Brnd_Name", "Gnrc_Name", "Tot_Clms", "Tot_30day_Fills", "Tot_Drug_Cst", } missing = required - set(reader.fieldnames or []) if missing: raise RuntimeError(f"Missing columns in {path.name}: {sorted(missing)}") for row in reader: level = row["Prscrbr_Geo_Lvl"] geo = row["Prscrbr_Geo_Desc"] generic = row["Gnrc_Name"] claims = int(d(row["Tot_Clms"])) fills = d(row["Tot_30day_Fills"]) cost = d(row["Tot_Drug_Cst"]) if level == "State" and geo in state: state[geo].published_all_claims += claims state[geo].published_all_cost += cost if generic not in INCLUDED_GENERICS: continue if level == "National": national.claims += claims national.fills += fills national.cost += cost key = (row["Brnd_Name"], generic) national_brands[key]["claims"] += claims national_brands[key]["fills"] += fills national_brands[key]["cost"] += cost elif level == "State": all_state_level_glp1_claims += claims if geo in state: state[geo].claims += claims state[geo].fills += fills state[geo].cost += cost state_brands[geo][(row["Brnd_Name"], generic)] += claims else: outside_51[geo] += claims return { "state": state, "state_brands": state_brands, "national": national, "national_brands": national_brands, "outside_51": dict(sorted(outside_51.items(), key=lambda x: (-x[1], x[0]))), "all_state_level_glp1_claims": all_state_level_glp1_claims, } def rankings(values: dict[str, Decimal | int]) -> dict[str, int]: # Deterministic ordinal ranking; state name resolves any exact tie. ordered = sorted(values, key=lambda s: (-values[s], s)) return {state: i + 1 for i, state in enumerate(ordered)} def build_rows(year: int, agg: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: national: NationalAgg = agg["national"] state_aggs: dict[str, StateAgg] = agg["state"] claim_ranks = rankings({s: a.claims for s, a in state_aggs.items()}) share_values: dict[str, Decimal] = {} for s, a in state_aggs.items(): share_values[s] = (Decimal(a.claims) / Decimal(a.published_all_claims) * 100) if a.published_all_claims else D0 share_ranks = rankings(share_values) rows: list[dict[str, Any]] = [] for state_name, a in state_aggs.items(): brand_counts = agg["state_brands"][state_name] if brand_counts: (top_brand, top_generic), top_claims = max( brand_counts.items(), key=lambda item: (item[1], item[0][0]) ) else: top_brand, top_generic, top_claims = "", "", 0 rows.append({ "year": year, "rank_by_claims": claim_ranks[state_name], "state": state_name, "state_fips": STATE_FIPS[state_name], "glp1_claims": a.claims, "glp1_30day_fills": fmt_decimal(a.fills, "0.1"), "national_claim_share_pct": fmt_decimal( Decimal(a.claims) / Decimal(national.claims) * 100 if national.claims else D0, "0.000001", ), "published_all_part_d_claims": a.published_all_claims, "glp1_share_of_published_state_part_d_claims_pct": fmt_decimal(share_values[state_name], "0.000001"), "rank_by_glp1_claim_share": share_ranks[state_name], "glp1_gross_drug_cost": fmt_decimal(a.cost, "0.01"), "gross_cost_per_claim": fmt_decimal(a.cost / Decimal(a.claims) if a.claims else D0, "0.01"), "fills_per_claim": fmt_decimal(a.fills / Decimal(a.claims) if a.claims else D0, "0.000001"), "top_glp1_brand": top_brand, "top_glp1_generic": top_generic, "top_brand_claims": top_claims, "top_brand_claim_share_pct": fmt_decimal( Decimal(top_claims) / Decimal(a.claims) * 100 if a.claims else D0, "0.000001", ), "published_all_part_d_gross_cost": fmt_decimal(a.published_all_cost, "0.01"), }) rows.sort(key=lambda r: (r["rank_by_claims"], r["state"])) brand_rows: list[dict[str, Any]] = [] for (brand, generic), metrics in agg["national_brands"].items(): brand_rows.append({ "year": year, "brand_name": brand, "generic_name": generic, "claims": metrics["claims"], "30day_fills": fmt_decimal(metrics["fills"], "0.1"), "gross_drug_cost": fmt_decimal(metrics["cost"], "0.01"), "claim_share_pct": fmt_decimal( Decimal(metrics["claims"]) / Decimal(national.claims) * 100 if national.claims else D0, "0.000001", ), }) brand_rows.sort(key=lambda r: (-r["claims"], r["brand_name"])) return rows, brand_rows def write_csv(path: Path, rows: list[dict[str, Any]], fieldnames: list[str]) -> None: with path.open("w", newline="", encoding="utf-8") as fh: writer = csv.DictWriter(fh, fieldnames=fieldnames) writer.writeheader() writer.writerows(rows) def write_json(path: Path, value: Any) -> None: path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") def build(source_files: dict[int, Path], output_dir: Path) -> None: output_dir.mkdir(parents=True, exist_ok=True) aggs: dict[int, dict[str, Any]] = {} panel_rows: list[dict[str, Any]] = [] brand_rows_all: list[dict[str, Any]] = [] for year in sorted(source_files): agg = aggregate_year(source_files[year]) aggs[year] = agg rows, brand_rows = build_rows(year, agg) panel_rows.extend(rows) brand_rows_all.extend(brand_rows) panel_fields = [ "year", "rank_by_claims", "state", "state_fips", "glp1_claims", "glp1_30day_fills", "national_claim_share_pct", "published_all_part_d_claims", "glp1_share_of_published_state_part_d_claims_pct", "rank_by_glp1_claim_share", "glp1_gross_drug_cost", "gross_cost_per_claim", "fills_per_claim", "top_glp1_brand", "top_glp1_generic", "top_brand_claims", "top_brand_claim_share_pct", "published_all_part_d_gross_cost", ] panel_path = output_dir / "medicare_glp1_state_panel_2019_2024.csv" write_csv(panel_path, panel_rows, panel_fields) write_json(output_dir / "medicare_glp1_state_panel_2019_2024.json", panel_rows) rows_2024 = [r for r in panel_rows if r["year"] == 2024] state_2024_fields = [f for f in panel_fields if f != "year"] state_2024_rows = [{k: r[k] for k in state_2024_fields} for r in rows_2024] state_2024_path = output_dir / "medicare_glp1_prescriptions_by_state_2024.csv" write_csv(state_2024_path, state_2024_rows, state_2024_fields) write_json(output_dir / "medicare_glp1_prescriptions_by_state_2024.json", state_2024_rows) # Wide state trend table. by_state: dict[str, dict[int, dict[str, Any]]] = defaultdict(dict) for row in panel_rows: by_state[row["state"]][row["year"]] = row trends: list[dict[str, Any]] = [] for state_name, year_rows in by_state.items(): c19 = int(year_rows[2019]["glp1_claims"]) c24 = int(year_rows[2024]["glp1_claims"]) ratio = c24 / c19 if c19 else 0 current = year_rows[2024] trend = { "state": state_name, "state_fips": STATE_FIPS[state_name], **{f"claims_{year}": int(year_rows[year]["glp1_claims"]) for year in range(2019, 2025)}, "absolute_change_2019_2024": c24 - c19, "percent_change_2019_2024": f"{(ratio - 1) * 100:.6f}" if c19 else "", "cagr_2019_2024_pct": f"{(ratio ** (1/5) - 1) * 100:.6f}" if c19 else "", "rank_by_claims_2024": current["rank_by_claims"], "glp1_share_of_published_state_part_d_claims_pct_2024": current["glp1_share_of_published_state_part_d_claims_pct"], "rank_by_glp1_claim_share_2024": current["rank_by_glp1_claim_share"], "national_claim_share_pct_2024": current["national_claim_share_pct"], "top_glp1_brand_2024": current["top_glp1_brand"], "top_brand_claim_share_pct_2024": current["top_brand_claim_share_pct"], } trends.append(trend) trends.sort(key=lambda r: (r["rank_by_claims_2024"], r["state"])) trends_fields = list(trends[0].keys()) trends_path = output_dir / "medicare_glp1_state_trends_2019_2024.csv" write_csv(trends_path, trends, trends_fields) # National trend. national_trend: list[dict[str, Any]] = [] first_claims = aggs[2019]["national"].claims first_cost = aggs[2019]["national"].cost previous_claims: int | None = None for year in range(2019, 2025): n: NationalAgg = aggs[year]["national"] national_trend.append({ "year": year, "glp1_claims": n.claims, "glp1_30day_fills": fmt_decimal(n.fills, "0.1"), "glp1_gross_drug_cost": fmt_decimal(n.cost, "0.01"), "gross_cost_per_claim": fmt_decimal(n.cost / Decimal(n.claims), "0.01"), "fills_per_claim": fmt_decimal(n.fills / Decimal(n.claims), "0.000001"), "year_over_year_claim_change_pct": "" if previous_claims is None else f"{(n.claims / previous_claims - 1) * 100:.6f}", "claim_index_2019_100": f"{n.claims / first_claims * 100:.6f}", "gross_cost_index_2019_100": f"{float(n.cost / first_cost * 100):.6f}", }) previous_claims = n.claims national_path = output_dir / "medicare_glp1_national_trend_2019_2024.csv" write_csv(national_path, national_trend, list(national_trend[0].keys())) brand_path = output_dir / "medicare_glp1_national_brand_mix_2019_2024.csv" write_csv(brand_path, brand_rows_all, [ "year", "brand_name", "generic_name", "claims", "30day_fills", "gross_drug_cost", "claim_share_pct", ]) # Quality checks and metadata. reconciliations: list[dict[str, Any]] = [] for year in range(2019, 2025): n = aggs[year]["national"].claims states_dc = sum(int(r["glp1_claims"]) for r in panel_rows if r["year"] == year) outside = sum(aggs[year]["outside_51"].values()) all_state_rows = aggs[year]["all_state_level_glp1_claims"] reconciliations.append({ "year": year, "50_states_dc_claims": states_dc, "national_claims": n, "residual_claims": n - states_dc, "identified_outside_50_states_dc_claims": outside, "national_minus_all_published_state_geography_rows": n - all_state_rows, "panel_share_of_national_pct": n and states_dc / n * 100, }) source_entries = [] for year, spec in SOURCE_FILES.items(): source_entries.append({ "year": year, "url": spec["url"], "sha256": spec["sha256"], "filename": spec["filename"], }) metadata = { "dataset_name": "Medicare GLP-1 Prescriptions by State, 2019–2024", "version": DATASET_VERSION, "assembled_on": ASSEMBLED_ON, "publisher": "The RX Index Research", "editorial_byline": "The RX Index Editorial Team", "source_publisher": "Centers for Medicare & Medicaid Services", "source_dataset": "Medicare Part D Prescribers - by Geography and Drug", "source_catalog_url": CMS_CATALOG_URL, "source_data_dictionary_url": CMS_DICTIONARY_URL, "temporal_coverage": "2019-01-01/2024-12-31", "geographic_scope": "50 states and District of Columbia; geography is prescriber practice location, not beneficiary residence", "national_totals_scope": "CMS national rows, which include claims outside the 50 states and D.C.", "drug_scope": { "included_generic_names": list(INCLUDED_GENERICS), "included_description": "All standalone GLP-1 receptor agonist generic names observed in the 2019–2024 CMS files, plus tirzepatide, a dual GIP/GLP-1 receptor agonist commonly grouped with GLP-1 drugs.", "excluded_generic_names": list(EXCLUDED_FIXED_COMBINATIONS), "excluded_description": "Fixed-ratio insulin/GLP-1 combination products are excluded so standalone incretin claims are not mixed with insulin-combination claims.", "fda_classification_sources": FDA_CLASSIFICATION_URLS, }, "metrics": { "glp1_claims": "CMS Tot_Clms; paid Part D prescription claims, including original prescriptions and refills. Published geography-drug cells with fewer than 11 claims are omitted by CMS.", "glp1_30day_fills": "CMS Tot_30day_Fills; standardized 30-day-equivalent fills.", "glp1_gross_drug_cost": "CMS Tot_Drug_Cst; gross amount paid by plans, beneficiaries, subsidies, and other third-party payers; not net of rebates.", "published_all_part_d_claims": "Sum of all published state-level drug records in the same CMS file. Because CMS omits geography-drug records with fewer than 11 claims, this is not a mathematically complete all-claims denominator.", "glp1_share_of_published_state_part_d_claims_pct": "100 × published GLP-1 claims / all published Part D claims in that prescriber geography.", "national_claim_share_pct": "100 × published state GLP-1 claims / CMS national GLP-1 claims.", }, "source_files": source_entries, "quality_checks": { "state_year_rows": len(panel_rows), "states_per_year": len(STATE_FIPS), "panel_to_national_reconciliation": reconciliations, "2024_top_brand_in_all_51_jurisdictions": len({r["top_glp1_brand"] for r in rows_2024}) == 1 and rows_2024[0]["top_glp1_brand"] or "Mixed", "2024_national_claims": aggs[2024]["national"].claims, "2024_50_states_dc_claims": sum(int(r["glp1_claims"]) for r in rows_2024), "2024_outside_50_states_dc_claims_by_geography": aggs[2024]["outside_51"], }, "limitations": [ "A claim is not a unique patient. A beneficiary can generate multiple claims and refills.", "CMS omits aggregated geography-drug records with fewer than 11 claims. State totals can therefore slightly undercount low-volume products; the published all-Part-D denominator is also incomplete by design.", "State is assigned from the prescriber's NPPES practice location, not the beneficiary's home address.", "The public geography-and-drug file does not identify diagnosis or treatment indication.", "The data do not distinguish diabetes treatment from cardiovascular, sleep-apnea, obesity, or other labeled or off-label uses.", "Gross drug cost is not net Medicare spending and does not reflect confidential rebates or all manufacturer concessions.", "Counts describe paid Part D claims; they do not measure prescriptions written but never filled, cash-paid prescriptions, Medicare Part B use, Medicaid, commercial insurance, or the 2026 Medicare GLP-1 Bridge.", "The 50-state-and-D.C. table excludes Puerto Rico, U.S. territories, armed-forces geographies, foreign addresses, and unknown geography; national CMS rows include them.", "Fixed-ratio insulin/GLP-1 products are excluded by the stated drug-scope rule.", ], } metadata_path = output_dir / "medicare_glp1_state_panel_2019_2024_metadata.json" write_json(metadata_path, metadata) metadata_2024 = { "title": "Medicare GLP-1 Prescriptions by State, 2024", "version": DATASET_VERSION, "data_year": 2024, "source_file": "CMS Medicare Part D Prescribers - by Geography and Drug, 2024", "source_filename": SOURCE_FILES[2024]["filename"], "source_url": SOURCE_FILES[2024]["url"], "source_sha256": SOURCE_FILES[2024]["sha256"], "retrieved": ASSEMBLED_ON, "scope": "All standalone GLP-1 receptor-agonist generic names observed in the 2019–2024 CMS files plus tirzepatide; fixed-ratio insulin combinations excluded.", "included_generic_names": list(INCLUDED_GENERICS), "geography": "Prescriber practice location as reported in NPPES, not beneficiary residence.", "claim_definition": "Published Medicare Part D prescription claims, including original prescriptions and refills. CMS omits geography-drug cells with fewer than 11 claims.", "national_totals": { "claims": aggs[2024]["national"].claims, "30_day_fills": float(aggs[2024]["national"].fills), "gross_drug_cost": float(aggs[2024]["national"].cost), }, "jurisdictions_in_main_table": 51, "notes": metadata["limitations"], } metadata_2024_path = output_dir / "medicare_glp1_prescriptions_by_state_2024_metadata.json" write_json(metadata_2024_path, metadata_2024) # README contains method and frozen source hashes, but intentionally does not # hash itself to avoid a circular checksum dependency. national_2019 = aggs[2019]["national"].claims national_2024 = aggs[2024]["national"].claims top_raw = rows_2024[0] top_share = min(rows_2024, key=lambda r: r["rank_by_glp1_claim_share"]) outside_2024 = sum(aggs[2024]["outside_51"].values()) state_sum_2024 = sum(int(r["glp1_claims"]) for r in rows_2024) suppression_residual_2024 = aggs[2024]["national"].claims - aggs[2024]["all_state_level_glp1_claims"] derived_to_hash = [ panel_path, output_dir / "medicare_glp1_state_panel_2019_2024.json", trends_path, national_path, brand_path, state_2024_path, output_dir / "medicare_glp1_prescriptions_by_state_2024.json", ] derived_hash_lines = "\n\n".join( f"{p.name}\nSHA-256: {sha256_file(p)}" for p in derived_to_hash ) source_hash_lines = "\n\n".join( f"{year}: {SOURCE_FILES[year]['url']}\nSHA-256: {SOURCE_FILES[year]['sha256']}" for year in sorted(SOURCE_FILES) ) readme = f"""Medicare GLP-1 Prescriptions by State, 2019–2024 Version {DATASET_VERSION} Assembled and last verified: July 31, 2026 PURPOSE This is a reproducible state-level aggregation of the Centers for Medicare & Medicaid Services (CMS) Medicare Part D Prescribers - by Geography and Drug annual files. It answers the literal search question "Medicare GLP-1 prescriptions by state" while preserving the distinction between paid prescription claims, 30-day-equivalent fills, unique beneficiaries, and patient residence. CORE DRUG SCOPE Included CMS generic-name values: {chr(10).join('- ' + name for name in INCLUDED_GENERICS)} The scope includes every standalone GLP-1 receptor-agonist generic name observed in the 2019–2024 CMS files. Tirzepatide is included because FDA identifies it as a dual GIP/GLP-1 receptor agonist and it is routinely grouped with GLP-1 drugs in public-policy discussion. Excluded fixed-ratio insulin/GLP-1 combinations: {chr(10).join('- ' + name for name in EXCLUDED_FIXED_COMBINATIONS)} GEOGRAPHY The published 51-jurisdiction tables include the 50 states and District of Columbia. CMS state geography is the prescriber's practice location recorded in NPPES, not the beneficiary's residence. National CMS totals also include claims assigned to Puerto Rico, U.S. territories, armed-forces geographies, foreign locations, and unknown geography. KEY DEFINITIONS - Claim: CMS Tot_Clms; a paid Part D prescription claim, including original prescriptions and refills. - 30-day fill: CMS Tot_30day_Fills; standardized 30-day-equivalent fills. - Gross drug cost: CMS Tot_Drug_Cst; ingredient cost, dispensing fees, sales tax, and applicable administration fees paid by Part D plans, beneficiaries, government subsidies, and other payers. It is not net of confidential rebates or manufacturer concessions. - Published all-Part-D claims: sum of all published state-level drug rows in the CMS geography file. CMS omits aggregated geography-drug records with fewer than 11 claims, so this denominator is not a mathematically complete count of every Part D claim. - State GLP-1 share: published state GLP-1 claims divided by published all-Part-D claims in that state. It is not a per-beneficiary, per-capita, prevalence, or patient-residence rate. METHOD 1. Download the annual CMS geography-and-drug CSV for each year from 2019 through 2024. 2. Verify every frozen source file against its SHA-256 hash. 3. Keep National and State geography rows. 4. Include rows whose Gnrc_Name exactly matches one of the eight included generic names. 5. Exclude fixed-ratio insulin/GLP-1 combinations by not including their generic-name values. 6. Sum Tot_Clms, Tot_30day_Fills, and Tot_Drug_Cst by year and prescriber geography. 7. Sum every published drug row inside each state to create the published Part D denominator. 8. Group filtered rows by brand inside each state and select the largest claim count. 9. Rank raw claims and GLP-1 share independently. 10. Reconcile the 51-jurisdiction sum to the CMS national total and disclose the residual. Equivalent class filter: Gnrc_Name IN ( {chr(10).join(" '" + name + "'," for name in INCLUDED_GENERICS[:-1])} '{INCLUDED_GENERICS[-1]}' ) HEADLINE RESULTS - 2019 CMS national total: {national_2019:,} claims - 2024 CMS national total: {national_2024:,} claims - 2024 50 states plus D.C.: {state_sum_2024:,} published claims - 2024 identified claims outside the 50 states plus D.C.: {outside_2024:,} - 2024 national-minus-all-published-state-geography residual: {suppression_residual_2024:,} claims - {top_raw['state']}: {int(top_raw['glp1_claims']):,} claims (largest raw state count in 2024) - {top_share['state']}: {top_share['glp1_share_of_published_state_part_d_claims_pct']}% of published Part D claims (largest share in 2024) - Ozempic was the largest included brand by claim count in all 50 states and D.C. in 2024. IMPORTANT LIMITATIONS - Claims are not unique patients. - CMS omits aggregated geography-drug records with fewer than 11 claims. This creates small undercounts for low-volume products and makes the published all-Part-D denominator incomplete. - The file does not identify diagnosis or indication. - State means prescriber location, not patient residence. - Gross drug cost is not net spending. - These are paid Part D claims, not all prescriptions written and not all GLP-1 use. - The Medicare GLP-1 Bridge began in 2026 and operates outside the Part D claim flow represented here. - Do not interpret geographic differences as causal effects without additional demographic, eligibility, plan-design, clinical, supply, and prescriber-market data. PRIMARY DOCUMENTATION CMS dataset catalog: {CMS_CATALOG_URL} CMS data dictionary: {CMS_DICTIONARY_URL} FDA tirzepatide classification: {FDA_CLASSIFICATION_URLS['Tirzepatide']} FDA albiglutide classification: {FDA_CLASSIFICATION_URLS['Albiglutide']} FDA lixisenatide classification: {FDA_CLASSIFICATION_URLS['Lixisenatide']} SOURCE FILES AND SHA-256 {source_hash_lines} DERIVED DATA FILES AND SHA-256 {derived_hash_lines} REPRODUCTION Run: python build_medicare_glp1_dataset.py --source-dir /path/to/cms/files --output-dir /path/to/output Without --source-dir, the script downloads the frozen CMS files and verifies their hashes. """ readme_path = output_dir / "medicare_glp1_state_panel_2019_2024_README.txt" readme_path.write_text(readme, encoding="utf-8") # Add hashes for derived data to main metadata after the files exist. metadata["derived_files"] = [ {"filename": p.name, "sha256": sha256_file(p)} for p in derived_to_hash ] write_json(metadata_path, metadata) # Manifest covers every public bundle component except the ZIP itself. public_files = [ panel_path, output_dir / "medicare_glp1_state_panel_2019_2024.json", trends_path, national_path, brand_path, metadata_path, readme_path, state_2024_path, output_dir / "medicare_glp1_prescriptions_by_state_2024.json", metadata_2024_path, output_dir / "build_medicare_glp1_dataset.py", ] manifest = { "dataset_version": DATASET_VERSION, "generated_on": ASSEMBLED_ON, "files": [ {"filename": p.name, "bytes": p.stat().st_size, "sha256": sha256_file(p)} for p in public_files ], } manifest_path = output_dir / "medicare_glp1_data_manifest.json" write_json(manifest_path, manifest) bundle_path = output_dir / "medicare_glp1_state_data_2019_2024_v1.1.0.zip" with zipfile.ZipFile(bundle_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: for p in public_files + [manifest_path]: zf.write(p, arcname=p.name) print(json.dumps({ "version": DATASET_VERSION, "national_2019": national_2019, "national_2024": national_2024, "state_sum_2024": state_sum_2024, "outside_51_2024": outside_2024, "suppression_residual_2024": suppression_residual_2024, "bundle": str(bundle_path), }, indent=2)) def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--source-dir", type=Path, default=None, help="Directory containing the six frozen CMS CSVs. If omitted, download them.") parser.add_argument("--cache-dir", type=Path, default=Path("cms_source_cache")) parser.add_argument("--output-dir", type=Path, default=Path(".")) args = parser.parse_args() source_files = resolve_source_files(args.source_dir, args.cache_dir) build(source_files, args.output_dir) if __name__ == "__main__": main()