Third in the series (#29,
#30). This round is from kicking the
tires on the new budget endpoint (/api/budget/, Beta, announced June 1) — first
replicating all four "Build With Tango" posts, then building an actual pocket-rescission
watchlist (discretionary accounts with appropriated-but-unobligated money, split into
expiring vs. safe via the period of availability).
~150 calls across accounts/, accounts/{id}/recipients/, accounts/{id}/quarters/, and
entities/{uei}/budget-flows/, on tango-python v1.1.1. The SDK wraps all four budget
surfaces (list_budget_accounts, get_budget_account, get_budget_account_recipients,
get_budget_account_quarters, get_entity_budget_flows) and the blog-post code runs as
written — see the correction note below; an earlier draft of this issue wrongly claimed it
didn't, because we were on a stale local install.
None of the items below block the work — we have workarounds for everything — but flagging
them while it's fresh.
What's working great
- The data is accurate. Every figure cited across all four blog posts replicates
exactly against the live API — HHS contract-share table, the NASA 080-0124 primes
($1.12B / $623M / $483M), Boeing's budget-flows series ($1.04B FY20 → $1.35B FY22), BARDA's
$567.9M apportioned-minus-obligated gap, VA 036-0110's $3.84B unobligated. We tried to
break the numbers and couldn't. (verify_blog_posts.py checks all of them.)
shape= carries over from the rest of Tango unchanged, including nested
(recipient.legal_business_name) — payloads stay small.
- The inverse
budget-flows/ surface is the standout. One UEI → full federal-account
funding history by year is a join we'd otherwise stitch from FPDS by hand. Worked first try.
quarters/ encodes period of availability in the TAS string — 075-2025/2025-1000
(one-year), 075-2025/2026-1000 (multi-year), 075-X-1000 (no-year) — and bundles every
active budget-year tranche under the account. Once parsed, it's a clean lapse-risk
classifier, and it rescued our analysis when bea_category turned out unreliable (item 4).
- Pre-computed
contract_share_of_obligated_capped makes the "is this account even
contractable" triage a one-field sort, as advertised.
Friction we hit (ranked by impact)
1. ✅ None of the blog-post code runs on the shipped SDK — RETRACTED (our error)
Correction. An earlier version of this issue claimed the blog methods didn't exist in
the SDK. That was wrong: we were on a stale v0.4.1 local install. tango-python v1.1.1
(released May 29, before the June 1 announcement) ships list_budget_accounts,
get_budget_account, get_budget_account_recipients, get_budget_account_quarters, and
get_entity_budget_flows, and the blog-post code runs verbatim after
pip install --upgrade tango-python. Apologies for the noise.
The only residual (very minor) suggestion: the posts don't state a minimum SDK version, so a
reader on an older pinned tango-python hits AttributeError. A one-line "requires
tango-python >= 1.0" note on the posts would prevent that. Everything below is API-level and
independent of SDK version.
2. 🔴 bea_category is inconsistent across fiscal years
The first filter anyone reaches for ("show me discretionary accounts") is unreliable.
Repro:
for fy in (2024, 2025, 2026):
r = client.list_budget_accounts(federal_account_symbol="075-0512",
fiscal_year=fy, shape="bea_category", limit=1).results[0]
print(fy, r["bea_category"])
Actual:
2024 Discretionary
2025 Discretionary
2026 Mandatory
Grants to States for Medicaid is mandatory — FY2026 looks corrected, FY2024/25 are wrong.
It's not a one-off; the whole distribution shifts year-to-year, and ~⅓ of accounts are null:
| FY |
Discretionary |
Mandatory |
Net interest |
null |
| 2025 |
1178 |
359 |
7 |
791 |
| 2026 |
1029 |
481 |
7 |
780 |
So a bea_category=Discretionary filter silently pulls $2T of mandatory programs into a
"discretionary" analysis in the older years. We stopped trusting the field and classified
expiring money from the quarters/ TAS instead.
Possible fix: reconcile bea_category across years (apply the FY2026 classification
back through the series), and document the null cases.
3. 🔴 Unsupported params are handled two different (both surprising) ways
Repro:
client.list_budget_accounts(fiscal_year=2024, totally_fake_field__gte=5) # -> 200, count unchanged (2332)
client.get_account_quarters(account_id, fiscal_year=2026) # -> 404 Resource not found
On accounts/, an unknown/typo'd param is silently ignored and you get the full
unfiltered set back with a 200 — you can't tell "filtered to everything" from "filter did
nothing." On quarters/, an unsupported param 404s, which reads as "this account
doesn't exist" when the account is fine.
Possible fix: reject unknown params with a 400 that names them (and ideally echo the
applied filters in the response envelope).
4. 🟠 Filter operators are partially implemented, silently
On obligated_total (FY2024, unfiltered count = 2,332):
| Filter |
count |
works? |
obligated_total__gte=2e8 |
699 |
✅ |
obligated_total__lte=2e8 |
1,633 |
✅ |
obligated_total__gt=2e8 |
2,332 |
❌ ignored |
obligated_total__lt=2e8 |
2,332 |
❌ ignored |
obligated_total__isnull=true |
2,332 |
❌ ignored |
__gte/__lte work; __gt/__lt/__isnull are accepted but do nothing. This cost real
time — we concluded "numeric filtering is broken," then found __gte works fine. Combined
with #3, a filter returning 200 tells you nothing about whether it applied.
Possible fix: implement the missing operators, or document the supported set per field.
5. 🟠 id is per (account, fiscal_year), not a stable account key
BARDA 075-1000 is id=20557 in FY2024 but id=19310 in FY2026. So every drill-down
(recipients/, quarters/) is year-bound and you must re-resolve the id for each year.
Surprising for something billed as "federal-account-grain" — callers assume the id
identifies the account, not the account-year.
Possible fix: make the id stable and take fiscal_year as a param on the sub-resources,
or rename it account_year_id so the binding is obvious.
6. 🟠 Descending ordering sorts NULLs first
Repro: list_budget_accounts(fiscal_year=2024, ordering="-contract_obligated") returns
rows with contract_obligated=null (Gifts & Donations, Congressional Publishing…) at the
top, burying the actual top contract accounts. You have to add contract_obligated__gte=1 to
push them out — which only works because __gte happens to be one of the implemented
operators (#4).
Possible fix: NULLS LAST on descending sorts.
7. 🟡 quarters/ forward-fills future quarters in the open year
For an FY2026 account today (we're in FY26 Q3), Q3 and Q4 cumulative both equal the last
actual value (Q2) — so Q4 shows a number that isn't real yet. Consumers have to detect
"latest quarter whose cumulative changed" to avoid overstating progress.
Possible fix: an is_actual / as_of_quarter flag on each row.
8. 🟡 quarters/ has obligated-cumulative per TAS but no per-TAS budget authority
You can see how fast each TAS is obligating but not its unobligated balance — so you
can't compute the dollar amount of one-year money actually at risk of lapsing. The only
unobligated magnitude is account-grain unobligated_balance, which isn't split by
availability period. Adding per-TAS BA (or unobligated) to quarters/ would make the
lapse-risk number directly computable instead of approximable from obligation pace.
9. 🟡 recipients/ mixes a null-key aggregate row in with real recipients
The top row of NASA 080-0124's recipients/ isn't Boeing — it's an unattributed bucket
(recipient_id=null, funding_office=null) of $2.1B / 128 contracts, larger than every
named prime. Post 2's example silently skips it; a naive
f"{r['recipient']['legal_business_name']}" crashes on the None (we hit exactly this).
Possible fix: an is_unattributed flag, or split the aggregate into its own field, and a
sentence in post 2 explaining what that bucket is.
10. 🟡 Capped ratios & unobligated_balance mislead on trust/financing accounts
obligated_to_apportioned_pct_capped caps at 1.0, hiding that trust funds obligate far more
than they apportion (075-8004: $23B apportioned, $278B obligated → ratio shows 1.0). And
unobligated_balance ≠ apportioned − obligated for those accounts (it includes carryover);
the two only coincide for clean annual discretionary money. Worth documenting which
definition unobligated_balance uses.
11. 🟢 Minor: cross-surface account_title mismatch + allocation-TAS parsing
- Same symbol, two titles: 080-0124 is "Deep Space Exploration Systems" in
accounts/ but
"Exploration" in budget-flows/. A reader joining the surfaces on
federal_account_symbol sees mismatched names.
- Allocation/transfer TAS carry a 3-part prefix (
069-017-2022/2022-1804) that breaks a
naive split('-')[1] when parsing availability. Easy to handle once you know — worth a
doc note.
Wishlist
- Per-TAS budget authority on
quarters/ (item 8) — turns lapse-risk from an
approximation into a direct calculation. Biggest single win for this use case.
- A stable account id +
fiscal_year param on sub-resources (item 5).
- Reconciled
bea_category (item 2) — or, if it's genuinely year-specific, a doc note
saying so and why.
- 400-on-unknown-param across all surfaces (item 3) — would have saved us the most time.
What we built
A pocket-rescission watchlist — discretionary accounts carrying appropriated-but-unobligated
money, split into expiring (one-year) vs. safe (no-year), late in the fiscal year. It came
out of just two surfaces:
accounts/ for the ranking: pull a fiscal year, take apportioned − obligated_total
as the unobligated headroom, sort descending. One paginated call set.
quarters/ for the lapse classification: parse the period of availability out of each
TAS string (075-2025/2025-1000 = one-year, …/2026… = multi-year, 075-X-… = no-year),
and bucket the obligation activity. One call per candidate account.
The second step is also what let us route around the bea_category problem (#2): instead of
trusting the category label, we keep accounts whose obligation is mostly in one-year TAS.
That cleanly drops the mandatory/financing accounts that leak through —
075-0512 Medicaid one-year share 1% -> dropped
086-0236 FHA financing one-year share 0% -> dropped
024-8424 FEGLI life-ins. one-year share 0% -> dropped
017-1804 Navy O&M one-year share 99% -> kept
— and leaves a watchlist of genuine annual money (DoD O&M, Military Personnel, Defense Health
Program, etc.). The whole thing is ~250 lines against the two endpoints; the hardest part was
the workarounds above, not the analysis. Once those were understood it was straightforward.
Third in the series (#29,
#30). This round is from kicking the
tires on the new budget endpoint (
/api/budget/, Beta, announced June 1) — firstreplicating all four "Build With Tango" posts, then building an actual pocket-rescission
watchlist (discretionary accounts with appropriated-but-unobligated money, split into
expiring vs. safe via the period of availability).
~150 calls across
accounts/,accounts/{id}/recipients/,accounts/{id}/quarters/, andentities/{uei}/budget-flows/, ontango-pythonv1.1.1. The SDK wraps all four budgetsurfaces (
list_budget_accounts,get_budget_account,get_budget_account_recipients,get_budget_account_quarters,get_entity_budget_flows) and the blog-post code runs aswritten — see the correction note below; an earlier draft of this issue wrongly claimed it
didn't, because we were on a stale local install.
None of the items below block the work — we have workarounds for everything — but flagging
them while it's fresh.
What's working great
exactly against the live API — HHS contract-share table, the NASA 080-0124 primes
($1.12B / $623M / $483M), Boeing's budget-flows series ($1.04B FY20 → $1.35B FY22), BARDA's
$567.9M apportioned-minus-obligated gap, VA 036-0110's $3.84B unobligated. We tried to
break the numbers and couldn't. (
verify_blog_posts.pychecks all of them.)shape=carries over from the rest of Tango unchanged, including nested(
recipient.legal_business_name) — payloads stay small.budget-flows/surface is the standout. One UEI → full federal-accountfunding history by year is a join we'd otherwise stitch from FPDS by hand. Worked first try.
quarters/encodes period of availability in the TAS string —075-2025/2025-1000(one-year),
075-2025/2026-1000(multi-year),075-X-1000(no-year) — and bundles everyactive budget-year tranche under the account. Once parsed, it's a clean lapse-risk
classifier, and it rescued our analysis when
bea_categoryturned out unreliable (item 4).contract_share_of_obligated_cappedmakes the "is this account evencontractable" triage a one-field sort, as advertised.
Friction we hit (ranked by impact)
1. ✅
None of the blog-post code runs on the shipped SDK— RETRACTED (our error)Correction. An earlier version of this issue claimed the blog methods didn't exist in
the SDK. That was wrong: we were on a stale v0.4.1 local install.
tango-pythonv1.1.1(released May 29, before the June 1 announcement) ships
list_budget_accounts,get_budget_account,get_budget_account_recipients,get_budget_account_quarters, andget_entity_budget_flows, and the blog-post code runs verbatim afterpip install --upgrade tango-python. Apologies for the noise.The only residual (very minor) suggestion: the posts don't state a minimum SDK version, so a
reader on an older pinned
tango-pythonhitsAttributeError. A one-line "requirestango-python >= 1.0" note on the posts would prevent that. Everything below is API-level and
independent of SDK version.
2. 🔴
bea_categoryis inconsistent across fiscal yearsThe first filter anyone reaches for ("show me discretionary accounts") is unreliable.
Repro:
Actual:
Grants to States for Medicaid is mandatory — FY2026 looks corrected, FY2024/25 are wrong.
It's not a one-off; the whole distribution shifts year-to-year, and ~⅓ of accounts are null:
So a
bea_category=Discretionaryfilter silently pulls $2T of mandatory programs into a"discretionary" analysis in the older years. We stopped trusting the field and classified
expiring money from the
quarters/TAS instead.Possible fix: reconcile
bea_categoryacross years (apply the FY2026 classificationback through the series), and document the null cases.
3. 🔴 Unsupported params are handled two different (both surprising) ways
Repro:
On
accounts/, an unknown/typo'd param is silently ignored and you get the fullunfiltered set back with a 200 — you can't tell "filtered to everything" from "filter did
nothing." On
quarters/, an unsupported param 404s, which reads as "this accountdoesn't exist" when the account is fine.
Possible fix: reject unknown params with a 400 that names them (and ideally echo the
applied filters in the response envelope).
4. 🟠 Filter operators are partially implemented, silently
On
obligated_total(FY2024, unfiltered count = 2,332):obligated_total__gte=2e8obligated_total__lte=2e8obligated_total__gt=2e8obligated_total__lt=2e8obligated_total__isnull=true__gte/__ltework;__gt/__lt/__isnullare accepted but do nothing. This cost realtime — we concluded "numeric filtering is broken," then found
__gteworks fine. Combinedwith #3, a filter returning 200 tells you nothing about whether it applied.
Possible fix: implement the missing operators, or document the supported set per field.
5. 🟠
idis per (account, fiscal_year), not a stable account keyBARDA 075-1000 is
id=20557in FY2024 butid=19310in FY2026. So every drill-down(
recipients/,quarters/) is year-bound and you must re-resolve the id for each year.Surprising for something billed as "federal-account-grain" — callers assume the id
identifies the account, not the account-year.
Possible fix: make the id stable and take
fiscal_yearas a param on the sub-resources,or rename it
account_year_idso the binding is obvious.6. 🟠 Descending
orderingsorts NULLs firstRepro:
list_budget_accounts(fiscal_year=2024, ordering="-contract_obligated")returnsrows with
contract_obligated=null(Gifts & Donations, Congressional Publishing…) at thetop, burying the actual top contract accounts. You have to add
contract_obligated__gte=1topush them out — which only works because
__gtehappens to be one of the implementedoperators (#4).
Possible fix: NULLS LAST on descending sorts.
7. 🟡
quarters/forward-fills future quarters in the open yearFor an FY2026 account today (we're in FY26 Q3), Q3 and Q4 cumulative both equal the last
actual value (Q2) — so Q4 shows a number that isn't real yet. Consumers have to detect
"latest quarter whose cumulative changed" to avoid overstating progress.
Possible fix: an
is_actual/as_of_quarterflag on each row.8. 🟡
quarters/has obligated-cumulative per TAS but no per-TAS budget authorityYou can see how fast each TAS is obligating but not its unobligated balance — so you
can't compute the dollar amount of one-year money actually at risk of lapsing. The only
unobligated magnitude is account-grain
unobligated_balance, which isn't split byavailability period. Adding per-TAS BA (or unobligated) to
quarters/would make thelapse-risk number directly computable instead of approximable from obligation pace.
9. 🟡
recipients/mixes a null-key aggregate row in with real recipientsThe top row of NASA 080-0124's
recipients/isn't Boeing — it's an unattributed bucket(
recipient_id=null,funding_office=null) of $2.1B / 128 contracts, larger than everynamed prime. Post 2's example silently skips it; a naive
f"{r['recipient']['legal_business_name']}"crashes on theNone(we hit exactly this).Possible fix: an
is_unattributedflag, or split the aggregate into its own field, and asentence in post 2 explaining what that bucket is.
10. 🟡 Capped ratios &
unobligated_balancemislead on trust/financing accountsobligated_to_apportioned_pct_cappedcaps at 1.0, hiding that trust funds obligate far morethan they apportion (075-8004: $23B apportioned, $278B obligated → ratio shows 1.0). And
unobligated_balance ≠ apportioned − obligatedfor those accounts (it includes carryover);the two only coincide for clean annual discretionary money. Worth documenting which
definition
unobligated_balanceuses.11. 🟢 Minor: cross-surface
account_titlemismatch + allocation-TAS parsingaccounts/but"Exploration" in
budget-flows/. A reader joining the surfaces onfederal_account_symbolsees mismatched names.069-017-2022/2022-1804) that breaks anaive
split('-')[1]when parsing availability. Easy to handle once you know — worth adoc note.
Wishlist
quarters/(item 8) — turns lapse-risk from anapproximation into a direct calculation. Biggest single win for this use case.
fiscal_yearparam on sub-resources (item 5).bea_category(item 2) — or, if it's genuinely year-specific, a doc notesaying so and why.
What we built
A pocket-rescission watchlist — discretionary accounts carrying appropriated-but-unobligated
money, split into expiring (one-year) vs. safe (no-year), late in the fiscal year. It came
out of just two surfaces:
accounts/for the ranking: pull a fiscal year, takeapportioned − obligated_totalas the unobligated headroom, sort descending. One paginated call set.
quarters/for the lapse classification: parse the period of availability out of eachTAS string (
075-2025/2025-1000= one-year,…/2026…= multi-year,075-X-…= no-year),and bucket the obligation activity. One call per candidate account.
The second step is also what let us route around the
bea_categoryproblem (#2): instead oftrusting the category label, we keep accounts whose obligation is mostly in one-year TAS.
That cleanly drops the mandatory/financing accounts that leak through —
— and leaves a watchlist of genuine annual money (DoD O&M, Military Personnel, Defense Health
Program, etc.). The whole thing is ~250 lines against the two endpoints; the hardest part was
the workarounds above, not the analysis. Once those were understood it was straightforward.