IPL death-over bowling economy from Cricsheet ball-by-ball data
In this article (7 sections)
Related programme: the Data Analytics with Generative AI course (3–4 months; ₹90,000 (inclusive of GST) · EMI from ₹4,000/month).
Economy rate in the last five overs of an IPL innings is one of the most quoted and most misunderstood bowling numbers. This project computes it properly from Cricsheet's ball-by-ball files: runs charged the way official scorers charge them, legal balls counted correctly, super overs excluded, and small samples handled so that a bowler with two good overs does not top the table. You end with a ranked, defensible death-bowling table and a short note on what it can and cannot say.
Getting the data
Cricsheet's downloads page offers IPL data in JSON and CSV. Take ipl_csv2.zip, the "Ashwin" format. It contains, for each match, <id>.csv (deliveries) and <id>_info.csv (match details), plus all_matches.csv with every delivery together.
The copy we downloaded held 1,243 matches, with the latest dated 31 May 2026. The file header is:
match_id, season, start_date, venue, innings, ball, actual_delivery, batting_team, bowling_team,
striker, non_striker, bowler, runs_off_bat, extras, wides, noballs, byes, legbyes, penalty,
non_boundary, wicket_type, player_dismissed, other_wicket_type, other_player_dismissed,
fielder_1, fielder_2, fielder_3Read Cricsheet's own site for its terms of use and credit it in your README.
Four traps we found in the real file
- 1`ball` is not a number. It encodes over and delivery as "15.3". Overs count from zero, so "15.3" is the third delivery of the 16th over. When an over has ten or more deliveries (wides and no-balls), the file contains values like "3.10". Read as a float, that becomes 3.1 and collides with the first delivery. Read the column as a string and split on the dot.
- 2Super overs are extra innings. Innings 3 to 6 exist in the file and are super overs. Drop them from death-over analysis.
- 3Season labels are not all years. The file uses "2007/08", "2009/10" and "2020/21" alongside plain years. Map them to the calendar year from
start_daterather than parsing the label. - 4Not every run is the bowler's. Byes and leg-byes are not charged to the bowler. Wides and no-ball penalty runs are, along with runs off the bat.
The runs and wickets rules, written down
| Quantity | Rule |
|---|---|
| Runs conceded | runs_off_bat + wides + noballs |
| Legal balls | Deliveries where wides == 0 and noballs == 0 |
| Bowler's wickets | wicket_type in bowled, caught, caught and bowled, lbw, stumped, hit wicket |
| Not the bowler's | run out, retired hurt, retired out, obstructing the field |
| Economy | runs conceded × 6 ÷ legal balls |
The computation on a synthetic sample
The deliveries below are synthetic. The players "Bowler A", "Bowler B" and "Bowler C" are placeholders, so nothing here describes real IPL performances. The column names match Cricsheet's.
import pandas as pd
import numpy as np
rng = np.random.default_rng(11)
rows = []
for match_id in range(1, 41):
for innings in (1, 2):
for over in range(15, 20):
bowler = rng.choice(["Bowler A", "Bowler B", "Bowler C"], p=[0.5, 0.45, 0.05])
delivery, legal = 0, 0
while legal < 6:
delivery += 1
wide = int(rng.random() < 0.05)
runs = 0 if wide else int(rng.choice([0, 1, 2, 4, 6], p=[0.3, 0.35, 0.1, 0.15, 0.1]))
legbye = int(not wide and runs == 0 and rng.random() < 0.05)
wicket = None if wide or runs else rng.choice([None, "caught", "run out"], p=[0.85, 0.12, 0.03])
rows.append({"match_id": match_id, "innings": innings, "ball": f"{over}.{delivery}",
"bowler": bowler, "runs_off_bat": runs, "wides": wide, "noballs": 0,
"byes": 0, "legbyes": legbye, "wicket_type": wicket})
legal += 0 if wide else 1
rows.append({"match_id": 1, "innings": 3, "ball": "0.1", "bowler": "Bowler C", "runs_off_bat": 6,
"wides": 0, "noballs": 0, "byes": 0, "legbyes": 0, "wicket_type": None}) # a super over
df = pd.DataFrame(rows)
df["over"] = df["ball"].str.split(".").str[0].astype(int) # never float("3.10")
death = df[(df["innings"] <= 2) & df["over"].between(15, 19)].copy()
BOWLER_WICKETS = {"bowled", "caught", "caught and bowled", "lbw", "stumped", "hit wicket"}
death["conceded"] = death["runs_off_bat"] + death["wides"] + death["noballs"]
death["legal"] = (death["wides"] == 0) & (death["noballs"] == 0)
death["bowler_wkt"] = death["wicket_type"].isin(BOWLER_WICKETS)
t = death.groupby("bowler").agg(balls=("legal", "sum"), runs=("conceded", "sum"),
wickets=("bowler_wkt", "sum"))
t["economy"] = (t["runs"] * 6 / t["balls"]).round(2)
# Shrink toward the pool average, weighting by balls bowled (prior worth 120 balls)
pool = t["runs"].sum() * 6 / t["balls"].sum()
k = 120
t["economy_shrunk"] = ((t["runs"] * 6 + pool * k) / (t["balls"] + k)).round(2)
print(t[t["balls"] >= 30].sort_values("economy_shrunk"))
print("excluded for small samples:", list(t.index[t["balls"] < 30]))Shrinkage is the part interviewers remember. A bowler with 18 balls and an economy of 5 is probably not better than one with 600 balls at 8.5. Pulling each rate toward the pool average in proportion to sample size makes that visible. The prior weight of 120 balls is a tuning choice. Show the ranking at two or three values.
Plan for the real analysis
- 1Load
all_matches.csvwithdtype={"ball": str}and derive the over from the string. - 2Map each match to a calendar year from
start_date, then restrict to recent seasons, for example the last three. State the window. - 3Apply the scoring rules above, then compute raw and shrunk economy, dot-ball percentage and boundary percentage.
- 4Split by innings. Defending a target and bowling first are different jobs.
- 5Rank with a minimum of 120 legal death balls, and list who fell below it.
Deliverable
- A ranked table of the top 15 bowlers: legal balls, runs, economy, shrunk economy, dot percentage, boundary percentage and wickets.
- A scatter of dot-ball percentage against boundary percentage. The two ways to be economical show up as different clusters.
- A funnel plot of economy against balls bowled, with pool-average control limits, to show why small samples mislead.
- A README with the source, download date, format, scoring rules, filters, shrinkage method and the caveat that economy ignores match situation.
Questions a sports analytics interviewer will ask
- Why are byes not charged to the bowler, and did you check that in the data?
- How did you handle an over with more than nine deliveries?
- What does shrinkage assume about bowlers?
- Would you trust this ranking to pick a death bowler for an auction? What would you add?
- How would you account for the batting strength faced?
For a model-based follow-up on the same files, see the venue and toss project. Our Data Analyst programme covers this cleaning-first approach across many kinds of data.
Frequently asked questions
Where can I get free IPL ball-by-ball data?
Cricsheet publishes IPL match data at cricsheet.org/downloads in several formats. The "Ashwin" CSV format (ipl_csv2.zip) includes one CSV per match, a matching _info.csv file, and an all_matches.csv with every delivery. Check cricsheet.org for its current terms of use before you publish.
Which overs count as death overs in T20?
Overs 16 to 20 are the common definition. In Cricsheet's CSV the ball column counts overs from zero, so over 16 is written as 15.1 onward. Taking the whole-number part of the ball value and keeping 15 to 19 selects the death overs.
Pankit Kumar has 10 years in Data Science & AI, building and shipping production systems in regulated pharma and clinical environments. He is a freelance trainer at Boston Institute of Analytics, AnalytixLabs and Scaler, and has taught this material to thousands of working professionals.
This article is part of our Data Analytics with Generative AI programme — 3–4 months. The full analyst stack — Excel, SQL, Power BI and Python pipelines — then a generative-AI layer you can prove is right.
Explore Data Analytics with Generative AI