df.plot as a shortcut, and when to drop back to matplotlib.
Overview
What it does for you
pandas has a built-in plotting interface. df.plot() draws a chart directly from a DataFrame or Series. It calls matplotlib under the hood, returns a matplotlib Axes, and accepts most of the same keyword arguments.
The value of the pandas plotting interface is speed of exploration. One call draws a chart with labelled axes, a legend from column names, and date formatting from a DatetimeIndex. Doing the same in raw matplotlib takes five or six lines. For a quick look at data during analysis, that difference matters.
The interface covers line charts, bar charts, scatter plots, histograms, box plots, area charts, pie charts, hexbin plots and kernel density estimates. Each is a kind= argument or a method: df.plot.bar(), df.plot.scatter(x=..., y=...), df.plot.hist(). The defaults are tuned for DataFrames: column names become legend entries, the index becomes the x axis, and grouped operations map naturally to grouped charts.
The trade-off is control. pandas plotting handles the common cases quickly but makes unusual layouts harder. Once you need a second y axis, custom tick formatting, insets or precise spacing, you are better off using matplotlib directly. Knowing when to drop back is part of the skill.
This page covers the common chart types through pandas, the relationship between the pandas wrapper and the matplotlib objects underneath, and the patterns for combining pandas convenience with matplotlib control. It also covers the DataFrame shapes that map cleanly to charts and the reshaping operations that produce them.
The most common mistake is treating df.plot as a separate library. It is not. It returns a matplotlib Axes. You can call ax.set_title, ax.set_ylabel, ax.legend and any other matplotlib method on the result. The two layers are designed to work together.
A second common mistake is plotting a wide DataFrame when a long one is needed. pandas plots each column as a series. If your groups are in rows rather than columns, the chart draws the wrong thing. df.melt() or df.pivot() reshapes the frame. The page covers both.
df.plot() draws each column as a line against the index, adds a legend labelled with the column names. Formats the x axis rightly for the index type.
For a time-indexed frame that is a dated axis, sensible tick spacing, one line per column and a legend. From one call. Doing the same in raw matplotlib is five or six lines.
That is the case for using it: for exploration. For the common chart types, it's simply faster.
Worth knowing
df.plot() draws every column against the index and supplies labels and a legend from the column names.
It returns an Axes and accepts ax=, which is how pandas plots fit into a matplotlib layout.
kind= covers line, bar, barh, hist, box, kde, area, pie, scatter and hexbin; df.plot.bar() is the same with better syntax.
Each column is a series and each row a group — so the frame's shape decides the chart, and df.T swaps them.
subplots=True gives one panel per column and returns an array of axes; the y axes are independent unless you pass sharey.
pandas is a shortcut for the common cases; annotations, thresholds and shading are easier in matplotlib, and the two mix freely.
Plotting from pandas
df.plot as a shortcut, and when to drop back to matplotlib.
df.plot draws every column against the index
One call, and pandas supplies the labels and the legend.
example_01.pymatplotlib
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
idx = pd.date_range("2024-01-01", periods=60, freq="D")
rng = np.random.default_rng(0)
df = pd.DataFrame({"north": rng.normal(0, 1, 60).cumsum() + 20,
"south": rng.normal(0, 1, 60).cumsum() + 25},
index=idx)
ax = df.plot(figsize=(7, 3.2))
ax.set_ylabel("value")
print("df.plot() gave, for free:")
print(" the dates on the x axis (from the index)")
print(" a line per column")
print(" a legend, labelled with the column names")
print()
print("It returns an Axes, so everything you know still applies:")
print(" type:", type(ax).__name__)
Output
It returns an Axes, and takes one
Which is how pandas plots fit into a matplotlib layout.
example_02.pymatplotlib
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
rng = np.random.default_rng(1)
df = pd.DataFrame({"a": rng.normal(0, 1, 200), "b": rng.normal(1, 2, 200)})
fig, axes = plt.subplots(1, 2, figsize=(9, 3))
df.plot(kind="hist", bins=30, alpha=0.6, ax=axes[0], legend=True)
axes[0].set_title("pandas hist into a subplot")
df.plot(kind="scatter", x="a", y="b", ax=axes[1], alpha=0.4, s=12)
axes[1].set_title("pandas scatter")
fig.tight_layout()
print("ax= sends the plot to an axes you made.")
print("Without it, pandas creates its own figure - which is why a")
print("df.plot inside a subplot loop draws in the wrong place.")
Output
The kinds
One argument covers most of the chart types.
example_03.pymatplotlib
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
rng = np.random.default_rng(2)
df = pd.DataFrame({"a": rng.normal(10, 2, 60), "b": rng.normal(12, 3, 60)})
cat = pd.DataFrame({"value": [23, 45, 12, 38]},
index=["alpha", "beta", "gamma", "delta"])
fig, axes = plt.subplots(2, 2, figsize=(9, 5))
cat.plot(kind="bar", ax=axes[0, 0], legend=False, title="bar")
cat.plot(kind="barh", ax=axes[0, 1], legend=False, title="barh")
df.plot(kind="box", ax=axes[1, 0], title="box")
df.plot(kind="area", ax=axes[1, 1], stacked=False, alpha=0.4, title="area")
fig.tight_layout()
print("kind = line bar barh hist box kde area pie scatter hexbin")
print()
print("df.plot.bar() is the same thing with nicer syntax, and gives")
print("autocompletion in an editor.")
print()
try:
df.plot(kind="kde")
except Exception as e:
print("kde needs scipy, which is not loaded here:")
print(" ", type(e).__name__, "-", str(e)[:44])
print(" Several pandas plot kinds have optional dependencies.")
Output
Grouped and stacked, from a frame
A wide frame is already a grouped bar chart.
example_04.pymatplotlib
import matplotlib.pyplot as plt
import pandas as pd
df = pd.DataFrame({"product a": [10, 15, 12],
"product b": [8, 18, 14],
"product c": [5, 4, 9]},
index=["q1", "q2", "q3"])
fig, (a, b) = plt.subplots(1, 2, figsize=(9, 3))
df.plot(kind="bar", ax=a, rot=0, title="grouped")
df.plot(kind="bar", stacked=True, ax=b, rot=0, title="stacked")
print("Each COLUMN becomes a series; each ROW becomes a group.")
print()
print("So the shape of the frame decides the chart. If the bars come")
print("out grouped the wrong way, the frame needs transposing or")
print("pivoting - not the plot call changing.")
print()
print("df.T.plot(kind='bar') swaps them.")
Output
subplots=True for small multiples
One panel per column, sharing the x axis.
example_05.pymatplotlib
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
idx = pd.date_range("2024-01-01", periods=80, freq="D")
rng = np.random.default_rng(3)
df = pd.DataFrame({c: rng.normal(0, 1, 80).cumsum() + base
for c, base in [("north", 20), ("south", 40), ("east", 5)]},
index=idx)
axes = df.plot(subplots=True, figsize=(7, 4.5), sharex=True, legend=False)
for ax, name in zip(axes, df.columns):
ax.set_ylabel(name)
print("subplots=True gives one panel per column and returns an ARRAY")
print("of axes rather than a single one.")
print()
print("Note the y axes are independent by default, so the panels are")
print("not comparable - pass sharey=True when they should be.")
Output
When to drop back to matplotlib
pandas is a shortcut; anything unusual is easier underneath.
example_06.pymatplotlib
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
idx = pd.date_range("2024-01-01", periods=60, freq="D")
rng = np.random.default_rng(4)
df = pd.DataFrame({"actual": rng.normal(0, 1, 60).cumsum() + 50}, index=idx)
df["target"] = 52
fig, ax = plt.subplots(figsize=(7.5, 3.5))
df["actual"].plot(ax=ax, color="#264653", linewidth=2, label="actual")
ax.axhline(52, color="crimson", linestyle="--", linewidth=1, label="target")
above = df["actual"] > 52
ax.fill_between(df.index, 52, df["actual"], where=above,
alpha=0.2, color="#2a9d8f")
i = df["actual"].idxmax()
ax.annotate("peak %.1f" % df["actual"].max(), xy=(i, df["actual"].max()),
xytext=(10, -20), textcoords="offset points",
arrowprops=dict(arrowstyle="->"))
ax.legend()
print("pandas drew the line. matplotlib did the threshold, the shading")
print("and the annotation - none of which df.plot exposes.")
print()
print("The two mix freely, because df.plot is a thin wrapper that")
print("returns the same Axes object.")
Output
It is a thin wrapper
The key thing to know is that df.plotreturns a matplotlib Axes, and accepts one through ax=.
That means it isn't a split system. all in this track applies to the result:
ax= also matters for a other reason: without it, pandas creates its own figure. A df.plot call inside a loop over subplots draws each chart in a new figure rather than the panel you intended. This Is a common and confusing result.
kinds
kind= selects the chart: "line" (the default), "bar", "barh", "hist", "box", "kde", "area", "pie", "scatter", "hexbin".
df.plot.bar() is equal and reads better, with the edge that an editor can autocomplete the method names and their args.
scatter and hexbin need x= and y= naming columns. They take two vars rather than plotting all against the index.
Most keyword args are passed through to matplotlib. alpha, color, linewidth and the rest work as expected.
The frame's shape is the chart
This is the part that finds whether the output is what you wanted.
Each column turns a series. Each row turns a spot on the x axis.
So a frame with quarters as rows and products as columns gives grouped bars with one cluster per quarter. If you wanted one cluster per product, the frame needs transposing. df.T.plot(kind="bar"). Not a other plotting arg.
When a chart comes out grouped the wrong way, the fix is nearly always a pivot, a groupby().unstack(), or a transpose. That is the reshaping module doing its job: getting the frame into the right shape is the plotting work. The plot call is then trivial.
stacked=True stacks bars or areas.
subplots=True
df.plot(subplots=True) gives one panel per column and returns an array of axes rather than one.
It's the fastest route to small multiples. It has a default worth knowing. The y axes are split. The panels aren't close. sharey=True fixes it, and without it a panel showing a range of 0–2 sits beside one showing 0–2000 at the same visual height.
layout=(2, 3) arranges them in a grid rather than a column.
When to drop back
pandas covers the common cases and exposes nothing beyond them.
Reach for matplotlib when you need:
annotations, arrows or text;
link lines and shaded regions;
a second y axis, or a secondary unit;
fine control of ticks, formatters or scales;
anything drawn conditionally — highlighting one series, greying the rest.
The good news is that this isn't a switch. df.plot(ax=ax) for the data and ax. methods for all else is the normal way to work. It's why the wrapper is worth having.
And when to leave both
For statistical charts — faceted grids, regression plots with trust bands, cat scatter with built-in jitter — seaborn sits on top of matplotlib and makes them in one call, returning matplotlib objects you can adjust later.
For interactive charts in a browser, Plotly or Altair. matplotlib's interactivity is limited and not its purpose.
Knowing where matplotlib stops is part of using it well. It's a drawing library that happens to have statistical conveniences, not a statistical graphics system.
What pandas adds
Beyond convenience, three things are truly easier through pandas.
The index turns the x axis, with date formatting handled. That alone drops a few lines for any time series.
Column names become labels, so the legend is built from the data rather than from a list you maintain apart.
Groupby output plots directly: df.groupby("k")["v"].sum().plot(kind="barh") goes from raw rows to a chart in one line, because the aggregation produces exactly the index-and-values shape the plot wants.
That last point is the real workflow: reshape until the frame *is* the chart, then plot it.
Common frame shapes
A time-indexed frame, one column per series → df.plot() gives a multi-line chart.
A frame indexed by group, one column per group → df.plot(kind="bar") gives grouped bars.
A long frame — one row per finding with a group column — needs pivoting first: df.pivot(index=..., columns=..., values=...).
A group-by result with two keys → .unstack() puts one key into columns, giving the wide shape the plot needs.
Recognising which of these you've, and which the chart needs, is most of the work. The plot call is one line either way.
Where pandas gets in the way
df.plot has its own views that at times conflict with matplotlib's.
It sets its own tick locators for date axes. This Are often good and are awkward to replace later. Setting a matplotlib locator on an axes pandas has already formatted at times has no effect. Plotting with ax.plot(df.index, df[col]) instead gives back full control.
It creates a figure if not given one. This Is the ax= issue.
And secondary_y=True exists and makes a twin axis with all the problems from that module, plus a legend that is harder to assemble.
For anything beyond the standard cases, dropping to ax.plot(df.index, df["col"]) costs one line and drops the ambiguity about who owns the axes.
Categorical bar charts
df.plot(kind="bar") uses the index as the groups and draws them at int spots, with the labels rotated 90 degrees by default.
rot=0 stops the rotation. kind="barh" is often better still, for the reasons in the bar module.
Sorting is a pandas step, not a plotting one: df.sort_values("v").plot(kind="barh"). That is a small example of the general pattern. The data handling belongs in pandas. The plot call should be trivial.
Beyond pandas
df.plot covers the standard charts. seaborn covers the statistical ones. Faceted grids, regression with bands, cat scatter with jitter built in. And returns matplotlib objects. The same adjustment methods apply later.
The three layers work together: pandas for the data, seaborn for the statistical display, matplotlib for the final control. Knowing which layer a problem belongs to is most of using them well. The answer for "make this exact thing look right" is almost always the bottom one.
A complete example
The common shape of real plotting code. Here The data work dominates:
summary = (df
.query("year == 2024")
.groupby(["region", "month"], as_index=False)["sales"].sum()
.pivot(index="month", columns="region", values="sales"))
fig, ax = plt.subplots(figsize=(7.5, 3.5))
summary.plot(ax=ax, linewidth=2)
ax.set_title("Sales by region, 2024", loc="left", fontweight="bold")
ax.set_ylabel("Sales (thousands)")
for side in ("top", "right"):
ax.spines[side].set_visible(False)
Four lines of pandas to get the frame into the shape of the chart, one to draw it, and four to finish it.
That ratio is normal, and it's why reshaping is a plotting skill.
Pitfalls
**Losing ax=**, so the plot lands in its own figure.
The wrong frame shape, giving bars grouped by the wrong var.
Rotated labels by default on kind="bar", fixed with rot=0 or by using barh.
**secondary_y=True**, which is a twin axis with all its problems.
given shared axes with subplots=True. Here The default is split.
A datetime index that is really strings, giving a cat axis with no gaps.
The last is worth checking with df.index.dtype before wondering why the spacing looks wrong.
The three layers
Real plotting code sits across three libraries. Knowing which one owns a problem saves a lot of searching.
pandas owns the data: filtering, grouping, pivoting, resampling. If the chart is grouped by the wrong thing, or the bars are clustered incorrectly, or a date axis is behaving like a group, the fix is here. In the shape or the dtypes, not in the plot call.
seaborn owns statistical display: faceting, regression with bands, cat scatter with jitter, spread checks. If you're writing twenty lines to build a grid of conditioned plots, this layer already has it.
matplotlib owns the drawing: annotations, link lines, exact ticks, colour control, layout, saving. all the other two give is a matplotlib object. This layer is always on hand underneath.
The common mistake is trying to solve a data problem in the plotting call. Adding args to df.plot to fix a grouping that should have been a pivot.
Plotting from pandas, in summary
df.plot() draws each column against the index and builds the legend from the column names.
It returns an Axes and takes one. This Is what makes it a shortcut rather than a split system. And ax= is needed, or a plot inside a subplot loop creates its own figure.
The frame's shape is the chart: columns are series, rows are groups. A chart grouped the wrong way needs a transpose or a pivot.
subplots=True gives small multiples and split y axes unless you say else.
Reach for matplotlib for annotations, thresholds, shading and exact control. And mix freely. It's the same Axes object either way.
Reshaping is the plotting work
The recurring lesson of this module is worth stating on its own.
The plot call is often one line. The work is getting the frame into a shape where that one line is correct.
A wide frame — one column per series, index as x — is what a multi-line chart or a grouped bar chart wants.
A long frame — one row per finding — is what seaborn and most statistical tools want.
An aggregated frame — one row per group — is what a bar chart of totals wants.
Moving between them is pivot, melt, groupby().agg() and unstack, all covered in the pandas track.
When a chart comes out grouped by the wrong var, or with the series and groups swapped, or with one line where there should be five, the fix is almost never an arg to plot. It's the shape of the frame. Recognising that at once saves a lot of time reading plotting docs for a thing the data work should have solved.
A closing note on Plotting from pandas
df.plot is a shortcut, and its value is scaled to how standard the chart is.
For a quick line chart of a time-indexed frame it's unbeatable. One call, correct axis, legend included. For anything with an annotation, a threshold, a highlighted series or a exact tick format, it stops helping and matplotlib underneath does the work.
The two together are the normal way to write plotting code. The boundary isn't a choice you make once. df.plot(ax=ax) followed by half a dozen ax. calls is a just right plain chart.
What is worth internalising is that most plotting problems are data problems. When the chart is grouped wrongly, or has too many lines, or shows a group as a number, the fix is in the frame.
Questions people ask about Plotting from pandas
Does pandas use matplotlib? Yes. df.plot builds a matplotlib figure and returns an Axes, so every matplotlib method works on the result.
How do I put a pandas plot on my own axes? Pass ax=ax to df.plot. This is how you place pandas charts in a subplot grid.
Why is my index on the x axis?df.plot uses the index for x by default. Set the index first, or pass x='column'.
How do I plot one column against another? Use df.plot(x='a', y='b'), or pass the columns to ax.plot directly.
How do I get dates formatted well? Use a DatetimeIndex. pandas then picks sensible date ticks for you, and you can adjust them with matplotlib's date tools.
Should I aggregate before plotting? Yes. Use groupby or resample first, so the chart draws the summary rather than every raw row.
Plotting from pandas: the short version
df.plot is a shortcut that returns a matplotlib Axes. This Is what makes it a convenience rather than a split system.
Its limits arrive quickly, and the boundary isn't a choice made once: df.plot(ax=ax) followed by half a dozen ax. calls is plain code. And most plotting problems are data problems.
Check yourself
0 of 4
Answer without scrolling back up.
What does `df.plot()` use for the x axis?
Which is why a time-indexed frame gives a dated axis for free, with one line per column and a legend.
Why pass `ax=` to `df.plot`?
df.plot returns and accepts a matplotlib Axes, which is what makes the two mix freely.
Your grouped bars are clustered by the wrong variable. What do you change?
Each column is a series and each row a group, so getting the frame into the right shape is the plotting work.
What is the default for the y axes with `subplots=True`?
Pass sharey=True, or a panel ranging 0-2 sits beside one ranging 0-2000 at the same visual height.
Cheat sheet
Plotting from pandas
Create charts from a pandas DataFrame in one line with df.plot. See when the shortcut works and when to fall back to raw matplotlib calls.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.