UFC API in Python: fight data, results, stats and rankings (2026)
A runnable Python guide to UFC fight data: pull the schedule, results, round-by-round stats and rankings history with requests and pandas through the UFCalendar Fight API.

Every few weeks someone asks the same question in a Discord or a subreddit: "is there a UFC API?" The honest answer has not changed since 2014. The UFC does not publish one. What it has is an undocumented endpoint behind ufc.com that changes shape whenever the site is rebuilt, and a statistics site that has been scraped by roughly four hundred GitHub repositories, most of which stopped working the last time a table gained a column.
I have written three of those scrapers myself. They all died the same way. So this is the guide I wanted in 2019: a short, runnable path from an empty Python file to a pandas DataFrame of UFC fight data, using a REST API that somebody else keeps alive. The API is ours, the UFCalendar Fight API, and I will be upfront that it costs money after a one day trial. If you would rather scrape, the scraping section at the end is honest about what that involves.

Photo: Head of the Respublic of Dagestan / CC BY 3.0
Step 0: a key, and the two lines of setup
Sign in at ufcalendar.com/account/api and click Start free trial. You get a working key on the spot: one day, 100 requests, every endpoint, no card. Put it in an environment variable so it never ends up in a notebook you later push to GitHub.
pip install requests pandas
export UFCAL_KEY=ufcalendar_...
Everything below is plain HTTPS with a bearer header. There is a pip install ufcalendar client if you want typed methods and pagination handled for you (source on GitHub), but for a tutorial I prefer to show the raw requests, because then you understand what the client is doing when it misbehaves.
import os, requests
API = "https://api.ufcalendar.com/v1"
H = {"Authorization": f"Bearer {os.environ['UFCAL_KEY']}"}
def get(path, **params):
r = requests.get(f"{API}/{path}", params=params, headers=H, timeout=30)
r.raise_for_status()
return r.json()
Every response is the same envelope: {"data": ..., "meta": ...}. Lists carry meta.pagination.next_cursor; pass it back as ?cursor= until it comes back null. That is the whole pagination story, and it is the first thing every scraper gets wrong.
Step 1: the upcoming schedule (and why the time zone field matters)
A bare call to /v1/events?org=ufc is the upcoming calendar, soonest first. Each row carries starts_at, main_card_at, prelims_at and early_prelims_at as UTC timestamps, plus the venue's IANA time zone in tz. That last field is the one people skip and then wonder why their bot announced a Paris card at the wrong hour in Los Angeles.
from datetime import datetime
from zoneinfo import ZoneInfo
for ev in get("events", org="ufc")["data"]:
start = datetime.fromisoformat(ev["starts_at"].replace("Z", "+00:00"))
local = start.astimezone(ZoneInfo(ev["tz"] or "UTC"))
print(local.strftime("%a %d %b %H:%M"), ev["title"], "(PPV)" if ev["is_ppv"] else "")
Add from= and to= for a date window, or status=completed to browse the archive newest first. If you need the card itself, take the event's slug and call /v1/events/{slug}: that returns both corners for every bout, the venue, and the broadcasters that have been confirmed for that specific event.
Step 2: results, and how to read the winner correctly
This is the part I see done wrong most often, so slow down here. A completed bout carries a result object with winner_fighter_id, method, round and time. The winner is an id, not a name, because names collide (there are two Bruno Silvas in the UFC, and they are not the same person). Map it back through the corners:
latest = get("events", org="ufc", status="completed", limit=1)["data"][0]
card = get(f"events/{latest['slug']}")["data"]
for f in card["fights"]:
r = f.get("result")
if not r:
continue
corners = {c["id"]: c["name"] for c in (f["fighter_a"], f["fighter_b"]) if c}
winner = corners.get(r["winner_fighter_id"], "draw / no contest")
print(f"{f['fighter_a']['name']} vs {f['fighter_b']['name']}: {winner} by {r['method']} R{r['round']} {r['time']}")
method is the official string ("KO/TKO", "Submission", "Decision - Unanimous"), referee and bonus sit next to the result, and during a live card the row typically updates within about two minutes of the verdict. If you are building anything that needs to react to results, do not poll this in a loop: Pro plans can register a webhook and receive a fight.result delivery instead. I wrote about the results side of the API separately.
Step 3: round-by-round statistics into pandas
Here is the payoff. /v1/fights/{id}/rounds returns one row per fighter per round: significant strikes landed and attempted, split by target (head, body, leg) and by position (distance, clinch, ground), plus takedowns, submission attempts, reversals, knockdowns and control time in seconds. The columns are the same on every row, which is exactly what a DataFrame wants.
import pandas as pd
rows = []
for f in card["fights"]:
rows += get(f"fights/{f['id']}/rounds")["data"]
df = pd.DataFrame(rows)
totals = (df.groupby("fighter_id")[["sig_strikes_landed", "takedowns_landed", "control_time_sec"]]
.sum()
.sort_values("sig_strikes_landed", ascending=False))
print(totals.head(10))
Two things to know before you trust the numbers. First, fighter_position is a or b and follows the corner order in the fight row, so join on fighter_id, never on position. Second, coverage: 8,819 of 9,016 UFC fights carry per-fight stat lines and 41,526 rounds carry round-by-round lines. The gap is the early 1990s, where the results exist but nobody was counting jabs. The stats endpoint guide lists every column.
For a fighter rather than a card, /v1/fighters/{slug}/stats returns career rates per scope (strikes landed per minute, accuracy, takedown average and so on for pro MMA and for UFC only), derived from the same rows. If your notebook and our fighter page disagree, one of us has a bug, and I would like to hear about it.
Step 4: rankings on any date since 2013
This is the endpoint nothing else offers, and the reason I bothered building the API at all. Official UFC rankings are archived point in time: 548 snapshots since February 4, 2013, stored only when the board actually changed, champion at rank 0. Ask for a date and you get the board that was valid that day.
board = get("rankings/ufc", date="2016-11-14")["data"]
print(board["snapshot_date"])
lw = next(d for d in board["divisions"] if d["division"] == "lightweight")
for e in lw["entries"][:6]:
print("C " if e["rank"] == 0 else f"#{e['rank']:<2}", e["name"])
That prints the lightweight board the week of UFC 205, with Eddie Alvarez at rank 0. Swap the date for the night of any fight and you have rank at fight time, which is what "ranked opponent" arguments should be built on instead of memory. A single fighter's full ranking history is /v1/fighters/{slug}/rankings, newest first; Islam Makhachev's is a long list. The rankings guide has the JSON shape.
What about scraping instead?
You can. UFCStats is static HTML and a few hundred lines of BeautifulSoup will get you fight totals. What it will not get you is the schedule (that lives on ufc.com behind a different, JavaScript rendered page), start times, the venue time zone, rankings history (nobody archived it; we did, from Wayback captures and daily polling), or anything from PFL, OKTAGON or BKFC, which live on three other sites with three other markups. And it breaks. Mine broke in 2021, 2023 and 2025. The comparison table on the API page is honest about what the enterprise feeds and the other self-serve APIs do better, including the one thing we deliberately do not serve, which is betting odds.
The whole thing in one file
Forty lines, no framework, prints the next card in your time zone, the last card's results, the top strikers by round data, and who was champion on any date you like. Run it inside the trial window and you will know whether the data is what you need before you pay anyone anything.
import os, requests, pandas as pd
from datetime import datetime
from zoneinfo import ZoneInfo
API = "https://api.ufcalendar.com/v1"
H = {"Authorization": f"Bearer {os.environ['UFCAL_KEY']}"}
get = lambda p, **q: requests.get(f"{API}/{p}", params=q, headers=H, timeout=30).json()["data"]
nxt = get("events", org="ufc")[0]
print("Next:", nxt["title"], datetime.fromisoformat(nxt["starts_at"].replace("Z", "+00:00")).astimezone(ZoneInfo(nxt["tz"] or "UTC")))
card = get(f"events/{get('events', org='ufc', status='completed', limit=1)[0]['slug']}")
for f in card["fights"]:
if f.get("result"):
names = {c["id"]: c["name"] for c in (f["fighter_a"], f["fighter_b"]) if c}
print(names.get(f["result"]["winner_fighter_id"], "draw/NC"), "by", f["result"]["method"])
rows = [r for f in card["fights"] for r in get(f"fights/{f['id']}/rounds")]
print(pd.DataFrame(rows).groupby("fighter_id")["sig_strikes_landed"].sum().nlargest(5))
board = get("rankings/ufc", date="2016-11-14")
print(board["snapshot_date"], [e["name"] for e in board["divisions"][0]["entries"][:3]])
If it prints, you have a UFC dataset. If it does not, the error body carries a request_id; send it to api@ufcalendar.com and I will look at the log myself.