UFC data in Python: a tutorial for schedule, results, stats and rankings
A step-by-step Python tutorial: pull the UFC schedule, results, round-by-round stats and rankings history into pandas. Runs on the free API plan, no card.
- Author
- Mate Bersenadze
- Published
- Sep 3, 2026
- Read time
- 7 min read
- Updated
- Oct 29, 2026
- Tags
- UFCFight ApiPythonUFC StatsRankingsIslam Makhachev

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 UFC API inside UFCalendar's MMA Fight Data API, and I will be upfront about money: there is a free plan of 1,000 requests a month, and heavier use is paid. 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 Get your free key. You get a working key on the spot, on the free plan: 1,000 requests a month at 10 a minute, no card, and it never expires. The helper below waits out the per-minute limit on its own, because Step 3 makes one call per bout. 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, time, requests
API = "https://api.ufcalendar.com/v1"
H = {"Authorization": f"Bearer {os.environ['UFCAL_KEY']}"}
def get(path, **params):
while True:
r = requests.get(f"{API}/{path}", params=params, headers=H, timeout=30)
wait = int(r.headers.get("Retry-After", "61"))
if r.status_code != 429 or wait > 60: # > 60 s = monthly quota, not the per-minute limit
break
time.sleep(wait)
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["card"]:
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["card"]:
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), 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. The API now also serves a consensus odds line per bout, averaged across sportsbooks, as information only, not betting advice.
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 on the free plan and you will know whether the data is what you need before you pay anyone anything.
import os, time, 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']}"}
def get(p, **q):
r = requests.get(f"{API}/{p}", params=q, headers=H, timeout=30)
if r.status_code == 429 and int(r.headers.get("Retry-After", "61")) <= 60:
time.sleep(int(r.headers["Retry-After"])) # free plan: 10 requests a minute
return get(p, **q)
return r.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["card"]:
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["card"] 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.
Related
The UFC API reference lists every endpoint, field and plan in one place. The UFC schedule in Python guide is the copy-paste version of Step 1, and the round-by-round stats guide documents every column Step 3 puts in your DataFrame.
More from the blog
- UFC 334 rankings stakes: what Gane vs Hokit and Harrison vs Nunes mean for both title pictures
- Projected UFC rankings after Cortes Acosta vs Walker: Walker in at 6
- Opinion: Tom Nolan should fight Mateusz Gamrot next
- Projected UFC rankings after UFC 333: Reyes up, Yakhyaev in
- Volkanovski's next challenger should come out of Sterling vs Vallejos