How to Map Thousands of Issuer XBRL Tags to Standard Line Items

You pull revenue for 2 companies to compare them, and the numbers come back under 2 completely different XBRL tags. Now multiply that across every line item, every filer, and every taxonomy year.

In this article, you will learn:

  • Why 4 companies tag the same line item with 4 different XBRL concepts, and why even one company changes its own tags over time
  • How to read a filer’s own tags and labels from the label linkbase
  • The 3 forces that keep a concept map growing forever — extensions, taxonomy churn, and dimensions
  • How XBRL tag mapping to standard line items collapses every variant to a single stable slug — with a free API

Say you got past the raw parse. Well done — genuinely, that part is not easy. You can read facts out of an instance document, resolve their contexts, handle the signs. So you pull revenue for 2 companies to compare them, and the numbers are right there.

Except they came from 2 different concept tags. And now you have to decide, in code, that those 2 tags mean the same thing. Do that for every line, for every filer, across every taxonomy year, forever — and you have just discovered what this entire category actually sells. It is not parsing. It is normalization. Let me show you why that job never finishes, what each source of growth in the map looks like up close, and where I think it stops being worth owning.

Why 2 companies never tag the same line the same way

The US-GAAP taxonomy is not a fixed chart of accounts. It is a large vocabulary, and filers choose among near-synonyms, add their own extension concepts, and scope facts with dimensional members. The identical economic line arrives under different tags:

What it meansTags you will actually see
Revenueus-gaap:Revenues, us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax, us-gaap:SalesRevenueNet, issuer extensions
Net incomeus-gaap:NetIncomeLoss, us-gaap:ProfitLoss, us-gaap:NetIncomeLossAvailableToCommonStockholdersBasic
Cost of revenueus-gaap:CostOfRevenue, us-gaap:CostOfGoodsAndServicesSold, us-gaap:CostOfGoodsSold

Pick the wrong member of a synonym set and you compare net-of-returns revenue at one company with a subtotal at another. None of this is an error in the filing — it is the taxonomy working as designed. The comparability is yours to manufacture. And the first version of your map, the one that handles the table above, is the easy version. What keeps it from ever staying finished are 3 forces: taxonomy churn, custom extensions, and dimensions.

The same company changes its own tags over time

Here is the wrinkle almost nobody sees coming, and it cost me a chart I had already shipped: a single company’s time series can break with no change in the business at all. When the revenue recognition standard changed, us-gaap:SalesRevenueNet was deprecated in favor of us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax, and filers migrated on their own schedules. So a company’s 2016 filings tag revenue one way and its 2020 filings tag it another, for the exact same line. If your map keys on the tag, your 10-year revenue chart has a discontinuity at the transition year — not because revenue jumped, but because the concept you were keyed on stopped being reported and a different one started. A correct map has to know that both tags land on the same standard line, and it has to keep knowing that as each new taxonomy release retires more concepts and mints new ones.

See a filer’s own tags and labels first

So the map is never done and the tags keep shifting underneath you. Before you can map anything, you have to know what the filer actually used — and happily, they tell you. The label linkbase (EX-101.LAB) pairs each concept tag with the human label the filer chose, and you can pull it directly. The SEC Filings API gives you the file URLs and the Documents and Exhibits API fetches it clean:

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]

def fetch_file(accession, filename):
    url = (f"{BASE_URL}/secfilings/{accession}/content"
           f"?filename={filename}&api_key={API_KEY}")
    return requests.get(url).text

f = latest_10k("AAPL")
manifest = requests.get(
    f"{BASE_URL}/secfilings/{f['accession']}/documents?api_key={API_KEY}").json()["data"]
lab = next(d for d in manifest if d["document_type"] == "EX-101.LAB")
label_linkbase = fetch_file(f["accession"], lab["filename"])  # tag -> filer's label

Do this across companies and the problem is right there in front of you: the same economic line, different tags, different labels. Reconciling them is the map.

Extensions have no dictionary — you infer them from the graph

Synonyms and taxonomy churn are manageable because at least the tags come from a published vocabulary. Extensions are different. Now we get to the part of the map you cannot pre-write, no matter how clever you are, because these tags live in the issuer’s own namespace and do not exist until the filing does. A tag like aapl:SomeSegmentRevenueMember or a bespoke income line has no entry in any standard taxonomy. There is nothing to look it up against. What you have instead is the graph: an extension concept still sits inside the calculation and definition linkbases, so it has a parent it rolls up into and siblings it sits beside. The only reliable way to place an extension is to read what it aggregates to and infer meaning from position — “this custom line is a child of Operating Expenses, so it is an operating expense.” That is a per-filing inference, not a dictionary lookup, and you meet each new extension only after it has already produced a missing or misplaced number in your output, because until the filing existed you had no way to anticipate it.

Dimensions: the total is not always the un-scoped fact

Extensions grow the map one tag at a time. Dimensions grow it in a different direction entirely. Third and last growth source, and this one is my least favourite: dimensions. A single concept, say revenue, can appear once as the company total and again scoped to a ProductOrServiceAxis or StatementBusinessSegmentsAxis member for each product line or segment. Naively you would say the total is the fact with no dimensional member. Usually true — but not always. Some filers report the total as a member (a “total” or default member on the axis), and some report only the members and expect the total to be the un-dimensioned fact. Your map has to encode which reading applies, per concept, per filer, or you will silently pick a single segment’s revenue and label it the company’s total. The number will look reasonable. It will be one product line.

The free route is a dictionary that never stops growing

Each of those forces is manageable in isolation. The problem is that they compound. Put all 3 together and you can finally see the shape of what you would be taking on. The DIY answer is a concept map from every tag you have ever seen to a standard line. The first version runs to a few hundred entries and feels completely tractable — mine did, and I remember thinking this would be done by Friday. Then reality arrives: custom extension tags have no fixed list, so you infer each one from the graph after it has already caused a wrong number; the taxonomy itself changes on the SEC’s annual schedule, deprecating concepts and opening gaps in a map that was complete last year; dimensions multiply the surface, so the map must encode “this tag, with this member, is the total”; and every gap fails silently, landing on the companies you review least. The parser is a weekend. The concept map is a standing team, and its cost is measured per quarter, not once at launch.

Get one comparable line-item set from the API

That is the cost of the DIY route: a map you build once and maintain every quarter, indefinitely. The Financial Statements API maintains the concept map so you do not have to — the synonyms, the taxonomy transitions, the extension inference and the default-member logic all resolve on ingestion. Every revenue tag maps to a single Revenue line with a stable slug. The slug does not change when the filer changes their tag, so it is the key you build on:

def line_items(nodes):
    """Yield every leaf line item, however deep the category tree goes."""
    for node in nodes.values():
        if "sections" in node:
            yield from line_items(node["sections"])
        else:
            yield node

def lines_by_slug(ticker, statement="IS", frequency="Annual", period="5y"):
    url = (f"{BASE_URL}/statements"
           f"?ticker={ticker}&statement={statement}"
           f"&frequency={frequency}&period={period}&api_key={API_KEY}")
    data = requests.get(url).json()
    suffix = "-" + data["metadata"]["frequency"].lower()      # "-annual", "-ttm"
    out = {}
    for item in line_items(data["data"]):
        vals = item["values"]
        slug = item["metadata"]["slug"].removesuffix(suffix)  # revenue-annual -> revenue
        out[slug] = vals[0]["reportedValue"]["raw"] if vals else None
    return out

a = lines_by_slug("AAPL")
b = lines_by_slug("MSFT")
for slug in ("revenue", "gross-profit", "operating-income", "net-income-towards-common-stockholders"):
    print(slug.ljust(40), str(a.get(slug)).rjust(16), str(b.get(slug)).rjust(16))

2 details in that function are easy to get wrong. On annual data every slug carries an -annual suffix (revenue-annual), and on trailing-twelve-month data a -ttm suffix, so the function strips the suffix that matches the response’s own metadata.frequency; skip that and a.get("revenue") returns None without raising anything. And it walks the tree recursively, because balance-sheet lines sit one level deeper than income-statement lines. Net income on the income statement is net-income-towards-common-stockholders.

Both companies expose a revenue slug even though their underlying tags differ, and that same slug stays put across a company’s own history even when the tag underneath it migrated across a taxonomy release. That is the whole point of normalization: you compare on the standard line, and the tag soup underneath — the synonyms, the extensions, the shifting taxonomy — is somebody else’s maintenance problem.

Mapping is template-aware, not one-size

Stable slugs solve the tag problem, but they assume every company maps to the same target set of lines. That is not quite true. Normalization is not the same target for every company. A bank has no gross-profit line to map to. It has net interest income. A REIT has funds from operations where a manufacturer has operating income. The API maps into one of 4 industry templates, named in metadata.template, so the target line-item set fits the company. Check the template before comparing peers, because a slug that exists in one template may be absent or mean something different in another:

for t in ("AAPL", "JPM", "O", "DUK"):
    url = (f"{BASE_URL}/statements"
           f"?ticker={t}&statement=IS&frequency=Annual&period=5y&api_key={API_KEY}")
    print(t.ljust(6), requests.get(url).json()["metadata"]["template"])
# AAPL -> general   JPM -> capital_markets   O -> real_estate   DUK -> utilities

Edge cases to keep in mind

The template gets you most of the way, but a few details will bite you if you assume everything is uniform.

Build on the slug, not the display name. The name is human-facing and may vary; the slug is the stable machine key. Key every join and every comparison on slug. If you take one implementation detail from this page, take that one — it is the difference between a panel that survives a taxonomy update and one that quietly does not.

Compare within a template. Group companies by metadata.template before comparing, so a bank’s net interest income is not placed next to a retailer’s gross profit.

A missing slug is real, not a bug. If a peer’s line does not appear for a company, the filer did not report it. Treat an absent slug as not reported, distinct from a zero.

A stable slug is what makes a time series safe. Because the slug survives a tag migration, a chart keyed on revenue stays continuous across the year a filer switched concepts — the discontinuity that a tag-keyed map would introduce simply does not exist.

Start Building

That covers the full surface of the problem — from synonyms to extensions to dimensions to templates. Here is the distinction I would leave you with. Parsing XBRL gets you facts. Mapping them gets you comparability — and the map is the part that never stops costing you. Thousands of issuer tags. Extensions you can only infer from the graph, after they have already gone wrong. Dimensions that hide the total in plain sight. A taxonomy that churns every single year on somebody else’s schedule.

All of that collapses into a stable set of standard line items keyed by slug, and Business Quant keeps it current so you do not have to.

Get an API key, compare 2 companies on the same slugs, and go retire your concept map. Someone on your team will thank you.

Frequently asked questions

Why do companies tag the same line item with different XBRL concepts?

The US-GAAP taxonomy offers near-synonym tags, lets filers add custom extensions in their own namespace, and lets them scope facts with dimensional members. So revenue can appear under Revenues, RevenueFromContractWithCustomerExcludingAssessedTax, SalesRevenueNet or an extension, all validly. Making those comparable is normalization.

Can the same company change its own XBRL tag over time?

Yes, and it is a common cause of broken time series. When the taxonomy deprecates a concept, filers migrate to the replacement on their own schedule, so a company can tag revenue one way in older filings and another way in newer ones. A map keyed on the raw tag shows a discontinuity at the transition; a map keyed on a stable line item does not.

How do I map a custom extension tag that is not in any standard taxonomy?

You cannot look it up, because it lives in the issuer namespace and did not exist before the filing. You infer it from the calculation and definition linkbases: an extension concept still has a parent it rolls up into, so you place it by what it aggregates to. That is a per-filing inference, which is why extensions are a recurring cost rather than a one-time entry.

Is the un-dimensioned fact always the company total?

Not always. Some filers report the total as a default member on a dimensional axis and report only members otherwise, while others expect the total to be the un-dimensioned fact. Your map has to encode which reading applies per concept, or you can silently select a single segment value and treat it as the total.

Should I key my code on the line item name or the slug?

Use the slug. The name is human-facing and can vary by template or frequency label, while the slug is the stable machine key that does not change when a filer changes their underlying tag or when the taxonomy revises a concept. On annual and TTM data the slug carries an -annual or -ttm suffix, so strip the suffix that matches the response frequency before comparing.

Are the Business Quant APIs free to use?

Yes. Sign up for a free API key and the concept map is maintained on the server side, so you compare companies on stable slugs without ever touching a raw XBRL tag.