How to Extract Financial Data from an 8-K Earnings Release
A company reports after the bell and the 8-K hits EDGAR with this quarter's numbers. You reach for the exhibit and discover there is no XBRL — just HTML tables built for a human eye, not a parser.
In this article, you will learn:
- Why 8-K earnings releases have no XBRL and what is actually inside an Item 2.02 exhibit
- How to extract financial data from an 8-K by finding the right filing, pulling its Results of Operations section as clean HTML, and parsing the table positionally
- How to read the normalized quarter from the 10-Q and reconcile it against the preliminary 8-K number
- The edge cases that break a naive scrape — with a free API that handles the hard parts
A company reports after the closing bell. The stock is already moving in the after-hours session on a headline someone pulled straight off the wire. Minutes later an 8-K hits EDGAR with the quarter’s numbers attached as an exhibit, and you want those numbers in your model tonight — not in 2 weeks when the 10-Q shows up. So you reach for the 8-K. And you hit the thing nobody warns you about.
The 8-K has the numbers. It just flatly refuses to hand them to you cleanly. Understanding why is the difference between a fragile scrape you rewrite every quarter and a pipeline you can actually trust.
An 8-K earnings release has no XBRL
The clean, machine-tagged XBRL that makes a 10-K or 10-Q parseable is not in the 8-K. An earnings 8-K reports its financials under Item 2.02, Results of Operations and Financial Condition, with the actual numbers living in an EX-99.1 press-release exhibit — HTML built for a human to read, complete with merged header cells, footnote markers, currency symbols and typographic negatives.
There is a reason for this, and it is not an oversight. Financial-statement XBRL tagging is an obligation attached to the periodic reports — the 10-K and 10-Q — not to the earnings release that precedes them. On top of that, the Item 2.02 information and its press-release exhibit are generally furnished to the SEC rather than filed, a distinction with real legal weight and one practical consequence for you: the financial tables in an earnings 8-K are not tagged. The only inline XBRL you will find on a modern 8-K is a handful of cover-page tags — the filer’s name, the report date, the trading symbol — never the income statement. So there is nothing to query. The revenue number is a string in a table cell, and its meaning comes from where it sits, not from a tag that names it.
This also tells you something important about the Financial Statements API. That endpoint builds normalized statements from the 5 periodic filings — 10-K, 10-Q, 20-F, 40-F and 6-K — so the structured quarter it returns is sourced from the 10-Q, not the 8-K. The 8-K is the earlier, rawer read, and to get it you work with the filing itself. The good news, and the point of this article, is that working with the filing no longer means scraping EDGAR.
What is actually inside an earnings 8-K
So the numbers are in there, but nothing tags them for you. Before you fetch anything, it helps to have a mental model of the document you are opening, because “the 8-K” is really a small bundle of files, not one page:
- The primary 8-K document — a short cover that names the event under Item 2.02 and points at the exhibits. It rarely contains the tables themselves.
- EX-99.1 — the press release. This is where the income-statement table, the headline balance-sheet lines and the EPS figures actually live.
- EX-99.2 (not always present) — supplementary material: prepared remarks, an investor deck, or additional schedules. Some filers put segment detail or a non-GAAP reconciliation here rather than in EX-99.1.
2 filers reporting the same kind of quarter can package this completely differently. One attaches full statements to EX-99.1; another leads with a condensed income statement and pushes the balance sheet and cash flow into EX-99.2. There is no rule that forces a layout, which is exactly why a “just grab the exhibit” approach that works on one company returns half a quarter on the next.
The route most people try first and where it breaks
With the document structure in mind, the next question is how to get it. Your instinct will be to script this against EDGAR directly, and honestly it looks like about 6 lines: hit the company’s filing index, find the latest 8-K, download the exhibit, parse the table. That is what I thought too. What you actually walk into is a wall of small, unglamorous obstacles — none of them interesting, all of them real:
The rate limit. EDGAR allows roughly 10 requests per second per IP, across every EDGAR domain, and it does not care how many machines you run. Breach it and you get a 403 and about a 10-minute block on that IP — and retrying the 403 extends the block. The budget is shared across everything behind the address, so the pipeline that ran fine on your laptop starts failing the moment it lands in CI or on a serverless function behind one NAT. There is no paid SEC tier to buy your way out of this.
The User-Agent requirement. EDGAR rejects requests that do not send a User-Agent naming a real contact, and it rejects common library defaults on the first request. Because User-Agent is a forbidden header in the browser Fetch spec, front-end JavaScript physically cannot make a compliant EDGAR request — any browser-based tool needs a server in the path just to fetch a filing.
Which 8-K is the earnings one? A large company files dozens of 8-Ks a year — officer changes, debt issuance, material agreements, dividend declarations. Only some report results. The trap here is Item9_01, “Financial Statements and Exhibits,” which appears on nearly every 8-K and simply means “this filing has attachments.” Routing on it flags everything. The section that actually distinguishes an earnings release is Item2_02, and finding it by hand means opening filings one at a time.
The exhibit filename is unguessable. Even once you have the right filing, EX-99.1 is not at a predictable URL. Apple’s exhibits carry names like a10-kexhibit21109272025.htm — you cannot construct that string, you have to fetch and parse the filing index page (another request against that shared rate limit) just to learn it.
None of these is hard on its own. Stacked together, and multiplied across every issuer you follow, they turn “grab tonight’s number” into a small system with a maintenance surface. The rest of this guide replaces that surface with 3 GETs.
Find the earnings 8-K in seconds
The SEC Filings API indexes new filings within seconds of EDGAR acceptance and tells you, per filing, which item sections it contains in an items array. An earnings 8-K is exactly the one whose items include Item2_02. Query by ticker, filter to that, and you have the accession and the exact publish time — no index-page scraping, no guessing:
import requests
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://data.businessquant.com"
def latest_earnings_8k(ticker):
"""Most recent 8-K that reports results (Item 2.02)."""
url = (f"{BASE_URL}/secfilings"
f"?ticker={ticker}&formtype=8-K&page_size=25&api_key={API_KEY}")
filings = requests.get(url).json()["data"] # newest first
for filing in filings:
if "Item2_02" in filing.get("items", []): # results, NOT Item9_01
return filing
return None
f = latest_earnings_8k("AAPL")
print(f["accession"], f["publishdatetime"]) # e.g. 2026-05-01 16:31:02
2 details do the heavy lifting. The items array does the routing, so you never open the wrong 8-K; and note that the check is on Item2_02, not Item9_01 — the latter is on almost every 8-K and would match filings that have nothing to do with earnings. One more thing to internalize now, because it matters everywhere item keys appear: pass them back verbatim. The keys are case-sensitive with an underscore separator, and a stray .lower() silently breaks every 8-K lookup you write.
Pull the Results of Operations section as clean HTML
Finding the filing is step one. Step 2 is getting the actual tables out of it without scraping the full exhibit. Once you have the accession, the Section Extraction API returns a single item section, already cleaned — SGML headers stripped, inline-XBRL namespaces removed — so you get the earnings tables without the surrounding filing boilerplate. It is pre-extracted at ingestion, so the fetch is sub-second:
def earnings_section_html(accession):
url = (f"{BASE_URL}/secfilings/{accession}/content"
f"?item_key=Item2_02&api_key={API_KEY}")
return requests.get(url).text # clean HTML, not JSON
html = earnings_section_html(f["accession"])
If you want to fail loudly rather than parse an empty string, confirm the section is present before you fetch it. The companion /items call lists every item_key in a filing, and that list is your ground truth: it tells you whether the filer actually included Results of Operations, so your pipeline can always distinguish “this filing omitted the section” from “my code broke.”
def has_section(accession, item_key):
url = f"{BASE_URL}/secfilings/{accession}/items?api_key={API_KEY}"
return item_key in requests.get(url).json().get("items", [])
assert has_section(f["accession"], "Item2_02") # verify before you fetch
Fetch the raw exhibit when you need the whole thing
The section extraction covers most earnings use cases, but sometimes you need more. The extracted section is scoped to the Item 2.02 boundary, which is what you want most of the time. Occasionally a filer tucks supplementary schedules — extra segment tables, a non-GAAP bridge — into the exhibit in a way the section boundary does not include. When you need the entire press release, the Documents and Exhibits API lists every file in the filing as a typed manifest, so you can fetch the EX-99.1 by its normalized type rather than its unguessable filename:
def find_exhibit(accession, doc_type_prefix="EX-99"):
url = f"{BASE_URL}/secfilings/{accession}/documents?api_key={API_KEY}"
for doc in requests.get(url).json()["data"]:
if doc["document_type"].startswith(doc_type_prefix): # type, not filename
return doc["filename"]
def fetch_document(accession, filename):
url = (f"{BASE_URL}/secfilings/{accession}/content"
f"?filename={filename}&api_key={API_KEY}")
return requests.get(url).text
Filter on document_type, never on the filename or description — the type is normalized, the filename is whatever the filer happened to call it. The primary HTML comes back with inline XBRL already stripped, and every record carries both a url_sec path and a url_bq mirror on edgar.businessquant.com that is not SEC-rate-limited — useful when you are pulling exhibits in bulk and do not want to spend the shared EDGAR budget.
Turn the section table into rows
At this point you have the HTML, either the scoped section or the full exhibit. Now we have clean HTML with the income-statement table sitting inside it. The parse from here is positional — a cell’s meaning is its place in the grid, not a tag you can query — so we walk the rows, pull the label and the numbers, and normalize the accounting formatting as we go. Here is a compact version that handles the common cases:
import re
from html import unescape
def cells(row_html):
raw = re.findall(r"<t[dh][^>]*>(.*?)</t[dh]>", row_html, re.S | re.I)
return [unescape(re.sub(r"<[^>]+>", "", c)).strip() for c in raw]
def to_number(text):
t = text.replace(",", "").replace("$", "").strip()
if not t or t in ("-", "\u2014"):
return None
neg = t.startswith("(") and t.endswith(")") # (1,234) is negative
t = t.strip("()")
try:
val = float(t)
except ValueError:
return None
return -val if neg else val
def parse_rows(html):
rows = []
for row in re.findall(r"<tr[^>]*>(.*?)</tr>", html, re.S | re.I):
c = cells(row)
if not c:
continue
label = c[0]
nums = [to_number(x) for x in c[1:] if to_number(x) is not None]
if label and nums:
rows.append((label, nums)) # nums[0] = latest period
return rows
for label, nums in parse_rows(html)[:12]:
print(label.ljust(34), nums[0])
That is the whole shape of it: split rows, strip tags, convert parenthesized negatives, drop separators. It will get you a usable read on a well-behaved filer tonight. But be honest about what this compact version does not yet handle — a “in millions” scale note in the header that silently rescales every value, columns that mix “3 months” with “6 months ended,” footnote superscripts glued to numbers, and colspan headers that shift which column is this quarter. Each of those is a specific, fixable trap, and together they are the reason a production earnings parser is a real build rather than a snippet. That full treatment lives in why earnings press release tables break scrapers.
Read the normalized quarter from the 10-Q
The positional parse gives you the 8-K number tonight, but it is the early, preliminary read, expressed in the company’s own labels and scale. For the structured, normalized, comparable quarter — standard line items, subtotals flagged, more than 20 years of history behind it — you read the Financial Statements API, which sources the quarter from the 10-Q and returns it as one GET. This is the number you reconcile the 8-K against:
ticker = "AAPL"
url = (f"{BASE_URL}/statements"
f"?ticker={ticker}&statement=IS&frequency=Quarter&period=5y&api_key={API_KEY}")
data = requests.get(url).json()
for category in data["data"].values():
for section in category["sections"].values():
vals = section["values"] # newest first
if vals:
m = section["metadata"]
print(m["slug"].ljust(20), vals[0]["reportedValue"]["fmt"])
Each line item comes back keyed by a stable slug like revenue and net-income, already scaled and sign-corrected, so the normalized quarter drops straight into a model with no parenthesis-stripping or scale-hunting. Between the 2 endpoints you get the best of both timelines: the 8-K number the night it prints, and the normalized version once the 10-Q lands.
Reconcile the preliminary number against the final one
You now have 2 numbers for the same quarter: the 8-K figure from tonight and the normalized 10-Q figure from the API. I want to be clear that these 2 reads are not redundant. They are a workflow. The 8-K figure is the company’s reported number ahead of the full report, and it genuinely can move when the 10-Q lands — a reclassification, a rounding difference between the condensed press-release table and the full statement, or occasionally an outright revision.
So treat the 8-K as the signal and the normalized 10-Q as the record of truth, and reconcile them on the period. Trusting either one alone is how you end up defending a number you cannot source.
One subtle trap when you store both: use the right date. A filing carries 3 of them — reportperiod (the fiscal period the numbers describe), filingdate (the day EDGAR accepted it), and publishdatetime (the minute it actually went public). If you are backtesting or timestamping “when the market knew,” that is publishdatetime on the 8-K, not reportperiod. Using the fiscal-period date as the moment of knowledge is a classic look-ahead-bias bug — it credits your model with information weeks before it existed.
Edge cases to keep in mind
The pipeline above handles the golden path. A few details will trip you if you do not account for them.
Confirm the section before you fetch it. Not every 8-K reports results. Filtering on Item2_02 in the items array, and checking /items before extraction, is what distinguishes an earnings 8-K from the dozens filed for other events — and keeps an empty section from masquerading as a parser failure.
The 8-K figure is preliminary. It is the company’s reported number ahead of the full report, and it can be revised when the 10-Q is filed. The normalized 10-Q value is the one to reconcile against — and the timing gap between the 2 has its own article.
Some numbers are non-GAAP. Earnings releases lead with adjusted figures — adjusted EPS, adjusted operating margin — that will not tie to the GAAP statement in the 10-Q. When you reconcile, match GAAP to GAAP; the adjusted line is a different quantity, not a discrepancy.
The Financial Statements API is keyed by ticker. You do not need the accession or a CIK to read the normalized statement — only to work with the raw 8-K. Reconciliation joins the 2 on ticker and period.
Start Building
With the edge cases handled, the full path is short. The 8-K earnings release is the earliest read you can get on a quarter, and it is trapped in HTML with no XBRL — not out of malice, but because tagging belongs to the periodic reports rather than the release.
Find the earnings 8-K with the SEC Filings API by routing on Item2_02. Pull its Results of Operations section as clean HTML with the Section Extraction API. Parse the table positionally, using the converter above. Then read the normalized quarter from the 10-Q through the Financial Statements API and reconcile the 2. 4 steps, and none of them involve you scraping an index page at 4:05pm.
- Financial Statements API — the normalized quarter from the 10-Q, by ticker.
- SEC Filings API — find the earnings 8-K and its Item 2.02 in seconds.
- Section Extraction API — the Results of Operations section as clean HTML.
Get an API key, pull the latest earnings 8-K for a name you track, and parse its table tonight.
Frequently asked questions
Does an 8-K earnings release contain XBRL?
No. An earnings 8-K reports its financials under Item 2.02 as an HTML press-release exhibit, typically EX-99.1, built for human reading, and that content is furnished rather than filed. Financial-statement XBRL tagging is an obligation of the periodic reports, so it comes later with the 10-Q. The only inline XBRL on a modern 8-K is a few cover-page tags, never the income statement, which is why the numbers cannot be parsed with XBRL tooling.
What is the difference between EX-99.1 and EX-99.2 on an earnings 8-K?
EX-99.1 is the press release, where the income statement, headline balance-sheet lines and EPS usually live. EX-99.2, when present, holds supplementary material such as prepared remarks, an investor deck or extra schedules. Some filers push segment detail or a non-GAAP reconciliation into EX-99.2, so if a number seems missing from the section, list the documents and check the second exhibit.
Is 8-K data available from the Financial Statements API?
The Financial Statements API builds normalized statements from 5 periodic filings: 10-K, 10-Q, 20-F, 40-F and 6-K. Quarterly data is sourced from the 10-Q. To read the earlier 8-K earnings release itself, use the SEC Filings API to find it and the Section Extraction API to pull its Results of Operations section.
How do I find the earnings 8-K rather than some other 8-K?
Query the SEC Filings API by ticker for form type 8-K and filter to filings whose items array contains Item2_02, the Results of Operations and Financial Condition section. Do not route on Item9_01, which appears on nearly every 8-K and only means the filing has attachments. Item2_02 is the earnings signal, and the response gives you its accession and exact publish time.
How do I get the earnings tables as clean HTML?
Pass the accession and item_key Item2_02 to the Section Extraction API and it returns that section as clean HTML with SGML headers and inline-XBRL stripped. Item keys are case-sensitive, so pass them back verbatim. To work from the raw exhibit instead, list the filing with the Documents and Exhibits API and fetch the EX-99.1 file by its document_type.
Are the Business Quant APIs free to use?
Yes. Sign up for a free API key and the SEC Filings, Section Extraction, Documents and Financial Statements endpoints all run as shown in the samples.