Player match stats
The per-player stat line — which fields are always present, which are nullable, and the map_number trap.
GET /v1/{game}/matches/{id}/stats returns a whole-match aggregate row per player, plus — where we
have them — one row per player per map. Read the next section before you depend on the per-map rows:
they are not present on every match, and data_available.map_stats tells you which is which.
Check per-map coverage before you rely on it
The aggregate rows are there for every match we have stats for. The per-map rows are not yet there for every match, and a match with only aggregate rows is indistinguishable from a full one until you look.
data_available.map_stats on the match tells you in advance, and it is narrower than
data_available.stats on purpose — both describe this same endpoint:
| flags | what you get from /stats |
|---|---|
stats: false | nothing; the endpoint returns an empty list |
stats: true, map_stats: false | the whole-match aggregate rows only |
stats: true, map_stats: true | aggregate rows and one row per player per map |
match = get(f"/v1/cs2/matches/{match_id}")["data"]
if not match["data_available"]["map_stats"]:
... # aggregate only — don't build a per-map view from this matchTo page only over matches that carry the per-map layer, filter the list with ?has=map_stats:
GET /v1/cs2/matches?status=completed&has=map_statsCoverage is improving
Per-map rows land automatically on matches as they finish. Older matches are being filled in
continuously, so a match that reads map_stats: false today may well carry per-map rows later —
re-check the flag rather than caching the answer.
Maps that were never played out carry no per-map rows at all, and that is correct rather than
missing: see status on Map results for forfeits, abandons and
walkovers.
The map_number trap
map_number is null on the whole-match aggregate row. Every player therefore appears
maps + 1 times: once per map, once in total.
rows = get(f"/v1/cs2/matches/{match_id}/stats")["data"]
per_map = [r for r in rows if r["map_number"] is not None]
aggregate = [r for r in rows if r["map_number"] is None]Summing all rows double-counts every player. This is the single most common mistake against this endpoint.
Always present
kills is net of team kills — a kill on a teammate subtracts one, which is how upstream counts
it. The duel matrix is the only place the two are separable, via
its kind field.
kills, assists, deaths, adr and rating are on every row. rating is our own computed
rating, not a third party's — don't compare it numerically against a rating from elsewhere.
Nullable
The richer detail comes from a source that doesn't cover every match, so these are null rather
than 0 where unavailable — an honest absence, not a claim that nothing happened:
kast, headshots, first_kills, first_deaths, trade_kills, trade_deaths, clutches,
multikills_2k/3k/4k/5k, damage, utility_value.
KAST units differ by endpoint
kast here is a fraction between 0 and 1 — multiply by 100 for the percentage usually shown.
On a round row it is a count of 0–5 players, and on a team profile the *_pct fields are
already 0–100. Three different units for related ideas; check which endpoint you're reading.
team_id
team_id is the team the player played for in this match, which is not always their current
team — rosters change, and stand-ins happen. It is nullable where attribution couldn't be resolved.
Use it rather than the player's own team_id when attributing historical performance.
Field reference
Prop
Type
For what ADR, KAST and rating mean as concepts, the marketing site has explainers at /learn/cs2-adr and /learn/cs2-kast. This page documents the fields as served.