A small pipeline for turning Public Use File (PUF) survey data into a structured YAML codebook and a human-readable TXT frequency report.
It combines:
- A raw survey data file (
.csv) - A format catalog (
.txt) defining value codes and labels - Excel lookup files for variable formats, variable labels, and variable notes
...into a single YAML codebook describing every variable's format, description, value distributions, related survey questions, and notes. That YAML can then be rendered into a clean text report.
.
├── makeYaml.py # Step 1: builds the YAML codebook from source files
├── makeCodebook.py # Step 2: converts a YAML codebook into a TXT report
├── makeAll.py # Runs both steps end-to-end in one command
├── requirements.txt
├── tests/
│ └── test_makeYaml.py # Unit tests for makeYaml.py
├── Data Files/
│ ├── sfpuf2023_1_fall.csv # Source survey data (-f)
│ └── sfpuf2023_1_fall_labels.xlsx # Variable label lookup (--label-excel)
├── 2023 Formats/
│ ├── puf_formats_2023.txt # Format/value catalog (-c)
│ └── sfpuf2023_1_fall_formats.xlsx # Variable format lookup (--format-excel)
└── 2023 PUF Notes/
└── PUFNotes2023.xlsx # Variable notes lookup (--notes-excel)
The Data Files/, 2023 Formats/, and 2023 PUF Notes/ folders match the scripts' default argument paths. A new year's files follow the same layout with the year swapped (e.g. 2024 Formats/, 2024 PUF Notes/) — see Generating Outputs for a New Year for more information.
pandas>=3.0.3
pytest>=9.1.1
PyYAML>=6.0.3
openpyxl>=3.1.5
openpyxl is required because the Excel lookup files are read via pandas.read_excel(..., engine="openpyxl").
Install with:
pip install -r requirements.txt{
"-f, --file": {
"type": ".csv",
"description": "Source survey response data. Filename must match the pattern sfpuf<year>_<n>_<season>.csv (e.g. sfpuf2023_1_fall.csv) so the year/season can be parsed automatically."
},
"-c, --catalog": {
"type": ".txt",
"description": "PUF format catalog listing 'value <FORMAT_NAME>' blocks with 'code = \"label\"' mappings."
},
"--format-excel": {
"type": ".xlsx / .xls",
"description": "Maps each Variable to its Format name."
},
"--label-excel": {
"type": ".xlsx / .xls",
"description": "Maps each Variable to its descriptive Label."
},
"--notes-excel": {
"type": ".xlsx / .xls",
"description": "Contains per-variable notes (var_nm, file, yr, qnbr, notes, notes2, notes3), filtered by season/year."
},
"-o, --output-dir": {
"type": "directory",
"description": "Where the generated file(s) should be saved. Defaults to '.' (the current directory). Present on all three scripts — makeYaml.py, makeCodebook.py, and makeAll.py — and in makeAll.py it controls where both the .yaml and .txt outputs land."
}
}python makeYaml.py \
-f "Data Files/sfpuf2023_1_fall.csv" \
-c "2023 Formats/puf_formats_2023.txt" \
--format-excel "2023 Formats/sfpuf2023_1_fall_formats.xlsx" \
--label-excel "Data Files/sfpuf2023_1_fall_labels.xlsx" \
--notes-excel "2023 PUF Notes/PUFNotes2023.xlsx" \
-o .Produces codebook_FALL_2023.yaml in the output directory. All arguments have defaults matching the paths above, so running python makeYaml.py with no flags will use those defaults.
python makeCodebook.py codebook_FALL_2023.yamlProduces codebook_FALL_2023.txt in the current directory (falls back to codebook_report.txt if the filename doesn't match the expected codebook_<season>_<year>.yaml pattern). Pass -o if you want it saved somewhere else.
python makeAll.py \
-f "Data Files/sfpuf2023_1_fall.csv" \
-c "2023 Formats/puf_formats_2023.txt" \
--format-excel "2023 Formats/sfpuf2023_1_fall_formats.xlsx" \
--label-excel "Data Files/sfpuf2023_1_fall_labels.xlsx" \
--notes-excel "2023 PUF Notes/PUFNotes2023.xlsx" \
-o .This runs makeYaml's codebook-building logic and immediately feeds the result into makeCodebook's report generator, producing both the .yaml and .txt outputs in one pass.
Like makeYaml.py, all of makeAll.py's arguments have defaults matching the paths above, so python makeAll.py with no flags at all will also work as long as your files live at those default locations.
- File extensions:
check_file_type()inmakeYaml.pyenforces that each input matches its expected extension (.csvfor data,.txtfor the catalog,.xlsx/.xlsfor the Excel lookups) and raises aValueErrorimmediately if not. - Filename convention: the source CSV's filename must match
sfpuf<year>_<n>_<season>.csv. This is enforced byextract_file_metadata()inmakeYaml.py, which parses that pattern to derive the year and season used throughout the pipeline (and in the output filenames). If the filename doesn't match, aValueErroris raised before any data is processed. - Lookup completeness:
build_codebook_data()inmakeYaml.pyrequires every column in the survey data to have a matching entry in both the format lookup and the label lookup — a missing entry raises aKeyError. It also requires each variable's format name to exist in the parsed PUF catalog; if not, aKeyErroris raised there too.
build_codebook_data() in makeYaml.py processes each column in the survey data as follows:
- Look up its format name and description/label.
- If the format is
CONTIN(continuous/numeric), collapse all numeric values to a singleLOW-HIGHplaceholder before counting. - Look up value-level labels from the parsed catalog (
parse_puf_catalog) and count how often each code appears in the data, skipping any codes with zero occurrences. - Attach any matching notes and question numbers from the notes Excel file, matched on variable name, file/season, and year.
The result is dumped to YAML with one entry per variable, e.g.:
INT_SPPROXY:
format: PROXY
description: Self - respondent or proxy
value_distributions:
- code: 1
label: Self
frequency: 11437
- code: 2
label: Proxy
frequency: 1579
question_numbers:
- IN4
notes: Results include all respondents.makeCodebook.py then walks that YAML and renders it as a formatted, fixed-width TXT report with a header, one section per variable, a code/label/frequency table, and any associated question numbers or notes.
If you need to add a variable, change a label, or add a code, edit the underlying source files directly — nothing is meant to be hand-edited in the generated YAML/TXT, since those are regenerated from scratch each run.
Parsed line-by-line by parse_puf_catalog(). The expected shape is:
value FORMAT_NAME
1 = "Yes"
2 = "No"
. = "Missing";
Rules to know when editing:
- A
value <NAME>line starts a new format block; everycode = "label"line after it belongs to that format until the nextvalueline. FORMAT_NAMEis matched case-insensitively but is stored uppercased, sovalue proxyandvalue PROXYare equivalent.- Codes can contain letters, numbers, dots, and dashes (e.g.
1,.R,LOW-HIGH). - The label must be in double quotes; a trailing semicolon is optional.
- If a label contains a colon (e.g.
"1: Yes"), only the text after the colon is kept ("Yes"). Don't rely on colon-prefixed labels if you want the prefix preserved. - Lines that don't match the
valueorcode = "label"patterns exactly are silently ignored — no error is raised, so a typo (missing quote, wrong=spacing) just means that code quietly never shows up rather than failing loudly. Double-check new entries render as expected in the output YAML.
Must have at least two columns: Variable and Format. One row per survey column, mapping it to the format name that should match a value block in the catalog (periods are stripped and the value is uppercased before matching, so contin. and Contin both resolve to CONTIN).
Must have at least two columns: Variable and Label. One row per survey column with its human-readable description.
Must have these columns: var_nm, file, yr, qnbr, notes, notes2, notes3.
var_nmmust match the survey column name exactly.filemust match the formatted season string (e.g.PUF_FALL).yris matched against the file's year — leave it blank/NaN if the note should apply to that variable across all years.qnbrcan hold a comma-separated list (e.g."IN4, IN5") and becomes aquestion_numberslist in the YAML.notes,notes2,notes3are free text; empty/NaN cells are skipped, non-empty ones are added verbatim.
There's no year-specific logic hardcoded into the scripts — everything is driven by the file paths and filenames you pass in. To process a new year:
- Get the new source CSV and make sure its filename follows
sfpuf<year>_<n>_<season>.csv(e.g.sfpuf2024_1_fall.csv). This is what drives the year/season used in every output filename. - Get or update the catalog
.txtfor that year. If the value formats haven't changed, you can often reuse last year's catalog — otherwise add/updatevalueblocks for any new or changed formats. - Update the format Excel so every column in the new CSV has a
Variable/Formatrow. - Update the label Excel so every column in the new CSV has a
Variable/Labelrow. - Update the notes Excel with any new rows for that year/season (or leave
yrblank on existing rows if the note still applies). - Run
makeAll.pypointing at the new year's files:
python makeAll.py \
-f "Data Files/sfpuf2024_1_fall.csv" \
-c "2024 Formats/puf_formats_2024.txt" \
--format-excel "2024 Formats/sfpuf2024_1_fall_formats.xlsx" \
--label-excel "Data Files/sfpuf2024_1_fall_labels.xlsx" \
--notes-excel "2024 PUF Notes/PUFNotes2024.xlsx" \
-o .- Check the console output — any
KeyErrormeans a survey column is missing from the format or label Excel, or a format name isn't defined in the catalog. Fix the relevant lookup file and re-run.
- Strict filename matching: the source CSV must match
sfpuf<year>_<n>_<season>.csvexactly (via regex inextract_file_metadata()), or the whole run aborts before any processing happens. Extra suffixes, different prefixes, or missing parts will fail. - All-or-nothing lookups: there's no partial-run or skip-and-warn mode. A single survey column missing from the format Excel, label Excel, or catalog raises a
KeyErrorand stops the entire run — you can't generate a partial codebook. - Silent catalog parsing failures: malformed lines in the catalog
.txt(wrong quoting, unexpected spacing) are skipped without any warning rather than raising an error, so a typo in the catalog can quietly result in a missing code rather than a visible failure. - Colon-stripping in labels: any catalog label containing a colon has everything before the first colon discarded, which may not be what you want for labels that legitimately contain colons.
- Overwrites: existing output files (
.yamland.txt) are overwritten/removed if they already exist at the target path. - Single file per run: each invocation handles exactly one season/year combination — there's no built-in batch mode for processing multiple files at once.
- Test coverage gap: the test suite (
tests/test_makeYaml.py) only coversmakeYaml.py;makeCodebook.pyandmakeAll.pycurrently have no automated tests.
pytest tests/The test suite covers:
convert()— numeric string/int/float coercionextract_file_metadata()— filename parsing (valid and invalid cases)check_file_type()— extension/category validationparse_puf_catalog()— parsingvalue/code-label blocks from the catalog.txtfilebuild_codebook_data()— full integration of survey data, format/label/notes lookups, and catalog value distributions