Draw a Player Heatmap from Event Data

SoccerIntermediatePython~7 min read

What you'll build

A heatmap of where one player was involved across a match.

A heatmap of where one player was involved across a match.
Data: StatsBomb Open Data, retrieved June 2026

Every single thing a player does on the pitch - every pass, touch, tackle, and carry - is recorded with a location. Collect all of those points for one player and you can draw a heatmap: a smooth, glowing map of where on the pitch that player did their work. It's one of the most striking images in the sport, and it answers a question the box score never can. We'll build one from the 2018 World Cup final using real StatsBomb event data and mplsoccer, and we'll let the code pick the busiest player on the pitch automatically.

This builds on the pass map tutorial - we load the same match the same way, so the first lines are familiar. The new idea is turning a cloud of scattered points into a continuous density surface with a kernel density estimate, which mplsoccer draws for us in a single call.

Attribution, as always: StatsBomb Open Data is free but its license requires credit on anything you publish. We stamp it onto the figure and I'll flag it at the end. Data retrieved June 2026.

  1. Load the match and find the busiest player

    We load the final's events, then keep only rows that have both a location and a player - administrative events like the half kicking off have neither. To choose whose heatmap to draw, we ask which player appears in the most located rows. value_counts() tallies the rows per player and sorts them descending, so .index[0] is the single most-involved player in the match.

    python
    import matplotlib.pyplot as plt
    from mplsoccer import Pitch
    from statsbombpy import sb
    
    matches = sb.matches(competition_id=43, season_id=3)
    final = matches[matches["competition_stage"] == "Final"].iloc[0]
    events = sb.events(int(final["match_id"]))
    
    located = events.dropna(subset=["location", "player"])
    player = located["player"].value_counts().index[0]
    acts = located[located["player"] == player].copy()
    acts["x"] = acts["location"].str[0]
    acts["y"] = acts["location"].str[1]

    Once we have the player's name we filter down to just their rows in acts, then split each location list into x and y columns with the .str[0] / .str[1] accessors - the same trick the pass map used. Picking the player by activity rather than hard-coding a name means this script works for any match you point it at.

  2. See who got picked and what they did

    Let's confirm who the busiest player was and break down their actions by type, so we know the heatmap will be built on real, plentiful data.

    python
    print(f"Most-involved player: {player} ({len(acts)} actions)")
    print("\nAction breakdown:")
    print(acts["type"].value_counts().head(5).to_string())
    The most-involved player and their actions
    Most-involved player: Marcelo Brozović (271 actions)
    
    Action breakdown:
    type
    Pass             99
    Ball Receipt*    73
    Carry            67
    Pressure         12
    Ball Recovery     5

    The busiest player in the final was Croatia's Marcelo Brozović with 271 located actions - a huge number that tells you how much of the game ran through him. The breakdown is the profile of a deep midfield metronome: 99 passes, 73 ball receipts, 67 carries, then 12 pressures and 5 ball recoveries. Those 239 passes-receipts-carries are touches of the ball all over the middle of the park, which is exactly the kind of activity that paints a broad, bright heatmap. A believable name and a sensible breakdown are your sign the data is right before you spend effort on the chart.

  3. Draw the pitch

    As in every mplsoccer chart, we create a Pitch with pitch_type="statsbomb" so its coordinates line up with the data, then pitch.draw() returns a figure and axes already marked out. We keep our warm "paper" background so the heatmap's colors sit on a calm surface.

    python
    pitch = Pitch(pitch_type="statsbomb", line_color="#20242B",
                  pitch_color="#FBF7EE", linewidth=1.1)
    fig, ax = pitch.draw(figsize=(8.6, 5.6))
    fig.set_facecolor("#FBF7EE")

    One thing to keep in mind as you read the finished map: StatsBomb stores this player attacking toward the right, so the heatmap is oriented with their attacking direction to the right of the pitch. We note that in the title so nobody misreads which way the player was going.

  4. Lay down the heatmap with a kdeplot

    This is the heart of the tutorial. A pile of points is hard to read; a density surface is instantly legible. pitch.kdeplot takes the scattered x and y points and estimates a smooth density - brighter where actions cluster, fading to nothing where the player rarely went. We fill the contours, use many levels for a smooth gradient, and pick the warm inferno colormap so dense areas glow.

    python
    pitch.kdeplot(acts["x"], acts["y"], ax=ax, fill=True, levels=40,
                  thresh=0.05, cmap="inferno", alpha=0.7)
    pitch.scatter(acts["x"], acts["y"], ax=ax, s=8,
                  color="#20242B", alpha=0.3, zorder=2)

    A few arguments are doing real work here. fill=True shades the area between contour lines instead of drawing bare outlines; levels=40 sets how many shading bands to use, and more bands means a smoother fade; thresh=0.05 tells it not to bother shading the very sparsest 5% so the pitch isn't tinted edge to edge. The faint dark dots are the raw actions themselves, laid on top at low opacity so you can see the actual points the smooth surface is built from - it keeps the chart honest about where the data really is.

  5. Title it, credit the source, and save

    Last, a title that names the player and states the attacking direction, then save through the figure. Because this is a published chart from StatsBomb data, the caption credits the source - required by the license every single time you share an image made from it.

    python
    ax.set_title(f"{player}: where the work happened\n"
                 f"2018 World Cup final - StatsBomb Open Data (attacking right)",
                 fontsize=11.5, color="#20242B")
    fig.savefig("player_heatmap.png", dpi=144, bbox_inches="tight")
    Smooth density heatmap of Marcelo Brozović's actions in the 2018 World Cup final, glowing brightest through central midfield, attacking right
    Data: StatsBomb Open Data, retrieved June 2026

    Read the finished heatmap and you can see Brozović's job in one glance: the glow concentrates through the centre of the pitch, a little deeper than the halfway line, exactly where a holding midfielder lives. The heatmap turns 271 cold coordinates into an intuitive story about a player's territory - and it's the same handful of lines for any player in any match. Make the attribution a reflex: it rides along with every chart you publish from this data.

Troubleshooting

The heatmap is blank or a single flat color

Usually too few points reached kdeplot. A density estimate needs a decent cloud of locations; one or two points can't form a surface. Check len(acts) is comfortably into the dozens, and that you filtered to a player who actually played - print the value_counts() from step 1 to confirm.

ModuleNotFoundError or an error on kdeplot

mplsoccer's kdeplot relies on seaborn and scipy under the hood. If it errors, install them with pip install seaborn scipy in the same environment, then re-run. The pitch can draw without them, but the density surface cannot.

The hot zone looks on the wrong side of the pitch

It probably isn't wrong - StatsBomb stores every player attacking toward x=120 (the right), so the heatmap is oriented attacking-right by default. That's why the title says so. If you want it flipped, mirror the coordinates with acts["x"] = 120 - acts["x"] and acts["y"] = 80 - acts["y"] before plotting.

Challenge yourself

Pick a specific player instead of the busiest one - set player = "Antoine Griezmann" (StatsBomb stores full names, so print located["player"].unique() to find the exact spelling) and redraw. Then go a step further and build a heatmap of just one action type: filter acts to acts["type"] == "Pressure" before the kdeplot to see where a player did their defensive pressing, versus where they touched the ball overall. The contrast between a player's pressing map and their possession map is one of the most revealing things in event data, and the related pass network tutorial takes these same locations in a different direction.

Get the code

Here's the complete, working script for this tutorial. It runs exactly as shown.

Download the finished script (31_draw_a_player_heatmap_from_event_data.py)

This script imports a small shared helper (and reads any bundled sample data) that live next to it in /downloads/ — grab these into the same folder so it runs as-is: sdt_common.py.

More Soccer tutorials

A team's completed passes drawn as arrows on a proper pitch with mplsoccer.
Soccer Intermediate

Draw a Pass Map with mplsoccer

Filter a match's passes from StatsBomb event data and draw them as arrows on a correctly-proportioned pitch using mplsoccer, with StatsBomb attribution.

~7 min