Home / Guides / Downloading documents
Downloading documents

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/download is 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

More downloading documents guides

Run this guide with your own key

500 free API calls a month. Every example on this page works on the free plan.

Get free API key →