How to read 8-K item codes from EDGAR with Python
Every 8-K declares what it is about in its own header. You can classify most of the corpus without reading a word of the filing, and without a model.
The header is the signal
An 8-K is a current report: a public company files one when something material happens. What makes it tractable is that the filer must declare which numbered items the report covers, and EDGAR puts that declaration in the SGML header that prefixes every full-submission file, before any of the prose.
So the category of an 8-K is a structural read, not an interpretation. Item 1.03 is bankruptcy or receivership. Item 3.01 is a listing deficiency. Item 5.02 is a change among directors or principal officers. That mapping comes from the form itself and does not need a model, which also means it does not need reviewing when a model changes.
EDGAR writes titles, not numbers
This is the part that catches people. The header does not generally carry "1.03". It carries ITEM INFORMATION: Bankruptcy or Receivership, and filers vary the punctuation, the plural and the trailing colon. Matching on exact titles silently drops real filings.
Match on a distinctive substring per item instead, and honour the numeric ITEMS tag when a filer does include it. Anything that matches no known anchor is ignored rather than guessed at, which is the safe default: an unrecognised item costs you one signal, an invented one costs you the record's meaning.
import re
# EDGAR writes the items as TITLES, not numbers. Match on a distinctive
# substring: filers vary the punctuation and the plural, so exact-title
# equality drops real filings. Live examples that break it: "Cost Associated
# with Exit or Disposal Activities" (singular) and a 5.02 title with a stray
# colon.
ITEM_ANCHORS = (
("bankruptcy or receivership", "1.03"),
("material cybersecurity", "1.05"),
("completion of acquisition or disposition", "2.01"),
("triggering event", "2.04"),
("exit or disposal", "2.05"),
("delisting", "3.01"),
("failure to satisfy a continued listing", "3.01"),
("non-reliance", "4.02"),
("changes in control", "5.01"),
("departure of directors", "5.02"),
("other events", "8.01"),
)
ITEM_INFO = re.compile(r"ITEM INFORMATION:\s*(.+)")
NUMERIC_ITEMS = re.compile(r"<ITEMS>\s*([0-9]+\.[0-9]+)")
def item_codes(submission_text: str) -> list[str]:
"""The 8-K item codes, read structurally from the SGML header."""
header = submission_text.split("<DOCUMENT>", 1)[0]
codes = set(NUMERIC_ITEMS.findall(header)) # honoured when a filer sends them
for title in ITEM_INFO.findall(header):
low = title.strip().lower()
for anchor, code in ITEM_ANCHORS:
if anchor in low:
codes.add(code)
break
return sorted(codes)Cap the download, do not measure it
A full-submission .txt inlines EVERY exhibit, and the size is decided by the filer. One live 8-K we fetched was 77.7 MB across 30 documents, and buffering it cost 226 MB of process memory once the bytes, the decoded string and the chunk join were all resident at once. That is what killed our own worker on 2026-08-01, in a 512 MB container, eleven minutes into a run.
The header you need ends inside the first couple of kilobytes. Across 40 live filings the boundary between the header and the primary document sat between 27 and 99 KB, median 40 KB, so a 4 MB cap is roughly forty times the observed worst case and still trivial.
Cap the READ rather than checking the size afterwards. By the time you can measure a body you have already paid for it, and a Content-Length check still leaves you a redirect away from downloading it anyway.
One consequence worth handling: if you cap, your delimiters must accept end-of-string as a closing tag, or a harmless cut inside an exhibit you were never going to read surfaces as "no extractable filing text" and loses the filing.
MAX_BYTES = 4 * 1024 * 1024 # ~40x the largest header we have measured
def submission_head(url: str, max_bytes: int = MAX_BYTES) -> str:
"""Read the start of a full-submission .txt and hang up."""
chunks, total = [], 0
with requests.get(url, headers=HEADERS, stream=True, timeout=60) as response:
response.raise_for_status()
for chunk in response.iter_content(64 * 1024):
chunks.append(chunk)
total += len(chunk)
if total >= max_bytes:
break # we have the header; stop paying
return b"".join(chunks).decode("utf-8", "replace")Filings with no event-bearing item never produce a row, so they are re-fetched on every pass rather than skipped. That is how a fixed cost meets a growing baseline and a memory failure looks random when it is not.
What the item code cannot tell you
Item 5.02 covers departures AND appointments, in one code. A CEO leaving and a CFO being hired arrive identical at this layer. If you have to choose without reading the text, default to the departure: missing an exit is the expensive error and a false exit is the cheap one.
Item 8.01 is "Other Events" and carries no structured signal at all. A filing whose only item is 8.01 has told you nothing except that it is material, and some of the most valuable filings land there because no other code fits.
Neither of these is fixable with better parsing. They are limits of the form, and a pipeline that pretends otherwise is inventing precision.
Two dates, and they are not the same
CONFORMED PERIOD OF REPORT is when the event happened. The filing date is when EDGAR received it. Most filings close that gap quickly, but the tail is long and it does not stop at weeks: an amended or late filing can report an event from years earlier. Choosing the wrong field silently shifts your whole timeline, and sorting on the wrong one puts an old event at the top of a feed.
The acceptance timestamp is stamped in US Eastern. Republishing it unchanged is more faithful than converting it, because a correct conversion needs a DST database and a wrong one is invisible.
Common questions
Can I get 8-K item codes without downloading the whole filing?
Yes, and you should. The item codes are in the SGML header at the very start of the full-submission file, so a capped streaming read of the first few hundred kilobytes gets them. The complete file inlines every exhibit and can run to tens of megabytes.
How do I tell a CEO departure from a CEO appointment?
Not from the item code: Item 5.02 covers both. You have to read the filing text, and when the text is genuinely ambiguous the safe default is to treat it as a departure, because hiding an exit is the error that costs the reader something.
Is the item code always present?
It is on the overwhelming majority of filings, and it is what we key on. The gap is a filing whose only item is 8.01, Other Events, which is a declaration that something material happened without saying what.
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.