The idea
Express gets the marks right. The layout (everything around the marks) decides whether the chart reads like a draft or a finished report: a title that states the finding, a legend that doesn't steal space, margins that fit a dashboard tile, and a note pointing at the spike everyone will ask about.
All of it goes through one method, fig.update_layout(...), plus a few helpers for lines and annotations.
import pandas as pd
import plotly.express as px
sales = pd.read_csv("sales.csv")
completed = sales[sales["status"] == "completed"]
monthly = (completed.assign(month=completed["order_date"].str[:7])
.groupby(["month", "channel"], as_index=False)["revenue"].sum())
fig = px.line(monthly, x="month", y="revenue", color="channel", markers=True)
fig.show()
Titles that say something
A title can state the finding instead of the topic, with a smaller subtitle for the details:
fig.update_layout(
title=dict(
text="Web drove the April peak<br><sup>Completed revenue per month and channel, 2024 (Rp)</sup>",
x=0, xanchor="left",
)
)
fig.show()
print(fig.layout.title.x, fig.layout.title.xanchor)
0 left
<br>breaks the line;<sup>makes the second line small, a simple subtitle.x=0, xanchor="left"aligns the title with the left edge, where people start reading.
Dot notation: magic underscores
Layout properties are nested: the title has a font, which has a size. You can write the nesting as a dict, or with underscores:
fig.update_layout(title_font_size=22, xaxis_title="Month", yaxis_title="Revenue (Rp)")
print(fig.layout.title.font.size, fig.layout.xaxis.title.text)
22 Month
title_font_size=22 is short for title=dict(font=dict(size=22)). Both styles are everywhere in Plotly code; underscores are handy for one or two settings.
The legend
By default the legend sits on the right and takes space from the plot. A horizontal legend above the chart is often cleaner:
fig.update_layout(
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1, title_text=""),
)
fig.show()
print(fig.layout.legend.orientation, fig.layout.legend.y)
h 1.02
The coordinates are fractions of the plot area: y=1.02 is just above the top, x=1 with xanchor="right" aligns it to the right edge. showlegend=False hides it entirely, which is right when there's only one series, or when you label the lines directly.
Size and margins
Plotly picks a size and leaves generous white space around the chart. For a report or a slide you might want a fixed size, and in a small dashboard tile you'll want much tighter margins so the chart itself gets the space:
fig.update_layout(width=700, height=400, margin=dict(l=40, r=20, t=80, b=40))
print(fig.layout.margin.l, fig.layout.height)
40 400
Leave width unset in dashboards and apps, so the chart fills its container. Tight margins matter in small dashboard tiles, where Plotly's defaults waste a lot of space.
Unified hover
For time series with several lines, one hover box listing all series at that x is easier to read than separate boxes:
fig.update_layout(hovermode="x unified")
fig.show()
Pointing at the story: lines, areas, notes
A chart shows what happened, but the reader still has to work out why it matters. Was April good? Compared with what? A target line, a shaded band for the promo period and a note on the big spike answer those questions on the chart itself:
fig.add_hline(y=2000000, line_dash="dash", line_color="gray",
annotation_text="Monthly target", annotation_position="top left")
fig.add_vrect(x0="2024-03", x1="2024-05", fillcolor="#5669FD", opacity=0.08, line_width=0,
annotation_text="Ramadan promo", annotation_position="top right")
fig.add_annotation(x="2024-04", y=2963000, text="USB-C Hub orders", showarrow=True, arrowhead=2)
fig.show()
print(len(fig.layout.shapes), len(fig.layout.annotations))
2 3
add_hline/add_vline: a reference line across the chart (target, average, launch date).add_hrect/add_vrect: a shaded band (a promo period, a normal range).add_annotation: a note at a data point, with an optional arrow.
Each line or rectangle is a shape in the layout, and each text is an annotation.
Annotate the one or two things a reader must notice. A chart with a highlighted promo period and a labelled spike answers the questions before the meeting starts.
update_layout vs update_traces
| To change | Use | Examples |
|---|---|---|
| things around the marks | update_layout | title, legend, size, margins, hovermode, fonts |
| the marks themselves | update_traces | line width, marker size, colours, hovertemplate |
| one axis | update_xaxes / update_yaxes | titles, ranges, tick formats, gridlines |
fig.update_traces(line_width=3, marker_size=8)
print(fig.data[0].line.width, fig.data[0].marker.size)
3 8
Why this matters for clean code
Formatting code grows quickly. Keep it readable by grouping settings into a few update_* calls in a fixed order (layout, axes, traces, annotations), each with a clear purpose. When the same settings appear in every chart, move them into a function or a template, which are the next two modules.
Recap
update_layout(title=dict(text=..., x=0, xanchor="left"));<br><sup>...</sup>adds a subtitle.- Magic underscores:
title_font_size=22=title=dict(font=dict(size=22)). - Legend:
legend=dict(orientation="h", y=1.02, x=1, xanchor="right", yanchor="bottom"), orshowlegend=False. width,height,margin=dict(l, r, t, b)size the chart; leave width unset in apps.hovermode="x unified"for multi-line time series.add_hline,add_vline,add_hrect,add_vrect,add_annotationpoint at the story.update_layoutfor surroundings,update_tracesfor marks,update_xaxes/update_yaxesfor axes.