Kathuko › SQL › Part 0: Start here

0.3 19c+

The cricket data, and how we made it

The Coastal Cricket League (CCL) is made up. Six teams, two seasons (2025 and 2026), 32 matches, and every one of its 7,551 deliveries. The teams, players and grounds are invented; any resemblance to a real player is a coincidence (and no agent will ring about image rights). What is real is the shape of the data: it behaves like real T20 cricket, so the questions you ask of it are the ones analysts ask of real matches.

The league

Expected result · 6 rows
TEAM_CODETEAM_NAMEVENUE_NAME
TPATampa TidesBayshore Oval
CHNChennai CometsMarina Ground
LDSLeeds LarksKirkstall Park
DBNDurban DhowsUmgeni Oval
AKLAuckland AlbatrossHarbourside Park
KHIKarachi KitesClifton Ground
select t.team_code, t.team_name, v.venue_name
from   cr_teams t
join   cr_venues v on v.venue_id = t.home_venue_id
order  by t.team_id;

Each season, every team plays every other team once (15 league matches, at the home team's ground), then the top two meet in a final. Winning is worth 2 points, a tie 1.

Expected result · 2 rows
SEASONCHAMPIONRUNNER_UPWIN_TYPEWIN_MARGIN
2025Chennai CometsDurban DhowsWICKETS6
2026Leeds LarksChennai CometsWICKETS6
select m.season,
       w.team_name as champion,
       l.team_name as runner_up,
       m.win_type,
       m.win_margin
from   cr_matches m
join   cr_teams w on w.team_id = m.winner_id
join   cr_teams l on l.team_id = case when m.winner_id = m.home_team_id
                                      then m.away_team_id
                                      else m.home_team_id end
where  m.stage = 'FINAL'
order  by m.season;

The tables

CR_VENUES 1──< CR_TEAMS 1──< CR_PLAYERS
    │              │              │
    └────< CR_MATCHES >───────────┘ (player of the match)
                  │
                  1
                  │
                  ^
           CR_DELIVERIES  (batter, non-striker, bowler, player out, fielder → CR_PLAYERS)

CR_MATCHES, one row per match:

Column Meaning
MATCH_ID 1 to 32, in date order
SEASON, MATCH_NO 2025 or 2026; 1 to 16 within the season (16 is the final)
STAGE LEAGUE or FINAL
MATCH_DATE, VENUE_ID When and where
HOME_TEAM_ID, AWAY_TEAM_ID The two teams. "Home" just means it was their ground
TOSS_WINNER_ID, TOSS_DECISION Who won the toss, and whether they chose to BAT or FIELD first
WINNER_ID The winner; null for a tie
WIN_TYPE, WIN_MARGIN RUNS (batting first won), WICKETS (the chase won) or TIE; and by how many
PLAYER_OF_MATCH_ID The award

CR_DELIVERIES, one row per ball. The key is (MATCH_ID, INNINGS_NO, SEQ_NO):

Column Meaning
INNINGS_NO 1 (batting first) or 2 (the chase)
SEQ_NO 1, 2, 3... every delivery of the innings in order, wides and no-balls included. This is the column to order by
OVER_NO 1 to 20
BALL_NO Delivery number within the over, extras included (so it can reach 7 or 8)
BATTING_TEAM_ID, BOWLING_TEAM_ID Who's batting and who's bowling
BATTER_ID, NON_STRIKER_ID, BOWLER_ID Who faced it, who was at the other end, who bowled it
RUNS_BATTER Runs off the bat: 0, 1, 2, 3, 4 or 6
RUNS_EXTRAS, EXTRA_TYPE Extras and their kind: WD, NB, LB, B (null if none)
WICKET_KIND bowled, caught, lbw, stumped, run out; null if nobody was out
PLAYER_OUT_ID, FIELDER_ID Who was out (on a run out it can be the non-striker), and who caught or ran them out

CR_BALLS_V is CR_DELIVERIES joined to CR_MATCHES (for SEASON, MATCH_NO, MATCH_DATE), plus columns most questions need:

Column How it's worked out
OVER_BALL Scorer's notation like 15.2, counting legal balls only
TOTAL_RUNS RUNS_BATTER + RUNS_EXTRAS
IS_LEGAL 1 unless it was a wide or a no-ball
IS_FOUR, IS_SIX 1 for a four or a six off the bat
IS_DOT 1 when nothing at all was scored and it was a legal ball (a wicket ball can be a dot)
IS_WICKET 1 for any dismissal
IS_BOWLER_WICKET 1 for a dismissal credited to the bowler (not run outs)

The 1/0 columns are numbers on purpose, so you can add them up: sum(is_six) is the number of sixes. (26ai has a real BOOLEAN type; Part 10 shows it. The dataset sticks to what runs on 19c.)

A quick feel for it, the top run-scorers of 2025:

Expected result · 5 rows
PLAYER_NAMETEAM_CODERUNSBALLSSTRIKE_RATE
Karthik RamanCHN366245149.4
Siddharth IyerCHN273190143.7
Sipho NdlovuDBN272202134.7
Hamza SiddiquiKHI257181142
Oliver HartleyLDS234157149
select p.player_name,
       t.team_code,
       sum(b.runs_batter)                                            as runs,
       sum(case when b.extra_type = 'WD' then 0 else 1 end)           as balls,
       round(100 * sum(b.runs_batter)
                 / sum(case when b.extra_type = 'WD' then 0 else 1 end), 1) as strike_rate
from   cr_balls_v b
join   cr_players p on p.player_id = b.batter_id
join   cr_teams   t on t.team_id   = p.team_id
where  b.season = 2025
group  by p.player_name, t.team_code
order  by runs desc
fetch  first 5 rows only;

Balls faced leave out wides, because a wide isn't counted as a ball the batter faced. Notice case when b.extra_type = 'WD' then 0 else 1 end: when EXTRA_TYPE is null, null = 'WD' is not true, so the ELSE branch counts the ball. That's NULL logic working for us, and Part 1 covers it properly.

How we made the data

The data comes from a small cricket simulator (tools/make_cricket.py in the course repo), run once with a fixed random seed. The same seed always gives the same matches, ball for ball, so every expected result in this guide stays true for everyone.

What the simulator does, ball by ball:

  1. Picks the bowler for each over from the fielding side's five best bowlers: at most 4 overs each, never two overs in a row, fast bowlers favored in the powerplay and at the death, spinners in the middle overs.
  2. Decides if it's a wide or a no-ball (about 3% and 0.7% of deliveries). Those are bowled again, so the over gets an extra delivery.
  3. Decides the outcome of a legal ball: a dot, 1, 2, 3, 4, 6 or a wicket. The chances depend on the phase (more fours in the powerplay, more sixes and more wickets at the death) and on skill ratings: an opener scores more and gets out less than a tail-ender, and a strike bowler concedes less than a part-timer.
  4. Picks how a wicket happened: mostly caught, then bowled, lbw, run out, and stumped (only off spinners). A catch goes to a fielder, sometimes the keeper, sometimes the bowler ("caught and bowled"). A run out can dismiss the non-striker.
  5. Rotates the strike on odd runs and at the end of each over, and sends in the next batter after a wicket.
  6. Ends the innings after 20 overs or 10 wickets, and ends the chase the moment the target is reached.

Then it works out the result, the points table, which two teams reach the final, and the player of the match (the best all-round contribution on the winning side).

The numbers come out like real T20: first innings average about 162, the highest score is 207, there are two centuries, and teams are bowled out now and then.

Planted on purpose

A few patterns are rare in real cricket but too good to leave out of a SQL guide, so the simulator forces them at fixed places. Everything else is left to chance.

Match What we planted Used in
1 (2025), 1st innings A hat-trick in one over: over 18, balls 2, 3, 4 Part 6
20 (2026), 1st innings A hat-trick spread across two overs: last ball of over 17, first two of over 19, same bowler Part 6
3 (2025), 1st innings Four sixes in a row by one batter: over 16 Part 6
25 (2026), 2nd innings A collapse: wickets tumbling in overs 9 to 11 Part 6
11 (2025), 1st innings A maiden over in the powerplay: over 3 Part 5

What the simulator leaves out

Real cricket has more than our data does: rain-shortened matches, super overs, free hits after no-balls, rarer dismissals (hit wicket, obstructing the field), retired batters and substitutes. None of that changes how the SQL works, and leaving it out keeps the data easy to reason about.

Try it yourself

How many matches did each team win in 2025? (Hint: one team didn't win any. Make sure it still shows up with 0. And the wins only add up to 15 of the 16 matches: one was a tie.)

Try itShow the answer
Expected result · 6 rows
TEAM_NAMEWINS
Durban Dhows5
Chennai Comets4
Karachi Kites3
Tampa Tides2
Leeds Larks1
Auckland Albatross0
select t.team_name, count(m.match_id) as wins
from   cr_teams t
left   join cr_matches m on m.winner_id = t.team_id and m.season = 2025
group  by t.team_name
order  by wins desc, t.team_name;

The season filter sits in the ON clause, not in WHERE. In WHERE, m.season = 2025 would throw away Auckland's row (its M.SEASON is null) and the 0 would disappear. And count(m.match_id), not count(*): count(*) would count Auckland's empty row as 1.

Downloads

Plain SQL text: open it in any editor before you run it.

Part 0 quiz

5 questions on all of Part 0. Get 4 right to pass. Retake it as often as you like.