# Data preparation is documented in kohli_odi_all.R. This chunk adds first-innings
# totals (to get chase targets) once, then caches the result.
inn_file <- here("data/clean/kohli_innings_enriched.csv")
if (!file.exists(inn_file)) {
  inn0  <- read_csv(here("data/clean/kohli_innings.csv"),
                    col_types = cols(match_id = col_character()), show_col_types = FALSE)
  balls <- readRDS(here("data/raw/all_odi_balls.rds"))
  totals <- balls |> filter(innings == 1) |> group_by(match_id) |>
    summarise(inn1_total = sum(runs_off_bat + extras, na.rm = TRUE))
  inn0 |> left_join(totals, by = "match_id") |> write_csv(inn_file)
}

inn <- read_csv(inn_file, col_types = cols(match_id = col_character()),
                show_col_types = FALSE) |>
  mutate(
    situation = factor(if_else(chasing, "Chasing", "Batting first"),
                       levels = c("Batting first", "Chasing")),
    target    = if_else(chasing, inn1_total + 1, NA_real_),
    status    = if_else(out, "Dismissed", "Not out"),
    result    = case_when(is.na(winner) ~ "No result / tie",
                          winner == batting_team ~ "Won", TRUE ~ "Lost"),
    era       = cut(year, c(2007, 2012, 2017, 2022, 2030),
                    labels = c("2008-12", "2013-17", "2018-22", "2023+")),
    tip = paste0(format(start_date, "%d %b %Y"), " v ", bowling_team, "<br>",
                 runs, " off ", balls_faced, " balls (", status, ")<br>",
                 venue, "<br>Result: ", result)
  )

summ <- function(d) d |>
  summarise(n = n(), dismissals = sum(out), balls = sum(balls_faced),
            total_runs = sum(runs),
            average = total_runs / dismissals,
            not_out_pct = 100 * mean(!out),
            fifty_pct = 100 * mean(runs >= 50),
            dismissals_per_100 = 100 * dismissals / balls, .groups = "drop")

I come from the same part of Delhi as Virat Kohli, and I have followed him since he first played for India. The innings I remember best come from India’s 2018 tour of South Africa, where he scored three hundreds in six innings and seemed to carry the team almost on his own. Later he set a world record with his 50th ODI hundred. These memories shaped what I believed about him, and this report asks whether the data supports it.

The standing that Virat Kohli enjoys as a chaser rests upon a single statistic. When one examines the 303 One Day International innings contained within this dataset, the picture that emerges is as follows:

cat('<div class="stats">',
    card(r1(f$average), "batting average when batting first", pal[["Batting first"]], "#FFFFFF"),
    card(r1(c_$average), "batting average when chasing", pal[["Chasing"]], "#000000"),
    card(paste0("+", r1(c_$average - f$average)), "runs: the gap this story examines", "#EEEEEE", "#111111"),
    '</div>', sep = "\n")
51.0
batting average when batting first
65.7
batting average when chasing
+14.7
runs: the gap this story examines

Of what, precisely, is that gap actually composed, and to what extent can it legitimately be relied upon? If you position your cursor over any point or line, further detail will be displayed; thereafter, you may employ the dropdown menu in chart 3 together with the filters in chart 5 in order to verify the claim for yourself.

1. The myth, innings by innings

# The two innings that anchor the story, found by date and checked against the data
story_dates   <- as.Date(c("2013-10-16", "2023-11-15"))
inn_h         <- inn |> arrange(start_date) |> mutate(n_h = cumsum(runs >= 100))
fastest_balls <- min(inn$balls_faced[inn$runs >= 100])

story <- inn_h |> filter(start_date %in% story_dates) |>
  mutate(fastest = runs >= 100 & balls_faced == fastest_balls,
         lab = paste0(runs, if_else(out, "", "*"), " off ", balls_faced, " balls\n",
                      if_else(fastest, "Fastest ODI hundred, ", ""),
                      if_else(chasing, paste0("chasing ", target, "\n"), "batting first\n"),
                      if_else(n_h == 50 & runs >= 100, "50th ODI hundred\n", ""),
                      "v ", bowling_team),
         hj = if_else(start_date < as.Date("2018-01-01"), 0.2, 0.8))
if (nrow(story) != 2) stop("Expected two story innings; check the dates against the data.")
s13 <- story[1, ]; s23 <- story[2, ]

p1 <- ggplot(inn, aes(start_date, runs, colour = situation, shape = status,
                      size = balls_faced, text = tip)) +
  geom_point(alpha = 0.75) +
  geom_point(data = story, aes(x = start_date, y = runs), inherit.aes = FALSE,
             shape = 21, size = 7, fill = NA, colour = "black", stroke = 1) +
  geom_text(data = story, aes(x = start_date, y = runs, label = lab, hjust = hj),
            inherit.aes = FALSE, vjust = 0, nudge_y = 10, size = 3.2,
            colour = "grey15", lineheight = 0.95) +
  scale_colour_manual(values = pal) +
  scale_shape_manual(values = c("Dismissed" = 16, "Not out" = 1)) +
  scale_size_continuous(range = c(1.5, 5)) +
  scale_y_continuous(expand = expansion(mult = c(0.02, 0.15))) +
  labs(title = paste0("Every ODI innings, ", min(inn$year), "-", max(inn$year),
                      ": chases more often end not out"),
       x = NULL, y = "Runs scored", colour = "Situation", shape = "Outcome",
       size = "Balls faced") +
  theme_minimal(base_size = 12)
avg_lab <- paste0("Career average ", r1(career_avg))
p1w <- ggplotly(p1, tooltip = "text")
# Tidy the legend labels ggplotly builds from combined aesthetics, e.g. "(Chasing,Not out)"
for (i in seq_along(p1w$x$data)) {
  nm <- p1w$x$data[[i]]$name
  if (!is.null(nm)) p1w$x$data[[i]]$name <- sub(",", ", ", gsub("^\\(|\\)$", "", nm))
}
p1w |>
  # Drawn as its own trace so it sits on top of the points, and appears in the legend
  add_lines(x = as.numeric(range(inn$start_date)), y = career_avg, inherit = FALSE,
            name = avg_lab, line = list(dash = "dash", color = "#222222", width = 2),
            text = avg_lab, hoverinfo = "text") |>
  add_annotations(x = as.numeric(max(inn$start_date)), y = career_avg, text = avg_lab,
                  xanchor = "right", yanchor = "bottom", showarrow = FALSE,
                  bgcolor = "rgba(255,255,255,0.85)", font = list(size = 12))

Data: Cricsheet (n.d.). Each point is one innings. Hollow points are not-out innings. The dashed line is his career average, and the two circled innings are described below. Hover for opponent, venue and result. Text description: a scatter of 303 innings from 2008 to 2026, coloured by situation, with the two circled innings labelled.

Within this dataset, two innings reveal contrasting facets of his batting. In the match dated 16 October 2013, he scored 100 not out from 52 deliveries faced against Australia, which stood as the fastest hundred of his ODI career in this data, while pursuing a target of 360. In the match dated 15 November 2023, he made 117 against New Zealand, which was his 50th ODI hundred, a world record that surpassed Tendulkar’s 49 (Guinness World Records, n.d.), at a point when the team was batting first. The first of these represents precisely the type of run chase upon which his reputation was established. The second, by contrast, does not constitute a chase in any sense.

2. Similar innings, different averages

avg <- inn |> group_by(situation) |> summ()

plot_ly() |>
  add_trace(data = inn, x = ~situation, y = ~runs, color = ~situation, colors = pal,
            type = "violin", box = list(visible = TRUE), points = "all",
            jitter = 0.3, pointpos = 0, text = ~tip, hoverinfo = "text") |>
  add_trace(data = avg, x = ~situation, y = ~average, inherit = FALSE,
            type = "scatter", mode = "markers+text",
            marker = list(symbol = "diamond", size = 12, color = "black"),
            text = ~paste0("Average ", round(average, 1)), textposition = "middle right",
            name = "Batting average", hoverinfo = "text") |>
  layout(title = list(text = "Similar spread of scores, but a higher average when chasing",
                      x = 0.02),
         xaxis = list(title = ""), yaxis = list(title = "Runs per innings"),
         showlegend = FALSE)

Data: Cricsheet (n.d.). Diamonds mark the batting average (runs per dismissal). Based on 136 first-innings and 167 chasing innings. Text description: violin and box plots of runs per innings for each situation, with the average marked.

The overall pattern exhibited by his scores is comparable across both of these situations. In percentage terms, he attained a score of 50 or above in 44.3% of his chases and in 42.6% of his first innings.

3. How long does he last?

# Kaplan-Meier survival: not-out innings are treated as "still batting" when they ended
inn_s <- inn |> mutate(t = pmax(balls_faced, 1))
fit <- survfit(Surv(t, out) ~ situation, data = inn_s)

lv <- levels(inn$situation)
km <- tibble(strata = rep(names(fit$strata), fit$strata), t = fit$time, surv = fit$surv) |>
  mutate(situation = factor(sub("situation=", "", strata), levels = lv)) |>
  select(situation, t, surv)
km <- bind_rows(tibble(situation = factor(lv, levels = lv), t = 0, surv = 1), km) |>
  arrange(situation, t) |>
  mutate(tip = paste0(situation, "<br>After ", t, " balls: ",
                      round(100 * surv), "% of innings still going"))

at <- summary(fit, times = c(50, 100), extend = TRUE)
at_tbl <- tibble(situation = factor(sub("situation=", "", as.character(at$strata)), levels = lv),
                 t = at$time, surv = 100 * at$surv, lo = 100 * at$lower, hi = 100 * at$upper)
pick <- function(sit, tt) at_tbl |> filter(situation == sit, t == tt)
pct  <- function(sit, tt) with(pick(sit, tt), paste0(round(surv), "% (95% interval ", round(lo), "-", round(hi), "%)"))

# One survival curve pair per era, switched with a dropdown
subsets <- c("All years", levels(inn$era))

km_for <- function(d) {
  d <- d |> mutate(t = pmax(balls_faced, 1))
  fit <- survfit(Surv(t, out) ~ situation, data = d)
  tab <- tibble(strata = rep(names(fit$strata), fit$strata), t = fit$time, surv = fit$surv) |>
    mutate(situation = factor(sub("situation=", "", strata), levels = lv)) |>
    select(situation, t, surv)
  tab <- bind_rows(tibble(situation = factor(lv, levels = lv), t = 0, surv = 1), tab) |>
    arrange(situation, t) |>
    mutate(tip = paste0(situation, "<br>After ", t, " balls: ", round(100 * surv), "% still batting"))
  s50 <- summary(fit, times = 50, extend = TRUE)
  list(km = tab, s50 = setNames(100 * s50$surv, sub("situation=", "", as.character(s50$strata))),
       n = table(d$situation))
}
sets <- map(subsets, \(sb) km_for(if (sb == "All years") inn else filter(inn, era == sb)))

set_title <- function(sb, x) paste0(sb, ": after 50 balls he is still batting in ",
  round(x$s50[["Chasing"]]), "% of chases and ", round(x$s50[["Batting first"]]),
  "% of first innings<br><sup>", x$n[["Chasing"]], " chases, ", x$n[["Batting first"]], " first innings</sup>")

fig3 <- plot_ly()
for (i in seq_along(subsets)) {
  for (sit in lv) {
    fig3 <- fig3 |> add_lines(data = filter(sets[[i]]$km, situation == sit), x = ~t, y = ~surv,
                              name = sit, legendgroup = sit, showlegend = (i == 1),
                              line = list(shape = "hv", color = pal[[sit]], width = 2.5),
                              visible = (i == 1), text = ~tip, hoverinfo = "text")
  }
}
buttons <- map(seq_along(subsets), \(i) list(
  method = "update", label = subsets[i],
  args = list(list(visible = rep(seq_along(subsets) == i, each = 2)),
              list(title = list(text = set_title(subsets[i], sets[[i]]), x = 0.02)))))

fig3 |> layout(
  title = list(text = set_title(subsets[1], sets[[1]]), x = 0.02),
  xaxis = list(title = "Balls faced", range = c(0, 160)),
  yaxis = list(title = "Share of innings still going", tickformat = ".0%", range = c(0, 1)),
  margin = list(t = 120),
  updatemenus = list(list(type = "dropdown", buttons = buttons, x = 0.02, xanchor = "left",
                          y = 1.0, yanchor = "bottom", pad = list(t = 4))),
  shapes = list(
    list(type = "line", x0 = 50, x1 = 50, y0 = 0, y1 = 1, line = list(dash = "dot", color = "grey")),
    list(type = "line", x0 = 100, x1 = 100, y0 = 0, y1 = 1, line = list(dash = "dot", color = "grey"))))

Data: Cricsheet (n.d.). Each step down is a dismissal. Not-out innings are treated as still batting when they ended. Use the dropdown to switch era; the small eras give noisy curves. Text description: two step curves fall from 100% as balls are faced, one for batting first and one for chasing, with dotted lines at 50 and 100 balls.

A batting average is calculated by dividing the number of runs scored by the number of dismissals, and since a chase concludes once the target has been reached, innings played while chasing are consequently left not out more frequently (21.6% compared with 8.1%). Were Kohli’s chasing innings to have ended not out at the same rate as his first innings, the resulting chasing average would stand at approximately 56.1, rather than the figure of 65.7. This adjustment is responsible for accounting for roughly 65% of the difference.

The survival curve constitutes a more equitable assessment, in that it accounts for not-outs in an accurate manner. Having faced 50 deliveries, he remains at the crease in 53% (95% interval 46-61%) of chases and in 49% (95% interval 41-58%) of first innings. Upon reaching 100 deliveries, the corresponding figures stand at 26% (95% interval 20-35%) and 16% (95% interval 11-24%). Since the intervals overlap, the survival curves are consistent in direction with the dismissal rates, yet they do not establish the point conclusively: he is dismissed 1.8 times per 100 balls while batting first, and 1.4 times while chasing. Consequently, the adjustment described above is most appropriately interpreted as an upper-bound estimate of the extent to which the gap merely reflects the manner in which chases conclude.

4. The gap over time

era_tbl <- inn |> group_by(era, situation) |> summ()

p4 <- ggplot(era_tbl, aes(era, average, colour = situation, group = situation)) +
  geom_line(linewidth = 0.8) +
  geom_point(aes(size = n,
                 text = paste0(situation, ", ", era, "<br>Average: ",
                               round(average, 1), "<br>Innings: ", n))) +
  geom_text(aes(label = round(average)), vjust = -1.4, size = 3.3, show.legend = FALSE) +
  scale_colour_manual(values = pal) +
  scale_size_continuous(range = c(3, 8)) +
  scale_y_continuous(limits = c(0, NA), expand = expansion(mult = c(0, 0.15))) +
  labs(title = paste0("The chasing edge has shrunk: ", r1(first_era$gap), " runs in ",
                      first_era$era, ", ", r1(last_era$gap), " in ", last_era$era),
       x = NULL, y = "Batting average", colour = "Situation", size = "Innings") +
  theme_minimal(base_size = 12)
ggplotly(p4, tooltip = "text")

Data: Cricsheet (n.d.). Point size shows the number of innings behind each average. Each point rests on a modest sample, so read differences between eras as indicative only. Text description: two lines of batting average by era, one per situation, with the gap narrowing over time.

5. Test it yourself

You may filter the data by year, by opposition, or by result, and both charts will update accordingly as you make your selections. On the left-hand side, every chase is plotted according to the target that he was set and the number of runs that he scored, with the colour indicating whether India emerged victorious. On the right-hand side, each innings is plotted according to the number of balls faced and the number of runs scored. Selections that are small in size tend to produce results that are unreliable, and therefore it is advisable to keep a close watch on how many points remain.

result_pal <- c("Won" = "#009E73", "Lost" = "#D55E00", "No result / tie" = "#999999")
sd <- SharedData$new(inn, key = ~match_id)

chase <- plot_ly(sd, x = ~target, y = ~runs, color = ~result, colors = result_pal,
                 type = "scatter", mode = "markers",
                 text = ~paste0(tip, "<br>Target: ", target), hoverinfo = "text") |>
  layout(xaxis = list(title = "Target to chase (chases only)"),
         yaxis = list(title = "Runs scored"))

sc <- plot_ly(sd, x = ~balls_faced, y = ~runs, color = ~situation, colors = pal,
              type = "scatter", mode = "markers", text = ~tip, hoverinfo = "text") |>
  layout(xaxis = list(title = "Balls faced"), yaxis = list(title = "Runs"))

bscols(widths = c(3, NA),
       list(filter_slider("year", "Year", sd, ~year, sep = ""),
            filter_select("opp", "Opposition", sd, ~bowling_team),
            filter_checkbox("res", "Result", sd, ~result)),
       subplot(chase, sc, nrows = 1, titleX = TRUE, titleY = TRUE, margin = 0.05) |>
         layout(title = list(text = "Runs against the target (chases) and against balls faced, for your selection",
                             x = 0.02)))

Data: Cricsheet (n.d.). Text description: two linked scatter plots of his innings that respond to the year, opposition and result filters.

What the data does and does not show

The average that Kohli achieves while chasing is greater than the average he records in the first innings; however, the magnitude of this difference remains uncertain, since a bootstrap interval for the gap extends from 0.3 to 29.6 runs. In both situations, he accumulates runs at a comparable rate and reaches a score of 50 or more with a comparable frequency. A portion of the gap, amounting to approximately 65%, arises from the manner in which chases conclude, while the remainder is consistent with the possibility that he is dismissed less frequently per ball when he is chasing, namely 1.4 as opposed to 1.8 per 100 balls.

cat('<div class="stats">',
    card(paste0(r1(sr_f), " vs ", r1(sr_c)), "strike rate: batting first vs chasing", "#EEEEEE"),
    card(paste0(r1(f$fifty_pct), "% vs ", r1(c_$fifty_pct), "%"), "innings reaching 50: batting first vs chasing", "#EEEEEE"),
    card(paste0(r1(f$dismissals_per_100), " vs ", r1(c_$dismissals_per_100)), "dismissals per 100 balls: batting first vs chasing", "#EEEEEE"),
    '</div>', sep = "\n")
93.7 vs 94.7
strike rate: batting first vs chasing
42.6% vs 44.3%
innings reaching 50: batting first vs chasing
1.8 vs 1.4
dismissals per 100 balls: batting first vs chasing

Notes and limitations

  • The innings referenced here are those drawn from matches included in Cricsheet’s men’s ODI dataset, through 03 October 2026. The dataset comprises 303 innings, 14,987 runs, and 256 dismissals, which corresponds to an average of 58.5. ESPNcricinfo’s Statsguru (ESPNcricinfo, n.d.) lists 305 innings and 15,109 runs at an average of 58.78 as at 27 October 2026. Cricsheet has 2 fewer innings and 122 fewer runs. The shortfall is two innings against Afghanistan: 67 batting first on 22 June 2019 and 55* chasing on 11 October 2023 (ESPNcricinfo, n.d.). No Afghanistan matches appear anywhere in the Cricsheet ODI files used here, so these innings are not analysed and Afghanistan does not appear in the opposition filter. Statsguru’s split by match innings gives an average of 51.12 batting first and 66.16 chasing (a gap of 15.0 runs, against 14.7 here), and 1.8 and 1.4 dismissals per 100 balls, against 1.8 and 1.4 here. The patterns in this story therefore hold in the official figures too.
  • The term “Chasing” refers to batting second. The presence of not-outs during chases is partly inherent, since the innings concludes once the target has been achieved.
  • The not-out adjustment constitutes a straightforward standardisation: it recomputes the chasing average by applying the not-out rate observed in first innings. This adjustment is intended for illustrative purposes and does not represent a causal estimate.
  • Within the survival curve, innings in which the batsman was not dismissed are regarded as censored observations, since he was still at the crease at the moment the innings concluded. In the context of a run chase, the innings concludes once the target has been achieved, and because this outcome depends on the batting performance of the team as a whole, the resulting curves should be understood as indicative rather than exact.
  • Because the era groups contain only a small number of observations, any differences observed between them ought to be interpreted as indicative only.
  • The bootstrap procedure resamples entire innings and does not take into account variations in the quality of the opposition, the nature of the pitch, or the strength of the team.

Data and references

Cricsheet. (n.d.). Cricsheet: Freely available structured data for cricket [Data set]. https://cricsheet.org/

ESPNcricinfo. (n.d.). Batting records: One-Day Internationals, V Kohli [Data set]. Statsguru. Retrieved 27 October 2026, from https://stats.cricinfo.com/ci/engine/player/253802.html?class=2;template=results;type=batting

Guinness World Records. (n.d.). Most hundreds in a One-Day International career (male). Retrieved 27 October 2026, from https://guinnessworldrecords.com/world-records/most-hundreds-in-a-one-day-international-career-male

Acknowledgement of generative AI use

I used Claude to help in writing and debugging the R scripts that download and prepare the Cricsheet data. I chose the subject (Virat Kohli’s ODI career), chose to compare batting first with chasing, ran the code, and reviewed the results.

Anthropic. (2026). Claude (Claude Sonnet 5.5) [Large language model]. https://claude.ai