Grants.gov does not offer a practical API for bulk opportunity data. What it offers instead is better once you know how to handle it: the entire corpus, every field, every status, published as one XML file every single day. We ran this feed in production and held 83,002 opportunities from it. This is how it works and what breaks.
https://prod-grants-gov-chatbot.s3.amazonaws.com/extracts/GrantsDBExtract{YYYYMMDD}v2.zip
Substitute today's date, for example GrantsDBExtract20260718v2.zip. That is a 77 MB ZIP
containing roughly 318 MB of XML. It is not a delta. It is the complete corpus, republished daily, with
every opportunity in every status and every field.
Two consequences follow from that, and both are good news. You never need to reconcile deletions or track a cursor, because tomorrow's file is authoritative. And because you keep what you ingest, you accumulate history that Grants.gov itself does not expose: an opportunity that closes disappears from their search, but stays in your table forever.
Do not mirror the feed by deleting rows that vanish. UPSERT the whole file on
opportunity_id, never delete, and compute status when you query rather than when you load.
INSERT INTO grant_opportunities (...) VALUES %s
ON CONFLICT (opportunity_id) DO UPDATE
SET ...
WHERE grant_opportunities.last_updated_date IS DISTINCT FROM EXCLUDED.last_updated_date;
That WHERE clause matters more here than almost anywhere else. You are re-loading the full
corpus every day, so without it you rewrite 83,000 rows daily to change nothing, generating WAL and vacuum
work forever. With it, a steady-state day touches only what actually moved.
ET.parse() will hold the entire tree in memory. Stream it and release each element as you
finish with it:
import xml.etree.ElementTree as ET
for _ev, elem in ET.iterparse(xml_path, events=("end",)):
tag = _strip(elem.tag)
if tag in ("OpportunitySynopsisDetail_1_0", "OpportunityForecastDetail_1_0"):
d = _collect(elem)
oid = _one(d, "OpportunityID")
if oid and oid not in seen:
seen.add(oid)
batch.append(_row(tag, d))
elem.clear() # without this, memory grows to the full 318 MB
OpportunitySynopsisDetail_1_0 is a posted opportunity.
OpportunityForecastDetail_1_0 is a forecast of one that has not opened yet. They do not share
a field set. Code written against one will silently drop fields from the other, and forecasts are roughly
where next quarter's pipeline lives, so dropping them is expensive.
Every tag arrives namespaced, so find("OpportunityID") returns None forever
and you will assume the field is missing. Strip the namespace and match the local name:
_strip = lambda t: t.split('}')[-1]
This one cost us real time. The feed does not just serve raw entities, it sometimes serves
double-encoded ones: ' which unescapes to ' which
unescapes to an apostrophe. A single html.unescape() leaves visible entity codes in your
titles. Unescape until the string stops changing, with a bound so a pathological input cannot loop:
import html
def _unescape(s):
"""grants.gov sometimes double-encodes (' -> ' -> ');
unescape until stable, bounded."""
for _ in range(3):
new = html.unescape(s)
if new == s:
return s
s = new
return s
09152026 is 15 September 2026. The danger is not that a naive parser fails, it is that
%d%m%Y succeeds on ambiguous dates and gives you a valid, wrong answer. Parse explicitly and
reject anything that is not eight digits:
def _pdate(s):
s = (s or "").strip()
if len(s) == 8 and s.isdigit():
try:
return datetime.datetime.strptime(s, "%m%d%Y").date()
except ValueError:
return None
return None
Fields like eligibility categories appear as several sibling elements, not one delimited string. If you
assign rather than append you keep only the last one. Collect into a list and store as
text[]:
def _collect(elem):
d = {}
for c in elem:
d.setdefault(_strip(c.tag), []).append((c.text or "").strip())
return d
| Metric | Value |
|---|---|
| Download | 77 MB ZIP, daily |
| Parsed | ~318 MB XML |
| Rows held | 83,002 opportunities |
| Storage | 344 MB with indexes |
| Schedule | One full re-sync per day |
The job is not expensive. The cost is that the feed changes shape without announcement, and every gotcha above was found in production rather than in documentation.