CS2 data reference

Round stats

Two rows per round, the signed economy tier, and what each counter measures.

GET /v1/{game}/matches/{id}/rounds returns two rows per round — one per team. Exactly one has won: true.

Join to a map with (match_id, map_number); rounds are numbered from 1 within a map and overtime keeps counting up, so round 25+ on a standard map is overtime rather than a restart.

economy_level is a signed tier, not a sentinel

economy_level can be negative, and a negative value is meaningful. It is a signed classification of the buy — eco and force-buy rounds sit below zero. It is not a missing-data sentinel, and filtering out negatives silently discards every eco round, which is usually the exact population an economy analysis is about.

Read it alongside equipment_value, enemy_equipment_value and money_spent — the raw dollar figures, with the opponent's value denormalised onto the same row so you can judge the buy matchup without a join.

A zero money_spent is usually a save, not a gap

About a quarter of rounds have money_spent: 0, and in most of them that is the truth: a team that saves buys nothing and carries the previous round's kit, so a zero spend beside a five-figure equipment_value is a save. Roughly 208,000 such rounds follow a round the team lost, which is exactly the shape of a save.

The field is genuinely absent on about 0.7% of matches, where every round of the match reads money_spent: 0. utility_value is absent the same way on about 2% of matches. Check the match, not the round: a scattered zero is a save, a match-wide zero is missing data.

Sides

team_side is lowercase ct or t. Sides swap at the half, so a team's side changes partway through a map — never assume a team's side from the map alone.

pistol_round flags round 1 and the second-half opener.

Counters

All integers, never null:

FieldMeaning
kills / deaths / assistsTeam totals for the round. deaths is 0–5.
damage, headshotsDamage dealt; how many kills were headshots. Five opponents at 100 HP puts a normal round at or under 500, which holds for 98.6% of rows; 1.4% run higher and nine rows in the whole corpus are physically impossible (up to 49,048). Treat an extreme value as upstream noise rather than a signal.
first_kills / first_deathsOpening duel. Across a round's two rows these sum to 0 or 1.
trade_kills / trade_deathsTrade discipline — a kill that avenged a teammate who just died, and a death that was subsequently traded.
clutches / clutch_attemptsWon, and reached. Divide for a conversion rate — guard the zero denominator.
bomb_plants / bomb_defuses0 or 1. Plants are always 0 on the CT side, defuses always 0 on the T side.
flash_assists, utility_valueKills enabled by blinding; dollar value of grenades used.
kastA count of 0–5 players who contributed — not a percentage.

Two further fields, end_reason and round_duration_ms, describe the round rather than the team and can be null — see below.

How the round ended, and how long it took

Two fields describe the round itself rather than either team, so both rows of a round carry the same value: end_reason and round_duration_ms.

end_reason is an open vocabulary, not a fixed set. These are the values in the data today:

end_reasonMeaning
CTWinCTs won by eliminating the Ts (or the clock ran out with no plant).
TerroristsWinTs won by eliminating the CTs.
TargetBombedThe bomb detonated.
BombDefusedThe bomb was defused.
TargetSavedThe round timer expired with the bomb unplanted.

Don't switch on end_reason exhaustively. It mirrors the game's own round-end names, and values we haven't seen yet — a draw, or a surrender — will appear verbatim the first time they happen rather than being mapped to something familiar. Handle an unknown value as valid.

round_duration_ms is wall-clock milliseconds between round boundaries, which is not the same as the round's live length: it includes the freeze time before the round and whatever else happened in that window — a timeout, a technical pause, or a scheduled break. The median round is 112 s. About 13% exceed 155,000 ms, which is longer than a CS2 round can physically run (a 1:55 round plus a 0:40 bomb timer), and 1% run past five minutes.

Those long rounds are not scattered at random: the round after a break carries the break. Round 13 — the half-time swap — is over 155 s on 19.1% of maps against a 13% average, and round 25, the first overtime round, on 19.3%; rounds 12 and 14, either side of half-time, are the calmest on the map at 6.2% and 5.8%. The five-minute tail is headed by round 1 and round 13 (map start and half-time) and is ordinary rather than exceptional — 756 of the 2,152 matches with duration data carry at least one, usually exactly one. So a long round is far more often a break than a slow round. Read the field as a pace signal — comparing pistol rounds to gun rounds, or one team's rounds to another's, ideally excluding rounds 1, 13 and 25 — never as an exact duration.

Both fields are null on rounds recorded before they were captured. They are populated for every match ingested from 2026-09-19 and were backfilled across roughly the three months before that, so filter on end_reason != null rather than assuming a cutoff date.

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

# One row per round: the two team rows agree on both fields.
timeline = {
    (r["map_number"], r["round_number"]): (r["end_reason"], r["round_duration_ms"])
    for r in rounds
}

Rebuilding a scoreline

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

from collections import Counter
score = Counter(r["team_id"] for r in rounds if r["won"] and r["map_number"] == 1)

That should reconcile with map 1's score_a/score_b from map results. If it doesn't, you're probably summing across maps.

On this page