Download XML filings from EDGAR
Find the XML documents inside a Form 4 or 13F filing, download them through SecFeed and parse them with Python's standard library.
Many SEC forms are filed as XML rather than HTML: Forms 3, 4 and 5, the 13F information table, Form D, Form 144, N-PORT and more. The XML is the most precise version of the data, because it is exactly what the filer submitted. This tutorial finds the XML file inside a filing, downloads it and parses it with xml.etree.ElementTree.
If you only need the data, SecFeed already parses these forms for you (see the Insider Trading API and 13F holdings API). Download the raw XML when you need a field those APIs do not expose, or want to archive the original.
Setup
import os
import requests
import xml.etree.ElementTree as ET
BASE = "https://secfeedapi.com"
HEADERS = {"Authorization": f"Bearer {os.environ['SECFEED_KEY']}"}
def get(path, **params):
r = requests.get(f"{BASE}{path}", params=params, headers=HEADERS, timeout=60)
r.raise_for_status()
return r
def xml_files(filing):
files = get("/v1/files", cik=filing["cik"], accessionNo=filing["accessionNo"]).json()
return [x for x in files if x["name"].endswith(".xml")]
/v1/files lists every file in a filing with its name, size and URL. /v1/download returns any of those files exactly as filed.
Step 1: the XML inside a Form 4
A Form 4 contains one XML document. Find the newest Form 4 for NVIDIA, download its XML and read the reporting owner and each non-derivative transaction:
f4 = get("/v1/filings", query='ticker:NVDA AND formType:"4"', size=1).json()["filings"][0]
doc = xml_files(f4)[0]
root = ET.fromstring(get("/v1/download", url=doc["url"]).content)
owner = root.findtext("reportingOwner/reportingOwnerId/rptOwnerName")
rel = root.find("reportingOwner/reportingOwnerRelationship")
title = rel.findtext("officerTitle") or ("Director" if rel.findtext("isDirector") in ("1", "true") else "Other")
print(f4["filedAt"], doc["name"], "|", owner, "|", title)
for t in root.iter("nonDerivativeTransaction"):
print(" ", t.findtext("transactionDate/value"),
t.findtext("transactionCoding/transactionCode"),
t.findtext("transactionAmounts/transactionShares/value"), "@",
t.findtext("transactionAmounts/transactionPricePerShare/value"))
Output:
2026-10-09 wk-form4_1791579847.xml | COXE TENCH | Director
2026-10-07 G 500000 @ 0
Transaction code G is a bona fide gift, which is why the price is zero. The file listing for this filing shows what else is in it:
0001197647-26-000011-index-headers.html
0001197647-26-000011-index.html
0001197647-26-000011.txt
wk-form4_1791579847.xml 5282 bytes
The XML file name is chosen by the filer's software, so match on the .xml extension instead of a fixed name.
Step 2: the information table inside a 13F-HR
A 13F-HR has two XML files: primary_doc.xml (the cover page and summary) and the information table with one infoTable element per position. The information table uses an XML namespace, so read it from the root tag:
f13 = get("/v1/filings", query='cik:1067983 AND formType:"13F-HR"', size=1).json()["filings"][0]
xs = xml_files(f13)
print(f13["companyName"], f13["filedAt"], [x["name"] for x in xs])
table = next(x for x in xs if x["name"] != "primary_doc.xml")
root = ET.fromstring(get("/v1/download", url=table["url"]).content)
ns = {"n": root.tag.split("}")[0].strip("{")}
rows = root.findall("n:infoTable", ns)
print(len(rows), "positions")
for r in rows[:4]:
print(" ", r.findtext("n:nameOfIssuer", namespaces=ns), r.findtext("n:cusip", namespaces=ns),
r.findtext("n:value", namespaces=ns), r.findtext("n:shrsOrPrnAmt/n:sshPrnamt", namespaces=ns))
Output:
BERKSHIRE HATHAWAY INC 2026-08-14 ['56757.xml', 'primary_doc.xml']
89 positions
ALLY FINL INC 02005N100 577211815 12561737
ALLY FINL INC 02005N100 128838056 2803875
ALLY FINL INC 02005N100 194285790 4228200
ALLY FINL INC 02005N100 78463301 1707580
The same issuer appears on several rows because a manager reports each combination of investment discretion and other managers separately. Sum value and sshPrnamt by CUSIP to get one position per security. Since 2023, value is reported in dollars rather than thousands.
Saving the files
To archive the originals, write the bytes as they come back:
open(table["name"], "wb").write(get("/v1/download", url=table["url"]).content)
Tips
- Every response from
/v1/downloadis the file exactly as filed, so checksums match EDGAR. - Some older filings (before XML schemas existed for a form) only have HTML or plain text.
xml_files()returns an empty list for those. - The full submission (
<accession>.txt) contains every document in the filing, including the XML, wrapped in SGML headers.
Next steps
- Download filings and exhibits
- Filing Download API and the parsed Insider Trading API
- Datasets: Form 4, 13F-HR