seaborn
seaborn is a layer on top of matplotlib for statistical charts. You hand it a tidy pandas DataFrame plus column names, and it does the parts that are tedious in raw matplotlib: splitting a series by a categorical column into colours, repeating the same chart once per group across a grid, computing and shading confidence intervals, choosing colour palettes that survive greyscale printing, and applying one coherent theme to everything. It also fits regressions, estimates kernel densities and draws correlation heatmaps in a single call. Everything it returns is still a matplotlib figure, so anything seaborn does not expose as a keyword argument you finish by hand in matplotlib afterwards.
Still the quickest route from a DataFrame to a defensible statistical chart, and the documentation teaches you statistics rather than just listing arguments. The catch is stalled development: the newest release is from January 2024 and the newer objects interface has been unfinished since 2022, so build on the classic function API and expect no new features.
Use it if
- Your data is already a long-form DataFrame and you want colour-by-category, facet-by-category and error bars without writing the groupby and the loop over subplots yourself
- You need statistical charts specifically: regression fits with confidence bands, KDEs and ECDFs, box and violin and strip plots, bootstrapped error bars, pair plots and correlation heatmaps
- You want charts that look presentable by default; set_theme plus a named palette gets you further in one line than an hour of matplotlib rcParams
- You are working in a notebook and iterating: one function call per chart, and the same call scales from a single axes to a faceted grid by swapping scatterplot for relplot
- You need a currently maintained release: 0.13.2 shipped in January 2024 and nothing has been published since, even though the repo was pushed in July 2026. Fixes that are merged on master are not installable, and 173 open issues (224 counting PRs) are waiting behind a project run by one person
- You are tempted by the seaborn.objects interface: it arrived in 0.12 in 2022, the docs still describe it as under development, and no stable release has finished it. Building a codebase on it means betting on an API that has been in limbo for years
- You need interactivity: the output is a static matplotlib figure, so hover tooltips, zoom, and linked selection do not exist. plotly and altair are built for that and seaborn will never be
- You want fine control over every element: seaborn's convenience stops where its keyword arguments stop, and past that you are reading matplotlib documentation anyway, with the extra step of working out which Axes object seaborn created
- Your data is wide rather than long: hue, col, row and style all assume one row per observation, so most real work starts with a pandas melt and ends with an unstack
- You are plotting hundreds of thousands of points: every marker becomes a vector artist, so large scatterplots are slow to draw and produce PDFs and SVGs that no viewer wants to open
Setup reality
pip install seaborn brings numpy, pandas and matplotlib, with oddly specific exclusions (numpy 1.24.0 and matplotlib 3.6.1 are both blocked because of bugs in those exact releases). Anything statistical beyond the defaults sits behind an extra: KDE plots and regression confidence bands need scipy, and lowess smoothing plus the residual diagnostics need statsmodels, both installed with pip install seaborn[stats]. Missing them fails at draw time with an ImportError, not at import, so it can survive into production notebooks. The bigger cost is conceptual. Half the library is axes-level (scatterplot, histplot, boxplot) and accepts ax=; the other half is figure-level (relplot, displot, catplot, lmplot) and builds its own figure while returning a FacetGrid. They are not interchangeable, passing ax= to relplot raises, and the axes-level functions forward unknown keywords straight to matplotlib, so a stray col= produces a confusing matplotlib property error instead of a useful message. One last trap: sns.load_dataset() in every tutorial downloads CSVs from GitHub at call time and fails in an offline CI container.
Patterns
Apply a theme once for the whole sessionset-theme-and-palette
import seaborn as sns
import matplotlib.pyplot as plt
sns.set_theme(style="whitegrid", context="talk", palette="deep")
# named palettes and custom ones
sns.color_palette("crest", as_cmap=True)
sns.set_palette(["#1b9e77", "#d95f02", "#7570b3"])
plt.rcParams["figure.dpi"] = 120 # seaborn does not manage thisset_theme changes global matplotlib rcParams, so it affects plots drawn by other libraries in the same process too. The old sns.set() is a deprecated alias. context controls font and line scaling: paper, notebook, talk, poster.
Know which function makes the figurefigure-level-vs-axes-level
import matplotlib.pyplot as plt
import seaborn as sns
# axes-level: draws into an Axes you own
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
sns.scatterplot(data=df, x="flipper_mm", y="mass_g", hue="species", ax=axes[0])
sns.boxplot(data=df, x="species", y="mass_g", ax=axes[1])
# figure-level: owns the whole figure, returns a FacetGrid
g = sns.relplot(data=df, x="flipper_mm", y="mass_g", hue="species",
col="island", kind="scatter", height=4)relplot, displot, catplot, lmplot, pairplot and jointplot are figure-level and reject ax=. Their axes-level counterparts (scatterplot, histplot, boxplot, regplot) accept ax= but cannot facet. Choosing wrong is the single most common seaborn error.
Histograms, KDEs and ECDFsplot-distributions
sns.histplot(data=df, x="mass_g", hue="species",
stat="density", common_norm=False, element="step", kde=True)
sns.displot(data=df, x="mass_g", col="island", kind="kde", fill=True)
sns.ecdfplot(data=df, x="mass_g", hue="species")With hue and stat='density', the default common_norm=True normalises across all groups together, so a small group looks tiny; set common_norm=False to normalise each group separately. kde=True needs scipy, installed via seaborn[stats].
Compare groups with box, violin and bar plotscategorical-comparison
sns.boxplot(data=df, x="species", y="mass_g", hue="sex", fill=False)
sns.violinplot(data=df, x="species", y="mass_g", split=True, hue="sex", inner="quart")
sns.barplot(data=df, x="species", y="mass_g",
estimator="median", errorbar=("pi", 95))
sns.catplot(data=df, x="species", y="mass_g", col="island", kind="box")barplot aggregates with the mean by default, which surprises people who expect it to plot raw values. The ci= argument was replaced by errorbar= in 0.12: ('ci', 95) bootstraps a confidence interval, ('pi', 95) is a percentile interval, 'sd' is a standard deviation, and None turns error bars off and speeds things up considerably.
Fit and draw a regression lineregression-plot
sns.regplot(data=df, x="flipper_mm", y="mass_g", ci=95, scatter_kws={"alpha": 0.4})
sns.lmplot(data=df, x="flipper_mm", y="mass_g",
hue="species", col="island", height=4)
sns.regplot(data=df, x="flipper_mm", y="mass_g", lowess=True)
sns.residplot(data=df, x="flipper_mm", y="mass_g")These draw a fit but never report it: no coefficients, no p-value, no R-squared. If anyone will act on the number, fit it in statsmodels and plot that. lowess=True and residplot's higher-order options require statsmodels.
Heatmap of a correlation matrixcorrelation-heatmap
import numpy as np
corr = df.corr(numeric_only=True)
mask = np.triu(np.ones_like(corr, dtype=bool))
sns.heatmap(corr, mask=mask, annot=True, fmt=".2f",
cmap="vlag", center=0, vmin=-1, vmax=1,
square=True, linewidths=0.5)Set center=0 with a diverging colormap or a correlation of 0.1 gets the same visual weight as -0.9. Without numeric_only=True, pandas raises on string columns. For clustered ordering use sns.clustermap, which is figure-level and returns its own grid.
Explore several variables at oncepair-and-joint-plots
sns.pairplot(df, vars=["flipper_mm", "mass_g", "bill_mm"],
hue="species", diag_kind="kde", corner=True)
sns.jointplot(data=df, x="flipper_mm", y="mass_g",
hue="species", kind="scatter", marginal_kws={"bins": 20})
sns.jointplot(data=df, x="flipper_mm", y="mass_g", kind="hex")pairplot draws n-squared subplots, so pass vars= rather than letting it pick up thirty numeric columns and hang the kernel. corner=True halves the work by dropping the redundant upper triangle. kind='hex' does not accept hue.
Adjust titles, labels and legends on a FacetGridcustomise-facet-grid
g = sns.relplot(data=df, x="date", y="sales", col="region",
col_wrap=3, kind="line", height=3, aspect=1.4)
g.set_axis_labels("Date", "Sales (USD)")
g.set_titles("{col_name}")
g.set(ylim=(0, None))
g.refline(y=target)
sns.move_legend(g, "upper left", bbox_to_anchor=(1.02, 1))
for ax in g.axes.flat:
ax.tick_params(axis="x", rotation=45)g.axes is a 2D numpy array of Axes unless you used col_wrap, in which case it is 1D; .axes.flat works for both. Reach the underlying figure with g.figure, since the old g.fig was deprecated in 0.12.
Get wide data into the shape seaborn wantsreshape-wide-to-long
long = wide.melt(
id_vars=["date"],
value_vars=["north", "south", "east"],
var_name="region",
value_name="sales",
)
sns.lineplot(data=long, x="date", y="sales", hue="region")Passing a wide frame directly works for a few functions but throws away the column name as a variable, so you lose hue, col and the legend. Melt first and everything downstream becomes one keyword argument.
The newer declarative interfaceobjects-interface
import seaborn.objects as so
(
so.Plot(df, x="flipper_mm", y="mass_g", color="species")
.add(so.Dot(alpha=0.6))
.add(so.Line(), so.PolyFit(order=1))
.facet(col="island")
.scale(color="deep")
.label(x="Flipper (mm)", y="Mass (g)")
.save("penguins.png", dpi=200, bbox_inches="tight")
)This is the grammar-of-graphics rewrite added in 0.12 and the documentation still marks it as under development, with no stable release since. It coexists with the function API rather than replacing it, so mixing both in one codebase means two mental models.
Write a chart to a file correctlysave-figure
ax = sns.scatterplot(data=df, x="flipper_mm", y="mass_g")
ax.figure.savefig("scatter.png", dpi=200, bbox_inches="tight")
g = sns.relplot(data=df, x="flipper_mm", y="mass_g", col="island")
g.figure.savefig("facets.png", dpi=200, bbox_inches="tight")
import matplotlib.pyplot as plt
plt.close("all") # in loops, or you leak figuresAxes-level functions return an Axes, so you save via ax.figure; figure-level ones return a grid whose figure is g.figure. Without bbox_inches='tight' an external legend gets cropped. In a loop, close figures or matplotlib warns after 20 and memory grows.
Finish the chart in matplotlibdrop-to-matplotlib
import matplotlib.pyplot as plt
from matplotlib.ticker import FuncFormatter
fig, ax = plt.subplots(figsize=(9, 5))
sns.lineplot(data=long, x="date", y="sales", hue="region", ax=ax)
ax.set_title("Monthly sales by region", loc="left", fontsize=14)
ax.yaxis.set_major_formatter(FuncFormatter(lambda v, _: f"${v:,.0f}"))
ax.axhline(target, ls="--", color="grey", lw=1)
ax.legend(title=None, frameon=False, ncols=3)
sns.despine(ax=ax)This is the normal end state, not a failure: seaborn covers the statistics and the defaults, matplotlib covers everything else. Use the axes-level function when you know you will need this, because reaching into a FacetGrid's axes afterwards is more work.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| matplotlib | PyPI | You need control over every artist and do not want a wrapper deciding the layout for you. |
| plotly | PyPI | The chart ships in a web page or dashboard and needs hover, zoom and selection. |
| altair | PyPI | You want a declarative grammar with interactivity for the web, backed by Vega-Lite. |
| plotnine | PyPI | You are coming from R and want a real ggplot2 grammar rather than seaborn's function-per-chart API. |