The federal contracting data layer

FFATA Sub-grants: Ingesting Pass-Through Assistance Data

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:

WindowResult
12 months, single requestRan 34 minutes and never finished
60 days, single chunkCan take 20+ minutes on its own
11 chunks, submitted seriallyHours
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

MetricValue
Rows held604,078 sub-grant reports
Storage1.2 GB with indexes
ScheduleDaily trailing action_date window
Scheduling noteOffset 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

Official sources