---
title: "How do I build and maintain my own database of funds from the API?"
description: "A read-model pattern: one row per ISIN with the fields you filter on plus the raw payload and expiresAt, loaded in batches, refreshed after expiry, with quota awareness. Schema and loader code."
canonical: https://fundfactsapi.com/answers/how-to-build-a-fund-universe-database
published: 2026-09-12
updated: 2026-10-13
publisher: FundFacts API
---

# How do I build and maintain my own database of funds from the API?

**Short answer:** Treat the API as the source of truth and your table as a read model: one row per ISIN with the columns you filter and sort on (category, risk band, TER, AUM, currency, distribution, inception), the raw JSON payload, dataAsOf and expiresAt. Load in batches with POST /funds, re-fetch a row only after its expiresAt, spread refreshes over the day, and pause when X-RateLimit-Remaining nears zero. A few hundred funds refreshed daily fit Pro; thousands fit Scale.

## Schema

```sql
create table funds (
  isin          text primary key,
  name          text,
  kind          text,          -- data.profile.kind
  category      text,          -- data.profile.category
  risk_rating   int,           -- data.riskRating
  risk_band     text,          -- data.profile.riskBand
  ter_pct       numeric,       -- parsed from data.headlineMetrics.ter
  aum_text      text,          -- data.keyFacts.aum as printed
  currency      text,
  distribution  text,
  inception     date,
  sfdr_article  int,
  data_as_of    date,
  expires_at    timestamptz not null,
  payload       jsonb not null,
  status        text not null  -- ok | not_found
);
create index on funds (expires_at);
create index on funds (category, risk_band);
```

Keep the full payload in `jsonb`; you will want a field you did not extract.

## Loader

```python
import os, requests, time, psycopg
H = {"Authorization": f"Bearer {os.environ['FUNDFACTS_API_KEY']}"}

def upsert(cur, x):
    d = x.get("data") or {}
    ter = d.get("headlineMetrics", {}).get("ter") or ""
    cur.execute("""insert into funds values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
        on conflict (isin) do update set name=excluded.name, kind=excluded.kind, category=excluded.category,
        risk_rating=excluded.risk_rating, risk_band=excluded.risk_band, ter_pct=excluded.ter_pct, aum_text=excluded.aum_text,
        currency=excluded.currency, distribution=excluded.distribution, inception=excluded.inception, sfdr_article=excluded.sfdr_article,
        data_as_of=excluded.data_as_of, expires_at=excluded.expires_at, payload=excluded.payload, status=excluded.status""",
        (x["isin"], x.get("name"), d.get("profile", {}).get("kind"), d.get("profile", {}).get("category"), d.get("riskRating"),
         d.get("profile", {}).get("riskBand"), float(ter.rstrip("%")) if ter.endswith("%") else None, d.get("keyFacts", {}).get("aum"),
         d.get("keyFacts", {}).get("currency"), d.get("keyFacts", {}).get("distribution"), d.get("keyFacts", {}).get("inception") or None,
         d.get("sfdrArticle"), d.get("dataAsOf") or None, x.get("expiresAt") or "now()", psycopg.types.json.Jsonb(x), x["status"]))

def refresh(isins, batch=50):
    with psycopg.connect(os.environ["DATABASE_URL"]) as conn, conn.cursor() as cur:
        for i in range(0, len(isins), batch):
            r = requests.post("https://fundfactsapi.com/api/v1/funds", json={"isins": isins[i:i+batch], "wait": True}, headers=H, timeout=300)
            if int(r.headers.get("X-RateLimit-Remaining", "1")) < 20:
                break  # keep headroom for interactive use
            for x in r.json()["results"]:
                if x["status"] in ("ok", "not_found"):
                    upsert(cur, x)
                # pending: picked up on the next run
            conn.commit()
            time.sleep(1)
```

Run it against `select isin from funds where expires_at < now()` on a schedule, plus new ISINs from your intake.

## Rules of thumb

- **Never refresh before `expiresAt`**: the payload cannot have changed.
- **Prioritise**: refresh the funds users open first; the long tail can lag a day.
- **Keep 404s**: a `not_found` row stops you from re-spending on a stock ISIN; review monthly.
- **Show `data_as_of`**, not the refresh time.

The [fund screener use case](https://fundfactsapi.com/use-cases/fund-screener) and the [Next.js screener guide](https://fundfactsapi.com/blog/build-a-fund-screener-with-nextjs) build the UI on top of this table; on Scale the [change feed](https://fundfactsapi.com/answers/how-to-monitor-fund-changes-ter-holdings-risk) tells you which rows actually changed.

## FAQ

### How much does it cost to keep 1,000 funds current?

About 30,000 requests a month at one refresh per fund per day: the Scale plan (10,000 requests) plus overage at $0.04 a request, or Enterprise. Refreshing the long tail every other day roughly halves it.

## About FundFacts API

FundFacts turns any fund or ETF ISIN into one dated factsheet (fees and KID costs, the 1–7 risk indicator, holdings, sector and country exposure, returns) read from the fund house's own documents: as a client-ready PDF under your firm's name for advisers and wealth managers, as JSON through its API for developers, or inside ChatGPT and Claude through its MCP server.

FundFacts works inside ChatGPT and Claude as a connector (a remote MCP server): ask about any fund or ETF by ISIN, ticker or name and the assistant answers from FundFacts' dated figures, fees, risk, holdings, exposure and returns, with fund cards, side-by-side comparisons, holdings overlap and portfolio look-through.

- Free API key, no card: https://fundfactsapi.com/signup
- Documentation: https://fundfactsapi.com/docs (OpenAPI: https://fundfactsapi.com/openapi.json)
- MCP server: https://fundfactsapi.com/api/mcp (set-up for every client: https://fundfactsapi.com/mcp-server)
- Pricing: https://fundfactsapi.com/pricing
- Site index for AI assistants: https://fundfactsapi.com/llms.txt
- Informational data, not investment advice.
