Total Liabilities from SEC EDGAR: Why the Obvious Tag Is Often Empty

You want total liabilities for a company. EDGAR publishes XBRL facts for free, there is a us-gaap element called Liabilities, and the request is three lines of Python. You run it over a few hundred tickers and a large share of them come back with nothing at all.

The tag is right. The request is right. The assumption underneath it is wrong. XBRL does not contain every figure you can compute from a balance sheet. It contains the figures the filer actually presented. If a company's balance sheet never prints a line reading "Total liabilities," there is no fact to tag, and the element is simply absent.

This is not an edge case and it is not a data quality problem at EDGAR. Ask the company concept endpoint for Liabilities at Coca-Cola, Amazon or Walmart and all three return a 404. Ask it for LiabilitiesAndStockholdersEquity and all three return a figure. Apple happens to publish both. Nothing about the three that do not is unusual — they run from the last liability line into the equity section without printing a subtotal on the way.

What the endpoint gives you

Company facts is one call, no key, no auth. The CIK is zero-padded to ten digits, so Apple's 320193 becomes CIK0000320193. Send a descriptive User-Agent with a real address on it and stay inside the rate ceiling — that is its own problem, and it has its own note.

import requests

HEADERS = {"User-Agent": "YourCompany you@example.com"}

def facts(cik):
    url = f"https://data.sec.gov/api/xbrl/companyfacts/CIK{cik:010d}.json"
    return requests.get(url, headers=HEADERS, timeout=30).json()

def latest(f, tag, unit="USD"):
    # A tag the filer never used is absent from the document entirely.
    # That is a presentation fact about the company, not a failed request.
    node = f["facts"].get("us-gaap", {}).get(tag)
    if not node:
        return None
    rows = [r for r in node["units"].get(unit, [])
            if r.get("form") in ("10-K", "10-Q")]
    return max(rows, key=lambda r: r["end"]) if rows else None

Run that across a universe and the empty returns are not failures of your code. They are filings in which no total-liabilities line was presented.

The figure that is always there

A balance sheet has two sides that must agree. The left totals to assets. The right totals to the claims on those assets, and presentation of the right side is where filers diverge. What they all print is the bottom line:

us-gaap:LiabilitiesAndStockholdersEquity

That is the sum of everything on the claims side, the figure that has to equal total assets, and the most reliably present element on that side of the taxonomy. Total liabilities is what remains of it once every equity claim is taken out.

Liabilities = (L + SE) − Equity − NCI − Mezzanine

Each subtraction is there for a reason, and skipping one produces a number that looks plausible and is wrong.

Equity. StockholdersEquity is parent-only. Its sibling StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest already has the minority stake folded in. Take whichever the filer published and know which one you took — take the inclusive one and also subtract MinorityInterest and you have removed the same money twice.

Noncontrolling interests. A parent consolidating a subsidiary it does not wholly own carries all of that subsidiary's assets, and carries the slice it does not own as a claim on the right side. MinorityInterest is equity, not liability. Leave it in and total liabilities is overstated by exactly the minority stake.

Mezzanine. Redeemable preferred, redeemable noncontrolling interests and shares subject to possible redemption sit between the two sections and belong to neither. TemporaryEquityCarryingAmountIncludingPortionAttributableToNoncontrollingInterests, RedeemablePreferredStockCarryingAmount and RedeemableNoncontrollingInterestEquityCarryingAmount are the tags to look for. They are not liabilities.

def total_liabilities(f):
    direct = latest(f, "Liabilities")
    if direct:
        return direct["val"], "reported"

    rhs = latest(f, "LiabilitiesAndStockholdersEquity")
    if not rhs:
        return None, "no right-hand-side total"

    period = rhs["end"]

    # Facts are not aligned for you. Equity from one quarter subtracted
    # from a right-hand side from another is a number with no meaning,
    # and nothing in the response will tell you it happened.
    def at(tag):
        r = latest(f, tag)
        return r["val"] if r and r["end"] == period else 0

    incl = at("StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest")
    equity = incl if incl else at("StockholdersEquity") + at("MinorityInterest")

    mezz = (
        at("TemporaryEquityCarryingAmountIncludingPortionAttributableToNoncontrollingInterests")
        or at("RedeemablePreferredStockCarryingAmount")
        or at("RedeemableNoncontrollingInterestEquityCarryingAmount")
    )

    return rhs["val"] - equity - mezz, "derived"

Check the answer before you use it

The derivation gives you a figure. It does not tell you the figure is right. The check is the identity itself: pull us-gaap:Assets for the same period and confirm it meets the right-hand side within a tolerance set as a fraction of assets rather than a flat amount, because filers round at a scale set by their own size.

If the two sides do not meet, one of three things is true: you missed a claim line, the filing uses a basis you have not accounted for, or the filing itself is wrong. Those are different problems with different responses, and the five mechanisms are worth knowing before you meet one.

The response that ruins the exercise is to reach for whichever candidate closes the gap. That converts a wrong number into a wrong number nobody can audit, and it destroys the discrepancy that was about to tell you something.

When to stop building this

The derivation above is forty lines. The work after it is not: period alignment across amended filings, extension tags no standard mapping reaches, taxonomy versions drifting under you, banks and insurers presenting a right-hand side shaped like nobody else's, and the standing job of noticing when a filer changes presentation between quarters.

That is the work here. Every balance sheet across 6,238 companies is reconciled against A = L + E before it is served, and the ones that do not close are flagged with the reason rather than quietly patched — total liabilities comes back as a field with the basis it was computed on attached to it. The free tier covers the whole universe. If you would rather keep your own pipeline, the sections above are the parts people usually find out about after they ship.

See it on a real filing

Every figure on these pages is as reported, drawn at true proportion. No account needed.