---
title: Competition structure
description: How qualifying, group/league phases and knockout rounds are modelled — and why round numbers repeat across stages.
badge: free
---

# Competition structure

A domestic league is a flat list of matchdays. A cup is not: it has qualifying
rounds, then a group or league phase, then knockouts — and **round numbers
restart in each of them**.

In the 2026/27 Champions League, `round_number` 5 means two different things:
matchday 5 of the league phase (18 matches in November) *and* the Round of 16
(16 matches in March). Same number, different competition stage.

That is why every event carries a **`stage`**.

## The three main stages

Most of a competition's matches sit in one big stage where every team plays
numbered matchdays. It has three shapes:

| `stage` | When | Example |
|---|---|---|
| `regular-season` | League with no knockouts | Premier League, Bundesliga |
| `league-phase` | Single table, no groups, feeds a knockout | Champions League, Europa League |
| `group-stage` | Split into groups, feeds a knockout | World Cup, Copa Libertadores |

Everything else — qualifying and knockout ties — uses its own slug, built from
the round's name: `qualification-round-1`, `playoff-round`, `round-of-32`,
`round-of-16`, `quarterfinals`, `semifinals`, `final`.

## Every match tells you where it sits

```json
{
  "round_number": 5,
  "round_name": "",
  "stage": "league-phase",
  "stage_name": "League phase",
  "round_label": "League phase · Matchday 5"
}
```

`round_label` is the one to display. `round_number` and `round_name` are the
raw upstream values and are kept for compatibility — but on their own they are
ambiguous, because `round_name` is empty for every main-stage match and
`round_number` repeats across stages.

## Filtering by stage

```
GET /api/v2/events/?league_id=7&season_id=1112&stage=league-phase
```

Returns the 144 Champions League league-phase fixtures — the 8 rounds, 18
matches each. Add `round` to narrow to one matchday:

```
GET /api/v2/events/?league_id=7&season_id=1112&stage=league-phase&round=5
```

`round` only works together with `stage`. On its own it would mix a matchday
with a knockout tie, which is the exact confusion this field exists to remove.

An unknown stage returns **400** listing the stages that season actually has,
rather than an empty list that looks like "no matches yet".

## Discovering the stages of a season

You do not have to guess the slugs. Every season lists its own structure, in
the order it is played:

```
GET /api/v2/leagues/7/seasons/
```

```json
{
  "id": 1112,
  "name": "UEFA Champions League 26",
  "stages": [
    {"stage": "qualification-round-1", "stage_name": "Qualification Round 1",
     "matches": 28, "rounds": 1, "start_date": "2026-07-07", "end_date": "2026-07-15"},
    {"stage": "playoff-round", "stage_name": "Playoff round",
     "matches": 14, "rounds": 1, "start_date": "2026-08-18", "end_date": "2026-08-26"},
    {"stage": "league-phase", "stage_name": "League phase",
     "matches": 144, "rounds": 8, "start_date": "2026-09-08", "end_date": "2027-01-27"}
  ]
}
```

`rounds` is how many matchdays that stage has — 8 for a Champions League league
phase, 3 for a World Cup group stage, 1 for a final. Stages appear as they are
scheduled, so a knockout stage shows up once its draw is published.

## The Champions League has no group stage

Since 2024/25 there are no groups: 36 teams, one table, each playing 8 different
opponents. If you are looking for `group-stage` in a recent Champions League
season you will not find it — the fixtures you want are `league-phase`, and
`group_name` is `null` on all of them.

The World Cup and Copa Libertadores still have real groups, and there
`group_name` is populated (`"Group A"`) and `round_label` reads
`Group A · Matchday 3`.

## Building a fixtures widget

Group by `stage` first, then by `round_number` within it:

```python
from collections import defaultdict

rounds = defaultdict(list)
for match in events:
    rounds[(match["stage"], match["round_number"])].append(match)
```

Grouping by `round_number` alone merges matchday 5 with the Round of 16.
