CS2 data reference

Map results

Per-map scores, the half-by-half split, and why overtime is null rather than zero.

GET /v1/{game}/matches/{id}/maps returns one row per map played, ordered by map_number.

A bo3 has up to 3 rows and a bo1 exactly 1 — but a series that ended early has fewer rows than its format allows, so never assume a count from format.

Scores

score_a and score_b are final rounds won by team A and team B (the parent match's team_a_id / team_b_id), including overtime.

map_name is the Valve identifier — de_ancient, de_mirage — never a display name, on every map that was actually played. Two exceptions are worth coding around: a forfeited map carries the literal string w.o. (110 rows corpus-wide, and one carries an empty string), and a handful of technical rows carry whatever the source recorded instead of a map. Match on the de_ prefix rather than assuming every value is one.

The half split

first_half_a/first_half_b and second_half_a/second_half_b break the map into halves, and are null together when the split isn't available for that map. The final score is authoritative regardless.

Starting side

first_half_side_a is the side team A started the map on — "ct" or "t", the same lowercase spelling as team_side on round stats. Team B started on the other, and the sides swap at the half, so with the half split you can attribute every round of regulation to a side without fetching the rounds.

It is populated on about half of all maps and null on the rest. It is never inferred, so treat null as "unknown" rather than defaulting it.

Overtime is null, not zero

overtime_a/overtime_b are null when the map didn't go to overtime — deliberately, so you can distinguish "no overtime" from "overtime happened and this team won no rounds in it". Treating null as 0 collapses that distinction.

maps = get(f"/v1/cs2/matches/{match_id}/maps")["data"]

for m in maps:
    ot = "" if m["overtime_a"] is None else f" (OT {m['overtime_a']}-{m['overtime_b']})"
    print(f"Map {m['map_number']}: {m['map_name']} {m['score_a']}-{m['score_b']}{ot}")

Maps that were never played out

Not every row here is a map two teams played. A series can be settled by a forfeit, abandoned mid-map, or awarded outright, and upstream still reports the result as a game. status says which you are looking at:

Prop

Type

status is null on older rows, where the source never said. Treat null as played.

rounds_played is the round count reported for the map, and is null where it was never collected. The score stays authoritative there.

Filter these out of any performance statistic

A 1–0 forfeit is not a map win on Dust2. Counting one distorts map win rates, ratings and head-to-head records alike, and it is a real population: hundreds of stored rows have a winning score below 13.

Our own numbers use two readings, and yours should match whichever you are reproducing. Series scores count played and technical, because upstream credits a technical map toward the result. Every performance statistic — team map records, ratings, per-map player stats — counts played only.

maps = get(f"/v1/cs2/matches/{match_id}/maps")["data"]
played = [m for m in maps if (m["status"] or "played") == "played"]

A walkover on the whole match, rather than one map, shows up as result_type: "walkover" on the match itself — see Match lifecycle.

Reconciling with the series score

The match's score_a/score_b count maps won, not rounds. So a 2–1 series with map scores 13–8, 10–13, 13–11 gives score_a: 2, score_b: 1. Mixing the two is a common source of confusing totals.

The map rows can also be fewer than the series score implies: a technical or walkover map counts toward score_a/score_b, and on a match awarded outright (result_type: "walkover") there are no map rows at all. Reconcile on the match's own score, not on a count of rows.

Which side started where

first_half_side_a"ct" or "t" — is the side team A opened on; team B opened on the other. It is null where we don't know, and never inferred. See Starting side above.

When each map began

started_at is the moment this map began, on the upstream's own clock — not the series start, and not the previous map's end. A three-map series therefore has three different starts, which is what you want for pacing a broadcast, aligning odds ticks to a map, or measuring how long a series actually ran.

It is never derived. A map we have no start for stays null rather than borrowing a neighbouring time, which would read as fact and be wrong by minutes to hours. It is also a real start, so on the first map of a series it can legitimately precede the match's scheduled_at — matches begin when both teams are ready.

Filter on the field, not on a date

The field is written forward, when a match's detail is fetched. Current matches carry it — 82–98% of maps per day over the last week — while the archive mostly does not: 614 of 51,410 stored maps (1.2%) have it today, and older rows fill in only if that match is re-fetched. Test started_at != null; do not assume a cutoff date.

On this page