If you pull revenue from the SEC's companyfacts API and the numbers look unreliable, the data is usually fine. The problem is that "revenue" is not one field. It is a handful of different us-gaap concepts, and the same period shows up in several filings.
Disclosure: I maintain the Actor mentioned at the end. The script is free and stands on its own.
Why there is no single revenue field
Every number in a 10-K is tagged with an XBRL concept from the us-gaap taxonomy. Companies choose the concept that fits their income statement, and the accounting rules and the taxonomy have changed over time. Three concepts cover most cases:
-
SalesRevenueNetis the older tag many companies used for net sales. -
RevenueFromContractWithCustomerExcludingAssessedTaxis the tag tied to the newer revenue-recognition standard (ASC 606), and many companies moved to it. -
Revenuesis a general total, common among banks and insurers.
A company can also change tags between years. I ran the script below against the live API, taking the first concept in my priority order for each year. Apple's annual revenue comes back as SalesRevenueNet for fiscal 2007 to 2015, Revenues for 2016, and RevenueFromContractWithCustomerExcludingAssessedTax from 2017 on. Microsoft goes Revenues (2008 to 2010), SalesRevenueNet (2011 to 2015), then the contract-revenue tag from 2016. If you hard-code one concept you lose years of history, and it looks like missing data.
Why values change between filings
- Duplicate periods. A 10-K includes prior-year comparatives, so fiscal 2023 appears in the 2023, 2024 and 2025 filings. Quarterly, year-to-date and annual durations are mixed together too. Filter on form, fiscal period and a start-to-end span of about 12 months.
-
Restatements and recasts. A later filing can report a different value for an old period. In my run, JPMorgan's
Revenuesfor 2014 is 94,205,000,000 in the earliest filing and 95,112,000,000 in the latest. Apple's fiscal 2008 is 32,479,000,000 first and 37,491,000,000 later. Neither is a bug. Decide whether you want as-originally-reported (earliest filing) or restated (latest). -
Dimensions are not there.
companyfactsreturns facts without segment dimensions, so a figure you see broken out by segment on a website may not be a single fact here. Compare against the actual filing, using theaccnaccession number, before concluding the API is wrong.
The API also has "frames" (/api/xbrl/frames/...), which return one value per company for a single concept and period. They are convenient for cross-company comparison, but they still depend on which concept each company used, so the fallback problem remains.
The free script
Standard library only. It tries the concepts in priority order per period (so a company that switched tags keeps full history), keeps 10-K full-year values, and dedupes by period end. Pass restated=True to keep the latest filing instead of the earliest. The SEC requires a User-Agent that identifies you, so use your own contact.
import datetime, json, urllib.request
UA = "YourName your@email.com" # use your own contact; the SEC requires one
CONCEPTS = [ # priority order
"RevenueFromContractWithCustomerExcludingAssessedTax",
"Revenues",
"SalesRevenueNet",
]
def get(url):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def cik_for(ticker):
for row in get("https://www.sec.gov/files/company_tickers.json").values():
if row["ticker"].upper() == ticker.upper():
return str(row["cik_str"]).zfill(10)
raise ValueError(ticker)
def annual_revenue(ticker, restated=False):
gaap = get(f"https://data.sec.gov/api/xbrl/companyfacts/CIK{cik_for(ticker)}.json")["facts"]["us-gaap"]
out = {}
for concept in CONCEPTS: # fallback per period, not per company
points = gaap.get(concept, {}).get("units", {}).get("USD", [])
for p in sorted(points, key=lambda p: p["filed"], reverse=restated):
if p["form"] != "10-K" or p["fp"] != "FY" or not p.get("start"):
continue
days = (datetime.date.fromisoformat(p["end"]) - datetime.date.fromisoformat(p["start"])).days
if 350 <= days <= 380:
out.setdefault(p["end"], (concept, p["val"], p["accn"]))
return dict(sorted(out.items()))
if __name__ == "__main__":
for t in ["AAPL", "MSFT", "JPM"]:
rows = annual_revenue(t)
used = {c for c, _, _ in rows.values()}
print(t, len(rows), "periods; concepts used:", sorted(used))
for end, (c, v, accn) in list(rows.items())[-3:]:
print(" ", end, f"{v:>15,}", c[:30], accn)
Real output
I ran exactly this on 2026-11-08:
AAPL 20 periods; concepts used: ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet']
2024-09-28 391,035,000,000 RevenueFromContractWithCustome 0000320193-24-000123
2025-09-27 416,161,000,000 RevenueFromContractWithCustome 0000320193-25-000079
2026-09-26 478,834,000,000 RevenueFromContractWithCustome 0000320193-26-000026
MSFT 19 periods; concepts used: ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet']
2024-06-30 245,122,000,000 RevenueFromContractWithCustome 0000950170-24-087843
2025-06-30 281,724,000,000 RevenueFromContractWithCustome 0000950170-25-100235
2026-06-30 331,839,000,000 RevenueFromContractWithCustome 0001193125-26-323660
JPM 19 periods; concepts used: ['Revenues']
2023-12-31 158,104,000,000 Revenues 0000019617-24-000225
2024-12-31 177,556,000,000 Revenues 0000019617-25-000270
2025-12-31 182,447,000,000 Revenues 0001628280-26-008131
Apple and Microsoft each use all three concepts over their history, while JPMorgan uses one. A single hard-coded tag would have returned a partial series for the first two.
Checking before you trust a number
- Compare one or two periods to the filing itself using the
accnvalue. - Pick one policy, earliest or latest, and apply it everywhere.
- Add a short
time.sleepwhen looping over many tickers (the SEC asks for at most 10 requests per second) and cache the ticker map, which this demo re-downloads for each ticker. - Foreign filers file 20-F or 40-F and may use the
ifrs-fulltaxonomy, which this script does not read.
If you would rather not maintain it
I publish an Apify Actor that reads the same SEC APIs and returns one row per reported data point, with concept, period and accession fields included. It costs $0.34 per 1,000 rows, plus standard Apify platform usage. The Python and JavaScript examples are in the examples repo. If the script above is enough for you, use it.
Not affiliated with or endorsed by the SEC. Not investment advice.
Top comments (0)