Skip to content

plotly scatter

Two columns of numbers, one dot per row — and a tooltip that finally tells you which row you are looking at.

What px.scatter actually does

How many times have you stared at a scatterplot, spotted the one dot way out on its own, and had no way at all to find out which row it was?

That question is most of the reason Bamboo Weekly moved to Plotly. Every solution I have written in 2026 uses Plotly Express; the changeover from Pandas plotting finished in the second half of 2025. A static chart sends you back to the data frame to hunt. A Plotly chart tells you when you point at the dot.

The function is about as simple as a plotting function gets. Give it a data frame and two column names, and every row becomes one dot:

import pandas as pd
import plotly.express as px

px.scatter(df, x='gdp', y='life_expectancy')

That is the signature in the official documentation, and it is worth knowing. But it is not how I write it. A chart is the last thing that happens to a data frame, so it belongs at the end of the chain that built it, and pipe is what puts it there: it hands the data frame to the function as its first argument, and every keyword argument after that goes straight through.

(
    df
    .loc[df['year'] == 2026]
    .pipe(px.scatter, x='gdp', y='life_expectancy',
          size='population', hover_name='country')
)

Same chart, chain unbroken. This is the form to prefer. pipe appears 208 times across the Bamboo Weekly solutions, and a plotting call that forces you to stop, invent a variable name, and start a new statement reads as foreign next to all of that.

Official documentation: plotly.express.scatter.

The arguments that earn their keep

df.pipe(px.scatter,
        x='...', y='...',        # the only two that are really required
        color='...',             # a column name, never a color name
        size='...',              # bubble chart; values must be >= 0
        hover_name='...',        # bold first line of the tooltip
        hover_data=['...'],      # extra lines in the tooltip
        trendline='ols',         # requires statsmodels
        log_x=True, log_y=True,  # log axes
        facet_col='...',         # one small panel per distinct value
        opacity=0.3,             # for when the dots pile up
        labels={'...': '...'},   # axis titles, without touching the layout
        title='...')

Note that color= and size= take column names, not a color and not a number: you are not saying "make the dots red," you are saying "let this column decide." And hover_name costs one keyword argument and answers the question I opened with — hover_name='Country' puts the country in bold at the top of the tooltip, hover_data=['units', 'Region'] adds lines below it. Nothing static comes close.

A worked example, on real data

The Global Coal Plant Tracker lists every coal-fired generating unit on earth. Bamboo Weekly #64 built a puzzle around it, and it suits a scatterplot: two numbers per row, plus a country and a region to hang them on.

url = ('https://www.bambooweekly.com/content/files/wp-content/uploads/2024/02/'
       'global-coal-plant-tracker-january-2024.xlsx')

df = pd.read_excel(url, sheet_name='Units',
                   usecols=['Country', 'Region', 'Status', 'Start year',
                            'Capacity (MW)',
                            'Annual CO2 (million tonnes / annum)'])

There are 13,906 units, 6,580 of them operating. My question is whether the countries with the biggest coal fleets are the ones that built them most recently, so I need one row per country:

by_country = (
    df
    .loc[df['Status'] == 'operating']
    .groupby(['Country', 'Region'])
    .agg(capacity_gw=('Capacity (MW)', 'sum'),
         units=('Capacity (MW)', 'size'),
         mean_year=('Start year', 'mean'))
    .assign(capacity_gw=pd.col('capacity_gw').div(1000))
    .reset_index()
)

by_country.nlargest(5, 'capacity_gw')
          Country    Region  capacity_gw  units    mean_year
11          China      Asia    1136.7310   3146  2009.182232
26          India      Asia     237.1482    844  2005.705602
70  United States  Americas     200.0902    417  1979.292566
31          Japan      Asia      55.1230    154  1998.896104
27      Indonesia      Asia      51.5566    254  2013.560000

Seventy-five countries. Now the chart, as the last step of the chain:

(
    by_country
    .pipe(px.scatter, x='capacity_gw', y='mean_year',
          color='Region', size='units', hover_name='Country', log_x=True,
          labels={'capacity_gw': 'Operating capacity (GW)',
                  'mean_year': 'Mean commissioning year'})
)

Four of those keyword arguments do four separate things. color='Region' is a string column, so Plotly splits the data into five traces — Americas, Oceania, Asia, Europe, Africa — and builds a clickable legend from them. size='units' scales each bubble by how many generating units the country runs, which is why China is an enormous blob and Guadeloupe, with two units and 64 MW, is a speck. hover_name='Country' is what makes that speck identifiable. And log_x=True is not decoration: capacity runs from 0.064 GW to 1,136.7 GW, so on a linear axis every country except China and India is squashed against the left edge.

The color carries the finding. Europe's median country has a mean commissioning year of 1982; Asia's has 2006. Old and new fleets separate cleanly by continent.

Ask for a trendline and you get the other half of the story:

(
    by_country
    .pipe(px.scatter, x='capacity_gw', y='mean_year', log_x=True,
          trendline='ols', trendline_options=dict(log_x=True),
          hover_name='Country')
)

Catch that figure in a variable and px.get_trendline_results(fig) hands back the actual statsmodels fit, which is brutal: R² of 0.0093 across 74 countries, p of 0.41. Fleet size tells you essentially nothing about fleet age. Add color='Region' and you get one fitted line per region rather than one overall, since the default trendline_scope is 'trace'; only the Americas comes out with anything worth a look, at R² 0.516. Pass trendline_scope='overall' for a single line through everything.

Finally, facet_col='Region' replaces the five overlapping colors with five side-by-side panels sharing one pair of axes, each titled Region=Asia and so on. Overlap is better for comparing, facets for reading, and each is one keyword argument.

Five mistakes people make

Assuming trendline='ols' works out of the box. It does not. Plotly does not depend on statsmodels and does not check for it until you build the chart, so the failure arrives late and unhelpfully, as a bare ModuleNotFoundError: No module named 'statsmodels' raised from inside plotly/express/trendline_functions/__init__.py. No friendly message, no hint about what to install. uv add statsmodels and it works.

Reaching for render_mode='webgl' to fix overplotting. Plotly already did it for you. The default render_mode='auto' switches from Scatter to Scattergl above 1,000 rows, and the boundary is exactly there: 1,000 rows renders as scatter, 1,001 as scattergl. So the 6,580 operating units are already WebGL. What WebGL does not fix is that 6,580 opaque dots on top of one another look like a single solid shape, and that is what opacity=0.3 is for. The argument you may genuinely need runs the other way: render_mode='svg' turns the acceleration off, which matters because browsers cap how many WebGL contexts a document may hold — Plotly's guidance is that more than about eight WebGL-backed figures on one page may not render at all.

Handing size= a column with negatives or NaN. Sizing by a net-change column is a natural thing to want, and 37 of these 75 countries have retired more coal capacity than they have under construction. Plotly refuses, listing the offending values: ValueError: Invalid element(s) received for the 'size' property of scatter.marker. Size may only be a number in [0, inf], and NaN fails the same check. Zero is fine. Take the absolute value and put the sign into color=, or drop the rows — but decide, because Plotly will not decide for you.

Expecting color= to behave the same way on every column. The split is by dtype. A string or categorical column gives one trace per distinct value plus a legend; a numeric column gives a single trace plus a continuous colorbar. Here color='Region' gives five traces and color='units' gives one. Force a number to behave discretely with .astype(str) or .astype('category'), and force a string of digits to behave continuously by converting it back. What does not work is color_discrete_sequence=: pass it alongside a numeric column and you still get the colorbar. Convert carefully, though — .astype(str) on a column with 38 distinct values gives you 38 legend entries.

Assuming missing values will announce themselves. On the axes, they will not. Nigeria has no commissioning year for any of its seven units, so its mean_year is NaN. Plotly keeps the row in the trace and draws nothing, and the trendline quietly fits 74 points instead of 75 — no warning, no error, no gap. That is the inconsistency worth remembering: NaN in size= raises loudly, NaN in x= or y= vanishes without a word. Run .isna().sum() before you plot, not after you have described the chart to someone.

Where it shows up in Bamboo Weekly

Fourteen solution posts use px.scatter. The first two questions of every issue sit outside the paywall, and in the first two posts below the scatterplot is one of them, so you can read that code without subscribing.

Bamboo Weekly #172: World Cup is the cleanest example of the piped form, asking whether matches played later in the day produce more goals: .pipe(px.scatter, x='hour', y='total_goals'), then the same chain again with trendline='ols' added. The correlation comes out at 0.013, and the chart is what makes that number believable.

Bamboo Weekly #155: Gold is the log-axis case, plotting the gold closing price against silver's. The first version is unreadable, because a $20 move in silver is near-vertical while the same move in gold is invisible; log_x=True, log_y=True rescues it, for the same reason the coal chart above needs log_x.

Bamboo Weekly #142: Hurricanes is the continuous-color case, and a nice trick besides: .pipe(px.scatter, x='LON', y='LAT', color='WMO_WIND') over per-storm mean positions turns a scatterplot into a rough map of the Caribbean and the Eastern Seaboard, with the fiercest storms glowing. WMO_WIND is numeric, so this one gets the colorbar rather than a legend.

Bamboo Weekly #174: Vacation is the tooltip case: .pipe(px.scatter, x='dissatisfaction', y='total', hover_data=['country']), plotting vacation days per country against how unhappy people are about them. The interesting part is a cluster in the top left, and it means nothing until you can hover over it and read the names.

Practice it

Work through a px.scatter() exercise, with instant feedback and no signup required: practice.lernerpython.com/bamboo-weekly/plotly-scatter/

Go deeper

A scatterplot is only as good as the data frame behind it, so read the reshaping pages next: groupby and agg for the one-row-per-country frame, merge and join for getting two measurements side by side, corr for the number the trendline draws, and pipe.

On the Plotly side, line and scatter is the full catalog, linear fits covers the trendline options beyond 'ols', facet plots covers facet_col and facet_row, and performance explains the WebGL switch.

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.