CS2 team rankings API: pull the board in Python
Reviewed by Phil McParlane, Founder, on
Bottom line: The CS2 team rankings API is one route,
GET /v1/cs2/rankings, and a single request can return the whole team board. Each row carries arank, a score calledvalue, the last five results and the move since last week.valueis our own rating minus twice its uncertainty, so sorting on it givesrankback exactly. The code below runs against the EsportsOdds CS2 data API, and every output was pulled on 28 October 2026.
The routes behind the board
A rankings widget, a weekly movers post or a team-strength feature all start with the same few calls. Three GET routes under /v1/cs2 do most of the work.
| Route | Returns | Limits |
|---|---|---|
/rankings | The team board, best first | limit from 1 to 500, default 100. No cursor. |
/rankings/history | The top teams at each weekly mark | weeks from 1 to 52, default 13. top from 1 to 25, default 10. |
/teams/{slug}?rank=1 | One team, with its board place in own_rank | Runs a query over the whole board, so it is off unless you ask. |
On 28 October 2026 the board held 365 teams, so one request with limit=500 reads all of them. Every call in this guide is one request.
Pull the board
Start with include=rating, which adds the two numbers behind each score. The get helper below is reused by every later example.
import os
import requests
BASE = "https://api.esportsodds.gg/v1/cs2"
HEADERS = {"Authorization": f"Bearer {os.environ['ESPORTSODDS_API_KEY']}"}
def get(path: str, **params):
resp = requests.get(f"{BASE}{path}", headers=HEADERS, params=params, timeout=15)
resp.raise_for_status()
return resp.json()["data"]
def show_board(limit: int = 5) -> None:
rows = get("/rankings", limit=limit, include="rating")
print(f"{'#':>2} {'Team':<10}{'Value':>8}{'Rating':>8}{'RD':>6} Move Form")
for t in rows:
move = "n/a" if t["rank_delta"] is None else f"{t['rank_delta']:+d}"
print(f"{t['rank']:>2} {t['name']:<10}{t['value']:>8.1f}{t['rating']:>8.1f}"
f"{t['rd']:>6.1f} {move:>4} {''.join(t['form'])}")
show_board()
On 28 October 2026 it printed:
# Team Value Rating RD Move Form
1 Vitality 1956.2 2056.8 50.3 +0 WLWWW
2 MOUZ 1884.8 1982.6 48.9 +1 WWWLL
3 Spirit 1876.2 1976.1 49.9 -1 LLWWL
4 Falcons 1850.3 1950.9 50.3 +0 LLWWL
5 Aurora 1836.5 1935.0 49.3 +1 WWWLW
Here is one row as the API sends it, with four fields trimmed: the ID, the country code, the role and external_rank:
{
"rank": 1,
"entity_type": "team",
"name": "Vitality",
"slug": "vitality",
"region": "Europe",
"metric": "glicko2_conservative",
"value": 1956.2,
"window": "3mo",
"form": ["W", "L", "W", "W", "W"],
"rank_delta": 0
}
Most fields explain themselves. These are the ones to learn:
| Field | Meaning |
|---|---|
rank | Place on the board, counted from 1. |
value | The score the board sorts on. |
metric | The name of that score: glicko2_conservative. |
window | The span of results the board looks at: 3mo. |
form | Up to five latest results, newest first. |
rank_delta | Places gained since a week ago. Positive means up. |
rating, rd and trend are not in the default response. They appear only when you ask for them with include. The board also takes a few parameters:
| Parameter | Effect |
|---|---|
limit | Rows returned, from 1 to 500. The default is 100. |
sort | rank, value or name, with a leading - to reverse it. |
region | One of Asia, CIS, Europe, North America, Oceania or South America. |
delta_days | How far back rank_delta looks, from 1 to 90 days. The default is 7. |
include | rating adds rating and rd. trend adds twelve weekly values. Join them with a comma. |
What value means
The board does not sort on a team's rating. It sorts on value, which is the rating minus twice its rd, the rating deviation. The rd is the model's doubt about the rating. In the output above, Vitality has a rating of 2056.8 and an rd of 50.3, so its value is 2056.8 minus 2 times 50.3, which is 1956.2. We checked all 365 rows: value matched rating - 2 * rd to within the rounding of the two inputs.
A team with few recent results has a large rd, so it scores lower than a team with the same rating and a longer record. That is on purpose. A short hot streak should not lift a new team above the best teams. On 28 October 2026 the ten highest teams had an rd between 48.7 and 50.3. The ten lowest had 130.5 to 186.4. The rd also grows with each week a team goes without a match, so a team that stops playing drifts down the board.
A team needs at least five rated matches in the last three months to appear at all. With fewer, it is left off the board rather than ranked last.
Two timing facts matter. The ratings are rebuilt from match results once a day, in the early morning UTC, so a new result reaches value, rank, rank_delta and trend once a day. The rd of an idle team keeps growing between rebuilds, so its value can still drift a little. form reads finished matches directly, so it can show a new result hours before the board moves.
Weekly movement with rank_delta
rank_delta is the team's place a week ago minus its place now, so a team that climbed three places reads 3. A 0 means the team kept its place. A null means it was not on the board then. Pass delta_days to look back further. This function lists the biggest risers and fallers among the top 50 over 14 days:
def movers(days: int = 14, show: int = 3) -> None:
rows = get("/rankings", limit=50, delta_days=days)
# None means "not on the board then", so it counts as no move here
risers = sorted((t for t in rows if (t["rank_delta"] or 0) > 0), key=lambda t: -t["rank_delta"])
fallers = sorted((t for t in rows if (t["rank_delta"] or 0) < 0), key=lambda t: t["rank_delta"])
print(f"Biggest moves among today's top 50, over {days} days")
for t in risers[:show]:
print(f" up {t['rank_delta']:>2} {t['name']} (now #{t['rank']})")
for t in fallers[:show]:
print(f" down {-t['rank_delta']:>2} {t['name']} (now #{t['rank']})")
movers()
Biggest moves among today's top 50, over 14 days
up 18 FOKUS (now #34)
up 17 SINNERS (now #37)
up 14 Imperial (now #29)
down 15 Nemesis (now #44)
down 14 GamerLegion (now #40)
down 8 DENDELE (now #47)
For a sparkline, add include=trend. Each row then carries trend, twelve weekly values of value, oldest first, with today's value last. An entry is null for a week in which the team had no rating yet. We compared the pieces on 28 October 2026. For the 23 teams in the history top 25 both this week and last, rank_delta equalled last week's place minus this week's. For the 25 teams in last week's top 25 that are also on the board now, the second-to-last trend value matched the history route to within 0.1. The two reads are made minutes apart and the history is cached, so they can differ in the last digit.
The board over time
/rankings/history returns the top of the board at each weekly mark, oldest first, and the last mark is now. You always get exactly weeks snapshots. Each snapshot is the board as it would have been served at that moment, with the same five-match rule applied to the three months before it. So a team appears only if it was active then. This function turns the number one spot into runs:
def number_ones(weeks: int = 26) -> None:
snaps = get("/rankings/history", weeks=weeks, top=1)
runs = [] # [team, number of weekly marks in a row at number one]
for snap in snaps:
team = snap["entries"][0]["name"] if snap["entries"] else "no ranked team"
if runs and runs[-1][0] == team:
runs[-1][1] += 1
else:
runs.append([team, 1])
print(f"Number one at each of the last {len(snaps)} weekly marks, oldest first")
for team, n in runs:
print(f" {team:<14}{n:>3} mark{'s' if n != 1 else ''}")
number_ones()
Number one at each of the last 26 weekly marks, oldest first
Vitality 16 marks
Spirit 6 marks
Vitality 4 marks
The route is computed once every ten minutes and shared, so the newest mark can be up to that old. At the time of our check, the latest snapshot matched the top ten of the current board, in rank, slug and value.
One team's place with own_rank
To place one team without pulling the board, ask the team route for its rank. It returns own_rank only when you pass rank=1:
def own_rank(slug: str) -> None:
team = get(f"/teams/{slug}", rank=1)
place = team["own_rank"]
if place is None:
print(f"{team['name']}: not ranked (under five rated matches in three months)")
else:
print(f"{team['name']}: #{place} on the board")
own_rank("vitality")
own_rank("00nation")
Vitality: #1 on the board
00NATION: not ranked (under five rated matches in three months)
The slug comes from the slug field of a board row, so read it there instead of guessing. MOUZ, for example, is mouz-cs2.
Traps to know about
limitcuts the board. There is no cursor. Alimitabove 500 is treated as 500.- Sorting changes order, not rank.
sort=-rank&limit=10returns the bottom ten with their real places.sort=-valuereturns the normal board order. regionrenumbers. Withregion=Europe,rankcounts inside the region, starting at 1.rank_deltastill measures movement on the whole board. The region name is case sensitive, and an unknown name returns an empty list, not an error. Of the 365 teams, 24 have no region, so a region filter never returns them.own_rankhas two meanings ofnull. Asked for withrank=1,nullmeans the team is below the five-match rule. Left out, it means you did not ask.external_rankis not our rank. It is a separate figure,nullfor 85 of the 365 teams, and it plays no part in the order. Sort onrankorvalue.- A bad parameter is a 400.
sort=winsorweeks=53returnsinvalid_parameterwith the allowed values in the message. - A subscription key is required. The sandbox key reads a fixed sample of past matches, not the rankings routes. See pricing for access.
How this board differs from Valve and HLTV
The board is our own rating, built from match results. It is not the Valve Regional Standings or the HLTV ranking, so it will not match either one. How CS2 rankings work explains how those two are built, and VRS defines the Valve one. The methodology page describes our rating. The public CS2 team rankings page shows the top 50 of the same board.
Where to go next
The ratings and rankings docs list every field, including /rankings/round-stats, which adds round-level figures for the top teams. For a worked use of a team's place, the match preview guide reads own_rank for both sides of a match.
One request gives you the board, and a few more give you its history week by week.