Skip to content

plotly express line

Interactive, hoverable, zoomable line charts from a data frame — one function call at the end of a method chain.

What px.line actually does

Why is the plotting call the one step in your analysis that is not a method on the data frame?

That is the first thing to notice about px.line. It is a function in Plotly Express, not a Pandas method, so it goes on the outside: you pass the data in rather than asking the data to draw itself. In exchange you get a chart that hovers, zooms, pans, and lets a reader click a series out of the legend, with no matplotlib anywhere.

The second thing to notice is that px.line is a shape reader. You tell it which parts of the data frame become the x axis, the y axis, and the color; it works out the rest, taking axis titles from your column names and the legend from your color column. It accepts two shapes. Long form is one row per observation, with a column naming the series — that is what color= is for. Wide form is one column per series, with the index as the x axis, and needs no arguments at all. This is why melt, pivot, and unstack keep turning up right before a chart. Reshaping is not preparation for plotting; it is most of the plotting.

What comes back is a plotly.graph_objects.Figure. In Jupyter or marimo, a figure as the last expression of a cell renders itself; everywhere else — inside a loop, inside a function, in a plain script — nothing appears until you call fig.show().

Official documentation: plotly.express.line, plus the Plotly guides to line charts and wide-form data.

Write it with pipe

The signature in the documentation looks like this:

px.line(df, x='Year', y='Renewables', color='Entity')

That works, and it is how most examples on the internet are written. But it reads as an interruption if the rest of your code is a method chain, because you have to stop, name a variable, and start again. So I write it this way instead:

(
    df
    .loc[df['Entity'].isin(g7)]
    .pipe(px.line, x='Year', y='Renewables', color='Entity')
)

pipe hands the object on its left to the function as that function's first argument, and passes every other argument straight through. Nothing is lost, and the chart becomes the last step of the chain rather than a wrapper around it. pipe shows up more than 250 times across 62 Bamboo Weekly solutions, so this is the form you will see if you read them. I spent a long time writing .pipe(lambda df_: px.line(df_, title='...')) before noticing that pipe forwards keyword arguments on its own, which makes the lambda unnecessary.

The arguments that earn their keep

px.line(df,
        x='Year', y='Renewables',   # long form: column names
        color='Entity',             # one line per value; also the legend
        labels={'Renewables': '% of electricity'},
        title='...',
        hover_data=['Code'],        # extra fields in the tooltip
        markers=True,               # dots on the data points
        log_y=True,                 # log scale, for anything inflationary
        facet_col='Entity',         # a small panel per value instead of a legend
        facet_col_wrap=4)

color= and labels= do most of the work. color= is the whole reason to use long form. labels= exists because Plotly names your axes after your columns, which is a fine default right up until the column is called OBS_VALUE. And facet_col is the escape hatch for when color= has too many values to read: pass facet_col='Entity', facet_col_wrap=4 on seven countries and instead of one crowded legend you get seven small panels across two rows, each titled Entity=Canada and so on.

A worked example, on real data

Our World in Data publishes the renewable share of each country's electricity generation as one CSV. The site returns 403 to Pandas' default user-agent, so pass one:

import pandas as pd
from plotly import express as px

url = 'https://ourworldindata.org/grapher/share-electricity-renewables.csv'

df = pd.read_csv(url, storage_options={'User-Agent': 'Mozilla/5.0'})
df
             Entity Code  Year  Renewables
0     ASEAN (Ember)  NaN  2000   19.334143
1     ASEAN (Ember)  NaN  2001   19.055025
2     ASEAN (Ember)  NaN  2002   17.666613
3     ASEAN (Ember)  NaN  2003   16.668121
4     ASEAN (Ember)  NaN  2004   15.696422
...             ...  ...   ...         ...
7580       Zimbabwe  ZWE  2020   58.569300
7581       Zimbabwe  ZWE  2021   70.711784
7582       Zimbabwe  ZWE  2022   67.337814
7583       Zimbabwe  ZWE  2023   56.156160
7584       Zimbabwe  ZWE  2024   57.115010

[7585 rows x 4 columns]

Textbook long form: 7,585 rows, 246 entities, one row per country per year. Filtering to the G7 since 2000 leaves 182 rows, and the chart is the last line of the chain:

g7 = ['Canada', 'France', 'Germany', 'Italy', 'Japan',
      'United Kingdom', 'United States']

(
    df
    .loc[df['Entity'].isin(g7) & df['Year'].ge(2000)]
    .pipe(px.line, x='Year', y='Renewables', color='Entity',
          labels={'Renewables': '% of electricity', 'Entity': 'Country'},
          title='Renewable share of electricity generation, G7')
)

Seven lines, one per country, with a legend headed "Country" instead of "Entity" because of labels=. Canada starts high and stays there, on hydro: 60.6 percent in 2000, 63.9 in 2025. The United Kingdom is the story — 2.6 percent in 2000, 52.0 in 2025 — and Germany runs almost the same path, 6.2 to 59.1. Hover over any point and Plotly names the country, the year, and the value.

The same chart from the other shape needs no arguments at all:

(
    df
    .loc[df['Entity'].isin(g7) & df['Year'].ge(2000)]
    .pivot(index='Year', columns='Entity', values='Renewables')
    .pipe(px.line)
)
Entity  Canada  France  Germany  Italy  Japan  United Kingdom  United States
Year
2000      60.6    12.7      6.2   18.8    9.2             2.6            9.2
2001      58.1    14.0      6.5   20.0    9.1             2.5            7.5
2002      60.0    11.4      7.6   17.4    8.9             2.9            8.7
2003      58.9    11.0      7.8   16.4   10.3             2.7            9.0
2004      58.3    11.0      9.5   18.3   10.0             3.6            8.7

Index becomes x, each column becomes a series, and the columns' own name becomes the legend title. The one thing you lose is the y-axis label: with no column name to use, Plotly writes value. That is what labels={'value': '% of electricity'} is for.

Five mistakes people make

Plotting rows that are not in order. px.line connects points in row order, not in x order, even on a numeric axis. Shuffle six years of German data and the trace comes back as [2022, 2021, 2024, 2020, 2023, 2025] — a line that doubles back on itself three times, with no warning. That is why .sort_values(['country', 'year']) and .sort_index() sit directly above so many px.line calls.

Long form without color=. Drop color='Entity' from the G7 chart and you do not get seven lines; you get one, with 182 points. It climbs through Canada's twenty-six years, then falls off a cliff — 63.9 in 2025 straight back to France's 12.7 in 2000 — and does that six more times. If your line chart looks like a saw blade, this is why.

color= on a column that is not categorical. Plotly draws one trace per distinct value. Point color= at a float column by mistake and you get exactly that: 182 traces, with legend entries named 60.607464 and 58.108753. Even a sensible choice can go wrong — color='Entity' across every country here is 226 traces cycling through a palette of 10 colors, which is not a chart. Filter, take the top n, or switch to facet_col.

Dates still stored as strings. Convert the year column with astype(str) and Plotly's axis autotyping resolves to category rather than linear. Categories are laid out in arrival order, evenly spaced, so unsorted rows come out misordered and a four-year gap is drawn the same width as a one-year gap. Run pd.to_datetime first.

Mismatching the shape and the arguments. Wide and long are both fine; mixing them is not. Ask for x='Year' on a pivoted frame whose years are in the index and you get ValueError: Value of 'x' is not the name of a column in 'data_frame'. Expected one of ['Canada', 'France', ...] but received: Year. Pass a list of seven country names as y= on the 182-row long frame and you get ValueError: All arguments should have the same length. Both messages tell you which shape Plotly thinks it has.

px.line or .plot.line()?

.plot.line() is fewer characters and stays inside the chain without pipe. px.line gives you hover, zoom, and a clickable legend, and its color=, facet_col, and labels= arguments do work that Pandas' plotting would send you into matplotlib for. Both end the data chain — .plot.line() returns a matplotlib Axes, px.line a Plotly Figure — though the Plotly figure at least chains onward through its own methods, so .pipe(px.line, ...).update_layout(hovermode='x unified') is one expression.

My own habits show the tradeoff better than an argument would. .plot.line() ran through 63 Bamboo Weekly solutions between April 2023 and August 2025; px.line starts in August 2025 and has appeared in 36 since. Static images are still right for a printed handout or an email. For anything a reader opens in a browser, the interactivity wins.

Where it shows up in Bamboo Weekly

Bamboo Weekly #139: Chinese exports is the one to read first, and it is free. It uses .unstack(level=1) to get imports and exports into two columns, pipes the result straight into px.line, and then works out how to add a title — first with a lambda inside pipe, then without one: .pipe(px.line, title='US/China trade').

Bamboo Weekly #146: Thanksgiving travel, also free, starts with the simplest call there is — px.line(df['Numbers']) on 2,520 days of TSA checkpoint counts, showing the 2020 collapse and the slow recovery — then rebuilds it in long form: px.line(df.reset_index(), y='Numbers', x='Date', color='is_near_thanksgiving', hover_data=['is_thanksgiving']). Coloring by a boolean did not do quite what I expected, and I said so in the post: color= splits one series in two rather than highlighting part of one.

Bamboo Weekly #166: Income tax has the most fully specified px.line call I have written, plotting individual income tax as a share of GDP for 15 G20 countries from OECD data, with x='TIME_PERIOD', y='OBS_VALUE', color='Reference area', and a labels= dictionary renaming all three. It sorts by country and year first, for the reason in the first mistake above. Bamboo Weekly #141: Argentina uses px.line six times on inflation data, including log_y=True — because on a linear axis, Argentine prices make every year before the last one look flat. Those two are for paid subscribers.

Practice it

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

Go deeper

Because the shape decides the chart, read the reshaping pages next: unstack and groupby for one column per series, resample for the time axis, and pipe for the method that keeps all of it in one expression. On the Plotly side, the wide-form guide explains the two shapes properly, facet plots covers the alternative to a crowded legend, and renderers explains why a figure appears in one environment and not another.

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.