Draw a Pass Map with mplsoccer

SoccerIntermediatePython~7 min read

What you'll build

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

A team's completed passes drawn as arrows on a proper pitch with mplsoccer.
Data: StatsBomb Open Data, drawn with mplsoccer, retrieved June 2026

A pass map is one of the most recognizable images in football analytics: a pitch covered in arrows, each one a pass, the whole picture revealing how a team moved the ball. We're building one from scratch — the completed passes of France in the 2018 World Cup final, real StatsBomb event data, drawn as arrows on a correctly-proportioned pitch with the mplsoccer library. The result looks professional. The code is shorter than you'd guess.

This follows directly from your first StatsBomb pull. We're loading the exact same match the same way - so if you worked through that one, the first few lines will be old friends. The new ideas here are filtering for completed passes, pulling coordinates out of list-shaped columns, and letting mplsoccer handle the geometry of the pitch.

Attribution, same as always: this chart is built from StatsBomb Open Data, whose free license requires credit on anything you publish. We'll add it to the figure automatically, and I'll point it out at the finish.

  1. Install mplsoccer and load the match

    mplsoccer is a small library built on top of matplotlib that knows how to draw football pitches - the right dimensions, the penalty boxes, the centre circle - so you never have to plot a single line yourself. Install it alongside statsbombpy:

    python
    pip install mplsoccer statsbombpy

    Now load the 2018 World Cup final exactly as before: find the Final among the tournament's matches, note which team was home (France), and pull every event from the game.

    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]
    TEAM = final["home_team"]
    events = sb.events(int(final["match_id"]))

    Storing the team name in a TEAM variable instead of hard-coding "France" is a small habit that pays off: change that one line later and the whole map redraws for a different side.

  2. Filter for one team's completed passes

    The events DataFrame holds everything - passes, shots, tackles, the lot - for both teams. We want only France's passes, and only the ones that found a team-mate. Here's the key insight that trips people up: in StatsBomb data, a successful pass has no pass_outcome - the column is blank (NaN). An outcome is only recorded when something goes wrong: Incomplete, Out, intercepted. So "completed" means "outcome is missing."

    python
    passes = events[(events["type"] == "Pass") & (events["team"] == TEAM)].copy()
    passes = passes[passes["pass_outcome"].isna()].dropna(subset=["location", "pass_end_location"])

    Read that second line carefully. .isna() keeps the rows where pass_outcome is blank - the completed ones. The .dropna() at the end discards any pass missing its start or end coordinates, which we're about to need. It feels backwards that absence of data marks a successful pass, but that's the convention, and forgetting it is the single most common mistake in pass maps.

  3. Pull the coordinates out of the list columns

    StatsBomb stores positions as little two-element lists: location is [x, y] where the pass began, and pass_end_location is [x, y] where it arrived. To draw an arrow we need those four numbers as their own columns. The .str[0] and .str[1] accessors reach into each list and pull out the first and second element - it works on lists, not just strings.

    python
    passes["x"] = passes["location"].str[0]
    passes["y"] = passes["location"].str[1]
    passes["ex"] = passes["pass_end_location"].str[0]
    passes["ey"] = passes["pass_end_location"].str[1]

    Now every pass has a clean start point (x, y) and end point (ex, ey). Let's confirm the filter worked and see who did the passing.

    python
    print(f"{TEAM} completed {len(passes)} passes in the final.")
    top = passes["player"].value_counts().head(5)
    print("\nMost passes:")
    print(top.to_string())
    France's completed passers
    France completed 202 passes in the final.
    
    Most passes:
    player
    Paul Pogba            29
    Lucas Hernández Pi    22
    Antoine Griezmann     18
    Raphaël Varane        18
    Blaise Matuidi        18

    France completed 202 passes, with Paul Pogba pulling the strings at 29 - the most of anyone, fitting for the midfielder who ran the final. Seeing a believable list like this, topped by exactly the player you'd expect, is your signal that the filtering is correct before you spend effort on the chart.

  4. Draw the pitch

    This is where mplsoccer earns its keep. We create a Pitch and tell it pitch_type="statsbomb" - that one argument lines the pitch coordinates up perfectly with StatsBomb's data, so a pass at x=60 lands at the halfway line where it should. Then pitch.draw() hands back a matplotlib figure and axes, already painted with all the markings.

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

    The pitch_type argument is doing all the heavy lifting. Without it you'd be calculating where the penalty box edges fall and whether your y-axis runs the right direction - mplsoccer knows the answer for every major data provider, StatsBomb included. You now have an empty, correctly-scaled pitch waiting for arrows.

  5. Add the passes as arrows

    The pitch object has an arrows() method made for exactly this. We feed it our four coordinate columns - start x and y, end x and y - and it draws an arrow for every pass in one vectorized call. We add small dots at each pass origin too, so you can read where moves began, and a title up top.

    python
    pitch.arrows(passes["x"], passes["y"], passes["ex"], passes["ey"], ax=ax,
                 color="#3A7D44", width=1.4, headwidth=4, headlength=4, alpha=0.65)
    pitch.scatter(passes["x"], passes["y"], ax=ax, s=14,
                  color="#C2B280", alpha=0.5, zorder=1)
    ax.set_title(f"{TEAM}: completed passes, 2018 World Cup final\n(attacking left to right)",
                 fontsize=13, color="#20242B")
    fig.savefig("pass_map.png", dpi=144, bbox_inches="tight")
    Pass map of France's completed passes in the 2018 World Cup final, arrows on a green pitch attacking left to right
    Data: StatsBomb Open Data, drawn with mplsoccer, retrieved June 2026

    The alpha=0.65 makes the arrows semi-transparent, so where France passed most often the overlapping arrows pool into darker, denser regions - an instant read on where their play was concentrated. And there's the attribution: because this is a published chart, the caption credits StatsBomb Open Data (drawn with mplsoccer). That credit is required by the license every time you share the image - make it a reflex.

Troubleshooting

My pass map is nearly empty or has far too few arrows

You probably filtered the wrong way on pass_outcome. Completed passes have a blank outcome, so you want .isna() - keep the rows where it's missing. If you accidentally kept rows where it's present, you've plotted only the failed passes, which are a small minority.

The arrows land in strange places or off the pitch

Almost always a pitch_type mismatch. The Pitch must be created with pitch_type="statsbomb" so its coordinate system matches the data. A default pitch uses different dimensions and your points will scatter wrongly.

TypeError when assigning the x/y columns

The .str[0] trick needs the column to actually hold lists. If a stray row has a missing location, the accessor returns NaN rather than erroring - but make sure you ran the .dropna(subset=["location", "pass_end_location"]) from step 2 first so every remaining row has real coordinates.

Challenge yourself

Change TEAM to final["away_team"] and redraw the map for Croatia - then put the two side by side and compare how the finalists moved the ball. For a harder version, colour each arrow by pass length: compute the distance from start to end, and use a longer or brighter arrow for the bigger switches of play. You'll start to see which players sprayed the ball across the pitch and which kept it short and safe - the same coordinates you pulled in step 3 are all you need.

Get the code

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

Download the finished script (14_draw_a_pass_map_with_mplsoccer.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

Both teams' shots on a pitch, sized by xG and marked for goals.
Soccer Intermediate

Build a Match Shot Map with Expected Goals

Plot every shot from a real match on a pitch with mplsoccer, sizing each by its expected-goals value and highlighting goals - the single most useful soccer viz there is.

~8 min