How do I get fund or ETF data by ISIN in a pandas DataFrame?
pandas DataFrames from an ISIN: holdings, sector and country weights, calendar returns and NAV series into DataFrames from one API call, via pandas.json_normalize.Published September 12, 2026 · Updated October 13, 2026 · by FundFacts APIShort answer
With pandas the work is in the shape of the payload: every breakdown is a list of label and weight objects and every series a list of dated points, so pandas.json_normalize turns holdings, sector and country weights or the NAV series into tidy DataFrames without reshaping. Call GET https://fundfactsapi.com/api/v1/funds/{isin} with a bearer API key (the fundfacts package from GitHub does this for you), then pass data.topHoldings, data.sector, data.geography or data.indexedPerformance.points to json_normalize. The Free plan gives 5 lookups a month with no card.
With pandas the work is in the shape of the payload: every breakdown is a list of label and weight objects and every series a list of dated points, so pandas.json_normalize turns holdings, sector and country weights or the NAV series into tidy DataFrames without reshaping.
What you need
- An API key from fundfactsapi.com/signup. The Free plan is 5 lookups a month and asks for no card. Keys start with
ffk_. - The ISIN of the share class you want. Every share class has its own ISIN;
GET /search?q=turns a name into ISINs and is free. - Every breakdown in the payload is a list of
{label, weight}objects and every series a list of{date, fund, index}points, sopandas.json_normalizeproduces tidy frames without any reshaping.
The code
python# pip install git+https://github.com/fundfactsapi/fundfactsapi-python pandasimport pandas as pdfrom fundfacts import FundFactsff = FundFacts() # reads FUNDFACTS_API_KEY; free key at https://fundfactsapi.com/signupfund = ff.get_fund("IE00B4L5Y983")d = fund["data"]holdings = pd.json_normalize(d["topHoldings"]) # name, weightsectors = pd.json_normalize(d["sector"]).set_index("label") # weight by sectorcountries = pd.json_normalize(d["geography"]).set_index("label") # weight by countrycal = d["calendarReturns"]calendar = pd.DataFrame({"fund": cal["fund"], "benchmark": cal["benchmark"]}, index=cal["years"])series = pd.json_normalize(d["indexedPerformance"]["points"]) # date, fund, index (rebased to 100)series["date"] = pd.to_datetime(series["date"])monthly_returns = series.set_index("date")["fund"].pct_change()print(fund["name"], d["headlineMetrics"]["ter"], "as of", d["dataAsOf"])print(sectors.head(), calendar, sep="\n\n")
A universe of funds as one table
pythonisins = ["IE00B4L5Y983", "IE00B3RBWM25", "LU1681043599"]batch = ff.get_funds(isins) # Starter and above; one request per fund foundrows = []for r in batch["results"]:if r["status"] != "ok":continue # not_found, pending (still loading; re-send later) or invalidd = r["data"]rows.append({"isin": r["isin"], "name": r["name"],"category": d["profile"]["category"], "risk": d["riskRating"],"ter": d["headlineMetrics"]["ter"], "aum": d["keyFacts"]["aum"],"vol3y": d["headlineMetrics"]["volatility3y"], "maxDD": d["metrics"]["maxDrawdown"],"asOf": d["dataAsOf"],})universe = pd.DataFrame(rows)universe["ter_pct"] = universe["ter"].str.rstrip("%").astype(float) # "0.20%" -> 0.20print(universe.sort_values("ter_pct"))
What comes back
The response is one JSON envelope: isin, name, cached, generatedAt, expiresAt, plan, quota and a data object. Inside data you get keyFacts (asset class, currency, fund size, inception date, distribution policy, number of holdings), headlineMetrics (TER, AUM, 3-year volatility and Sharpe, yield to maturity and duration for bond funds), riskRating (the 1–7 KID indicator), profile (a rule-based classification: kind, category, risk band, region focus, sector tilt), topHoldings, sector, geography, region, assetAllocation, creditQuality, maturity, calendarReturns, annualisedReturns, cumulativePerformance, indexedPerformance, metrics (max drawdown, P/E, income yield…), sfdrArticle, costs (PRIIPs entry, exit, ongoing, transaction and performance fees, reduction in yield) and dataAsOf, the date of the underlying figures.
Money and percentages arrive as the fund house prints them, as strings such as "0.20%" or "USD 151.7bn". Breakdown weights and return series are plain numbers in percent. Fields that do not apply to the fund's asset class are empty strings, null or empty arrays, never missing keys; treat empty as "not disclosed", not as zero.
A trimmed example for iShares Core MSCI World UCITS ETF (IE00B4L5Y983):
json{"isin": "IE00B4L5Y983","name": "iShares Core MSCI World UCITS ETF","cached": true,"expiresAt": "2026-09-13T06:00:00.000Z","data": {"keyFacts": { "assetClass": "Equities", "currency": "USD", "aum": "USD 151.7bn", "inception": "2009-09-25", "distribution": "Accumulating", "holdings": 1253 },"headlineMetrics": { "ter": "0.20%", "volatility3y": "11.8%", "sharpe3y": "1.68" },"riskRating": 6,"profile": { "kind": "equity", "category": "Global Equity", "riskBand": "high", "regionFocus": "Global", "sectorTilt": "Broad" },"topHoldings": [ { "name": "NVIDIA", "weight": 5.48 }, { "name": "APPLE", "weight": 5.24 } ],"sector": [ { "label": "Information Technology", "weight": 29.8 }, { "label": "Financials", "weight": 16.46 } ],"geography": [ { "label": "United States", "weight": 72.07 }, { "label": "Japan", "weight": 5.85 } ],"calendarReturns": { "years": ["2023", "2024", "2025"], "fund": [23.9, 18.7, 21.2], "benchmark": [23.8, 18.7, 21.1] },"metrics": { "maxDrawdown": "-16.5%" },"dataAsOf": "2026-09-01"}}
Things to know before shipping
First call is slow, later calls are fast. A fund never loaded before is read on demand from the documents the fund house publishes. That first call takes 15 seconds to about 3 minutes; set your client timeout to 300 seconds and do not fire a second request for the same ISIN while the first is running. Every later call returns from the store in well under a second with cached: true; past 24 hours the payload comes back flagged stale: true while a refresh runs in the background, and nothing older than 7 days is served without a new read.
Requests are counted per ISIN. One request is counted per fund found. X-RateLimit-Remaining on every response tells you how many are left this month; a 429 rate_limited response carries Retry-After. Search and /me are free. Cache payloads by ISIN until expiresAt and you will rarely spend more than one request per fund per day.
Batch when you have a list. POST /funds with { "isins": [...], "wait": true } answers up to 10 ISINs per call on Starter, 50 on Pro and 200 on Scale. ISINs still loading come back with status: "pending"; send them again a minute later.
Not every ISIN is a fund. A stock, bond or index ISIN returns 404 fund_not_found. Validate the format first (12 characters, Luhn check digit) so a typo does not spend a request; the ISIN validator shows the rule.
Show the as-of date. data.dataAsOf is the date printed on the fund's documents. Display it next to any figure.
Going further
- SDKs & widgets: the official Python client, its methods, errors and cache.
- Endpoints: batch, search, portfolio look-through, holdings overlap, factsheets, SCPI.
- Field reference: every key of
datawith type and example. - OpenAPI 3.1 spec to generate a client in any language.
- AI agents & MCP if a coding agent is writing this code for you.
Verify it yourself
The demo endpoint returns the live payload for a fund that is already in the store, without a key. Everything on this page can be checked against it.bashcurl -s https://fundfactsapi.com/api/v1/demo/funds/IE00B4L5Y983 | jq '{name, asOf: .data.dataAsOf, ter: .data.headlineMetrics.ter, risk: .data.riskRating, top: .data.topHoldings[:3]}'