FFATA Sub-grants: Ingesting Pass-Through Assistance Data
Updated
When a state receives a federal grant and passes part of it to a county, a university or a nonprofit, that second hop is reported under FFATA. It is the larger half of federal assistance by both count and dollars, and almost nobody works with it. We held 604,078 sub-grant reports in production. This is how to build the pipeline, including the two things that will otherwise cost you a weekend.
Why this is a guide and not a product.
We retired our sub-grants endpoints in July 2026 to concentrate on federal contracting. The data is free at source. What follows is the operational knowledge, which is not.
The source
Same USAspending bulk_download API as prime awards, different filter block:
POST https://api.usaspending.gov/api/v2/bulk_download/awards/
Content-Type: application/json
{
"file_format": "csv",
"filters": {
"sub_award_types": ["grant"],
"date_type": "action_date",
"date_range": {"start_date": "2025-10-01", "end_date": "2025-11-30"}
}
}
Note sub_award_types, not prime_award_types. Using ["grant"] gives you the assistance side; ["procurement"] gives you contract sub-awards, which is a different dataset with different keys and should live in a different table. More on that below, because merging them is the expensive mistake.
Use action_date here, not last_modified_date. FFATA sub-award reporting is late and irregular by nature, and in our testing the action-date lane was the one that behaved predictably.
The assistance compiler is much slower, and this is the fix
USAspending compiles these files server-side on demand. The assistance sub-award compiler is far slower than the prime-award one, and the difference is not marginal:
| Window | Result |
|---|---|
| 12 months, single request | Ran 34 minutes and never finished |
| 60 days, single chunk | Can take 20+ minutes on its own |
| 11 chunks, submitted serially | Hours |
| 11 chunks, submitted up front then polled together | ~23 minutes |
The key behaviour is that the API caches the compiled file per (start_date, end_date) pair. So a submit is not a request you wait on, it is a job you start. Submit every chunk first, then poll them all:
# WRONG: each chunk waits for the previous compile to finish
for start, end in chunks:
job = submit_job(start, end)
status = poll(job["status_url"]) # blocks for 20+ minutes
download(status["file_url"])
# RIGHT: start every compile, then collect
jobs = [submit_job(start, end) for start, end in chunks] # returns immediately
for job in jobs:
status = poll(job["status_url"]) # most are already done by now
download(status["file_url"])
Because the compile is cached, a poll that times out is not a failure. Exit, and the next run finds the file already built. That makes the job cron-safe: give it a bounded poll window, let it give up, and let tomorrow's run collect what today's run started.
Do not merge sub-grants into your contract sub-awards table
This is the mistake worth the most to avoid, and it fails silently rather than loudly.
If you run a reconciler that compares USAspending's per-day count against your own per-day row count, and you put both sub-grants and contract sub-awards in one table, your row counter now counts a larger population than the API's counter does:
ours (sub-contracts + sub-grants) > usaspending (sub-contracts only)
The comparison is then usa <= ours on every single day. Your reconciler reports "caught up" forever and goes blind. It does not error. It does not warn. It just stops finding gaps, and you learn about it months later when someone notices missing rows.
There is a second failure on top of it. Any per-day ceiling your reconciler already recorded was captured under the contracts-only regime. After the merge, a capped day skips its genuine sub-grant gap as a phantom and never fetches those rows at all.
Keep them in separate tables with separate jobs, even though the code looks nearly identical. We duplicated ours deliberately and revisited the decision twice, and both times duplication was correct.
Keys and dedup
Sub-grants key on FAIN and CFDA program, not PIID and NAICS. That is another reason they do not belong in a contract table: the join columns are different, and any query that assumes otherwise produces confident nonsense. Dedup on the sub-award report identifier and make the load idempotent so overlapping windows are free.
What it costs to run
| Metric | Value |
|---|---|
| Rows held | 604,078 sub-grant reports |
| Storage | 1.2 GB with indexes |
| Schedule | Daily trailing action_date window |
| Scheduling note | Offset it from your prime-award job so the two do not queue against USAspending together |
We still hold this data.
We ran this pipeline through July 2026 and kept 604,078 FFATA sub-grant reports. Given the compile times above, a one-time export may be worth more than the backfill. Get in touch. Sold as a dated snapshot, not a feed.
Related
- Federal grant awards: the USAspending bulk_download pipeline
- Grants.gov XML extract: the daily opportunity feed
- FFATA contract sub-awards, which we do maintain
Official sources
- USAspending API documentation
- FSRS, where FFATA sub-award reports originate