Find the largest value — and find where it is.
Which is the biggest? And where is it? Those look like one question, but Pandas treats them as two, and gives you a separate method for each. .max() returns the value. .idxmax() returns the index label of the row where that value lives. .min() and .idxmin() are the same pair, pointed downward.
I think that splitting them up is exactly why idxmax stays mysterious for so long. On its own it looks like an oddly named cousin of max. Sitting next to max, it is obvious: read the name as "index of the max," and you have it. Together they also get you what you usually wanted all along — not the number, not the label, but the whole winning row:
df.loc[df['column'].idxmax()]
That one line is worth the price of admission. .idxmax() hands you a label, .loc[] takes labels, and the two click together.
Official documentation: DataFrame.max and DataFrame.idxmax — with min and idxmin alongside them.
The arguments that earn their keep
All four methods take the same three:
df.max(axis='index', # down each column (default), or across each row
skipna=True, # ignore NaN, or let a single NaN swallow the answer
numeric_only=False) # consider every column, or only the numeric ones
df.idxmax(axis='index', skipna=True, numeric_only=False)
axis is the one people forget. The default runs down each column and gives you one answer per column. Pass axis='columns' and it runs across each row instead, so .idxmax(axis='columns') tells you, for every row, which column name holds its largest value. That is a genuinely different and very useful question.
skipna=True is the default and it is almost always what you want. Setting it to False says "a missing value means I cannot answer," and on .max() you get NaN back. On .idxmax() there is no NaN label to return, so it raises instead.
numeric_only=False is the default on a data frame, which means Pandas will happily take the maximum of your text columns too. That is the source of the first mistake below.
A worked example, on real data
Here is the Global Coal Plant Tracker, the workbook behind Bamboo Weekly #64. One row per generating unit, 13,906 of them:
import pandas as pd
url = ('https://www.bambooweekly.com/content/files/wp-content/uploads/2024/02/'
'global-coal-plant-tracker-january-2024.xlsx')
units = pd.read_excel(url, sheet_name='Units',
usecols=['Country', 'Region', 'Plant name',
'Status', 'Capacity (MW)', 'Start year'])
operating = units.loc[lambda df_: df_['Status'] == 'operating']
What is the largest coal-burning unit running anywhere in the world?
operating['Capacity (MW)'].max()
1350.0
A number, with no idea whose it is. That is what .idxmax() is for:
operating['Capacity (MW)'].idxmax()
2575
A label — here an integer, because the frame came in with a default index, but a label all the same. Feed it to .loc[] and the answer arrives in full:
operating.loc[operating['Capacity (MW)'].idxmax()]
Country China
Plant name Huaibei Pingshan power station
Capacity (MW) 1350.0
Status operating
Start year 2022.0
Region Asia
Name: 2575, dtype: object
Now let me aggregate to the country level, which gives me a frame whose index is made of names rather than numbers:
by_country = (
operating
.groupby('Country')['Capacity (MW)']
.agg(total='sum', largest_unit='max', units='count')
)
by_country.head()
total largest_unit units
Country
Argentina 495.0 375.0 2
Australia 22403.0 750.0 53
Bangladesh 4775.0 660.0 10
Bosnia and Herzegovina 2090.0 300.0 10
Botswana 732.0 150.0 8
.idxmax() on a data frame runs down every column and returns one label each:
by_country.idxmax()
total China
largest_unit China
units China
dtype: str
China three times over, which surprises nobody. The other end is more interesting — .idxmin() finds the country with the smallest operating fleet, and .loc[] shows it whole:
by_country.loc[by_country['total'].idxmin()]
total 64.0
largest_unit 32.0
units 2.0
Name: Guadeloupe, dtype: float64
Turn the frame sideways and axis='columns' starts earning its keep. Pivoting capacity by status gives one column per status, so asking which column is largest asks which phase of life each country's coal fleet is mostly in:
capacity = (
units
.loc[lambda df_: df_['Status'].isin(['operating', 'construction', 'retired'])]
.pivot_table(index='Country', columns='Status',
values='Capacity (MW)', aggfunc='sum')
)
capacity.idxmax(axis='columns').head(8)
Country
Argentina operating
Australia operating
Austria retired
Bangladesh operating
Belgium retired
Bosnia and Herzegovina operating
Botswana operating
Brazil operating
dtype: str
Austria and Belgium have retired more coal capacity than they still run. The same table read the default way, down the columns, names the champion of each status:
capacity.idxmax()
Status
construction China
operating China
retired United States
dtype: str
Finally, the pairing that matters most in daily work. groupby(...).max() gives you a table of numbers:
operating.groupby('Region')['Capacity (MW)'].max()
Region
Africa 800.0
Americas 1300.0
Asia 1350.0
Europe 1100.0
Oceania 750.0
Name: Capacity (MW), dtype: float64
Whereas groupby(...).idxmax() gives you a table of labels — one row label per group — which you then hand straight back to .loc[] to recover the winning rows themselves:
operating.loc[operating.groupby('Region')['Capacity (MW)'].idxmax(),
['Region', 'Country', 'Plant name', 'Capacity (MW)']]
Region Country Plant name Capacity (MW)
11523 Africa South Africa Kusile power station 800.0
12436 Americas United States Amos Plant 1300.0
2575 Asia China Huaibei Pingshan power station 1350.0
6922 Europe Germany Datteln power station 1100.0
63 Oceania Australia Kogan Creek power station 750.0
That is the biggest operating coal unit on each continent, named, in three lines. groupby().max() could never have told me the plant names, because by the time it has taken the maximum, the rows are gone.
Four mistakes people make
A label is not a position. This is the big one. .idxmax() returns an index label, and if your index is anything other than a plain RangeIndex, that label is not a row number. by_country is indexed by country name, so:
by_country.iloc[by_country['total'].idxmax()]
# TypeError: Cannot index by location index with a non-integer key
An error is the good outcome. The dangerous case is an index of integers that are not positions — years, ZIP codes, district numbers — where .iloc[] quietly returns the wrong row and nothing complains. Use .loc[] with .idxmax(), always. If you genuinely want a position, the method for that is .argmax(), which pairs with .iloc[] the way .idxmax() pairs with .loc[]. On operating['Capacity (MW)'] the two disagree completely — .argmax() says 1194 and .idxmax() says 2575 — and both are right, because filtering operating out of a larger frame broke the match between label and position.
.max() on text compares lexically, and does it silently. With numeric_only=False as the default, a data frame of mixed types answers anyway:
units.max()
Country Zimbabwe
Plant name Štavalj Power Station
Capacity (MW) 6300.0
Status shelved
Start year 2037.0
Region Oceania
dtype: object
Zimbabwe is not the largest country and "shelved" is not the largest status — those are just the strings that sort last, and "Štavalj" only wins because Š sorts after Z in Unicode. Nothing warns you. Pass numeric_only=True when you mean numbers.
Ties go to the first occurrence, quietly. .idxmax() returns one label, and if several rows share the maximum you will never know. The Americas result above credits the Amos Plant, but:
operating.loc[lambda df_: (df_['Region'] == 'Americas')
& (df_['Capacity (MW)'] == 1300.0),
['Country', 'Plant name', 'Start year']]
Country Plant name Start year
12436 United States Amos Plant 1973.0
12693 United States Cumberland Steam Plant 1973.0
12694 United States Cumberland Steam Plant 1973.0
12829 United States Gavin Power Plant 1974.0
12830 United States Gavin Power Plant 1975.0
13174 United States Mountaineer Plant 1980.0
13334 United States Rockport Plant 1984.0
13335 United States Rockport Plant 1989.0
Eight units across five plants, all tied at 1300 MW. If ties are plausible in your data, compare against .max() with a boolean mask and see how many rows come back.
All-NaN raises on idxmax but not on max. Nigeria's operating units have no start years recorded at all. .max() shrugs and returns NaN; .idxmax() cannot, because there is no label to point at:
operating.loc[lambda df_: df_['Country'] == 'Nigeria', 'Start year'].max()
# nan
operating.loc[lambda df_: df_['Country'] == 'Nigeria', 'Start year'].idxmax()
# ValueError: Encountered all NA values
That asymmetry bites hardest inside a groupby, where one bad group takes down the whole call. operating.groupby('Country')['Start year'].max() returns a complete table; operating.groupby('Country')['Start year'].idxmax() raises ValueError: idxmax with skipna=True encountered all NA values in a group. Drop the empty groups first, or use .dropna() before you group.
Where it shows up in Bamboo Weekly
#9: US house prices is the clearest demonstration of why idxmax exists. I first found the peak with df.loc[df['price'] == df['price'].max()], then replaced it with df['price'].idxmax() — shorter, and about four times faster when I timed it. The frame has a MultiIndex, so the label that comes back is the tuple (Timestamp('2022-07-01 00:00:00'), 'NY'), which makes the point better than any explanation: idxmax returns whatever your index labels happen to be.
#34: House of Representatives uses both halves. groupby('state')['district'].max() finds how many districts each state has, and later a two-level groupby(...).idxmax() finds each state's majority party, with .str.get(1) pulling the party out of the tuple label.
#54: Household debt is the axis='columns' case. After averaging six categories of delinquent loans by year, .idxmax(axis='columns') names the largest category in each year, and .value_counts() on the result shows credit cards leading 14 years to student loans' 7.
#29: Auto accidents chains .pct_change().idxmax() over a pivot table of road deaths, so each column returns the year in which that country's fatalities jumped most. Counting those labels puts 2021 well out in front.
Practice it
Work through a max exercise, with instant feedback and no signup required: practice.lernerpython.com/bamboo-weekly/max/
Go deeper
If you want the top few rather than the top one, that is nlargest and its twin nsmallest, which beat sorting the whole frame with sort_values. If you want several of these answers at once, agg takes them by name — .agg(['mean', 'min', 'idxmin', 'max', 'idxmax']) gives you the extremes and their locations in a single pass. And since every idxmax ends in a lookup, loc is the method to be fluent in before this one.
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.
Related methods
.nlargest()— when you want the top few rather than the single largest.mean()— when you want the average rather than the largest or smallest.idxmax()— when you want to know where the largest value is, not what it is
See it on real data
Below are the 23 Bamboo Weekly exercises that use max on real-world data — try each one, then study the worked solution.
- Bamboo Weekly #183: Hiring
- Bamboo Weekly #177: European Summer
- Bamboo Weekly #165: Artemis II
- Bamboo Weekly #161: Missiles in Israel
- Bamboo Weekly #157: Government corruption
- Bamboo Weekly #156: Winter Olympics
- Bamboo Weekly #145: Economic indicators
- Bamboo Weekly #142: Hurricanes
- Bamboo Weekly #111: State taxes
- Bamboo Weekly #102: WordPress
- Bamboo Weekly #90: Voter participation
- Bamboo Weekly #85: PACs and parties
- Bamboo Weekly #84: Central banks
- Bamboo Weekly #65: Microplastics
- Bamboo Weekly #56: Rent increases
- Bamboo Weekly #39: WeWork
- Bamboo Weekly #34: House of Representatives
- Bamboo Weekly #29: Auto accidents
- Bamboo Weekly #26: Hot weather
- Bamboo Weekly #19: Working women
- Bamboo Weekly #16: Consumer oil prices
- Bamboo Weekly #9: US house prices
- Bamboo Weekly #7: Bank failures
Part of the Pandas Methods Index. See also practice by skill.