How to pull any account balance for every US credit union with Python
No data vendor sells the field I needed. It was sitting in free federal filings the whole time — in a format designed for regulators, not for prospecting.
The short version
NCUA publishes quarterly Call Report data for every federally insured credit union in the United States as free, comma-delimited bulk downloads. Any line item on the Call Report — over 900 of them — is available for all ~4,000 institutions at once. The workflow is four steps: download the quarterly ZIP, resolve your target account code from the included data dictionary, extract that column across the financial data files, then join it to the institution master file for names and locations.
The part most people get wrong is hardcoding the account code and filename. Codes change between quarters. Resolve them at runtime instead.
Why bother, when you could just buy a list
I was building a target list for a new product. The qualifying characteristic was a specific balance on a credit union's books — either they carried it or they didn't, and if they did, they were a strong fit.
I went looking for that field from the usual data providers. Nobody has it. You can buy asset size, member count, branch locations, and technographics all day long, but a specific balance line from a regulatory filing isn't a field anyone maintains, because almost nobody asks for it.
Which is exactly why it was worth having. A signal your competitors can't buy is a signal your competitors aren't working.
And it's public. Credit unions file a Call Report quarterly, and NCUA publishes the whole thing.
What NCUA actually publishes
The quarterly bulk data is a ZIP file per quarter, going back to March 1994. Inside are comma-delimited text files meant to be dropped into a database or spreadsheet. Everything keys on a single unique identifier per institution, CU_NUMBER.
Three things inside the archive matter:
- The institution master file — one row per credit union with name, charter, address, and
CU_NUMBER. This is what turns numbers into a callable list. - The financial statement files — wide tables where each row is a credit union and each column is an account code. This is where your balance lives.
- The account description file — the data dictionary, mapping human-readable descriptions to account codes. Filenames vary but it's the one with
acctdescin the name.
Step one: get the quarter you want
Pull the ZIP URL for the quarter you're after from the NCUA Call Report data page and pass it in. The URL pattern isn't stable enough across years to construct programmatically, so I treat it as an input rather than pretending I can guess it.
from pathlib import Path
import io, zipfile, requests
DATA_DIR = Path("data")
def fetch_quarter(zip_url: str, label: str) -> Path:
"""Download and extract one quarterly Call Report archive.
label is whatever you want the folder called, e.g. "2026-03".
"""
target = DATA_DIR / label
if target.exists() and any(target.iterdir()):
print(f"{label} already present, skipping download")
return target
target.mkdir(parents=True, exist_ok=True)
print(f"downloading {label} ...")
resp = requests.get(zip_url, timeout=180)
resp.raise_for_status()
with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
zf.extractall(target)
print(f"extracted {len(list(target.iterdir()))} files to {target}")
return target
Step two: resolve the account code instead of guessing it
This is the step that makes the script survive contact with next quarter. Rather than writing down an account code, describe what you're looking for in words and let the dictionary tell you the code.
import pandas as pd
from pathlib import Path
def load_dictionary(quarter_dir: Path) -> pd.DataFrame:
"""Load the account description file, whatever it's called this quarter."""
candidates = [
p for p in quarter_dir.iterdir()
if "acctdesc" in p.name.lower() and p.suffix.lower() in {".csv", ".txt"}
]
if not candidates:
raise FileNotFoundError(
f"No account description file in {quarter_dir}. "
f"Found: {[p.name for p in quarter_dir.iterdir()]}"
)
return pd.read_csv(candidates[0], dtype=str, encoding="latin-1")
def find_codes(dictionary: pd.DataFrame, *terms: str) -> pd.DataFrame:
"""Return dictionary rows whose description contains ALL search terms.
Case-insensitive. Pass the words you'd expect in the line item's
official description, not marketing language.
"""
text_cols = [c for c in dictionary.columns if dictionary[c].dtype == object]
blob = dictionary[text_cols].fillna("").agg(" ".join, axis=1).str.lower()
mask = pd.Series(True, index=dictionary.index)
for term in terms:
mask &= blob.str.contains(term.lower(), regex=False)
return dictionary[mask]
# Usage: search, read what comes back, THEN pick your code.
# matches = find_codes(dictionary, "charitable", "donation")
# print(matches.to_string())
#
# Do not skip reading the output. Similar descriptions exist and
# picking the wrong one gives you a plausible, entirely wrong list.
Run the search, read every row it returns, and pick deliberately. I've been burned here: two codes had near-identical descriptions, I grabbed the first, and produced a list that looked completely reasonable and was measuring the wrong thing. Nothing in the output tells you you're wrong. You just quietly build a campaign on bad data.
Step three: find the column and pull it
Now search every data file in the archive for the account code as a column name. This is deliberately brute-force — it costs a second or two and removes an entire class of breakage.
import pandas as pd
from pathlib import Path
ID_COL = "CU_NUMBER"
def _read(path: Path, **kw) -> pd.DataFrame:
return pd.read_csv(path, dtype=str, encoding="latin-1",
low_memory=False, **kw)
def extract_account(quarter_dir: Path, acct_code: str) -> pd.DataFrame:
"""Find whichever file holds acct_code and return CU_NUMBER + that column."""
for path in sorted(quarter_dir.iterdir()):
if path.suffix.lower() not in {".csv", ".txt"}:
continue
# peek at headers only — don't parse whole files looking for one column
try:
header = _read(path, nrows=0)
except Exception:
continue
cols = {c.strip().upper() for c in header.columns}
if acct_code.upper() in cols and ID_COL in cols:
print(f"found {acct_code} in {path.name}")
df = _read(path, usecols=lambda c: c.strip().upper()
in {ID_COL, acct_code.upper()})
df.columns = [c.strip().upper() for c in df.columns]
df[acct_code.upper()] = pd.to_numeric(
df[acct_code.upper()], errors="coerce"
).fillna(0)
return df.rename(columns={acct_code.upper(): "balance"})
raise KeyError(f"{acct_code} not found in any file in {quarter_dir}")
def attach_institutions(balances: pd.DataFrame,
quarter_dir: Path) -> pd.DataFrame:
"""Join balances to the institution master file for names and location."""
master = None
for path in sorted(quarter_dir.iterdir()):
if path.suffix.lower() not in {".csv", ".txt"}:
continue
try:
header = _read(path, nrows=0)
except Exception:
continue
cols = {c.strip().upper() for c in header.columns}
# the master file is the one carrying the institution name
if ID_COL in cols and any("NAME" in c for c in cols):
master = _read(path)
master.columns = [c.strip().upper() for c in master.columns]
break
if master is None:
raise FileNotFoundError("No institution master file found")
name_col = next(c for c in master.columns if "NAME" in c)
keep = [ID_COL, name_col] + [
c for c in ("STATE", "CITY", "ZIP_CODE") if c in master.columns
]
out = balances.merge(master[keep], on=ID_COL, how="left")
return out.rename(columns={name_col: "institution"})
At this point you have every federally insured credit union in the country, the balance you care about, and enough address detail to segment by geography. Filter to non-zero and you have your universe.
Step four: strip everyone you already have
A raw universe isn't a prospect list. Most of the recognizable institutions were already in our CRM in some state — open opportunity, past loss, someone's existing relationship. Handing reps a list where a third of the rows are accounts they already own is how you get a list ignored.
So: an anti-join. The problem is that institution names never match exactly. The same organization appears as Example Credit Union, Example CU, Example C.U., and EXAMPLE FEDERAL CREDIT UNION across three systems. Exact matching finds almost nothing and you conclude, wrongly, that everything is net new.
import re
import pandas as pd
from rapidfuzz import process, fuzz
# suffixes that carry no identifying information
NOISE = [
"federal credit union", "credit union", "fcu", "cu",
"incorporated", "inc", "llc", "the",
]
def normalize(name: str) -> str:
"""Reduce an institution name to its identifying core."""
if not isinstance(name, str):
return ""
s = name.lower()
s = re.sub(r"[^a-z0-9\s]", " ", s) # punctuation out
s = re.sub(r"\s+", " ", s).strip()
for suffix in NOISE: # longest first, see below
s = re.sub(rf"\b{re.escape(suffix)}\b", " ", s)
return re.sub(r"\s+", " ", s).strip()
# NOISE is ordered longest-first on purpose: strip "credit union" before
# "cu", or "cu" fires inside it and leaves debris behind.
NOISE.sort(key=len, reverse=True)
def anti_join(universe: pd.DataFrame,
existing: pd.DataFrame,
threshold: int = 88) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Split universe into (net_new, matched) against existing records.
Returns matched rows too — you want to eyeball scores near the
threshold before trusting either pile.
"""
universe = universe.copy()
universe["_key"] = universe["institution"].map(normalize)
known = [k for k in existing["company"].map(normalize).unique() if k]
scores, hits = [], []
for key in universe["_key"]:
if not key:
scores.append(0)
hits.append("")
continue
match = process.extractOne(
key, known, scorer=fuzz.token_set_ratio, score_cutoff=threshold
)
scores.append(match[1] if match else 0)
hits.append(match[0] if match else "")
universe["_score"] = scores
universe["_matched_to"] = hits
matched = universe[universe["_score"] >= threshold]
net_new = universe[universe["_score"] < threshold]
return net_new.drop(columns="_key"), matched.drop(columns="_key")
Two things about that threshold
Use token_set_ratio, not plain ratio. Word order and extra words are the whole problem here. Token-set comparison treats the name as a bag of words, so first community example and example first community score as the same organization, which they are.
Never trust the cutoff blindly. I return the matched pile as well as the net-new pile specifically so I can sort by score and read the band from roughly 85 to 92 by hand. That's where the genuine ambiguity lives — regional institutions with near-identical names that are actually different charters. Ten minutes of reading there prevents both failure modes: suppressing a real prospect, and handing a rep an account another rep already owns.
What I'd do differently
Three things, in order of how much they cost me:
Version the output from day one. I overwrote a single output file for the first few runs. When someone asked why an institution had appeared and then disappeared from the list, I had no way to answer. Now every run writes to a timestamped file and the newest is a copy, not the original.
Store the resolved account code alongside the results. When you resolve the code at runtime, the code becomes part of your provenance. Write it into the output so that six months later you can prove which line item you measured.
Pull two quarters, not one. A single quarter tells you who holds the balance. Two quarters tell you who started holding it, and recency is the far stronger buying signal. This was obvious in hindsight and I shipped a quarter of outreach without it.
Why this matters beyond credit unions
The specific dataset is incidental. The pattern generalizes: regulated industries file structured data about themselves, and those filings routinely contain the exact qualifying signal your product needs. Banks, insurers, hospitals, public companies, registered nonprofits, licensed contractors — all of them produce machine-readable public filings.
Data vendors sell what enough customers ask for. If your ICP is defined by something unusual, nobody's asking, so nobody's selling. That gap is buildable in an afternoon with a ZIP file and pandas, and it's one of the few genuinely durable advantages available to a small go-to-market team.
Questions I get about this
Is NCUA Call Report data free to use?
Yes. It's published as free public bulk data in comma-delimited text, going back to March 1994, and as US government work it carries no license restriction.
How do I find the right account code?
Search the account description file included in each quarterly archive — it's the data dictionary. Give it words you'd expect in the official description of the line item, then read every result before choosing.
Do the account codes change between quarters?
Yes, which is the main reason this script resolves codes at runtime. There are over 900 codes and NCUA can add or retire them each quarter.
Why fuzzy matching instead of a straight join?
Because institution names are recorded inconsistently across systems. An exact join finds almost nothing and tells you your entire universe is net new, which is the most expensive possible wrong answer.
How long does this take to run?
The download dominates. Extraction and the join are seconds. The fuzzy anti-join against a few thousand CRM records finishes in well under a minute.
I'm Thomas Lopez. I run revenue operations solo — Salesforce architecture, pipeline analytics, and the data plumbing underneath outbound — for a nonprofit fundraising SaaS platform.