Skip to content

plotly express bar

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.

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