NMTC Eligibility
Grounds NMTC eligibility answers in two published, audited packages instead of guessing. nmtc-mapper geocodes an address to a census tract and looks the tract up in the CDFI Fund's NMTC Low-Income Community (LIC) eligibility table. nmtc-screener runs a structured first-pass feasibility score on a project.
When to use
- "Is 2400 Grand Concourse, Bronx NY NMTC eligible?"
- "Is census tract 36005023702 a low-income community?"
- "Is tract 36005023702 flagged severe distress — or deep distress — in the CDFI Fund's eligibility table?" (a tract carries a distress flag; a CDE makes the 85%/20% commitments — see the commitment-basis rule)
- "Does my pipeline meet the 85% investment commitment?" — answered by pointing at that rule, never by returning a number: these packages never see a QLICI amount.
- "Screen this $8.5M grocery project for NMTC feasibility."
When NOT to use
- Anything requiring the official CDFI Fund allocation decision — this is a screening/eligibility lookup, not an allocation award or legal determination.
- Historic Tax Credit, LIHTC, or Opportunity Zone investment structuring
(OZ flag is reported by the mapper, but OZ deal mechanics are
oz-tracker). - NMTC transaction / credit / capital-stack modeling beyond the screener's
first-pass estimate — that depth lives in
nmtc-calc.
Install
pip install "nmtc-mapper>=0.5.0" nmtc-screener
Verified 2026-08-14 (PyPI) against nmtc-mapper>=0.5.0 (resolved 0.5.0 at
the time) and nmtc-screener 0.1.0 (nmtc-calc 0.2.1 is pulled in as a
dependency). Quote the floor, not the resolved point version — the point version
moves on every release and this line does not. The >=0.5.0 floor is not
cosmetic — 0.4.0 is where nmtc_eligible became tri-state (see below), 0.4.1
binds the geocoder vintage to the eligibility table's 2020 tract basis (see Data
dependencies & fragility), 0.4.2 is the release that stopped reporting 168
statutorily-eligible tracts as ineligible, and 0.5.0 is the release that
stopped returning a confident False for every unconfirmed Opportunity Zone and
for every field of a tract it never read. A reader on 0.3.x following this
skill's third-state guidance would never see None, because 0.3.x collapses
"could not determine" into False.
The third reason is this skill's own rule, shipped as a defect. The
third-state rule below says a fabricated negative "kills a deal that may
genuinely qualify," and that "a false 'ineligible' is exactly as damaging as a
false 'eligible,' in the opposite direction." Pre-0.4.2 the backing package
delivered exactly that harm — not through a None rendered as "no," but through
a confident False. A tract can reach LIC status by three routes; the CDFI Fund
published the poverty and 80%-AMI routes in the workbook's column C and the
§45D(e)(5) high-migration-rural route (MFI ≤ 85% AMI in a county with ≥10%
net out-migration over 20 years) in column N (the layout in force through
June 2026 — see the note below). Pre-0.4.2 read column C alone as the entire
verdict while separately parsing, storing and surfacing column N as
is_high_migration_rural. Verified against the live table this session:
1,422 tracts carry the high-migration-rural designation, and 168 of them fail
both the ≥20%-poverty and ≤80%-AMI prongs — all non-metro, all in the
(80%, 85%] MFI band, so §45D(e)(5) is the only route by which they qualify.
Those 168 were reported ineligible by a package that was, in the same object,
reporting the evidence of their eligibility. 0.4.2 reads the verdict as C or
N. That is why no floor below 0.4.2 is defensible and none of this is
version-hygiene preference: 0.4.2 is the line below which this skill's central
rule is violated by its own dependency. (All four figures re-derived against the
live table on 0.5.0 this session, not carried forward: 1,422 HMR tracts, 168
failing both prongs, all non-metro, all in the (80%, 85%] band, and all 168 now
nmtc_eligible=True.)
On the current workbook a pre-0.4.2 install does not answer at all. The Fund
moved the C/N boundary in July 2026, folding the high-migration-rural route
into column C and renaming that column's header. 0.4.1 pins column C's exact
header string, so against the workbook the loader downloads today it raises
EligibilitySchemaError and loads nothing (executed this session). The 168-tract
divergence was real against the pre-July-2026 edition; today the same defect
presents as a hard load failure. Either way 0.4.2 is the release that reads
C or N and is therefore correct on both sides of the boundary move.
The fourth reason is the same defect one field over, and it is why the floor
is now >=0.5.0. Through 0.4.3 is_opportunity_zone was a plain bool, so
the package answered "not an Opportunity Zone" about tracts it had no basis to
answer for: 78,039 of the 85,395 tracts received a confident False (every
row in the table that is not in the 8,764-tract designation set), and the
geocode-no-match branch hardcoded is_opportunity_zone=False for an address it
never resolved to a tract at all. The designations are 2010-tract-based and this
package's table and geocoder are 2020-basis, so a vintage miss and a genuine
non-designation are the same observation — a distinction the package cannot
make and therefore must not assert. 0.5.0 makes the field Optional[bool],
never False, and adds opportunity_zone_status to say which of the three
states it is in. Below 0.5.0 this skill has to correct its own dependency in
prose on every OZ answer, which is exactly the posture the third-state rule
exists to make unnecessary. 0.5.0 also drops is_nmtc_native_area, a field that
could only ever say "I don't know" (see the note under the field list).
Import names (dist name ≠ import name):
| dist | import |
|---|---|
| nmtc-mapper | nmtcmapper |
| nmtc-screener | nmtc_screener |
The answer space is TRI-STATE (0.4.0 — read this before anything else)
nmtc_eligible is Optional[bool] — True, False, or None. There
are three outcomes, not two:
nmtc_eligible |
distress_level |
meaning |
|---|---|---|
True |
deep / severe / lic |
verified eligible — the table says YES |
False |
ineligible |
verified ineligible — the table says NO |
None |
unknown |
INDETERMINATE — no verdict was reached |
None / "unknown" means "could not be determined." It is NOT "not
eligible." Never render None as "no," "ineligible," "not eligible," or a
falsy False. A None reached two ways: the address did not geocode, or the
tract is absent from the ~85k-tract universe (a bad/mistyped GEOID, or a
vintage mismatch). Neither is a NO — both are "we don't know."
EligibilityResult.eligibility_status (property, 0.4.0) collapses this into one
explicit four-way string so you never have to infer intent from a None:
verified-eligible | verified-ineligible | not-found | geocode-failed
not-found and geocode-failed are the two indeterminate cases. summary()
prints indeterminate results as ❓ UNKNOWN — … (indeterminate, NOT ineligible)
on the eligibility line itself — that inline qualifier is defined in
nmtcmapper/eligibility/checker.py::EligibilityResult.summary, not a footer.
0.5.0 extends the tri-state contract to every field that can be unobtainable
Through 0.4.3 only the verdict was tri-state, and its neighbours fabricated
inside the very branches written to protect it: the two indeterminate branches
set every supporting boolean to a confident False about a tract no row was
ever read for. Six fields are Optional[bool] in 0.5.0:
| field | None when |
|---|---|
nmtc_eligible |
either indeterminate branch (0.4.0) |
is_non_metro |
either indeterminate branch (0.5.0) |
is_high_migration_rural |
either indeterminate branch (0.5.0) |
severe_distress |
either indeterminate branch (0.5.0) |
deep_distress |
either indeterminate branch (0.5.0) |
is_opportunity_zone |
on every path — True or None, never False (0.5.0) |
The rule that ties them together: when eligibility_status is not-found or
geocode-failed, every tract-derived field is None, because nothing was
read. For a tract that was found, a False on the four supporting booleans
is unchanged and fully supportable — it is the Fund's published NO, present as
a strict YES/NO on all 85,395 rows. is_opportunity_zone is the exception in
both directions: it is keyed on designation-set membership rather than on
tract_found, so a retired 2010 GEOID that is designated still returns a
correct True alongside tract_found=False, and it is never False at all.
Two consequences worth stating because they bite silently:
Noneis falsy.if result.severe_distress:and'Yes' if x else 'No'keep running after the type change and start meaning something else. Switch oneligibility_status/opportunity_zone_status, or testis True/is Noneexplicitly.summary()does this — every line is a three-branch switch.poverty_rate,ami_ratioandunemployment_ratehave two kinds of missing, and they are different answers.Nonemeans no row was read (the indeterminate branches);NaNmeans a found tract whose metric the Fund published asNA— 1,583 rows for poverty and 2,358 for AMI — which still carry a real published verdict. Sor.poverty_rate is Noneis not a missing-value test on this field; usepd.isna()for "no number either way" andeligibility_statusto tell which kind.summary()prints two different sentences for the two states (0.5.0).
The hard failure rule (non-negotiable)
If a tool errors, report the error verbatim and stop. NEVER estimate NMTC eligibility from general knowledge, from the address alone, or from what a neighborhood "seems like." Eligibility is a specific tract-level lookup against a specific CDFI Fund table; there is no valid way to infer it. A wrong "eligible" answer can send a real deal down a dead end. A user asking for a "best guess," "ballpark," or "rough" eligibility answer does not override this rule; decline and report that the lookup failed.
The third-state rule (non-negotiable — the load-bearing addition)
The hard failure rule above governs a tool that errors. This rule governs a
lookup that succeeds and returns UNKNOWN (nmtc_eligible is None,
distress_level == "unknown", eligibility_status in {not-found, geocode-failed}). An unknown verdict is a result, not an error — and it
must be reported as its own answer:
- Report it as "NMTC eligibility could not be determined for this tract", and name the tract ID (or state the address did not geocode). Say why: tract absent from the vintage's universe, or address failed to geocode.
- Never collapse it into "not eligible," "no," or "ineligible."
- Never soften it into "probably not eligible" or "likely ineligible."
- Never resolve it from a neighboring tract, the ZIP, the city, or the address's apparent neighborhood — the same anti-pressure posture as the best-guess rule above.
Why, inline (a model reading this needs the reason, not just the rule): a
None rendered as "not eligible" is a fabricated negative. It kills a deal
that may genuinely qualify — the tract simply was not checkable in this vintage,
and the correct next step is to re-check against the vintage in force at
application time, not to declare the deal dead. A false "ineligible" is exactly
as damaging as a false "eligible," in the opposite direction.
The vintage-scope rule (non-negotiable)
This package carries the 2016–2020 ACS vintage ONLY (the 85,395-tract table
below). NMTC LIC eligibility is governed by the ACS vintage tied to the deal's
QLICI close date, and answering the wrong vintage confidently is the same
class of failure as rendering None as "not eligible" — a confident answer
against data that does not govern. The CDFI Fund's transition rules (primary:
CDFI Fund, 2016-2020 ACS Data FAQ, updated Feb 1, 2024 —
NMTC_LIC_FAQs_2020_ACS_Sept1_2023_Update_Jan2024.pdf at
cdfifund.gov/system/files/2024-01/, announced at cdfifund.gov/news/567;
secondary, quoted verbatim: NMTC Coalition,
nmtccoalition.org/2023/09/06/new-nmtc-data):
| QLICI close date | Governing data | May this package answer? |
|---|---|---|
| before Sept 1, 2023 | must use 2011–2015 ACS | No — 2011–2015 is not carried here |
| Sept 1, 2023 – Aug 31, 2024 | may use either 2011–2015 or 2016–2020 | Yes, but the 2011–2015 vintage is equally permitted |
| on/after Sept 1, 2024 | must use 2016–2020 ACS on 2020 tracts | Yes — authoritative |
Apply it:
- Close date before Sept 1, 2023 → the 2016–2020 table does not govern; 2011–2015 ACS does, and this package does not carry it. Do not answer from the 2016–2020 table. Say so and route the user to the CDFI Fund's CIMS (CDFI Information Mapping System), which carries the governing vintage.
- Close date in the Sept 1, 2023 – Aug 31, 2024 window → the 2016–2020 answer is valid and permitted, but state that 2011–2015 is also an acceptable basis in this window, so the deal may qualify under the other vintage even if 2016–2020 says NO.
- Close date on/after Sept 1, 2024, or unknown → state which vintage the answer is based on (2016–2020) and that it is valid for QLICIs closing on/after Sept 1, 2023 and mandatory on/after Sept 1, 2024. If the close date is unknown or earlier, confirm it before relying on the answer — for a pre-Sept-1-2023 closing the 2011–2015 vintage (not carried here) governs.
Island Areas are a second scope hole of the same class. This table's
~85,395 rows cover the 50 states + DC + Puerto Rico only (PR verified
present this session). The Island Areas — American Samoa, Guam, the CNMI, and
the US Virgin Islands — were NOT covered by the 2016–2020 ACS and are absent
from this package's table entirely. The CDFI Fund publishes a separate
Island Areas NMTC LIC file — NMTC_LIC_Territory_2020_December_2023.xlsx, built
on the 2020 Island Areas Decennial Census (not the 2016–2020 ACS), released
Dec 19, 2023 and available in CIMS as of Jan 25, 2024 — which this package
does not carry. Per the CDFI Fund's 2016-2020 ACS Data FAQ (updated Feb 1,
2024, General Q3): "For Island areas, CDEs should continue to use 2011-2015
NMTC Low-Income Community eligibility data and follow the same transition dates
outlined in question 3." An Island Area address/tract that is absent here is
therefore "not carried by this package," never "ineligible" — route to CIMS
or to the separate territory file; do not answer it from this 2016–2020 ACS
table.
The commitment-basis rule (non-negotiable)
The three rules above govern what this lookup may say about a tract. This one governs what it may say about a CDE, and the answer is nothing.
The CDFI Fund's two distress commitments are measured on QLICI dollars. The CY 2024-2025 Allocation Application asks, at Question 25(a), whether the Applicant will commit to *"providing at least 85% of its QLICIs (in terms of aggregate dollar amounts)"* in the qualifying areas, and at 25(b)(i) for "the percentage of its QLICIs (in terms of aggregate dollar amounts)" it will commit to providing in the 20% tier — a figure it selects, not one it enters (see the field shape below). The Fund's review-process document states both in one sentence (quoted verbatim; downloaded from the source this session):
1. Targeting Areas of Higher Distress (Question 25). The Applicant indicated that it will commit to providing at least 85% of its QLICIs in specified areas of severe distress and/or areas characterized by multiple indicia of distress. The Applicant indicated that it will commit to providing at least 20% of its QLICIs to "Deep Distress" areas.
— CDFI Fund, CY 2024-2025 New Markets Tax Credit Program Allocation Application
Review Process, General Characteristics of a Highly Ranked Application, §C.1;
cdfifund.gov/system/files/2025-12/CY_2024_25_NMTC_Program_Review_Process.pdf.
The Application collects no percentage for Q25(a) — read the field shape first
Both commitments are entered as selections, not as computed figures. Read
verbatim from the instrument this session (CY 2024-2025 NMTC Program Allocation
Application, 142 pp., 1,525,626 bytes, SHA-256
0280c6bc7b35f6015e2c2b1be4b1c07b3864f2dcbaeadfbbbf8bded8de12834f, downloaded
from cdfifund.gov/system/files/2024-11/ and text-extracted locally with
pypdf):
| field | printed p. | Response | Field Type |
|---|---|---|---|
| 25(a) | 38 | ☐ Yes / ☐ No |
Dropdown Menu |
| 25(a) items 1–12 | 39–40 | Yes / No, each item separately |
Dropdown Menu |
| 25(b)(i) | 41 | 0 / 5 / 10 / 15 / 20, if selected enter exact percentage 20-100% in 25(b)(ii) |
Dropdown Menu |
| 25(b)(ii) | 41 | ___% of QLICIs — completed "only … by Applicants that select '20%'", admitting "any percentage amount starting from 20% and up to 100%" |
Numerical – Percentage |
| 25(b) items 1–4 | 41–42 | Yes / No, each item separately |
Dropdown Menu |
| 25(c) | 42 | Text Entry — track record in the areas selected |
Text box |
- Q25(a) is answered Yes or No. The 85% is the threshold printed in the question text — the figure the Applicant commits to, not a figure it supplies. There is no percentage field for Q25(a) anywhere in Question 25.
- Q25(b)(i) is a selection from 0 / 5 / 10 / 15 / 20, and only selecting 20 reaches a free numeric field — 25(b)(ii), a separate question, which then admits 20% to 100%. That single field is the only free-entry percentage in the whole of Question 25, and it is unreachable from any other rung.
- Both bind forward, over capital the Applicant does not yet have. Q25(a)'s Question Notes, verbatim (printed p. 38): "If the Applicant receives an NMTC Allocation, it will be required to meet the percentage figure identified, and such requirement will be a term of its Allocation Agreement."
- The pipeline is not the deployment, and the Fund says so three times. Immediately above Question 25 (printed p. 38): "NOTE: The CDFI Fund does not expect that each and every investment will be in an area identified in Question 25." Of the Table A5 sample transactions (printed p. 23): "It is not expected that the Applicant will invest in all of the listed projects" — those transactions "should be representative of the types of projects that will be undertaken with an NMTC Allocation." And Q25(b)(i)'s notes (printed p. 41): "Applicants will not be held to the individual commitments to any of the areas listed below and will have the flexibility to invest in any of the areas as long as the overall commitment percentage is met."
The operational consequence, and it binds exactly as hard as the rest of this rule: a CDE asking "what is my Q25(a) percentage" is asking for something the Application does not collect. The right answer is what the commitment means and what evidence bears on it — never a computed share, and never a recommendation to answer No because a current pipeline falls short.
That last clause is the harm case. A CDE whose identified projects sit at 60% today is not thereby a "No" on Q25(a): the commitment governs the QLICIs it will make with an allocation it has not yet received, and the Fund has said in three separate places that today's project list is not that deployment. Tell that CDE its "Q25(a) share is 60%" and it checks No, scores lower, and forfeits points it was entitled to claim — understating itself to a federal agency on a number the agency never asked it for. A fabricated share here is the same class of failure as a fabricated negative on eligibility (third-state rule), pointed at the Applicant instead of the tract.
And do not overcorrect into "it's only a Yes/No, so check Yes." The same
Question Notes make the answer binding — "it will be required to meet the
percentage figure identified, and such requirement will be a term of its
Allocation Agreement" — so Yes is a consequential answer too, enforceable
against an Allocatee that misses it. Neither direction is this layer's call to
make. What you can honestly give a CDE is evidence, per prospective QLICI:
which Q25 route each project's tract can be shown to satisfy, which it cannot,
and which this package cannot see at all (it reaches two of the twelve items
under 25(a) and two of the four under 25(b), and computes no multi-indicia
measure). The CDE aggregates that evidence over its own QLICI dollars and owns
the commitment. Carry the tri-state through: a None on any flag is "not
determined for this tract," a third column in that evidence — never a "does not
qualify."
The denominator is the CDE's own QLICI dollars — not QEI, not project count,
not tract count. A QEI is what a tax-credit investor puts into a CDE; a QLICI
is what the CDE puts out into QALICBs (references/cdfi-industry-primer.md).
They are different quantities on different sides of the CDE, and the credit is
sized on the first while both commitments are sized on the second. Bucketing QEI,
or dividing counts instead of dollars, produces a number that is not the
commitment — under a label that says it is.
These packages never see a QLICI amount. A tract-level designation answers
"if a QLICI were made here, would it count toward the numerator?" It cannot
answer "what share of this CDE's QLICIs qualifies?" — that needs the CDE's own
deployment ledger, which is not an input to nmtcmapper or nmtc_screener.
The two questions are not the same question at different scales; the second one
has an input the first one does not.
A severe_distress=False is therefore not a "does not count toward the 85%."
A QLICI counts toward the Q25(a) commitment when it is made in an area
characterized by at least one of items 1–5 or at least two of items
6–12. Severe Distress is only item 1. The
other single-item routes are NMTC Native Areas, U.S. Island Areas,
Non-Metropolitan Counties, and Targeted Populations; the two-of list runs
25%-poverty / 70%-MFI / 1.25× unemployment, Brownfield sites, ARC/DRA areas,
Colonias, federal MUA/HPSA areas, FEMA disaster counties, and USDA LILA
food-access tracts. Of those twelve this package returns exactly two —
severe_distress (item 1) and is_non_metro (item 4) — and computes no
multi-indicia measure at all. Two of the routes it cannot reach are ones this
skill already declines elsewhere: Native Areas (see the field-list note) and
Island Areas (see the vintage-scope rule). And the gap is not only in what
the package omits: derived against the live table this session, 10,532 tracts
are non-metro and not severe (3,754 of them also LIC), so reading
severe_distress alone understates the qualifying set even within the two routes
the package does return.
The same holds one tier down, and harder. Q25(b)'s 20% tier is not Deep
Distress alone — it is any one of four: Deep Distress, NMTC Native Areas,
High Migration Rural Counties, and U.S. Island Areas. A deep_distress=False
says nothing about the other three. 1,185 tracts are high-migration-rural and
not deep (live table, this session), and is_high_migration_rural is a field
this package returns — so here too a negative on the flag the label names is not
a negative on the commitment.
The two commitments nest, and the Fund says so as a rule — "A QLICI that
meets this commitment will also automatically meet the commitment made in
Question 25(a)" (Application, Q25(b)(i) Question Notes, printed p. 41). The
20% is carved out of the
85%, never added to it. The package's two flags happen to nest the same way —
re-derived over all 85,395 rows this session, deep_distress is a strict
subset of severe_distress: 8,061 deep-and-severe, 0 deep-and-not-severe,
13,121 severe-and-not-deep, against 21,182 severe-flagged. That is a fact
about two columns, not the reason the commitments nest; do not offer it as one.
So: never state or imply that a CDE meets, clears, is on track for, or fails either commitment on the basis of anything these packages return — not from one tract, not from a batch of tracts, and above all not from a percentage of tracts, which is a share of the wrong thing. Answer what the lookup answers: whether a QLICI made in this tract would be an area-qualifying one, on the routes the package can see, and say which route. Then direct the user to their own QLICI dollar amounts, scored against the full Q25 area list — and be clear what that arithmetic is for: deciding what to commit to, and meeting the commitment once made. It is not an entry on the form. Q25(a) takes a Yes or a No.
Worked example — address eligibility (executed)
import nmtcmapper as nm
m = nm.NMTCMapper()
result = m.check_address("2400 Grand Concourse, Bronx, NY 10458")
result.summary() # prints a formatted block; returns None
print(result.eligibility_status) # -> 'verified-eligible'
Actual output this session (nmtc-mapper 0.5.0, clean-venv PyPI install, cold
cache, isolated HOME, live CDFI Fund + Census downloads — 85,395 tracts and
8,764 OZ tracts loaded). Every demographic and eligibility figure re-executed
unchanged from the revision of this file that recorded it on 0.4.2; the
Opportunity Zone line is the one line that moved, and that is the release:
NMTC Eligibility Result
==================================================
Address: 2400 Grand Concourse, Bronx, NY 10458
Census Tract: 36005023702
NMTC Eligible: ✅ YES
Distress Level: SEVERE
Description: Severe Distress — qualifies for 85% investment commitment
Poverty Rate: 32.1%
AMI Ratio: 53.2%
Unemployment: 10.7%
Non-Metro: No
Opportunity Zone: ❓ NOT CONFIRMED — not on the 2018 designation list, which is
2010-tract-based (indeterminate, NOT "not an Opportunity Zone")
High Migration: No
eligibility_status is verified-eligible. Tract 36005023702 verified
present in the live 2016–2020 table this session.
The Description: line is the package's own string, reproduced verbatim — read
it through the commitment-basis rule. DISTRESS_LEVELS["severe"] reads
"qualifies for 85% investment commitment"; what the flag establishes is
narrower than that, and has no quotient anywhere in it: a QLICI made in tract
36005023702 would satisfy item 1 of the Q25(a) area list — one of the five
single-item routes by which a QLICI can be an area-qualifying one. Whether to
commit to the 85% is a Yes/No the CDE answers for itself, and no percentage
is filed for it. A tract does not "qualify for" a commitment — a
CDE makes one, over its own QLICI dollars, and nothing in this result speaks to
that share. Quote the line as the package's label; say what it means in your own
words alongside it, and never carry it forward as the skill's own claim.
The Opportunity Zone line may now be reported as printed — that is the point
of 0.5.0. Through 0.4.3 summary() printed a bare Opportunity Zone: No here
and this skill's job was to re-narrate it, because is_opportunity_zone was a
plain bool and a False could not distinguish not-designated from a 2010/2020
vintage miss. The package now carries the qualifier itself: executed this
session on 0.5.0, 36005023702 returns is_opportunity_zone is None and
opportunity_zone_status == 'not-confirmed'. Report it as "not confirmed as
an Opportunity Zone" — which is what the line says. Still never write "not an
Opportunity Zone": the underlying ambiguity has not gone away, it has been made
visible. The qualifier is printed inline on the same line, so quoting the
line alone carries it; do not strip the second line when copying.
The EligibilityResult fields (read these, don't re-derive): address,
tract_id, nmtc_eligible (Optional[bool] — True / False / None),
distress_level (str: 'deep', 'severe', 'lic', 'ineligible',
'unknown'), poverty_rate, ami_ratio, unemployment_rate (each
Optional[float] with two kinds of missing — see the tri-state section),
is_non_metro, is_high_migration_rural, severe_distress, deep_distress
(all four Optional[bool] as of 0.5.0), geocode_success (plain bool),
is_opportunity_zone (Optional[bool] — True or None, never False),
and tract_found (bool, 0.4.0 — False when the tract is absent from the
table). Properties: distress_description (plain-English line, e.g. "Severe
Distress — qualifies for 85% investment commitment"), eligibility_status
(the four-way string above) and opportunity_zone_status (0.5.0 — the
three-way string designated / not-confirmed / no-tract; see the OZ rule
below).
distress_description returns DISTRESS_LEVELS[distress_level] verbatim, and
both distress strings assert more than the flag behind them carries. The
severe string names the 85% commitment without its QLICI-dollar denominator and
without the "and/or multiple indicia" alternative route — see the
commitment-basis rule. The deep string, "Deep Distress — highest need,
strongest NMTC application score", asserts a scoring outcome the package
cites no source for; there is a real Q25(b) Deep Distress commitment worth points,
but that is not what this string says and this package does not establish it.
Quote either property if you quote it, and qualify it on the adjacent line — do
not restate either claim in the skill's own voice, and do not paraphrase a
package constant into prose.
is_high_migration_rural is the field that exposes a stale install. It is
one of the three routes to LIC status (§45D(e)(5)), and pre-0.4.2 the package
surfaced it while excluding it from the verdict — see the install note. On a
pre-0.4.2 install one of two things happens, and both mean the eligibility
verdict is wrong or absent: against the current workbook the loader raises
EligibilitySchemaError and returns nothing; against a cached pre-July-2026
workbook it returns is_high_migration_rural=True alongside
nmtc_eligible=False — a result contradicting itself. The remedy for both is
the same: upgrade to the >=0.5.0 floor. Check it with tract
01013953500, the first of the 168 — on 0.5.0 it returns
nmtc_eligible=True, is_high_migration_rural=True, distress_level='lic'
(re-executed this session). The pre-0.4.2 load failure was re-executed too: a
0.4.1 install against the workbook the Fund serves today raises
EligibilitySchemaError naming column index 2's renamed header, and loads
nothing.
is_nmtc_native_area was REMOVED in 0.5.0 — and Native Area status cannot be
determined from this package at all. Through 0.4.3 the field existed and was
False for all 85,395 tracts (True count 0), because nothing in the .xlsb
ever populated it; reading it now raises AttributeError on a result and
KeyError on an enriched frame, which is deliberate — a field that can only
ever say "I don't know" invites a reader to treat the absence of True as
meaningful, and failing loud is safer than failing silent.
State the absence; do not fill it. If a user asks whether a tract is in an NMTC Native Area, the honest answer is that this lookup cannot tell them — not "no," and not an inference from the tract's location or name:
- The CDFI Fund publishes no tract-keyed NMTC Native Areas resource. Its April 2025 NMTC Compliance & Monitoring FAQs Q31 enumerates the eleven resources it links for determining Area-of-Higher-Distress status, and Native Areas is not among them. The Fund's CIMS map service does carry tract-level native-area qualification layers — but for Native Initiatives and the Bank Enterprise Award, not for NMTC; the NMTC layer family has no native-area member. So this is narrower than "no source exists": the Fund has published a tract-keyed native-area determination for two other programs and not for this one.
- The criterion is live, so "unknown" is not the same as "irrelevant." The same FAQ's Q32 names "NMTC Native Areas: Federal Indian Reservations, Off-Reservation Trust Lands, Hawaiian Home Lands, and Alaska Native Village Statistical Areas" as one of the Areas of Deep Distress criteria added in the CY 2024–2025 Application. A deal may genuinely qualify on it; this package simply cannot say so.
- It is a spatial determination, not a join. Those four classes are Census
AIANNH legal geographies. Their GEOIDs are four-digit AIANNH codes with
no state or county component (e.g.
2430, Navajo Nation, which itself spans three states), while a tract GEOID isSSCCCTTTTTT. An identifier that carries no state cannot nest into the state→county→tract chain, so the answer requires a polygon intersection of TIGER/Line AIANNH shapefiles against tract shapefiles — plus a coverage rule (any overlap? centroid? majority land?) that the Fund has not published for NMTC. Any answer this package gave would be inventing that rule.
Route the user to the CDFI Fund's CIMS and to the Application/Compliance FAQ for the criterion, and say plainly that the mapper does not carry it.
Note .summary is a method — call result.summary(). result.summary
alone returns the bound method object, not the text.
Worked example — verified-ineligible tract + the NaN honesty rule (executed)
A tract that is present in the table with an explicit NO flag — distinct
from an absent tract (next example). Verified this session: 11001980000 is
one of the 85,395 rows, flagged not-eligible, with null (NaN) poverty and
income. The CDFI Fund documents several reasons a tract carries null
demographics: per its 2016-2020 ACS Data FAQ (updated Feb 1, 2024;
NMTC_LIC_FAQs_2020_ACS_Sept1_2023_Update_Jan2024.pdf, General Q2), the Census
Bureau could not estimate income or poverty for such tracts —
a significant majority have no or very low population, and the remainder's
population is largely in group quarters (e.g. prisons, college dormitories),
which the ACS excludes from income and poverty calculations. Which of those
applies to 11001980000 is not something this lookup reports, so do not assert
it.
import nmtcmapper as nm
m = nm.NMTCMapper()
r = m.check_tract("11001980000") # present, explicit NO, null demographics
print(r.nmtc_eligible, r.distress_level, r.poverty_rate, r.ami_ratio)
print(r.eligibility_status, "| tract_found:", r.tract_found)
Actual output this session (0.5.0):
False ineligible nan nan
verified-ineligible | tract_found: True
poverty_rate and ami_ratio came back NaN — the Fund does not publish an
income or poverty estimate for this tract (see the FAQ Q2 reasons above) —
render them "not available," never invent a number. As of 0.5.0 summary()
does this for you, and says which kind of missing it is; the same call on this
tract prints (executed this session):
Poverty Rate: not available — the CDFI Fund published no value for this tract
AMI Ratio: not available — the CDFI Fund published no value for this tract
Through 0.4.3 those two lines rendered as nan% for all 1,583 poverty / 2,358
AMI tracts in this state. Note the wording is deliberately different from
the ❓ UNKNOWN — tract not read that the indeterminate branches print: this
tract was read and the Fund did publish a verdict for it, and only the
metric is absent. Do not collapse the two into one word.
nmtc_eligible=False / eligibility_status='verified-ineligible' /
tract_found=True is a real NO from the table — the answer is
ineligible. This is NOT the third state; contrast the next example, where the
tract is absent and the honest answer is "unknown."
Worked example — the third state: an ABSENT tract (executed)
The teaching case for None/"unknown". A syntactically valid GEOID that is
not in the 2016–2020 universe (a mistyped tract, or one from a different
vintage). Verified absent this session: 36061980000 is not among the
85,395 rows.
import nmtcmapper as nm
m = nm.NMTCMapper()
r = m.check_tract("36061980000") # a tract ABSENT from the 2016-2020 universe
print(r.nmtc_eligible, r.distress_level, r.eligibility_status, r.tract_found)
r.summary()
Actual output this session (0.5.0):
None unknown not-found False
NMTC Eligibility Result
==================================================
Address: Census Tract 36061980000
Census Tract: 36061980000
NMTC Eligible: ❓ UNKNOWN — tract not in eligibility table (indeterminate, NOT ineligible)
Distress Level: UNKNOWN
Description: Indeterminate — eligibility not verified (no match / tract absent)
Poverty Rate: ❓ UNKNOWN — tract not read
AMI Ratio: ❓ UNKNOWN — tract not read
Unemployment: ❓ UNKNOWN — tract not read
Non-Metro: ❓ UNKNOWN — tract not read
Opportunity Zone: ❓ NOT CONFIRMED — not on the 2018 designation list, which is
2010-tract-based (indeterminate, NOT "not an Opportunity Zone")
High Migration: ❓ UNKNOWN — tract not read
This block is why the floor moved to >=0.5.0. On 0.4.3 the same call
printed Non-Metro: No, Opportunity Zone: No and High Migration: No, and
omitted the three demographic lines entirely — three fabricated negatives and
three silent omissions sitting directly underneath a correct ❓ UNKNOWN
verdict, in the skill's own teaching case for the third state. Every one of
those lines now qualifies itself inline, so the block can be pasted whole.
Report this as: "NMTC eligibility could not be determined for tract
36061980000 — it is absent from the 2016–2020 eligibility universe." Do not
report it as "not eligible." The Description line —
"Indeterminate — eligibility not verified (no match / tract absent)" — is the
verbatim value of DISTRESS_LEVELS["unknown"] in
nmtcmapper/data/schema.py.
The program administrator documents this exact case — it is not just a
first-principles argument. The CDFI Fund's 2016-2020 ACS Data FAQ (updated
Feb 1, 2024; NMTC_LIC_FAQs_2020_ACS_Sept1_2023_Update_Jan2024.pdf, Q10,
"I can't find a 2010 census tract in the 2016-2020 ACS Low-Income Community
data. Where is it?") explains
that the 2011–2015 data is built on 2010 census tracts and the 2016–2020
data on 2020 tracts, and that as part of the 2020 census the Bureau
eliminated certain 2010 tracts and folded their land into new tracts — so a
tract absent from this table is a vintage artifact, not an ineligibility
finding. The FAQ routes the reader to the Census Bureau tract-relationship
files and to CIMS for geocoding; do the same rather than reporting "not
eligible."
The same third state reaches you from check_address when an address does not
geocode: nmtc_eligible=None, distress_level="unknown",
eligibility_status="geocode-failed", and summary() prints "❓ UNKNOWN —
address could not be geocoded (indeterminate, NOT ineligible)." (executed this
session on 0.5.0 against a deliberately unresolvable address). On that branch
0.5.0 also returns opportunity_zone_status == 'no-tract' and prints
"Opportunity Zone: ❓ UNKNOWN — no census tract resolved" — through 0.4.3 this
branch hardcoded is_opportunity_zone=False, asserting a non-designation about
an address that never resolved to a tra
…(truncated)