If your small business is certified as women-owned (WOSB), 8(a), HUBZone or service-disabled veteran-owned (SDVOSB), set-aside contracts are where your odds are best, because only certified firms can bid. SAM.gov's search page works, but it's slow to filter, and the official API needs an API key tied to a SAM.gov account with low daily limits.
There's an easier route: SAM.gov publishes every contract opportunity as a free daily CSV file, with no key and no login. This post shows how to stream it in Python and pull just the set-asides you qualify for.
Run on Oct 22, 2026, for notices posted in the last 7 days:
- 6,156 notices were posted in total.
- 1,228 were total small-business set-asides and 240 were SDVOSB set-asides.
- 57 were HUBZone set-asides, 38 were 8(a) set-asides plus 13 8(a) sole-source notices, and 34 were WOSB or EDWOSB set-asides.
- 3,880 had no set-aside at all.
The data source
Contract Opportunities full extract, published by GSA through data.gov and refreshed daily. It's about 200 MB, Windows-1252 encoded, with one row per notice, and it's sorted newest first. That last part is the trick: you only need to read the first few thousand rows.
The script
import csv, io, sys, requests
from collections import Counter
from datetime import date, timedelta
csv.field_size_limit(sys.maxsize)
URL = "https://s3.amazonaws.com/falextracts/Contract%20Opportunities/datagov/ContractOpportunitiesFullCSV.csv"
DAYS = 7
since = (date.today() - timedelta(days=DAYS)).isoformat()
def recent_opportunities():
"""Stream SAM.gov's public daily extract (about 200 MB, newest first) and stop once rows get older than the window."""
with requests.get(URL, stream=True, timeout=300) as r:
r.raise_for_status()
r.raw.decode_content = True
reader = csv.DictReader(io.TextIOWrapper(r.raw, encoding="cp1252", errors="replace", newline=""))
older = 0
for row in reader:
if row["PostedDate"][:10] >= since:
older = 0
yield row
else:
older += 1
if older > 300: # small safety margin; the file is sorted newest first
return
KEEP = ["NoticeId", "Title", "Type", "PostedDate", "ResponseDeadLine", "Department/Ind.Agency", "SetASideCode",
"SetASide", "NaicsCode", "PopState", "Link"] # contact name/email/phone columns are deliberately skipped
rows = [{k: r.get(k, "") for k in KEEP} for r in recent_opportunities()]
print(len(rows), "notices posted since", since)
print(Counter(r["SetASide"] or "(no set-aside)" for r in rows).most_common(12))
wosb = [r for r in rows if r["SetASideCode"] in ("WOSB", "WOSBSS", "EDWOSB", "EDWOSBSS")]
print(len(wosb), "WOSB/EDWOSB set-asides, e.g.:")
for r in wosb[:3]:
print(" -", r["PostedDate"][:10], r["NaicsCode"], r["Title"][:70], r["Link"])
Because it streams and stops as soon as rows get older than the window, a 7-day pull reads only a small slice of the 200 MB file.
Three things that will trip you up
-
Encoding. The file is Windows-1252, not UTF-8. Read it as UTF-8 and you'll crash on the first curly quote. Use
encoding="cp1252", errors="replace". -
Huge text fields. Some description cells are bigger than Python's default CSV field limit, hence
csv.field_size_limit(sys.maxsize). -
Set-aside codes vs labels. Filter on
SetASideCode, which is short and stable (SBA,SDVOSBC,HZC,8A,WOSB,EDWOSB, plus sole-source codes like8AN,WOSBSSandSDVOSBS). TheSetASidelabel text changes wording over time.
Make it yours
- Add
and r["NaicsCode"].startswith("2382")(or your NAICS codes) to the filter. - Filter on
PopStatefor place of performance near you. - Run it every morning with cron or a scheduled GitHub Action, and email yourself the new rows (de-duplicate on
NoticeId). - The script leaves out the contact-person columns on purpose. Use the notice
Linkto reach the contracting office the official way.
Optional: I run this as a hosted, scheduled search with set-aside, NAICS and state filters: WOSB Federal Contracts (ready-made search) (paid per result). Disclosure: it's my tool. The script above does the same job for free.
Top comments (0)