How to parse SEC Form D filings with Python
Form D is plain XML, so there is no scraping and no model involved. The work is in four fields that do not mean what they look like.
What Form D actually is
Form D is the notice an issuer files when it sells securities without registering them, most often under Regulation D. It is due within 15 days of the first sale, which is why it tends to be public well before a round is announced anywhere else.
It is filed as XML, not as a document. There is nothing to scrape and nothing to summarise: every field below is read straight out of primary_doc.xml with the standard library. That also means a parser either works or raises, with no confidence score in between.
Find the filings
EDGAR publishes a daily form index, one row per dissemination, sorted by form type. Filter it to D and D/A.
Parse the rows from the RIGHT. A company name contains arbitrary internal spaces, so a fixed-width or left-anchored split breaks on real names, but the last three columns (CIK, date filed, and the archive path) never contain a space.
A 404 on the index is normal and is not an error: EDGAR publishes nothing at the weekend or on a market holiday, and the current day's index appears the following day.
import requests
from datetime import date
# SEC requires a descriptive User-Agent naming a real contact.
# A generic one, or none at all, is answered with 403 rather than data.
HEADERS = {"User-Agent": "Example Research (hello@example.com)"}
def daily_form_index(day: date) -> str:
"""The raw .idx text for one day, or '' when EDGAR published no index."""
quarter = (day.month - 1) // 3 + 1
url = (
"https://www.sec.gov/Archives/edgar/daily-index"
f"/{day.year}/QTR{quarter}/form.{day:%Y%m%d}.idx"
)
response = requests.get(url, headers=HEADERS, timeout=30)
if response.status_code == 404:
return "" # weekend, holiday, or not published yet
response.raise_for_status()
return response.text
def form_d_rows(text: str):
"""Yield (form_type, cik, accession) for the D and D/A rows."""
for line in text.splitlines():
parts = line.split()
if len(parts) < 5:
continue
form_type, cik, file_name = parts[0], parts[-3], parts[-1]
if form_type not in ("D", "D/A"):
continue
if not cik.isdigit() or not file_name.endswith(".txt"):
continue
accession = file_name.rsplit("/", 1)[-1].removesuffix(".txt")
yield form_type, cik.zfill(10), accessionSEC requires a descriptive User-Agent naming a real contact. A generic one, or none at all, is answered with 403 rather than data.
Read the XML
The accession number and the filed date are NOT in the XML. They come from the index row, so carry them through and join them in.
Two element paths are worth copying exactly, because the published spec appendix disagrees with the filings and the filings win. dateOfFirstSale is under offeringData/typeOfFiling, not directly under offeringData. And stateOrCountry appears in several blocks on one filing, so scope it to primaryIssuer/issuerAddress rather than taking the first match.
Real filings omit optional elements freely, so every find() below can return None on a filing that is perfectly valid. Production code wants None-safe accessors around each one; they are left out here so the paths stay readable.
from xml.etree import ElementTree as ET
def primary_doc_url(cik: str, accession: str) -> str:
# The CIK in an Archives path is NOT zero-padded, though the index hands it
# to you padded. int() is the whole fix, and getting it wrong is a 404.
return (
"https://www.sec.gov/Archives/edgar/data"
f"/{int(cik)}/{accession.replace('-', '')}/primary_doc.xml"
)
def parse_form_d(xml_bytes: bytes) -> dict:
root = ET.fromstring(xml_bytes)
issuer = root.find("primaryIssuer")
offering = root.find("offeringData")
amounts = offering.find("offeringSalesAmounts")
securities = offering.find("typesOfSecuritiesOffered")
industry = offering.find("industryGroup").findtext("industryGroupType")
sold = amounts.findtext("totalAmountSold")
return {
"cik": issuer.findtext("cik").zfill(10),
"entity_name": issuer.findtext("entityName"),
# stateOrCountry appears in several blocks on one filing. Scope it to the
# issuer's own address or you may read a related person's state instead.
"state": issuer.find("issuerAddress").findtext("stateOrCountry"),
"industry_group": industry,
# dateOfFirstSale sits under typeOfFiling, NOT directly under
# offeringData. The spec appendix says otherwise; the filings win.
"first_sale": offering.find("typeOfFiling").findtext("dateOfFirstSale"),
"amount_sold_raw": sold, # keep the string as filed
"amount_sold_usd": to_usd(sold), # and the number, separately
# Two independent signals, either one sufficient.
"is_pooled_fund": (
securities.findtext("isPooledInvestmentFundType") == "true"
or industry == "Pooled Investment Fund"
),
}The amount field is the one that will bite you
totalAmountSold is CUMULATIVE. An amendment restates the running total for the same offering rather than reporting the new money, so summing the filings for one issuer double counts, and the more successful the raise, the worse the error. If you want incremental amounts you have to diff a D/A against the previous filing for that offering.
"Indefinite" is a legal value in the amount fields, not a missing one. It is not zero, and coercing it to zero turns an open-ended offering into a reported failure. Keep the raw string beside the number and leave the number null.
The same rule holds for every unfiled checkbox and count: absent means unknown, never false and never zero. "No investors listed" and "zero investors" are different facts about a filing.
import re
_MONEY = re.compile(r"^\d+(?:\.\d+)?$")
def to_usd(raw):
"""A Form D amount as a number, or None.
"Indefinite" is a legal value in these fields, not a missing one, and it is
not zero. Keep the raw string beside the number so the record can still say
what was actually filed.
"""
if raw is None:
return None
cleaned = raw.replace("$", "").replace(",", "").strip()
return float(cleaned) if _MONEY.match(cleaned) else NoneFilter the pooled funds out
A large share of raw Form D volume is funds filing their own raises: hedge funds, VC funds, real-estate vehicles. If what you want is operating companies raising money, they are noise, and they are large enough to dominate any ranking by amount.
There are two independent signals and either is sufficient: the isPooledInvestmentFundType flag under typesOfSecuritiesOffered, and an industryGroupType of "Pooled Investment Fund". The flag is omitted entirely on non-fund filings rather than filed as false, which is why the check above reads it as a string comparison rather than a boolean.
What Form D will not tell you
There is no website field, and no domain anywhere on the filing. If you need to resolve an issuer to a company on the internet, that is a separate project with its own error rate, and guessing is worse than leaving it unresolved.
industry_group is the filer's own checkbox from a fixed SEC list, not an assigned classification. A large minority of filers select "Other" or "Other Technology", and private issuers have no SIC code to fall back on, so there is no join that fixes it. We measured this on 2026-07-26 before building anything on top of it, and the answer was to lean on the filed detail instead: investor count, minimum investment, security types and revenue band are all on the filing and all unambiguous.
A ticker is not a useful key here either. On a sample of our own issuer table taken on 2026-07-26, fewer than one in twenty had a symbol at all, which is what you would expect from a private-market form.
Common questions
Do I need an API key to read Form D from EDGAR?
No. EDGAR is free and public. What it requires is a User-Agent header that names a real contact, and staying under ten requests a second across every sec.gov host. Requests without a proper User-Agent are refused with 403.
Why does summing totalAmountSold give me too much?
Because the field is cumulative and an amendment restates it. One offering that files an original and two amendments reports its running total three times. Deduplicate by issuer and offering, keep the latest filing, or diff consecutive filings if you want the incremental amount.
Can I get the founders or investors from a Form D?
The filing has a related-persons block, but it is personal data and we do not read it. Everything in this guide, and everything in our Funding API, is business-level: issuer, amounts, security types, industry and state.
Everything above is real work, and it does not stop.
The parsing is the easy half. The rest is the daily pass, the rescan window, the filings that arrive backdated and the ones that never produce a row. We run it and serve the result as JSON, and the free key needs no card. If you would rather own the pipeline, the code above is what we would write.