In NBA stats pace is the number of possessions a team gets per 48 minutes. It is used as a measure of how fast a team plays. While it is generally a good measure, it is flawed in a few ways. The first reason is that it doesn’t separate offensive pace and defensive pace. When pace is brought up, often people are talking about the pace of a team’s offense. A team that plays fast on offense and forces their opponents to play slow on defense may not have a fast pace by the traditional definition. The second is that getting offense rebounds slows down your pace. A team that generates quick shots but gets a lot of offensive rebounds may have a slower pace number than a team that is slower to get their first shot on a possession but doesn’t offensive rebound as often. Using python and the possession data I shared here I’m going to calculate the average time of possession excluding second chance time on offense and defense and compare those to the pace numbers.

import pandas as pd
possessions = pd.read_csv('possession_details_00217.csv')

We have the start time, end time and second chance time in the table already. Let’s create a new column for possession length, excluding second chance time, by subtracting the end time and second chance time from the start time. We will call this column “first_chance_time”.

possessions['first_chance_time'] = possessions['StartTime'] - possessions['EndTime'] - possessions['SecondChanceTime']

The table has data on all possessions, including end of period possessions where a team has no real chance to score with a last second heave. As I noted here, I count possessions by including the “OffPoss” or “DefPoss” key in the “PlayerStats” column. Those end of period possessions where a team has no real chance to score won’t have the “OffPoss” key, so if we keep only the rows with the “OffPoss” key we can remove those possessions. Note that when we read the csv the type on the “PlayerStats” column is a string. If you want to sum up stats in this column you will need to convert it to a dictionary. For our purposes though, we can just check if the string contains the “OffPoss” string.

counted_possessions = possessions[possessions.PlayerStats.str.contains('OffPoss')]

Now we can use the groupby function to calculate the mean of the “first_chance_time” column for each team on offense.

first_chance_time_by_team = counted_possessions[['OffenseTeamId', 'first_chance_time']].groupby('OffenseTeamId').mean()

Let’s take a look at the results.

first_chance_time_by_team
first_chance_time
OffenseTeamId
1610612737 14.302262
1610612738 14.614438
1610612739 14.161539
1610612740 13.371514
1610612741 13.755922
1610612742 15.117969
1610612743 14.296213
1610612744 13.349889
1610612745 14.018915
1610612746 13.876505
1610612747 13.315304
1610612748 14.780122
1610612749 13.994308
1610612750 14.925630
1610612751 14.375985
1610612752 14.571052
1610612753 14.222292
1610612754 14.358370
1610612755 13.513031
1610612756 13.847236
1610612757 14.813750
1610612758 14.945469
1610612759 14.961810
1610612760 13.656384
1610612761 14.115663
1610612762 14.796022
1610612763 14.996125
1610612764 14.424539
1610612765 14.491313
1610612766 14.263511

This gives us the average time for each team but with just the team id, it’s not very useful. I have a map of team id to team abbreviation that we can use to merge with the above table.

team_id_abbreviation_map = {
    1610612737: 'ATL',
    1610612738: 'BOS',
    1610612739: 'CLE',
    1610612740: 'NOP',
    1610612741: 'CHI',
    1610612742: 'DAL',
    1610612743: 'DEN',
    1610612744: 'GSW',
    1610612745: 'HOU',
    1610612746: 'LAC',
    1610612747: 'LAL',
    1610612748: 'MIA',
    1610612749: 'MIL',
    1610612750: 'MIN',
    1610612751: 'BKN',
    1610612752: 'NYK',
    1610612753: 'ORL',
    1610612754: 'IND',
    1610612755: 'PHI',
    1610612756: 'PHX',
    1610612757: 'POR',
    1610612758: 'SAC',
    1610612759: 'SAS',
    1610612760: 'OKC',
    1610612761: 'TOR',
    1610612762: 'UTA',
    1610612763: 'MEM',
    1610612764: 'WAS',
    1610612765: 'DET',
    1610612766: 'CHA',
}

teams = pd.DataFrame.from_dict(team_id_abbreviation_map, orient='index')
teams.columns = ['Team']

first_chance_time_by_team = first_chance_time_by_team.merge(teams, left_index=True, right_index=True)

Now we can sort the results.

first_chance_time_by_team.sort_values(by=['first_chance_time'])
first_chance_time Team
OffenseTeamId
1610612747 13.315304 LAL
1610612744 13.349889 GSW
1610612740 13.371514 NOP
1610612755 13.513031 PHI
1610612760 13.656384 OKC
1610612741 13.755922 CHI
1610612756 13.847236 PHX
1610612746 13.876505 LAC
1610612749 13.994308 MIL
1610612745 14.018915 HOU
1610612761 14.115663 TOR
1610612739 14.161539 CLE
1610612753 14.222292 ORL
1610612766 14.263511 CHA
1610612743 14.296213 DEN
1610612737 14.302262 ATL
1610612754 14.358370 IND
1610612751 14.375985 BKN
1610612764 14.424539 WAS
1610612765 14.491313 DET
1610612752 14.571052 NYK
1610612738 14.614438 BOS
1610612748 14.780122 MIA
1610612762 14.796022 UTA
1610612757 14.813750 POR
1610612750 14.925630 MIN
1610612758 14.945469 SAC
1610612759 14.961810 SAS
1610612763 14.996125 MEM
1610612742 15.117969 DAL

So the Lakers, Warriors and Pelicans are the fastest teams, and the Spurs, Grizzlies and Mavericks are the slowest. We can perform the same exercise for the team on defense.

opponent_first_chance_time_by_team = counted_possessions[['DefenseTeamId', 'first_chance_time']].groupby('DefenseTeamId').mean()
opponent_first_chance_time_by_team = opponent_first_chance_time_by_team.merge(teams, left_index=True, right_index=True)
opponent_first_chance_time_by_team.sort_values(by=['first_chance_time'])
first_chance_time Team
DefenseTeamId
1610612751 13.731740 BKN
1610612756 13.873823 PHX
1610612737 13.972635 ATL
1610612757 13.982557 POR
1610612750 13.989717 MIN
1610612742 14.030748 DAL
1610612752 14.048389 NYK
1610612753 14.060700 ORL
1610612766 14.145543 CHA
1610612759 14.177091 SAS
1610612746 14.195889 LAC
1610612739 14.203967 CLE
1610612763 14.213172 MEM
1610612755 14.223131 PHI
1610612764 14.224541 WAS
1610612748 14.233789 MIA
1610612762 14.250223 UTA
1610612740 14.269125 NOP
1610612758 14.306128 SAC
1610612743 14.309011 DEN
1610612738 14.327065 BOS
1610612747 14.346368 LAL
1610612765 14.382196 DET
1610612761 14.404660 TOR
1610612754 14.464885 IND
1610612745 14.528096 HOU
1610612744 14.592067 GSW
1610612741 14.603764 CHI
1610612749 14.932726 MIL
1610612760 14.991538 OKC

OKC really illustrates the issues when you just look at pace. If you go by pace, they are a mid-pack team that plays at a slightly slower than average pace. You may wonder why a team with a one man fast break like Russell Westbrook isn’t playing at a faster pace. We can dig deeper and see why that may not actually be the case. When you split up offense and defense and exclude second chance time, they average 13.7 seconds per possession on offense, 5th fastest in the league, while their opponents average 15 seconds per possession, the slowest in the league. So they play fast on offense and force opponents to play slow on defense. They also lead the league in offensive rebounding rate, so all those second chances will slow down their pace numbers. This leads to them having a below average number for pace despite playing pretty fast on offense.

Pace is fine as a general measure, but when used to describe how fast a team plays on offense it can lead to some incorrect conclusions. Digging deeper by splitting up offense and defense can give us a better idea of the speed at which a team plays.