nmtc-mapper 🗺️
Automated NMTC eligibility checker for addresses and census tracts.
Pass a DataFrame of addresses and get back a tri-state nmtc_eligible column
(True / False / None), distress level, poverty rate, AMI ratio, and more —
using official CDFI Fund and Census Bureau data. No manual lookups required.
nmtc_eligible is Optional[bool]: True (verified eligible), False
(verified ineligible — the CDFI Fund file explicitly says NO), or None
(indeterminate — the address could not be geocoded, or the tract is absent
from the ~85k-tract universe). None is not a falsy "ineligible": treating
it as False fabricates a verified-ineligible answer. The additive
eligibility_status column names the five outcomes explicitly —
verified-eligible / verified-ineligible / not-found /
not-covered-territory / geocode-failed.
Why nmtc-mapper?
The CDFI Fund provides a manual web tool (CIMS) for checking NMTC eligibility one address at a time. nmtc-mapper automates this — pass a whole DataFrame of addresses and get results in seconds, using the same official data source.
A batch is all-or-nothing, on purpose.
enrich()geocodes concurrently viaasyncio.gatherwithoutreturn_exceptions, so the first transport failure or ambiguous address raises and aborts the entire batch. At 10,000 addresses that is close to certain, so plan for a retry rather than for a partial frame. This is deliberate: the alternative — a silent per-rowNone— became a fabricated "ineligible" downstream, and losing time is strictly better than losing truth. A genuine no-match is not a failure and does not abort; it yieldseligibility_status = "geocode-failed"for that row alone. Per-row failure capture needs a designed contract (which column carries the error, howeligibility_statusreports transport failure vs no-match) and is 0.7.0's — not shipped yet.
Installation
# docs-check: skip shell installation command, not executable Python
pip install nmtc-mapper
Quickstart
# docs-check: skip NMTCMapper() downloads the multi-MB CDFI Fund file and check_address hits the live Census geocoder
from nmtcmapper import NMTCMapper, EligibilityResult
mapper = NMTCMapper()
# Single address (geocodes automatically) -> EligibilityResult
result: EligibilityResult = mapper.check_address("1234 S Michigan Ave, Chicago, IL 60605")
result.summary()
print(result.nmtc_eligible) # True / False / None (None = indeterminate)
print(result.eligibility_status) # "verified-eligible" | "verified-ineligible"
# | "not-found" | "not-covered-territory"
# | "geocode-failed"
print(result.opportunity_zone_status)# "designated" | "not-confirmed" | "no-tract"
print(result.distress_level) # "deep" / "severe" / "lic" / "ineligible" / "unknown"
print(result.poverty_rate) # 0.38 — but see "Two kinds of missing" below
# Known census tract (no geocoding needed)
result = mapper.check_tract("17031840100")
print(result.nmtc_eligible) # True
# Batch — enrich a DataFrame of addresses
import pandas as pd
df = pd.read_csv("projects.csv") # must have 'address' column
df = mapper.enrich(df, address_col="address")
print(df["nmtc_eligible"].value_counts())
print(df["distress_level"].value_counts())
# If you already have census tract IDs
df = mapper.enrich(df, tract_col="tract_id")
# Summary stats
mapper.eligible_count(df)
EligibilityResult is the return type of both check_address() and
check_tract() — a frozen-in-shape dataclass, exported from the top level so you
can type-annotate against it. It carries the fourteen fields listed under
Output Columns plus three read-only properties:
eligibility_status, opportunity_zone_status and distress_description.
Two loaders and the geocoder are exported directly, for callers who want the data without the mapper:
# docs-check: skip load_eligibility_table downloads the multi-MB CDFI Fund file; geocode_address hits the live Census API
from nmtcmapper import load_eligibility_table, load_sample_table, geocode_address
table = load_eligibility_table() # the real 85,395-tract frame, indexed by GEOID
demo = load_sample_table() # the 12-tract synthetic sample, offline
tract = geocode_address("1234 S Michigan Ave, Chicago, IL 60605") # -> "17031..." or None
geocode_address returns None for exactly one thing — a genuine no-match
(HTTP 200, zero address matches). Every other failure raises; see
Exception hierarchy.
What the eligibility percentage is a percentage of
eligible_count(df) returns counts plus one rate, and that rate's
denominator is named in its key:
| Key | Meaning |
|---|---|
total |
rows in the frame |
determined |
nmtc_eligible + ineligible — rows with a real verdict |
indeterminate |
None verdicts: no geocode match, or tract absent from the universe |
nmtc_eligible |
verified eligible |
ineligible |
verified ineligible — the Fund's file explicitly says NO |
pct_eligible_of_determined |
Optional[float]: nmtc_eligible / determined, None when determined == 0 |
deep_distress / severe_distress / lic_only |
distress-tier counts |
pct_eligible was removed in 0.5.0 and reading it now raises KeyError. It
divided by total, which folds every indeterminate row into the denominator: on
1 eligible / 1 ineligible / 8 indeterminate it reported 10.0% where the
eligible share of what was actually determined is 50.0%. A rate over total
can only be read as "the other 90% are not eligible", which is a verdict for
eight rows no row was read for — and it moves with your address-formatting
quality rather than with eligibility. If you want the old lower-bound reading,
compute nmtc_eligible / total yourself from two returned keys; that direction
is explicit, the reverse was not recoverable.
None rather than 0.0 when nothing was determined, for the same reason: 0.0
asserts "none of the determined rows are eligible" about an empty set.
Failure behavior & offline / demo mode
NMTCMapper() downloads the official CDFI Fund eligibility and Opportunity Zone
files (cached under ~/.nmtcmapper/cache). As of 0.3.4 it fails loud: if
a download or parse fails, it raises a typed error instead of silently
substituting demo data. (Before 0.3.4 any failure silently fell back to a
12-tract synthetic sample, which could report a real, eligible tract as
"ineligible" — see the CHANGELOG.)
# docs-check: skip constructs NMTCMapper(), which downloads the CDFI Fund file
from nmtcmapper import NMTCMapper, NMTCMapperError
try:
mapper = NMTCMapper()
except NMTCMapperError as e:
# Blocked network, moved URL, corrupt file, etc. — never a fabricated answer.
print(f"Could not load real NMTC data: {e}")
raise
Opportunity Zone 2.0 — eligibility for nomination
EligibilityResult carries two OZ 2.0 flags, sourced from Treasury's OZ 2.0
data-transparency file. They are a different program from is_opportunity_zone,
which is OZ 1.0, and the two are not versions of one answer: OZ 1.0 designations
are 2010-tract-based, OZ 2.0 eligibility is 2020-basis on the 2024 TIGER vintage.
Read the status strings rather than the booleans.
# docs-check: skip constructing NMTCMapper() downloads the CDFI Fund file
result = mapper.check_tract("17031840100")
result.oz2_nomination_status # eligible-for-nomination | ineligible-on-treasury-inputs
# | ineligible-no-inputs-published
# | refused-connecticut-scheme | not-determined | no-tract
result.rural_area_qoz_status # rural-eligible | not-rural-on-treasury-determination
# | not-determined-not-an-eligible-lic | ...
is_oz2_nomination_eligible is True for 25,332 tracts and False for 60,197.
The False is a real published fact — the source is a full tract universe of
85,529 rows carrying an explicit 0/1, not a list of the eligible — and it means
"not an eligible LIC on the 2020-2024 ACS / 2020 DECIA inputs Treasury used",
not "not eligible". Rev. Proc. 2026-14 §5.04 states the Appendix is not
exhaustive and reserves a mechanism for nominating an unlisted tract.
is_rural_area_qoz_eligible is restricted to tracts that are eligible LICs:
True for 8,334, False for 16,998, None otherwise. Treasury populates its
rural column for the whole universe, but determines rural status over eligible
tracts only, so the 20,377 ineligible-but-rural-flagged rows are reported as
None. The flag does not mean "this tract is rural": it means the tract, if
nominated and if designated, would be a QOZ comprised entirely of a rural area.
Nothing here is a designation. No tract has been designated a 2027 QOZ and
none can be yet — the nomination window runs to 2026-10-28 at the latest and the
Secretary's consideration period to 2026-12-28. A State may designate only 25% of
its LICs, so True is not "will be an OZ".
Connecticut is refused, and says so. The CDFI Fund keys Connecticut on legacy
counties (09001-09015); Treasury keys it on COG/planning regions (09110-09190).
The two share zero GEOIDs. A legacy CT key returns None from every OZ 2.0 flag
with oz2_nomination_status == "refused-connecticut-scheme"; a CT answer requires
a 09110-09190 key. No crosswalk is offered — converting a published federal
determination onto another geography would be an inference wearing a
determination's column name.
Exception hierarchy
Every class below is exported from the top level, so you can catch broadly or
precisely. All sixteen names are spelled out — a glob like *DownloadError is a
gesture, and you cannot type a glob into an except clause.
# docs-check: skip ASCII diagram of the exception hierarchy, not executable Python
NMTCMapperError every error this package raises
├─ EligibilityDataError the CDFI Fund eligibility dataset could not be obtained
│ ├─ EligibilityDownloadError 403 / 404 / DNS / timeout / connection, no usable cache
│ ├─ EligibilityParseError obtained but unreadable: corrupt bytes, HTML error page, missing sheet, bad zip
│ ├─ EligibilitySchemaError read, but the layout moved: wrong column count, a renamed header at a bound index, or a degenerate parse — also raised by the cell-value allowlists
│ └─ EligibilityValueError a parsed number is outside its plausible bound (e.g. the AMI ratio flipping from ~0.91 to percent scale ~91)
├─ OZDataError the Opportunity Zone designation dataset could not be obtained
│ ├─ OZDownloadError 403 / 404 / DNS / timeout / connection
│ └─ OZParseError obtained but unreadable, or no recognizable tract column
├─ OZ2DataError the OZ 2.0 nomination-eligibility dataset (Treasury) could not be obtained
│ ├─ OZ2DownloadError 403 / 404 / DNS / timeout / connection
│ ├─ OZ2ParseError obtained but unreadable, or the oz2_for_data_transparency sheet is missing
│ └─ OZ2SchemaError read, but the layout moved: column count, a renamed header, the row-count floor, a flag outside {0,1}, or a GEOID SCHEME that is not the one the binding declares
└─ GeocoderError address -> census tract resolution failed
├─ GeocoderTransportError unreachable or unreadable after retries: HTTP status, timeout, connection/DNS, undecodable body
└─ AmbiguousAddressError multiple matches resolving to DIFFERENT tracts, so there is no single right answer
Catch at whatever level you mean:
# docs-check: skip constructing NMTCMapper() downloads the CDFI Fund file
from nmtcmapper import (
NMTCMapper, NMTCMapperError,
EligibilityDataError, EligibilityDownloadError, EligibilityParseError,
EligibilitySchemaError, EligibilityValueError,
OZDataError, OZDownloadError, OZParseError,
OZ2DataError, OZ2DownloadError, OZ2ParseError, OZ2SchemaError,
GeocoderError, GeocoderTransportError, AmbiguousAddressError,
)
try:
mapper = NMTCMapper()
result = mapper.check_address("1234 S Michigan Ave, Chicago, IL 60605")
except EligibilitySchemaError as e:
raise SystemExit(f"CDFI Fund file layout changed; upgrade nmtc-mapper: {e}")
except EligibilityDataError as e:
raise SystemExit(f"No eligibility data: {e}")
except AmbiguousAddressError as e:
print(f"Needs a more specific address: {e}")
except GeocoderTransportError as e:
print(f"Census geocoder unreachable — retry later: {e}")
except NMTCMapperError as e:
raise SystemExit(f"nmtc-mapper failed: {e}")
EligibilitySchemaError refuses to parse on rather than read eligibility out of
an unverified column, and it offers no bypass — the verdicts it would let
through are not trustworthy. AmbiguousAddressError is raised only when the
candidate matches disagree; if every match resolves to the same tract the
ambiguity is harmless and the answer is returned. GeocoderTransportError is the
one a batch caller is most likely to meet, and it aborts the batch (above).
The shape of this tree is asserted by tests/test_constraints.py, not just
drawn here — the docs gate checks that each name appears in this file and
cannot check the inheritance relationships.
Explicit demo / offline data — for examples, tests, or an air-gapped demo, opt in to the synthetic sample dataset. This performs no network calls and stamps the mapper so you can tell demo answers from real ones:
# docs-check: run sample-mode
from nmtcmapper import NMTCMapper, load_sample_table
mapper = NMTCMapper.from_sample() # 12 sample tracts + 6 OZ tracts, offline
print(mapper.data_source) # "sample" (real data → "cdfi_fund")
df = load_sample_table() # the raw 12-tract sample frame
⚠️ Sample data is 12 synthetic-vintage tracts for demos and tests. It is never valid for a real NMTC eligibility answer.
Eligibility Rules (2016-2020 ACS — mandatory since Sept 1, 2024)
A census tract qualifies as a Low-Income Community (LIC) if it meets ANY of:
-
Poverty rate >= 20% — 26 U.S.C. §45D(e)(1)(A)
-
Median Family Income <= 80% of metro/state AMI — §45D(e)(1)(B)
-
Median Family Income <= 85% of the applicable area AMI, for a non-metropolitan tract in a high migration rural county — §45D(e)(5), added by section 223 of the American Jobs Creation Act of 2004. A high migration rural county is one with net out-migration of at least 10% of its population over the 20 years ending with the most recent census. Through the July-2026 file, 1,422 tracts carried the Fund's high-migration-rural column and 168 of them qualified on this route alone; the September-2026 file narrows that column to 1,318 — see
is_high_migration_ruralunder Output Columns.Both conjuncts are required, and the second is not obvious from the statute. §45D(e)(5)(A) substitutes 85% into §45D(e)(1)(B)(i) — the non-metropolitan branch — so the band cannot reach a metropolitan tract, which is governed by (1)(B)(ii) and never touched by the substitution. But §45D(e)(5)(B) defines "high migration rural county" by out-migration alone, with no rurality and no metro test, so the designation by itself does not carry the non-metro requirement. On the current published file all 1,318 HMR tracts are non-metro, which makes the conjunct redundant as an empirical property of one file and not as a logical one — a test asserts the metro-HMR count is 0 and will fail loudly the day the Fund publishes one.
The CDFI Fund publishes the first two routes in the file's column C and the third in column N, and has moved the boundary between those columns once (July 2026). nmtc-mapper reads the verdict as C or N, so it does not depend on where the Fund currently draws it.
Distress levels:
- deep — the tract carries the CDFI Fund's deep-distress designation
- severe — the tract carries the CDFI Fund's severe-distress designation
- lic — NMTC eligible (meets LIC criteria) but not flagged severe/deep
- ineligible — Does not qualify
- unknown — indeterminate: geocode no-match, or the tract is absent from
the eligibility universe (paired with
nmtc_eligible = None; never "ineligible")
How distress is determined. For the official CDFI Fund file (the live download),
severe_distressanddeep_distressare read directly from the Fund's own pre-computed columns — the package does not recompute them from ACS variables. The CDFI Fund's published criteria for those designations are, for reference, poverty > 30% / MFI <= 60% AMI / unemployment >= 1.5x national (severe) and poverty > 40% / MFI <= 40% AMI / unemployment >= 2.5x national (deep). Each set is OR-ed internally and AND-ed with LIC. These are the workbook's own definitions, verbatim from its NOTES sheet —Severe distress=LIC AND (Poverty>30%; MFI<=60%;Unemployment>=1.5)andDeep distress=LIC AND (Poverty>40%; MFI<=40%;Unemployment>=2.5). (The September-2026 file's column headers shorten these toSevere Distress (Poverty>30%;MFI<=60%; OR Unemployment>=1.5)and the deep equivalent; the NOTES sheet keeps theLIC AND, and the published YES/NO values are byte-identical to the July-2026 file — 21,182 severe, 8,061 deep.) The deep criteria read identically in the CDFI Fund's NMTC Compliance Monitoring and Evaluation Frequently Asked Questions (updated April 2025), Q32: poverty rates "greater than 40%", median family income that "does not exceed 40%", and "unemployment rates at least 2.5 times the national average".LIC uses at least; distress uses strictly greater. That difference is deliberate — do not reconcile it. The LIC poverty prong is
>= 20%because §45D(e)(1)(A) defines it as a poverty rate "of at least 20 percent"; the distress poverty prongs are> 30%and> 40%because the Fund's column headers and FAQ Q32 say greater than. The boundary population is not hypothetical: 83 LIC tracts sit at exactly 30.0% poverty and 29 at exactly 40.0%. Of those qualifying on the poverty prong alone, the Fund publishedsevere = NOfor all 21 at 30.0% anddeep = NOfor all 13 at 40.0%.A threshold-based rule (
_compute_eligibility) backs the built-in synthetic sample only; it is not used for the official file. (Through 0.4.3 this sentence also named a "generic CSV path". There was no such path — the branch that also called this rule was unreachable dead code, and 0.5.0 deleted it.)
Data Sources
- CDFI Fund 2016-2020 ACS Low-Income Community Eligibility File
https://www.cdfifund.gov/documents/geographic-reports — listed as "New Markets
Tax Credit 2016-2020 ACS Low-Income Communities and Distress". The pinned
download is
NMTC_LIC_Eligibility_Dataset_9_3_2026.xlsx(September 2026). The Fund moves and re-publishes this file without notice — the August-2025.xlsbURL every release from 0.4.3 to 0.6.0 pinned began answering 403 on 2026-09-03, so a cold install of those versions cannot load the table at all; the pin was corrected in 0.6.1. The loader reads whichever container the Fund publishes (.xlsbor.xlsx) by sniffing the bytes, and a@livetest fetches the pinned URL and fails on any non-200. - US Census Bureau Geocoding API (free, no API key required) https://geocoding.geo.census.gov
What the eligibility universe covers — and what it does not
The CDFI Fund file holds 85,395 tracts: the 50 states, the District of Columbia, and Puerto Rico (981 tracts). It contains no rows for American Samoa, Guam, the Northern Mariana Islands, or the US Virgin Islands (state FIPS 60, 66, 69, 78) — 133 census tracts on 2020 geography, zero of them in the file.
That is a gap in a named federal criterion, not merely a coverage boundary. FAQ Q32 (April 2025) enumerates "US Island Areas: Island Areas of the United States, as determined by the United States Census Bureau including Puerto Rico, U.S. Virgin Islands, Guam, the Commonwealth of the Northern Mariana Islands, and American Samoa" as item 4 of the Areas of Deep Distress criteria. Puerto Rico is named in that criterion and is in the file, so it is covered here like any state. The other four are named in the criterion and are not in the file at all.
A tract in those four jurisdictions is therefore not ineligible — it is
absent from the universe, which this package reports as
nmtc_eligible = None / distress_level = "unknown". Determine it against the
CDFI Fund's CIMS tool instead.
Output Columns
.enrich() adds nine eligibility columns plus eligibility_status — ten in
all — and removes nothing you passed in:
| Column | Type | Notes |
|---|---|---|
nmtc_eligible |
Optional[bool] |
True / False / None. None = indeterminate, never a falsy "ineligible" |
distress_level |
str |
deep / severe / lic / ineligible / unknown |
poverty_rate |
Optional[float] |
may be NaN on a found tract — see below |
ami_ratio |
Optional[float] |
may be NaN on a found tract — see below |
unemployment_rate |
Optional[float] |
may be NaN on a found tract — see below |
is_non_metro |
Optional[bool] |
None only when no row was read |
is_high_migration_rural |
Optional[bool] |
None only when no row was read. Definition changed in 0.6.1 under a stable name — see below |
severe_distress |
Optional[bool] |
None only when no row was read |
deep_distress |
Optional[bool] |
None only when no row was read |
eligibility_status |
str |
verified-eligible / verified-ineligible / not-found / not-covered-territory / geocode-failed — the vocabulary is exported as nmtcmapper.ELIGIBILITY_STATUS_VALUES, in this order |
is_high_migration_rural — source and definition. This column carries the
CDFI Fund's column N of the eligibility workbook, whatever the Fund currently
publishes there, and the Fund changed what that is in September 2026:
| File | Column N heading | True count |
|---|---|---|
July 2026 .xlsb (0.4.2 – 0.6.0) |
High Migration Rural County Low-Income Community Census Tract | 1,422 |
September 2026 .xlsx (0.6.1 –) |
High Migration Rural County Census Tract for Deep Distress | 1,318 |
The 1,318 are a strict subset of the 1,422. The 104 dropped are all non-metro
tracts that are LIC by the poverty route (poverty >= 20%) with MFI between
85.7% and 134.4%; every one of the 1,318 kept has MFI <= 85% (or MFI NA). So
the column now flags the income-route §45D(e)(5) determination only, no longer
"any LIC tract in a high-migration rural county". The workbook's own NOTES sheet
describes the September-2026 re-publish as "Only formatting changes … No
eligibility changes", which is true of the verdict and not of this column.
No verdict moves: nmtc_eligible is column C or column N, the 104 are
column C YES in both files, and all 85,395 tracts carry the same
nmtc_eligible and distress_level under either file (0 differences; severe
and deep are byte-identical). A caller filtering on is_high_migration_rural
sees 104 fewer True rows than under 0.6.0, and this package will not keep a
1,422-row field alive from a file that no longer exists.
The four Optional[bool] columns are None exactly when eligibility_status
is not-found, not-covered-territory, or geocode-failed. For a found tract
their False is the CDFI Fund's published NO and is fully supportable. The
frame is object-dtype, so filter with df[col] != True — ~df[col] raises
TypeError once any indeterminate row is present.
is_opportunity_zone is not among them, and never has been. Batch callers get
no OZ answer; single-address and single-tract callers do, via
result.is_opportunity_zone / result.opportunity_zone_status. 0.5.0
deliberately does not close that gap — adding a column is a data-surface change,
not an honesty fix.
is_nmtc_native_area was removed in 0.5.0. df["is_nmtc_native_area"] now
raises KeyError; see Known limitations.
Two kinds of missing, in the three metric columns
poverty_rate, ami_ratio and unemployment_rate can be absent for two
different reasons, and the distinction is part of the contract:
None— no row was read at all (eligibility_statusisnot-found,not-covered-territory, orgeocode-failed).NaN— a found tract whose metric the Fund published asNA: 1,583 rows for poverty and 2,358 for AMI on the live file. Those tracts still carry a real published YES/NO verdict; only the number is missing.
So r.poverty_rate is None is not a missing-value test on this field. Use
pd.isna(r.poverty_rate) for "no number either way", and eligibility_status to
tell which kind. summary() prints two different phrases for the two states —
not available for the Fund's NA, tract not read for an indeterminate result.
Through 0.4.3 both a wrongly-guarded NaN and the resulting nan% reached the
printed block.
Opportunity Zone status
is_opportunity_zone is Optional[bool] and is True or None — never
False (0.5.0). Read opportunity_zone_status instead of the field's
truthiness; summary() does.
opportunity_zone_status |
is_opportunity_zone |
What it asserts | Live count |
|---|---|---|---|
"designated" |
True |
This GEOID is on the CDFI Fund's Dec-2018 designated-QOZ list. A claim about the list, which is 2010-tract-based — not about the parcel | 7,356 of 85,395 |
"not-confirmed" |
None |
Nothing. The GEOID is not on that list, and this package cannot tell "not designated" from "designated on a 2010 GEOID with no 2020 successor" from "an Island Area outside this table" | 78,039 of 85,395 |
"no-tract" |
None |
No census tract was resolved at all, so there was nothing to test membership against | when tract_id is None |
Three values, not four. The reasons behind not-confirmed are exactly what the
package cannot distinguish, so enumerating them as separate statuses would
re-introduce the fabrication in string form.
Why False is not returnable. The 2018 designations are legally fixed to
2010 census tracts; this package's table and geocoder are 2020-basis, and
1,408 of the 8,764 designations (16.07%) have no row in the 2020-basis table.
A non-match and a genuine non-designation are therefore the same observation
without a crosswalk, and no crosswalk ships: the 1,408 have 3,447 distinct 2020
successors, 1,299 of which also contain territory from 2010 tracts that were never
designated. Marking those True would assert a designation that was never made.
Membership is keyed on the designation set, not on tract_found. Pass one of
the retired 2010 GEOIDs directly and you get a correct True alongside
tract_found = False — the one place the OZ answer is more complete than the
eligibility answer.
Upgrading from 0.4.3: if not r.is_opportunity_zone: used to mean "not an OZ"
and now means "not confirmed" for 78,039 tracts; r.is_opportunity_zone is False now matches nothing, ever; sum(...) and int(...) over the field raise
TypeError. The CHANGELOG's UPGRADING table lists every call shape.
Known limitations
is_nmtc_native_area was removed in 0.5.0 — it is not tri-state, it is gone.
Reading it raises AttributeError on a result, KeyError on an enriched frame,
and passing it to EligibilityResult(...) raises TypeError. Tri-state where a
positive is obtainable; drop where it never is. No value was ever obtainable:
the CDFI Fund publishes no tract-keyed NMTC native-area resource, and the four
AIANNH classes (Federal Indian Reservations, Off-Reservation Trust Lands,
Hawaiian Home Lands, Alaska Native Village Statistical Areas) carry four-digit
Census codes with no state or county component, so they cannot nest into
SSCCCTTTTTT at all — the Navajo Nation spans three states. Establishing the
status is a polygon intersection, not a table join. The criterion itself is live:
Q32 of the CDFI Fund's NMTC Compliance Monitoring and Evaluation Frequently
Asked Questions (updated April 2025) names NMTC Native Areas as one of four
Areas of Deep Distress criteria; it is not among the eleven Areas of
Higher Distress resources Q31 lists. The field is gone because the package cannot
compute it, not because the criterion is unimportant. Use CIMS.
A leading-zero-stripped GEOID silently returns not-found. check_tract()
applies no normalization while both internal tables are zfill(11)-ed, so
"1013953500" — the standard form out of Excel and CSV — misses. It fails safe
(nmtc_eligible = None, never a fabricated False), but it is the most likely
real-world input error. Pass str(geoid).zfill(11) yourself. Normalization is
0.7.0, together with the item below, under one audited methodology for input
that was never a GEOID. This release does not normalize, and a malformed id is
never told it is a territory (a stripped California id starts with 60, American
Samoa's FIPS; only a well-formed 11-digit GEOID can carry a territory claim).
opportunity_zone_status says not-confirmed for input that was never a
GEOID. eligibility_status correctly reports not-found, but the OZ property
tests only whether tract_id is None, so junk input takes the not-confirmed
branch. 0.7.0, with the normalization item above — one piece of work.
Bundled methodology
Decision documents for the eligibility contract ship inside the distribution,
not just in the repository, so they travel with the installed package.
get_methodology_path() resolves one:
# docs-check: run methodology-path
from nmtcmapper import get_methodology_path
print(get_methodology_path().name)
fabricated_negatives.md (the default) records what a False asserts in every
boolean this package exposes, why is_opportunity_zone became Optional[bool]
while is_nmtc_native_area was dropped outright, and the regression invariants
those changes must not move.
Running Tests
# docs-check: skip shell command; the suite is run by CI, not by this gate
PYTHONPATH=. pytest tests/ -v
392 tests across all modules (including fail-loud, explicit-sample-mode,
tri-state eligibility, fabricated-negative, null-sentinel-rendering,
percentage-denominator, bool-coercion, exception-hierarchy-shape,
cell-value-allowlist, async-batch, cache-poisoning, schema-drift, OZ 2.0
answer-space, GEOID-scheme-discriminator, DECIA-territory-coverage,
malformed-GEOID-shape, forward-version-promise, docs-check-tool,
repo-marker-pinning, workbook-container-dispatch and AST-vacuity coverage).
37 of these are @live tests that hit the real CDFI Fund / Census / Treasury
endpoints; CI deselects them with -m "not live", leaving 355 offline.
Six of the live tests are the pinned-URL gates (tests/test_live_pinned_urls.py):
one per external URL this package pins, each failing on any non-200 and naming
where the replacement is published. They are the check that would have caught
the September-2026 file move eight days before 0.6.0 shipped, and because CI
deselects them they are a named pre-tag step in CONTRIBUTING.md.
40 of the offline tests are @repo gates that read the repository itself
(docs/, tools/, CHANGELOG.md, the source tree) and mean nothing against
an installed artifact; the release pipeline's wheel and sdist jobs run the
suite from a directory holding only tests/, pyproject.toml and README.md
and deselect them with -m "not live and not repo". Run the suite that way
yourself from outside a checkout — for instance from an unpacked sdist:
# docs-check: skip shell command; needs an installed wheel and a clean directory
pytest tests -m "not live and not repo" --import-mode=importlib --strict-markers
Who This Is For
- CDEs screening project locations for NMTC eligibility
- CDFI analysts qualifying borrower locations at scale
- Researchers analyzing geographic distribution of LIC tracts
- Anyone replacing manual CIMS lookups with automated Python
License
MIT 2026 Jay Patel
Metadata
Release files for nmtc-mapper 0.6.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| nmtc_mapper-0.6.1.tar.gz | 307.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| nmtc_mapper-0.6.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 437.4 kB
Release files / nmtc_mapper-0.6.1.tar.gz
| Download URL | nmtc_mapper-0.6.1.tar.gz |
|---|---|
| Size | 307.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
7acfe9b1f47f404b6ac3d814c73c743f4d10e6c8fc9d6ded4082d73ffe4deab0
|
|
BLAKE2b-256 checksum How to use checksums |
907bb52253f627366f3aea3542f28d55d6487df072656b6de36f800d81483714
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.13
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.
Transparency logRelease files / nmtc_mapper-0.6.1-py3-none-any.whl
| Download URL | nmtc_mapper-0.6.1-py3-none-any.whl |
|---|---|
| Size | 130.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
37be6986d4592299db2d34691b005406ed67087b89d59bebdff3d83ddd709554
|
|
BLAKE2b-256 checksum How to use checksums |
b57b3308338858e3dada53a08447cc253bc1f4281a26fcdbd91a543f183792de
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.13
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.
Transparency log