Turn a grouped result into a bar chart — as the last step of the chain, not as a separate paragraph of code.
Why does a bar chart so often end up as three extra lines at the bottom of an otherwise clean method chain?
It does not have to. px.bar is a function, not a method, so the obvious way to call it makes you stop the chain, name the intermediate result, and start again. Pandas already solved that: pipe hands a data frame to any function as its first argument and passes your keyword arguments straight through.
Both forms produce the same figure. I use the piped one almost exclusively: of the 62 px.bar calls across the Bamboo Weekly solutions, 56 go through pipe. A chart that breaks the chain reads as foreign in my code.
px.bar(df, x='category', y='value') # the signature in the docs
( # the way I write it
df
.groupby('category')['value'].sum()
.reset_index()
.pipe(px.bar, x='category', y='value', title='…')
)
Official documentation: plotly.express.bar
The arguments that earn their keep
df.pipe(px.bar,
x='category', y='value', # which column goes on which axis
color='series', # one color per value; stacks by default
barmode='group', # ... unless you ask for side by side
orientation='h', # bars run left to right
text_auto=',d', # print the value on the bar
category_orders={'category': [...]}, # fix the axis order
labels={'value': 'Votes cast'}, # rename axes and legend
title='…')
Two of these do most of the work. color= is how one bar becomes several, and orientation='h' is how a chart with long labels becomes readable. You will often find orientation= unnecessary, because Plotly infers it: hand it a numeric x and a string y and the bars come out horizontal without your asking.
text_auto=True prints the raw value on each bar; give it a format string instead — ',d' for thousands separators, '.1f' for one decimal, '.2s' for SI prefixes — and the chart is readable without hovering.
A worked example, on real data
Bamboo Weekly #137 used the UN's own record of Security Council voting: one row per member state per resolution, back to 1946.
import pandas as pd
from plotly import express as px
url = 'https://www.bambooweekly.com/content/files/2025/09/2025_7_21_sc_voting.csv'
df = pd.read_csv(url,
usecols=['ms_name', 'ms_vote', 'date', 'resolution', 'total_yes'],
parse_dates=['date'])
That is 40,929 rows. The ms_vote column holds four codes, and the shape of the institution is visible in the counts:
df['ms_vote'].value_counts()
ms_vote
Y 38642
X 1259
A 971
N 57
Name: count, dtype: int64
Yes, abstain, not voting, no. Almost everything the Council adopts, it adopts unanimously.
Who has cast the most votes? Member state names here run to things like "VENEZUELA (BOLIVARIAN REPUBLIC OF)", so this is a horizontal chart from the beginning.
(
df['ms_name'].value_counts()
.head(10)
.sort_values()
.reset_index()
.pipe(px.bar,
x='count', y='ms_name',
text_auto=',d',
labels={'count': 'Votes cast', 'ms_name': ''},
title='Who votes most often on the Security Council?')
)
ms_name count
0 PAKISTAN 523
1 ARGENTINA 687
2 USSR 725
3 BRAZIL 812
4 JAPAN 970
5 RUSSIAN FEDERATION 2062
6 CHINA 2787
7 FRANCE 2787
8 UNITED KINGDOM 2787
9 UNITED STATES 2787
Four bars of exactly 2,787 at the top, then Russia at 2,062 with the USSR's 725 further down as a separate country — the permanent five, and the moment at the end of 1991 when one of them changed its name. The .sort_values() is doing real work: horizontal bars build from the bottom up, so sorting ascending puts the biggest bar at the top, where a reader's eye lands.
Now a second dimension. color= splits each bar by a column, and the data has to be in long form — one row per bar segment:
p5 = ['CHINA', 'FRANCE', 'RUSSIAN FEDERATION',
'UNITED KINGDOM', 'UNITED STATES']
(
df
.loc[df['ms_name'].isin(p5) & df['ms_vote'].isin(['A', 'X'])]
.groupby(['ms_name', 'ms_vote']).size()
.reset_index(name='votes')
.pipe(px.bar, x='ms_name', y='votes', color='ms_vote',
barmode='group',
labels={'ms_name': '', 'votes': 'Votes', 'ms_vote': 'Vote'},
title='Abstentions (A) and non-participation (X)')
)
ms_name ms_vote votes
0 CHINA A 131
1 CHINA X 145
2 FRANCE A 59
3 FRANCE X 81
4 RUSSIAN FEDERATION A 140
5 RUSSIAN FEDERATION X 52
6 UNITED KINGDOM A 60
7 UNITED KINGDOM X 80
8 UNITED STATES A 96
9 UNITED STATES X 79
Ten rows, two colors, five pairs of bars. Russia abstains far more often than it skips a vote; China does the opposite. Drop barmode='group' and the same ten rows stack instead, answering a different question — how often each country declined to vote yes, by any route.
There is a shortcut, and it is the form both #136 and #137 use. Skip reset_index, hand px.bar a wide frame or a bare series, and Plotly reads the index as the x axis and each column as a series:
(
df
.loc[df['ms_name'].isin(p5) & df['ms_vote'].isin(['A', 'X'])]
.groupby(['ms_name', 'ms_vote']).size()
.unstack('ms_vote')
.pipe(px.bar, barmode='group')
)
ms_vote A X
ms_name
CHINA 131 145
FRANCE 59 81
RUSSIAN FEDERATION 140 52
UNITED KINGDOM 60 80
UNITED STATES 96 79
Same chart, no column names to type. The cost is that Plotly names the axes from the index and columns — ms_name, value, and a legend headed ms_vote — so you will usually want labels= anyway.
Four mistakes people make
Bars come out in data order, not sorted order. px.bar plots categories in the order it first meets them: alphabetical after a groupby, and whatever the file contained after a pivot_table(sort=False). Two fixes exist and they are not equivalent. .sort_values('votes') reorders the rows, and Plotly follows. category_orders={'ms_name': [...]} leaves the rows alone and writes the order onto the axis, which is what you want when several charts must share an order. Neither works if Plotly decides the axis is a number line: group abstentions by year, sort descending, and the bars still come out 1946, 1947, 1948, because the resolved axis type is linear and each bar lands at its own value. Cast that column to string and the type becomes category, at which point your sort is honored.
Expecting side-by-side bars, and getting stacked ones. This is the one that catches everybody, and it catches you two different ways. The obvious route is color=: split a chart by a second column and the segments pile on top of each other rather than standing next to each other. The less obvious route needs no arguments at all — hand px.bar a data frame with more than one numeric column and it treats every column as a series to stack:
wide = pd.DataFrame({'2023': [10, 8], '2024': [14, 12]},
index=['France', 'Japan'])
px.bar(wide) # two bars, each split into two segments — not four bars
The default barmode is 'relative', not 'group' and not even 'stack'. Relative stacks, and where values go negative it puts the positives above the axis and the negatives below, which is genuinely useful and nothing like what most people picture when they write the call. Stacking is the right default often enough to defend — it keeps the total readable — but if what you wanted was to compare 2023 against 2024 rather than add them together, the chart is quietly answering a different question.
Say barmode='group' and you get bars side by side. There is no way to get it by accident.
Long category labels on a vertical chart. Ten member-state names on the x axis render at an angle, overlapping and squeezing the plotting area into a strip. No font size fixes it; orientation='h' does. Worth checking when a chart looks truly bizarre: orientation='h' while still sending the category to x= and the number to y= raises no error. It draws the country names along the bottom, the counts up the side as categories, and one meaningless band across the middle.
Handing px.bar raw rows with duplicate x values. This is the dangerous one, because nothing warns you. Plotly does not aggregate, but it does stack, so every row sharing an x piles onto the same bar and you get a sum you never asked for:
px.bar(df.loc[df['ms_name'].isin(p5)], x='ms_name', y='total_yes')
Thirteen thousand rows go in. The figure comes back with a single trace holding 13,210 points and five distinct x values, and renders as five perfectly ordinary-looking bars — four reading 38,642 and one reading 29,656. Those are not vote counts, and they are not the average size of a winning majority, which is 13.87. They are total_yes summed over every row, a quantity with no meaning at all. The only visual tell is that the bars look faintly striped: they are thirteen thousand hairline segments stacked on one another.
Aggregate first, then plot. If you want Plotly to do the counting, px.histogram is the function that means it, and it takes histfunc='avg' when a sum is not what you want.
Plotly or Pandas?
Pandas draws this chart itself, with DataFrame.plot.bar, and for two years it was what I reached for: .plot.bar() appears 51 times across 30 Bamboo Weekly solutions, px.bar 62 times across 28. What makes those numbers interesting is that they barely overlap — the archive's last .plot.bar() ran on August 14, 2025, and its first px.bar a week later. That was a switch, not an experiment.
The honest split: .plot.bar() is hard to beat for a chart you will look at once and throw away, because it is a method that needs no pipe and takes the index as the x axis with no reset_index. px.bar earns its extra keystrokes the moment the chart has a reader other than you — hovering gives exact values, the legend toggles series on and off, and text_auto and labels cover in arguments what matplotlib makes you reach into an Axes object to do.
Where it shows up in Bamboo Weekly
#137: UN Security Council is the source of the data above, and the clearest example of the piped form: after a groupby on the index, the whole chart is .pipe(lambda s_: px.bar(s_)) on a series, plotting Council resolutions per year. The jump after 1990 is the finding.
#166: Income tax has the archive's most heavily argued px.bar call, over OECD data on income tax as a share of GDP: orientation='h', a color= boolean painting one country crimson against everyone else in steelblue, plus color_discrete_map, labels, title and height. It also has the sorting bug in its natural habitat — the highlighted country came out at the top rather than in rank order, because the axis order was taken from the categorical column instead of the values.
#136: Indian vehicles is the wide-form shortcut at scale: a pivot_table with years as the index and 74 vehicle classes as columns, piped straight into px.bar with no arguments at all. One of those 74 stacked series — motorcycles and scooters — dwarfs the rest.
#184: Parmesan cheese is the stacking mistake avoided in one argument. Decade-over-decade temperature change for Parma, Reggio Emilia and Reggio Calabria comes out of .resample('10YE').mean().diff(), so some values are negative, and the chart is .pipe(px.bar, barmode='group') — because stacking would have added three cities' warming into a number nobody wants.
All four show their px.bar code above the paywall, so you can read the calls without a subscription — and they are a sample, not the list: px.bar runs through 28 solutions in all.
Practice it
Work through a px.bar() exercise, with instant feedback and no signup required: practice.lernerpython.com/bamboo-weekly/plotly-bar/
Go deeper
Because px.bar will not aggregate for you, the pages worth reading next are the ones that get your data into shape: groupby for one row per category, value_counts for the counting special case, unstack for the wide form, and pipe for the chaining trick this page rests on. Plotly's guides to bar charts and wide-form data fill in the rest.
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
.plot()— for the built-in Pandas plotting that older solutions usepx.line()— when the x axis is time or another continuous runpx.choropleth()— when the categories are places and the shape of the map carries meaning
Part of the Pandas Methods Index. See also practice by skill.