Skip to main content
Shelter Export Playbook: copy‑paste CSV headers, compact data dictionary, small-team RACI, PII redaction and validation scripts

Shelter Export Playbook: copy‑paste CSV headers, compact data dictionary, small-team RACI, PII redaction and validation scripts

Turning a loose data standard into files your county will actually accept on the first try

Most shelters don't fail municipal reporting because their data is bad. They fail because the export doesn't match what the receiving system expects — a date formatted as MM/DD/YY when HMIS wants YYYY-MM-DD, a free-text breed field where animal control wants a controlled code, or a column header spelled intake_dt when the import spec says IntakeDate. The data is fine. The shipping is broken.

This is the companion piece to our lightweight shelter data governance standard. That post covered the taxonomy and ownership matrix. This one hands you the runnable parts: actual CSV headers you can paste, a machine-readable data dictionary, redaction patterns for the PII you're not allowed to send, and a Python snippet that fails loudly before the file leaves your building.

Everything here assumes a small team. Sometimes a team of one.

Start with the destination, not your database

The mistake that burns the most hours: building the export around what your shelter software spits out natively, then discovering the municipal target wants something completely different. You end up hand-editing CSVs in Excel at 9pm the night before the deadline.

Flip it. Pull the target's import spec first. For most small-shelter reporting you're hitting one of two destinations:

  1. Human services / HMIS-style systems — when your shelter also serves people (pet retention programs tied to housing, co-sheltering during displacement, surrender-prevention case notes that feed a Continuum of Care). These are strict about PII and element definitions.
  2. Animal-control / county reporting — intake and outcome counts, stray hold compliance, bite/quarantine records, rabies reporting.

They want different shapes. Build two separate export templates and never try to make one file serve both. The teams that get rejected most often are the ones who tried to ship a single "master export" to everyone.

Copy‑paste CSV header templates

Paste these directly. Keep the header row exactly as written — casing and underscores matter because the receiving parser is usually case-sensitive.

Animal-control intake/outcome export

recordid,species,breedcode,sex,alteredstatus,intakedate,intaketype,intakezip3,strayholdend,outcomedate,outcometype,outcomezip3,microchippresent,rabiestagpresent

AC-2026-000481,DOG,LAB-MIX,M,NEUTERED,2026-03-02,STRAY,940,2026-03-07,ADOPTION,941,Y,Y AC-2026-000482,CAT,DSH,F,INTACT,2026-03-04,,RTO,940,N,N AC-2026-000483,DOG,PIT-MIX,U,UNKNOWN,2026-03-05,CONFISCATE,938,2026-03-12,EUTHANASIA,938,Y,Y

Note the zip3 fields — that's a redaction pattern, not a typo. More on that below. The point is your template bakes redaction into the header design so no one has to remember to strip it later.

HMIS-style human-services export (pet-retention / co-sheltering)

enrollmentid,clienthash,householdsize,projectentrydate,projectexitdate,exitdestination,petcount,servicetype,referralsource,countyfips

EN-0098,8f3a1c...e21,3,2026-02-10,2026-02-24,RENTALNOSUBSIDY,2,PETDEPOSITASSIST,ANIMALCONTROL,06075 EN-0099,b7d9e4...a08,1,2026-02-12,,,1,SURRENDERPREVENTION,SELF,06075

The client_hash field is deliberately not a name, DOB, or SSN. If your human-services partner requires a real HMIS Personal ID, that mapping lives in their system under their data-use agreement — not in your flat file.

Compact machine-oriented data dictionary

This is the part that saves you during a rejection. When the county emails back "field 7 invalid," you want one lookup table that tells you exactly what's allowed. Keep this as a CSV in the same folder as your export script so validation reads from it directly.

field_nametyperequiredallowed_valuessensitivityvalidation_rulesample
record_idstringyespattern AC-YYYY-NNNNNNlowregex ^AC-\d{4}-\d{6}$AC-2026-000481
speciesenumyesDOG, CAT, OTHERlowin allowed setDOG
breed_codestringnocontrolled listlowlookup in breed_codes.csvLAB-MIX
sexenumyesM, F, Ulowin allowed setM
altered_statusenumyesNEUTERED, SPAYED, INTACT, UNKNOWNlowin allowed setNEUTERED
intake_datedateyesISO datelowYYYY-MM-DD, not future2026-03-02
intake_typeenumyesSTRAY, OWNERSURRENDER, CONFISCATE, TRANSFERIN, RETURNlowin allowed setSTRAY
intake_zip3stringyes3-digit prefixmediumregex ^\d{3}$940
strayholdenddatenoISO datelow≥ intake_date2026-03-07
outcome_typeenumnoADOPTION, RTO, TRANSFER_OUT, EUTHANASIA, DIED, MISSINGlowin allowed setADOPTION
microchip_presentenumyesY, NlowY or NY
client_hashstringyes (HMIS)64-char hexhighregex ^[a-f0-9]{64}$8f3a1c…
household_sizeintegeryes (HMIS)1–20mediumrange check3
county_fipsstringyes (HMIS)5-digit FIPSlowlookup in fips.csv06075

The pattern that keeps causing problems: shelters skip the sensitivity column because "it's just for us." Then someone exports the high-sensitivity fields to a volunteer's personal laptop. That column exists to make sensitive fields visibly sensitive so your redaction step can key off it automatically. Don't treat it as optional documentation — treat it as an access control signal baked into the file itself.

PII redaction and aggregation patterns

Animal records feel harmless until you realize a surrender record plus a full ZIP plus a date is often enough to identify a specific household. For anything touching human services, treat it like medical data.

  1. ZIP truncation — ship the 3-digit prefix, not the full 5. Keeps geographic reporting useful, kills household-level re-identification. (That's the zip3 in the templates.)
  2. One-way hashing for identifiers — never ship names or raw IDs to a partner file. Hash with a salt you keep private: - client_hash = sha256(salt + lower(trim(name)) + dob) - Same person always produces the same hash, so your partner can dedupe without ever seeing the identity.
  3. Small-cell suppression — if an aggregated count would be fewer than roughly 5 in a category (e.g., euthanasia by a specific intake ZIP), suppress it or roll it up. Low counts are the easiest way to accidentally point at one family.

Anything marked high sensitivity in the dictionary never appears in a file that leaves your network in plaintext.

A field-level rule of thumb that keeps teams out of trouble: anything marked high sensitivity in the dictionary never appears in a file that leaves your network in plaintext. If it has to travel, it travels hashed or aggregated.

The four‑role RACI — and how one person covers all four

Even if you're the only person doing this, the roles still exist. You're just wearing all the hats. Naming them matters because when something breaks, you need to know which hat failed.

TaskData OwnerPreparerValidatorSubmitter
Approve what fields get sharedACII
Pull & format the exportCRII
Run validation against the dictionaryICRI
Send to the municipal portalIICR
Sign off that it went throughAIIR

A = Accountable, R = Responsible, C = Consulted, I = Informed.

Cross-coverage for one-person teams

  1. Prepare the file in the morning.
  2. Run validation in the afternoon, after lunch, with the dictionary open — pretend you're auditing a stranger's file.
  3. Never submit the same hour you prepared. Deadline pressure is exactly when the intake_dt vs IntakeDate errors slip through.

For two-person teams, the fix is simpler: whoever didn't pull the data runs validation. That single swap catches most formatting errors before submission.

Step-by-step export checklist

Specs drift quietly. The destination adds a field, retires a code, or silently changes a date format between cycles. Following a consistent order every time is what keeps that from becoming a surprise on submission day.

  1. Pull the current import spec from the municipal target. Diff it against last month's — specs change quietly.
  2. Generate the raw export from your shelter software into a staging folder, not your submission folder.
  3. Apply redaction (ZIP truncation, hashing, small-cell suppression) before anything else touches it.
  4. Run automated validation against the data dictionary (script below). Do not open the file in Excel first — Excel silently mangles leading zeros in FIPS codes and ZIPs.
  5. Review the error log. Zero errors or you don't proceed.
  6. Generate the metadata block (below) and save it alongside the file.
  7. Submit through the official portal.
  8. Archive the submitted file + metadata + validation log together, read-only.

Tooling recommendation, kept boring on purpose: Python with pandas and csv for generation and checks, csvlint or frictionless for schema validation, and a plain private git repo for versioning the templates and dictionary. Nothing heavier is necessary.

Process diagram

The visual shows the handoff points where a validator should check format and redaction before submission.

Python snippet: generate and pre-send validation

This reads the data dictionary, validates every row, and refuses to write a submission file if anything fails.

import csv, re, hashlib, sys SALT = open(".salt").read().strip() # keep out of version control def hashclient(name, dob): raw = (SALT + name.strip().lower() + dob).encode() return hashlib.sha256(raw).hexdigest() def truncatezip(z): return str(z).zfill(5)[:3] # minimal rule set mirroring the data dictionary RULES = { "recordid": lambda v: re.match(r"^AC-\d{4}-\d{6}$", v), "species": lambda v: v in {"DOG", "CAT", "OTHER"}, "sex": lambda v: v in {"M", "F", "U"}, "intakedate": lambda v: re.match(r"^\d{4}-\d{2}-\d{2}$", v), "intakezip3": lambda v: re.match(r"^\d{3}$", v), "microchippresent": lambda v: v in {"Y", "N"}, } def validate(rows): errors = [] for i, row in enumerate(rows, start=2): # row 1 is header for field, check in RULES.items(): val = row.get(field, "") if not check(val or ""): errors.append(f"row {i}: '{field}'='{val}' failed") return errors with open("stagingexport.csv", newline="") as f: rows = list(csv.DictReader(f)) errs = validate(rows) if errs: print("VALIDATION FAILED — file not written") print("\n".join(errs[:50])) sys.exit(1) with open("submissionready.csv", "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=rows[0].keys()) writer.writeheader() writer.writerows(rows) print(f"OK — {len(rows)} rows validated and written")

The sys.exit(1) is the important line. It means the script cannot produce a submission file that failed validation. That's what protects you on a bad day when you're rushing to hit a deadline.

Data-quality audit routine

Validation checks structure. Audits check whether the data is true. Run these on a schedule, not just before a deadline.

CheckQuery (plain English)FrequencyAcceptable error rate
Orphan outcomesoutcomedate present but no intakedateWeekly0%
Impossible holdsstrayholdend < intake_dateWeekly0%
Missing enumsspecies/sex blankWeekly< 1%
Future datesany date > todayBefore each export0%
Duplicate record_idsame ID twiceBefore each export0%
Suspicious ZIP spread> 95% of intakes share one zip3Monthlyinvestigate

The one most shelters skip is "suspicious ZIP spread." If almost every record shows the same 3-digit ZIP, someone is defaulting the field instead of entering it. The export will pass structural validation perfectly and still be useless for the county's geographic planning. A file that validates clean and contains bad data is a harder problem to catch than one that outright fails — because nothing flags it.

Metadata block for every export

Ship a small sidecar file with each submission. When a question comes back six weeks later, this tells you exactly what you sent and how it was prepared.

exportid: AC-2026-03-R1 target: countyanimalcontrol schemaversion: 2.3 daterange: 2026-03-01 to 2026-03-31 rowcount: 318 redactionapplied: [zip3truncation, smallcellsuppression] preparedby: j.okafor validatedby: j.okafor (afternoon pass) validationlog: logs/2026-03run.txt submitted_at: 2026-04-02T14:10:00Z

Change-request and versioning template

Specs drift. The destination adds a field, retires a code, changes a date format. Track it or you'll spend an afternoon guessing why last quarter's file worked and this quarter's bounced.

CHANGE REQUEST -------------- id: CR-014 date: 2026-04-05 requestedby: county data liaison affected: animal-control export, breedcode change: county now requires FeLV/FIV status column for cats schemaversionbefore: 2.3 schemaversionafter: 2.4 action: add felineteststatus enum (POS,NEG,UNKNOWN,NOTTESTED) rollback: revert to 2.3 header, drop new column approvedby: data owner effective_export: 2026-05

Bump the schema_version in both the dictionary and the metadata block whenever a change request lands. That one number is how future-you reconstructs what a file was supposed to contain.

A real scenario

A roughly 40-kennel municipal-contract shelter was spending about half a day every month fixing export rejections — leading zeros stripped from FIPS codes, inconsistent date formats, the occasional full ZIP slipping through on surrender records. Two or three resubmissions per cycle was normal, and each round-trip with the county liaison ate another day of waiting.

They didn't buy anything. They wrote the data dictionary as a CSV, built the validation snippet above, and added the afternoon-validation rule so files never got submitted the same hour they were pulled. First clean month, zero resubmissions. By the third month the liaison stopped double-checking their files entirely. The time saved wasn't dramatic on paper — maybe half a day a month — but the deadline stress basically disappeared, which mattered more to the one person doing all four roles.

When this setup is overkill

If you submit counts once a year by typing numbers into a web form, you don't need a validation script. Build the dictionary anyway so the definitions are nailed down, but skip the tooling.

Where it earns its keep is any shelter submitting monthly or more, especially one touching human-services data where a redaction slip has real consequences. And if you're a one-person operation, the time-gap validation rule alone is worth adopting — not because you'll catch structural errors you'd otherwise miss, but because it forces the one moment of skepticism that deadline pressure tends to erase.

The destination defines the file. Build backward from their spec, let a script refuse anything broken, and keep the dictionary as the single source of truth. Do that and municipal reporting stops being the monthly fire drill and turns into something you barely think about.

Built for Shelters Tailored to animal shelter workflows and needs
Save Time Automate pet intake, scheduling, and records management
Engage Volunteers Simplify communication and shift coordination
Boost Adoptions Track applications and optimize adoption processes