How to Parse XBRL into Clean Financial Statements
You open an XBRL instance document expecting a clean income statement and find 500 tagged facts with no ordering, no subtotals, and no agreement between filers about which tag means “revenue.”
In this article, you will learn:
- Why an XBRL instance document is not a financial statement — it is a flat bag of tagged facts with no ordering or subtotals
- Where the missing structure lives: presentation, calculation and label linkbases, each an XLink graph you traverse yourself
- The silent traps — wrong roles, negated labels, period-start duplicates — that produce plausible-but-wrong numbers
- How to pull the instance and every linkbase by direct URL without scraping EDGAR
- And lastly, how to parse XBRL into clean financial statements from a single call — with a free API
Let me set the scene, because I suspect it is your scene too. You have a company’s 10-K. You want its income statement as clean JSON — revenue, cost of revenue, gross profit, operating income, net income — in order, with the subtotals marked. And XBRL is supposed to make this trivial. The filing is machine-tagged, the SEC hands you an instance document, job done.
Then you open the instance document, and the promise quietly falls apart. What you are holding is real data, but it is the raw material of a statement rather than the statement itself — and the distance between those 2 things is larger, and more unforgiving, than it looks.
An XBRL instance document is a bag of facts, not a statement
Open the file and you find 3 regions, none of which is a statement. First a block of context definitions, then a block of unit definitions, then hundreds of facts. A fact is a tuple: a concept, a context reference, a unit reference, a precision and a value.
{
"concept": "us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax",
"contextRef": "c-42",
"unitRef": "usd",
"decimals": "-6",
"value": "62753000000"
}
That fact tells you almost nothing on its own. It points at 3 things you have to resolve elsewhere: the context (which period, whether that period is an instant or a duration, and any dimensional members that narrow it to a segment), the unit (dollars, shares, dollars-per-share, a ratio, a foreign currency), and the concept (one of thousands of tags, and two companies reporting the identical line rarely pick the same one). Crucially, the same concept appears many times over — current year, prior year, prior-prior, and again scoped to each reportable segment — so “revenue” might occur 12 or more times in a single file. Nothing in the instance says which occurrence is the one that belongs on the face of the income statement.
The right mental model is not “a statement in XML.” It is a normalized fact table — a small data cube — whose schema and ordering have been deliberately stored in separate files. To reconstruct a statement you are running a query against that cube and you must already know which slice you want. You are handed a pile and told it is furniture.
Pull the instance and linkbases by direct URL
Before any parsing you need the files, and you do not have to scrape EDGAR for them or fight its rate limit and its SGML wrappers. The SEC Filings API returns each filing with an xbrl_attachments array listing every taxonomy and linkbase file by direct URL, plus an inline_xbrl flag telling you whether the facts are embedded in the primary document rather than shipped as a standalone instance:
import requests
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://data.businessquant.com"
def latest_10k(ticker):
url = (f"{BASE_URL}/secfilings"
f"?ticker={ticker}&formtype=10-K&page_size=1&api_key={API_KEY}")
return requests.get(url).json()["data"][0]
f = latest_10k("IBM")
print(f["accession"], "inline_xbrl:", f["inline_xbrl"])
for x in f["xbrl_attachments"]:
print(x["type"], x["url_doc_bq"]) # EX-101.CAL, EX-101.PRE, EX-101.LAB, ...
Those linkbases are exactly the structure the instance is missing: the presentation linkbase (EX-101.PRE) carries the intended order, the calculation linkbase (EX-101.CAL) carries which facts roll up into which subtotals and with what sign, and the label linkbase (EX-101.LAB) carries the human labels. You can fetch any of them cleanly by filename through the Documents and Exhibits API:
def fetch_file(accession, filename):
url = (f"{BASE_URL}/secfilings/{accession}/content"
f"?filename={filename}&api_key={API_KEY}")
return requests.get(url).text
manifest = requests.get(
f"{BASE_URL}/secfilings/{f['accession']}/documents?api_key={API_KEY}").json()["data"]
cal = next(d for d in manifest if d["document_type"] == "EX-101.CAL")
calc_linkbase = fetch_file(f["accession"], cal["filename"]) # clean XML
The linkbases are XLink graphs, not lookup tables
So you have the files. Now here is the thing that surprised me most, and I have watched it surprise everyone since: a linkbase is not a table you can index into. It is an XLink graph. Concepts appear as locators, and relationships between them appear as arcs, and the arcs are grouped by role — one role per statement or disclosure. A single presentation linkbase can hold dozens of roles: the face income statement, the face balance sheet, and every note table in the filing. Before you can order a single line you have to pick the right role, then walk its parent-child arcs in order, honoring each arc’s preferredLabel.
from lxml import etree
NS = {"link": "http://www.xbrl.org/2003/linkbase",
"xlink": "http://www.w3.org/1999/xlink"}
def presentation_edges(pre_xml, role_hint="StatementsOfIncome"):
root = etree.fromstring(pre_xml.encode())
for plink in root.findall("link:presentationLink", NS):
role = plink.get("{http://www.w3.org/1999/xlink}role") or ""
if role_hint.lower() not in role.lower():
continue # skip note-level roles
loc = {l.get("{http://www.w3.org/1999/xlink}label"):
l.get("{http://www.w3.org/1999/xlink}href").split("#")[-1]
for l in plink.findall("link:loc", NS)}
edges = []
for arc in plink.findall("link:presentationArc", NS):
parent = loc[arc.get("{http://www.w3.org/1999/xlink}from")]
child = loc[arc.get("{http://www.w3.org/1999/xlink}to")]
order = float(arc.get("order", "0"))
pref = arc.get("preferredLabel") # periodStart / periodEnd / negated
edges.append((parent, order, child, pref))
return sorted(edges, key=lambda e: (e[0], e[1]))
return []
That reconstructs the shape of one statement. You still have to overlay the calculation linkbase to know which children sum into which parent, and with what weight, and then join the whole tree back to the instance facts by concept and context. 3 files, 3 different graph structures, one statement.
The traps that turn an afternoon into a month
Now pay close attention to this section, because every single trap below produces a number. That is exactly what makes them so expensive. Nothing throws an exception. The dashboard renders beautifully. And the value is wrong.
- The wrong role. A note disclosure’s table can look almost identical to the face statement in the presentation linkbase. Match on the wrong
roleURIand you serve a footnote as your income statement. - preferredLabel and period start vs end. On a roll-forward — retained earnings, cash, stockholders’ equity — the same concept appears twice, once with a
periodStartLabeland once with aperiodEndLabel. IgnorepreferredLabeland you either double the row or grab the opening balance where you wanted the closing one. - Negated labels. Some labels are
negatedLabel: the value is displayed with a flipped sign for readability while the underlying fact and the calculation weight are unchanged. Apply that presentation flip to your math and you corrupt the subtotal that should have matched. - Inline XBRL. Most modern filings ship no standalone instance at all. The facts live inside the primary 10-K/10-Q HTML as
ix:nonFractionandix:nonNumericelements, each carryingscale,signandformatattributes you must apply to recover the real number. That is why theinline_xbrlflag above matters before you decide how to read the facts. - Precision, nils and duplicates.
decimals="-6"means the figure is stated to the nearest million; axsi:nil="true"fact is a deliberate blank, not a zero; and the same concept-and-context can appear more than once across an amended document. Each needs a rule.
None of this is conceptually deep. It is a long tail of small, unforgiving rules, and the failures are silent, so you only discover the ones you happen to eyeball. Reading the presentation linkbase to recover order, reading the calculation linkbase for subtotals and sign weights, resolving every context to a period and keeping only the ones you want, resolving units, and — the big one — collapsing thousands of issuer-chosen concept tags down to standard lines is a genuine multi-week build. That last step, normalization, is large enough to get its own article, and the full undertaking is scoped in what it takes to build an XBRL parser. And it drifts: next quarter a filer switches to a custom extension tag and your parser silently returns a statement with a hole in it.
Get the normalized statement from one call instead
Now that you have seen the shape of the work, I think the case for reading a statement instead of rebuilding one makes itself. The Financial Statements API does the parse, the linkbase resolution, the role selection, the context filtering, the sign handling and the tag normalization on ingestion, and serves you the finished statement. One endpoint covers 5 views — IS, BS, CF, plus pre-computed Ratios and Growth — across Annual, Quarter and TTM, as one GET built exactly like the docs show:
url = (f"{BASE_URL}/statements"
f"?ticker=IBM&statement=IS&frequency=Annual&period=all&api_key={API_KEY}")
data = requests.get(url).json()
print(data["metadata"]["template"]) # -> "general"
The response is already a statement. Line items sit inside named categories, each carrying an sno_total sort key and an itemtype that flags subtotals and grand totals, and each value comes as a full-precision raw number and a compact fmt string. Flatten it and sort:
rows = []
for category in data["data"].values():
for section in category["sections"].values():
m = section["metadata"]
latest = section["values"][0]["reportedValue"]["fmt"] if section["values"] else "-"
rows.append((m["sno_total"], m["itemtype"], m["name"], latest))
for sno, itemtype, name, latest in sorted(rows): # sno_total restores order
mark = " " if itemtype == "lineitem" else "* " # * = subtotal / grandtotal
print(mark + name.ljust(34) + latest)
No linkbase fetch, no role hunt, no context resolution, no sign table, no concept map. The sno_total key encodes the full hierarchy, so one sort rebuilds the layout, and itemtype tells you which rows render bold. Every trap from the section above — the wrong role, the period-start duplicate, the negated label, the inline-vs-standalone question — was resolved before the response reached you.
The same shape across every view and frequency
Because all 5 views share one schema, you do not need separate parsing logic for each statement. Moving from the income statement to the balance sheet or to a trailing-twelve-month series is a one-parameter change in the URL, and the response comes back in the same shape every time:
def statement_url(ticker, statement, frequency, period="10y"):
return (f"{BASE_URL}/statements"
f"?ticker={ticker}&statement={statement}"
f"&frequency={frequency}&period={period}&api_key={API_KEY}")
balance = requests.get(statement_url("IBM", "BS", "Annual")).json()
cash = requests.get(statement_url("IBM", "CF", "Annual")).json()
ttm = requests.get(statement_url("IBM", "IS", "TTM", "5y")).json()
ratios = requests.get(statement_url("IBM", "Ratios", "Annual")).json()
Edge cases worth knowing
The schema is consistent, but a few details will trip you if you assume everything behaves the same way.
Time range is one method or the other. Use the period shorthand (5y, 10y, all) or an explicit from_date/till_date pair — never both. And I will save you the debugging session I did not get saved from: the dates are day-month-year. from_date=01-03-2025 is 1 March, not 3 January. Build it with dt.strftime("%d-%m-%Y") and stop thinking about it.
Read the template before mapping field names. metadata.template is one of general, real_estate, utilities or capital_markets. A bank has net interest income where a manufacturer has gross profit, so branch on the template.
Use raw for math, fmt for display. Never parse fmt back into a number.
Values are newest-first. Each line item’s values array is latest period first, so values[0] is the most recent report.
Start Building
So, to bring it all together. An XBRL instance document is a pile of facts, not a statement. The linkbases that would give it structure are more files to fetch and traverse as XLink graphs. And the traps that turn this from an afternoon into a month all fail silently, which is the worst way for anything to fail.
My honest advice: pull the source by direct URL whenever you want to see it — you should, at least once, because understanding the shape of the raw data makes you better at everything downstream. Then call one endpoint and read a statement that already carries its own ordering and subtotal flags.
- Financial Statements API — normalized IS, BS, CF, Ratios and Growth by ticker.
- SEC Filings API — the filing and its XBRL linkbases by direct URL.
- Documents and Exhibits API — fetch any linkbase file cleanly by name.
Get an API key, pull one statement, and go delete your linkbase parser. You will not miss it.
Frequently asked questions
Why is an XBRL instance document not the same as a financial statement?
The instance is a flat list of tagged facts, each pointing at a separate context and unit, with no ordering, no subtotals and no agreement between filers on which tag means revenue. Rebuilding a statement means selecting the right presentation role, reading the calculation linkbase for subtotals and signs, filtering contexts, and normalizing thousands of tags.
What is a linkbase and why can I not just look values up in it?
A linkbase is an XLink graph, not a table. Concepts appear as locators and relationships as arcs, grouped by role, with one role for each statement or note. To order a statement you pick the correct role, walk its parent-child arcs by their order attribute, and honor each arc preferredLabel, then join the result back to the instance facts.
Do modern filings still ship a separate XBRL instance document?
Often not. Most current filings use inline XBRL, where the facts are embedded in the primary 10-K or 10-Q HTML as ix:nonFraction and ix:nonNumeric elements with scale and sign attributes you must apply. The SEC Filings API exposes an inline_xbrl flag so you know which case you are in before you start reading facts.
Do I still have to parse anything to use the Financial Statements API?
No. You query by ticker and get the finished statement as JSON. The API parses the XBRL, selects the correct roles, resolves the linkbases and normalizes the tags on ingestion, so you never fetch or parse an instance document yourself.
How do I rebuild statement order and mark subtotals from the response?
Every line item carries an integer sno_total sort key that encodes the hierarchy and an itemtype of lineitem, subtotal or grandtotal. Flatten the categories and sections, sort by sno_total, and render rows flagged subtotal or grandtotal as bold.
Are the Business Quant APIs free to use?
Yes. Sign up for a free API key and the SEC Filings, Documents and Financial Statements endpoints all run as shown, with more than 20 years of history available.