Skip to content

pandas sum

Totals down a column, across a row and per group — plus the one answer sum gives that no other reducer will.

What is the sum of nothing at all?

That reads like a riddle, and it is also the single most consequential fact about this method. Ask .mean(), .median() or .std() for a summary of an empty column and each of them shrugs: there is no average of no numbers, so you get NaN. .sum() does not shrug. The sum of no numbers is zero — a real mathematical fact, not a fudge, the same reason Python's sum([]) is 0 — and so .sum() answers 0.0, confidently, in precisely the situation where every one of its siblings admits it has nothing to say.

Filter a column down to no rows at all — storms here is the hurricane data loaded further down, and no storm has a season in the year 3000 — and watch the two methods part company:

gone = storms.loc[storms['SEASON'] > 3000, 'USA_WIND']

len(gone)      # 0
gone.sum()     # 0.0
gone.mean()    # nan

That is what makes sum the friendliest of the reducers and the most dangerous. Friendliest, because a total never comes back as a hole you have to handle. Most dangerous, because 0.0 is also a perfectly ordinary measurement, and once a genuine absence has been rendered as a zero, nothing downstream can tell the two apart.

Official documentation: DataFrame.sum and Series.sum. The arguments sum shares with the rest of its family — axis, skipna, numeric_only — are covered in mean, sum, median, std, min and max, which is where to start if you want the family. This page is about the parts that are sum and nothing else.

The arguments that earn their keep

df.sum(axis='index',        # down each column (default), or across each row
       skipna=True,
       numeric_only=False,
       min_count=0)         # values required before you get a number at all

min_count is sum's own, and it is the most useful argument almost nobody reaches for. It sets a floor: how many real values have to go into a total before Pandas is willing to give you one. Leave it at 0 and you always get a number, which is the behavior described above. Set min_count=1 and Pandas returns NaN whenever there was nothing to add. That one keyword converts a silently-fabricated zero back into an honest gap, and it works everywhere sum does — on a series, on a data frame, on a groupby, along either axis.

axis deserves a second mention here even though it belongs to the whole family, because row-wise totals are far more common for sum than for mean. Wherever your columns are pieces of one thing — quadrants of a storm, agencies rating the same bond, categories that partition a whole — .sum(axis='columns') is the question you actually want to ask.

A worked example, on real data

Here is IBTrACS, the tropical cyclone archive behind Bamboo Weekly #142: every North Atlantic storm from 1851 to 2025, one row per observation along each storm's track.

import pandas as pd

url = ('https://www.bambooweekly.com/content/files/2025/10/'
       'bw-142-ibtracs.csv')

storms = pd.read_csv(url, skiprows=[1], skipinitialspace=True,
                     parse_dates=['ISO_TIME'],
                     usecols=['SID', 'SEASON', 'NAME', 'ISO_TIME', 'NATURE',
                              'WMO_WIND', 'USA_WIND', 'TOKYO_WIND'])

storms.shape
(127619, 8)

Wind speed is a rate, and adding rates together gets you nothing. But meteorology has a quantity built for exactly this method: accumulated cyclone energy, which squares the wind speed at every six-hourly observation of a tropical system blowing at 34 knots or more, divides by ten thousand, and adds the results up. ACE is defined as a sum. It is the number that decides whether a season gets called "hyperactive."

ace = (
    storms
    .loc[lambda df_: (df_['NATURE'] == 'TS') &
                     (df_['USA_WIND'] >= 34) &
                     (df_['ISO_TIME'].dt.hour % 6 == 0) &
                     (df_['ISO_TIME'].dt.minute == 0)]
    .assign(ace=lambda df_: df_['USA_WIND'] ** 2 / 10_000)
)

ace['ace'].sum()
15653.900000000001

Hold on to that trailing 1; it comes back in the last mistake below. Now group it, which is where sum does most of its work:

ace.groupby('SEASON')['ace'].sum().nlargest(8)
SEASON
1933    258.5700
2005    247.9750
1926    229.5575
1893    227.3875
1995    227.3825
2017    224.8775
2004    224.7475
1950    211.2825
Name: ace, dtype: float64

1933 beats 2005, which surprises people who remember 2005 for Katrina, Rita and Wilma. Counting the storms in a season and adding up their energy are different questions, and they do not put the same year on top.

Because sum also gives you the denominator, a share of the whole is one more line. This is the pattern I use most often after a grouped total:

(
    ace
    .groupby('SEASON')['ace'].sum()
    .pipe(lambda s_: s_ / s_.sum() * 100)
    .nlargest(5)
    .round(2)
)
SEASON
1933    1.65
2005    1.58
1926    1.47
1893    1.45
1995    1.45
Name: ace, dtype: float64

There are 175 seasons in the file, so an even share would be 0.57 percent each. 1933 took nearly three times that. Percentages of a total are the reason to reach for sum twice in one expression, and pipe is what lets the second call see the result of the first without breaking the chain.

.cumsum() is the running-total sibling: same addition, but it reports every partial answer along the way instead of only the last one. This is how a hurricane season is actually followed, storm by storm:

(
    ace
    .loc[lambda df_: df_['SEASON'] == 2005]
    .groupby('NAME', sort=False)['ace'].sum()
    .cumsum()
    .round(1)
    .head(12)
)
NAME
ARLENE        2.6
BRET          2.9
CINDY         4.4
DENNIS       23.2
EMILY        56.1
FRANKLIN     62.8
GERT         63.3
HARVEY       68.7
IRENE        81.8
JOSE         82.3
KATRINA     102.3
LEE         102.5
Name: ace, dtype: float64

Dennis and Emily, in July, contributed more energy between them than Katrina did. Note sort=False on the groupby, which keeps the storms in the order they appeared rather than alphabetizing them — a running total in alphabetical order is not a running total of anything.

Finally, the trick that makes sum the counting method as well as the totaling one. Adding booleans adds ones and zeros, so summing a mask counts the True values, and doing it across the row counts them per row:

(
    storms[['WMO_WIND', 'USA_WIND', 'TOKYO_WIND']]
    .notna()
    .sum(axis='columns')
    .value_counts()
    .sort_index()
)
0    19132
1    53037
2    55450
Name: count, dtype: int64

Three agency columns, and no observation ever has three wind figures — 19,132 have none at all. That is sum answering a question about presence rather than magnitude, and the zero at the top is the honest kind.

Four mistakes people make

A column that is entirely missing totals 0.0. This is the one to internalize. TOKYO_WIND is in this file because IBTrACS uses a single schema worldwide, and the Japan Meteorological Agency does not track Atlantic hurricanes. All 127,619 values are missing:

storms['TOKYO_WIND'].mean()             # nan
storms['TOKYO_WIND'].sum()              # 0.0
storms['TOKYO_WIND'].sum(min_count=1)   # nan

mean says "I have nothing to tell you." sum says zero. In a grouped total it is worse, because the fabricated zero sits in a column of real numbers where nothing marks it out:

storms.groupby('NATURE')['USA_WIND'].sum()
NATURE
DS     117611.0
ET     520775.0
MX      31581.0
NR          0.0
SS      82954.0
TS    4974333.0
Name: USA_WIND, dtype: float64

NR is "not reported" — 170 observations, not one of which carries a wind figure. Read down that column and NR looks like a category of storms with no wind. Add the floor and the row tells the truth:

storms.groupby('NATURE')['USA_WIND'].sum(min_count=1)
NATURE
DS     117611.0
ET     520775.0
MX      31581.0
NR          NaN
SS      82954.0
TS    4974333.0
Name: USA_WIND, dtype: float64

The rule I use: if a zero in this column would mean something real, pass min_count=1. Money, counts and weights all qualify. It costs one keyword and it is the difference between "we measured nothing" and "we measured zero."

Summing a text column concatenates it. .sum() on strings does what + does on strings, and does it without a murmur. storms['NAME'].sum() returns a single string of 810,001 characters that opens UNNAMEDUNNAMEDUNNAMED. Its sibling refuses outright:

storms['NAME'].mean()
TypeError: Cannot perform reduction 'mean' with string dtype

That asymmetry is the whole point. mean protects you and sum does not, which makes sum the more dangerous of the two on a data frame you have not inspected:

storms.drop(columns='ISO_TIME').sum()
SID           1851175N262701851175N262701851175N262701851175...
SEASON                                                248224209
NAME          UNNAMEDUNNAMEDUNNAMEDUNNAMEDUNNAMEDUNNAMEDUNNA...
NATURE        TSTSTSTSTSTSTSTSTSTSTSTSTSTSTSTSTSTSTSTSTSTSTS...
WMO_WIND                                              2915940.0
USA_WIND                                              5727254.0
TOKYO_WIND                                                  0.0
dtype: object

The dtype is object, because a series cannot be part text and part number, and that alone should stop you. Pass numeric_only=True and the strings go away — but SEASON is still there, adding up years, and TOKYO_WIND still reports 0.0. Selecting the columns you meant beats filtering by type.

Summing a rate. WMO_WIND sums to 2,915,940 in that output. Pandas is not wrong; 2,915,940 really is the total of those 55,458 wind readings. It is simply not a quantity. Speeds, temperatures, prices per unit, rates per capita and percentages of different populations are all like this: arithmetic accepts them, meaning does not. The test is whether the parts belong to a single whole. Proportions from one value_counts(normalize=True) do, and summing them is exactly right — that is what Bamboo Weekly #46 does below. Two wind speeds measured three hours apart do not, and no keyword argument will tell you which case you are in.

Floating-point drift, and the reconciliation that fails because of it. Go back to that trailing 1. Add the same 35,018 numbers three different ways and you get three different answers:

total     = ace['ace'].sum()
by_decade = ace.groupby(ace['SEASON'] // 10 * 10)['ace'].sum().sum()
running   = ace['ace'].cumsum().iloc[-1]

print(total)
print(by_decade)
print(running)
15653.900000000001
15653.9
15653.899999998028
print(total == by_decade, total == running)
False False

Nothing here is a bug. .sum() adds in pairs, which keeps the error small; .cumsum() has to add strictly left to right, so 35,018 roundings accumulate and the running total lands about two billionths away from the total it should equal. Every one of these figures agrees with the others far past any precision you would ever report, and no two of them are equal.

This surfaces the moment you check your work — grand total against the sum of your subtotals, this quarter's report against last quarter's rebuild, your figure against the source's published one. An == between two floating-point totals is a test you will eventually fail for no reason. Compare rounded values instead, at whatever precision you actually report:

print(round(total, 2) == round(by_decade, 2) == round(running, 2))
True

For exact money, keep integer cents, or a Decimal column, and do not let it become a float in the first place.

Where it shows up in Bamboo Weekly

Seventy-one Bamboo Weekly exercises use sum on real data, 308 calls in all. Four worth studying, all free to read:

Bamboo Weekly #40: Sovereign bonds is the boolean row-wise sum at its most elegant. Three columns hold each country's rating from S&P, Moody's and Fitch, and ratings_df.isin(['AAA', 'Aaa']).sum(axis='columns') collapses them into a count of top-grade ratings per country. Keep the rows equal to 3 and you have the eight countries every agency rates AAA. The United States scores 1.

Bamboo Weekly #5: Ukrainian exports is the share-of-total pattern in its plainest form: df.groupby('Income group')['Tonnage'].sum() / df['Tonnage'].sum(). A grouped sum over a grand sum, and the result is a set of proportions — 46.6 percent of Ukraine's exports going to high-income countries against 2.7 percent to low-income ones, which is not what the reporting at the time led anyone to expect.

Bamboo Weekly #46: Pedestrians is the case where summing percentages is the correct move. It runs value_counts(normalize=True) on the pedestrian count per accident, unstacks by year, drops the zero-pedestrian column and sums what remains across the row. Those proportions all come from one whole, so they add: 18.3 percent of accidents involved a pedestrian in 2012, rising to 21.7 percent by 2021.

Bamboo Weekly #64: Coal power uses sum twice over, for two different purposes. df.memory_usage(deep=True).sum() totals a series that has one entry per column — 4,301,719 bytes before the columns become categories — and then a grouped sum of annual CO2 by country, sorted and ranked, answers the actual question.

Practice it

Work through a sum exercise, with instant feedback and no signup required: practice.lernerpython.com/bamboo-weekly/sum/

Go deeper

groupby is where most totals get computed, and agg is how you ask for a sum alongside a count so you can see how many values went into it — the manual version of what min_count automates. pivot_table arranges the same grouped sums as a grid, and defaults to mean, so pass aggfunc='sum' when you want totals. For totals over time, resample is the grouped sum with dates doing the grouping. count is the honest way to find out whether a column had anything in it before you totaled it, and round is what stands between you and the floating-point comparison above. The rest of the reducing family lives on mean, sum, median, std, min and max.

More Pandas videos on Python and Pandas with Reuven Lerner.

Bamboo Weekly is the practice. If you want the structured version — full courses with downloadable Jupyter notebooks, plus live Pandas office hours when you get stuck — that is LernerPython+Data. A paid Bamboo Weekly subscription is included with it.

See it on real data

Below are the 71 Bamboo Weekly exercises that use sum on real-world data — try each one, then study the worked solution.

Part of the Pandas Methods Index. See also practice by skill.