Extract revenue and net income history from 10-K XBRL
Build a multi-year revenue, net income and margin table for one company from XBRL facts, handling revenue tag changes and repeated prior-year values.
Every 10-K and 10-Q includes XBRL: each number in the financial statements tagged with a concept, a period and a unit. /v1/xbrl/company-concept returns every value a company has reported for one concept, across all its filings. This tutorial turns those facts into a clean annual table of revenue, net income and margin.
Two things to handle
- Repeated values. Each 10-K reports the current year and prior years, so the same fiscal year appears in several filings. Keep one value per period end; this script takes the most recently filed one, which reflects any revision.
- Changing tags. Since revenue recognition rules changed in 2018, many companies report revenue as
RevenueFromContractWithCustomerExcludingAssessedTaxinstead ofRevenues, and some switch back and forth. Read both and merge.
Annual values are the facts from a 10-K whose period runs about a year (350 to 380 days, which allows for 53 week fiscal years). Quarterly facts from 10-Qs and year to date facts are dropped.
The code
import os
from datetime import date
import requests
BASE = "https://secfeedapi.com"
HEADERS = {"Authorization": f"Bearer {os.environ['SECFEED_KEY']}"}
def concept(ticker, tag):
r = requests.get(f"{BASE}/v1/xbrl/company-concept", params={"ticker": ticker, "concept": tag}, headers=HEADERS, timeout=120)
if r.status_code == 404:
return []
r.raise_for_status()
return r.json()["units"].get("USD", [])
def annual(facts):
"""One value per fiscal year: full-year facts from 10-Ks, latest filing wins (each 10-K repeats prior years)."""
out = {}
for f in facts:
days = (date.fromisoformat(f["end"]) - date.fromisoformat(f["start"])).days if f.get("start") else 0
if f["form"] in ("10-K", "10-K/A") and 350 <= days <= 380:
if f["end"] not in out or f["filed"] > out[f["end"]]["filed"]:
out[f["end"]] = f
return out
ticker = "COST"
# companies switch revenue tags over time, so read both and keep the most recently filed value per year
revenue = annual(concept(ticker, "Revenues") + concept(ticker, "RevenueFromContractWithCustomerExcludingAssessedTax"))
net = annual(concept(ticker, "NetIncomeLoss"))
print(f"{ticker} annual results from XBRL (USD millions)")
print(f"{'Fiscal year end':16}{'Revenue':>12}{'Net income':>13}{'Margin':>9} Source 10-K filed")
for end in sorted(revenue)[-8:]:
rev, ni = revenue[end]["val"], net.get(end, {}).get("val")
margin = f"{ni / rev:.2%}" if ni else ""
print(f"{end:16}{rev / 1e6:>12,.0f}{(ni or 0) / 1e6:>13,.0f}{margin:>9} {revenue[end]['filed']}")
Output
Run on 2026-10-13 for Costco (fiscal years end around the start of September).
COST annual results from XBRL (USD millions)
Fiscal year end Revenue Net income Margin Source 10-K filed
2019-09-01 152,703 3,659 2.40% 2021-10-06
2020-08-30 166,761 4,002 2.40% 2022-10-05
2021-08-29 195,929 5,007 2.56% 2023-10-11
2022-08-28 226,954 5,844 2.57% 2024-10-09
2023-09-03 242,290 6,292 2.60% 2025-10-08
2024-09-01 254,453 7,367 2.90% 2026-10-07
2025-08-31 275,235 8,099 2.94% 2026-10-07
2026-08-30 303,154 9,226 3.04% 2026-10-07
200 25 22.6
225 25 21.1
Reading the result
- Costco's fiscal year ending 2023-09-03 had 53 weeks, which the 350 to 380 day window keeps.
- The source column shows which 10-K supplied each value. Years reported again in a later 10-K take the later filing.
- Margin here is net income over revenue. Swap in
OperatingIncomeLossorGrossProfitfor other margins, checking the company uses that tag.
Variations
- Several companies: loop over tickers. To compare one concept across all companies for one period, use
/v1/xbrl/frames(see Compare companies with frames). - Quarterly: keep 10-Q facts with a period of about 90 days.
- Full statements:
/v1/financial-statementsreturns the income statement, balance sheet and cash flow for the latest filings without choosing tags.