The idea
Put five categories' monthly revenue on one line chart and you get spaghetti: five tangled lines, a legend to decode, and nobody can follow Stationery. Faceting (also called small multiples) draws the same chart once per category, side by side, on the same scales. Each panel is simple, and comparing them is easy because the eye jumps between identical layouts.
Express makes it one argument: facet_col=.
import pandas as pd
import plotly.express as px
sales = pd.read_csv("sales.csv")
completed = sales[sales["status"] == "completed"]
monthly_cat = (completed.assign(month=completed["order_date"].str[:7])
.groupby(["month", "category"], as_index=False)["revenue"].sum())
print(monthly_cat.head())
print(len(monthly_cat))
month category revenue
0 2024-01 Coffee 285000
1 2024-01 Home 270000
2 2024-01 Kitchen 960000
3 2024-01 Stationery 178000
4 2024-02 Coffee 635000
24
Spaghetti first
The obvious way to show monthly revenue for five categories is one line chart with five coloured lines. Try it and see how readable it is:
fig = px.line(monthly_cat, x="month", y="revenue", color="category", markers=True,
title="Revenue by category (one chart)")
fig.show()
Five crossing lines. You can read the biggest one, but the smaller categories are lost.
facet_col: one panel per category
Now the same data, but instead of five lines on one chart, each category gets its own small chart, side by side. The only change is facet_col= instead of color=:
fig = px.line(monthly_cat, x="month", y="revenue", facet_col="category", markers=True,
title="Revenue by category (small multiples)")
fig.show()
print(len(fig.data), [a.text for a in fig.layout.annotations])
5 ['category=Coffee', 'category=Home', 'category=Kitchen', 'category=Stationery', 'category=Electronics']
Each category gets its own panel (and its own trace). The panels share the y-axis, so a tall line really is a big category, and the shapes are easy to compare: which categories grew, which were steady, which had one big month.
Wrapping into a grid
Five panels in one row get narrow. facet_col_wrap starts a new row after N panels:
fig = px.line(monthly_cat, x="month", y="revenue", facet_col="category",
facet_col_wrap=3, markers=True, height=500)
fig.show()
facet_row= splits into rows by another variable. Combine both for a grid of two variables, for example category × channel:
monthly_cat_ch = (completed.assign(month=completed["order_date"].str[:7])
.groupby(["month", "category", "channel"], as_index=False)["revenue"].sum())
fig = px.bar(monthly_cat_ch, x="month", y="revenue", facet_col="category", facet_row="channel",
height=450)
fig.show()
Shared or independent axes
By default, facets share axes. That's usually right: the point of small multiples is comparing like with like. But when one panel dwarfs the others, the small ones become flat lines. If you care about each panel's shape more than its size, free the y-axes:
fig = px.line(monthly_cat, x="month", y="revenue", facet_col="category",
facet_col_wrap=3, markers=True, height=500)
fig.update_yaxes(matches=None, showticklabels=True)
fig.show()
matches=None unlinks the axes, and showticklabels=True shows each panel's own scale (hidden by default on inner panels).
Independent axes make every panel look equally important. A category with Rp 50.000 of revenue can look as dramatic as one with Rp 5.000.000. When you free the axes, say so in the title or subtitle.
Cleaner panel titles
Express titles each panel category=Coffee. Strip the prefix with for_each_annotation, which runs a function on every annotation (the panel titles are annotations):
fig = px.line(monthly_cat, x="month", y="revenue", facet_col="category", markers=True)
fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1]))
fig.show()
print([a.text for a in fig.layout.annotations])
['Coffee', 'Home', 'Kitchen', 'Stationery', 'Electronics']
Ordering the panels
Panels follow the data order unless you set category_orders. Put the most important panel first, or follow a natural order:
order = (monthly_cat.groupby("category")["revenue"].sum()
.sort_values(ascending=False).index.tolist())
fig = px.line(monthly_cat, x="month", y="revenue", facet_col="category",
category_orders={"category": order}, markers=True)
fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1]))
fig.show()
print(order)
['Electronics', 'Kitchen', 'Coffee', 'Home', 'Stationery']
Facet or colour?
| Use colour (one chart) when | Use facets when |
|---|---|
| 2-3 groups | 4+ groups |
| the lines rarely cross | lines overlap and tangle |
| the exact comparison at each x matters | the overall shape of each group matters |
Why this matters for clean code
Faceting moves complexity out of the chart and into a consistent layout. In code, it means one px call with facet_col= instead of a loop that builds five figures with five sets of formatting that slowly drift apart. Same data, same function, same styling for every panel: a single source of truth for the whole grid.
Recap
facet_col=(andfacet_row=) draws one panel per value of a column.facet_col_wrap=Nwraps panels into rows of N.- Facets share axes by default;
update_yaxes(matches=None, showticklabels=True)frees them (and say so). - Panel titles are annotations: clean them with
for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1])). - Order panels with
category_orders. - Prefer facets over colour when there are many groups or the lines tangle.