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.
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.
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.
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.
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.
| 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 |