plot() and the arguments that do most of the work - style, width, colour and what happens with missing data.
Overview
The signature
A line plot connects points in order. It is the right chart when the x axis represents a sequence, usually time, and the y axis represents a measured quantity. The line implies continuity between the points. If the data is not continuous, a scatter plot is more honest.
ax.plot is matplotlib's most-used drawing method. It handles single lines, multiple lines, markers, colours, line styles and labels. Most of its options have sensible defaults, so a basic line plot is one call. Customising it for publication is a few more.
This page covers the call signature, the colour cycle, styling, format strings, markers, missing data, drawing order and performance. It also includes a worked example that applies the settings a finished chart needs: a title, axis labels, a legend, gridlines and cleaned-up spines.
The format-string shorthand ("ro--" for red circles with a dashed line) is compact but limited. For anything beyond a quick check, keyword arguments are clearer. Both are covered here so you can read other people's code in either style.
Line plots can mislead when the x values are not evenly spaced. A line drawn between a point in January and a point in June implies data for February through May. If those months have no readings, the line is a guess. Gaps in the data should be gaps in the line. Passing np.nan for missing values achieves that.
Performance matters once the number of points is large. A million-point line is a single artist and draws quickly. A loop that creates a thousand short lines creates a thousand artists and is far slower. The page ends with notes on when plot slows down and what to do about it.
ax.plot(y) draws y against its index. ax.plot(x, y) draws one against the other.
That one-arg form is convenient and at times misleading. A plot whose x axis reads 0 to 99 when the data is dated is often a forgotten x arg rather than a deliberate choice.
plot accepts lists, NumPy arrays and pandas Series. With a Series it uses the index as x. That is why plotting a time-indexed Series gives a dated axis for free.
Every example on this page uses ax.plot rather than plt.plot. The difference is that ax.plot draws on a specific axes, while plt.plot draws on whichever axes matplotlib considers current. Using the axes form is always safer.
Worth knowing
ax.plot(y) uses range(len(y)) as x; ax.plot(x, y) is explicit.
Each plot call takes the next colour from the axes' property cycle, wrapping after ten.
color, linestyle and linewidth do most of the styling; "0.4" is a grey level.
The format string "r--o" packs colour, style and marker into one word — terse, limited, and worth being able to read.
NaN leaves a gap in the line rather than being interpolated across, which is usually the honest choice.
Later draw calls paint on top; zorder overrides that.
Line Plots
plot() and the args that do most of the work.
x and y, or just y
With one argument matplotlib supplies the index as x.
example_01.pymatplotlib
import matplotlib.pyplot as plt
y = [3, 7, 5, 9, 6]
fig, (a, b) = plt.subplots(1, 2, figsize=(7, 2.5))
a.plot(y)
a.set_title("plot(y) - x is 0,1,2,...")
a2 = [10, 20, 30, 40, 50]
b.plot(a2, y)
b.set_title("plot(x, y)")
print("one argument means y; the x values are range(len(y))")
print("x used on the left :", list(range(len(y))))
print("x used on the right:", a2)
Output
Several lines, and where the colours come from
Each call takes the next colour from the axes' cycle.
example_02.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 10, 100)
fig, ax = plt.subplots(figsize=(6, 3))
for k in range(4):
ax.plot(x, np.sin(x + k), label="shift %d" % k)
ax.legend()
cycle = plt.rcParams["axes.prop_cycle"].by_key()["color"]
print("the default colour cycle:")
for c in cycle[:6]:
print(" ", c)
print()
print("Four calls took the first four. A fifth would take the fifth,")
print("and the eleventh would wrap around to the first.")
Output
Style, width and colour
The three arguments that turn a default line into a deliberate one.
example_03.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 6, 40)
fig, ax = plt.subplots(figsize=(6, 3))
ax.plot(x, np.sin(x), color="crimson", linewidth=2.5, label="solid, thick")
ax.plot(x, np.cos(x), color="steelblue", linestyle="--", label="dashed")
ax.plot(x, np.sin(x) * 0.5, color="0.4", linestyle=":", linewidth=2, label="dotted grey")
ax.legend()
print("linestyle: '-' solid, '--' dashed, '-.' dashdot, ':' dotted")
print("color : a name, a hex string, or '0.4' for a grey level")
print("linewidth: points, default", plt.rcParams["lines.linewidth"])
Output
The format-string shorthand
Compact, common in examples, and worth being able to read.
example_04.pymatplotlib
import matplotlib.pyplot as plt
x = [1, 2, 3, 4]
fig, ax = plt.subplots(figsize=(6, 3))
ax.plot(x, [1, 4, 2, 5], "r--o", label="'r--o'")
ax.plot(x, [2, 5, 3, 6], "g:s", label="'g:s'")
ax.plot(x, [3, 6, 4, 7], "b-^", label="'b-^'")
ax.legend()
print("A format string packs colour, linestyle and marker into one word:")
print(" r = red, g = green, b = blue, k = black")
print(" -- dashed, : dotted, - solid")
print(" o circle, s square, ^ triangle")
print()
print("It is terse and limited - only eight colours, and no way to set")
print("width. Keyword arguments are clearer for anything real.")
Output
Missing values break the line
NaN leaves a gap rather than drawing through it, which is usually right.
example_05.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
x = np.arange(12)
y = np.array([1, 2, 3, np.nan, np.nan, 6, 7, 8, 9, np.nan, 11, 12], dtype=float)
fig, (a, b) = plt.subplots(1, 2, figsize=(7, 2.5), sharey=True)
a.plot(x, y, marker="o")
a.set_title("with NaN: gaps")
ok = ~np.isnan(y)
b.plot(x[ok], y[ok], marker="o")
b.set_title("dropped: joined through")
print("NaN is not plotted and the line is broken there.")
print("Dropping the points instead draws a line through the gap, which")
print("implies data you do not have. The gap is usually more honest.")
Output
Order matters, and so does zorder
Later calls draw on top, unless you say otherwise.
example_06.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 10, 200)
fig, (a, b) = plt.subplots(1, 2, figsize=(7, 2.5))
a.fill_between(x, 0, np.sin(x) + 1, color="lightsteelblue")
a.plot(x, np.sin(x) + 1, color="crimson", linewidth=2)
a.set_title("line drawn second")
b.plot(x, np.sin(x) + 1, color="crimson", linewidth=2)
b.fill_between(x, 0, np.sin(x) + 1, color="lightsteelblue")
b.set_title("fill drawn second - line hidden")
print("Same two calls, swapped. The right-hand line is under the fill.")
print()
print("zorder overrides the order: higher draws later.")
print(" line zorder default:", 2.0, " patch default:", 1.0)
print(" so a line normally sits above a fill anyway - unless the fill")
print(" is drawn after it, as on the right.")
Output
The colour cycle
Each call to plot on the same axes takes the next colour from the property cycle, a list of ten colours defined by the current style.
This is why a few lines drawn in a loop come out in other colours with no work. Why the eleventh line repeats the first.
plt.rcParams["axes.prop_cycle"] holds it. ax.set_prop_cycle(...) replaces it for one axes. This Is how you enforce a house palette.
Passing an clear color bypasses the cycle without advancing it.
Styling
Three keywords cover nearly all:
color — a name ("crimson"), a hex string ("#4c72b0"), a single letter ("r"), a grey level as a string ("0.4"), or an RGBA tuple.
linestyle — "-" solid, "--" dashed, "-." dashdot, ":" dotted, or a dash pattern as a tuple.
linewidth — in points.
Add alpha for transparency. This Is how you keep many shared lines readable. marker to show where the actual data points are.
That last one matters more than it looks. A smooth line through four points implies a lot of data that doesn't exist. Markers say where the readings are and let the line be what it's. A visual aid rather than a claim.
The format string
ax.plot(x, y, "r--o") sets colour, linestyle and marker in one string.
It's worth learning to read because it appears constantly in examples and docs. It isn't worth preferring. It supports only eight colours, can't set width or alpha. Is opaque to anyone who hasn't stored it.
The order within the string doesn't matter. Any part can be omitted. "o" alone draws markers with no connecting line. This Is a scatter plot by another route.
Missing data
NaN values are skipped, and the line is broken where they occur.
This is the right default. A line drawn straight through a gap asserts that the value moved smoothly across it. This Is just the thing you don't know.
If you want the line joined, dropping the missing points is clear about it:
ok = ~np.isnan(y)
ax.plot(x[ok], y[ok])
Note that None behaves like NaN here. A masked array's masked values are also skipped.
For a long series with occasional gaps, the broken line can look noisy. Drawing markers as well makes the pattern of missingness visible rather than merely untidy.
Drawing order
Artists are drawn in the order the calls are made. Later calls sit on top.
zorder replaces it. Higher values draw later. The defaults are set so that the usual expectations hold. Patches at 1, lines at 2, text at 3. Which is why a line most times appears above a filled region even when the fill was drawn second.
Where it matters most is grid lines. ax.grid() draws below the data by default. ax.set_axisbelow(False) puts it on top. This Is almost never what you want but is at times what you get from a style sheet.
Markers
marker="o" draws a symbol at each data point. The common ones are o circle, s square, ^ triangle, D diamond, . point, + and x.
Markers matter more than they look. A smooth line through five points implies a smooth link measured densely. Markers say where the readings really are and let the line be the visual aid it's.
markevery=10 draws one marker in ten. This Keeps the "here are the findings" signal on a dense series without a solid band of symbols.
markersize, markerfacecolor and markeredgecolor style them apart from the line. A hollow marker is markerfacecolor="none".
Steps and stems
Not each sequence is a line.
ax.step(x, y, where="post") draws a step function. This Is correct for anything that holds a value until it changes. A price, a setting, a state. A straight line between the points would claim a gradual transition that didn't happen.
where takes "pre", "post" or "mid", deciding whether the step happens at the start or the end of each interval. Getting it wrong shifts each value by one spot. The chart looks plausible either way.
ax.stem draws a vertical line to each point. This Suits sparse discrete data.
ax.fill_between under a line turns it into an area chart, right when the quantity builds up to a thing real and misleading when it doesn't.
Multiple lines and readability
Four or five lines on one axes is often the limit before the chart turns a tracing exercise.
Beyond that, the options in order of preference are. Highlight one and grey the rest, split into small multiples, or reduce to the two lines the chart is really about.
Where a few lines must stay, three things help.
Direct labels at the line ends, instead of a legend.
Sorting the legend to match the vertical order of the lines at their right-hand end. The eye can map them without hunting. ax.legend(handles=...) in the order you want.
Varying linestyle and colour, so the chart lives greyscale.
Interpolation is a claim
A line between two points asserts that the quantity passed through the values in between.
For a temp sampled hourly, that is fair. For monthly sales, the line between January and February is decorative. Nothing happened at "January the 15th" in the data.
That doesn't make the line wrong. Connecting points is how the eye reads a trend. A scatter of twelve unconnected points is harder to follow. But it's worth knowing that the line is an aid rather than data. This Is another arg for markers.
Where the gaps are truly large, breaking the line is more honest than spanning them. That is what leaving the NaN in does for you on its own.
Performance note
One plot call with a large array is fast. Many plot calls are slow. The cost is per artist rather than per point.
Drawing fifty lines in a loop creates fifty artists. Where they can be mixed, LineCollection draws them as one.
That matters at hundreds of lines rather than dozens. It's covered properly in the speed module.
A worked example
Turning a default line chart into a finished one is a short, fixed sequence.
fig, ax = plt.subplots(figsize=(7.5, 3.5))
ax.plot(x, y, color="#264653", linewidth=2, marker="o", markersize=4)
ax.set_title("What the chart shows", loc="left", fontsize=12, fontweight="bold")
ax.set_xlabel("Month of 2024")
ax.set_ylabel("Sales (thousands)")
ax.margins(x=0.02)
ax.grid(True, axis="y", alpha=0.3)
ax.set_axisbelow(True)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
Aspect, colour, weight, markers, a title that says a thing, labelled units, a faint grid on one axis, two spines removed.
Ten lines, none of them clever. The result looks deliberate. each one is covered one spot in this track, and together they're most of what splits a default matplotlib chart from a published one.
Reading a line chart critically
Three questions worth asking of any line chart, including your own.
Does the y axis start at zero. Should it? A line codes spot. Truncation is legitimate. And it changes the apparent size of each change. If a 2% movement fills the chart, the reader should be able to tell.
Are the x values evenly spaced? If the axis is cat or the points are irregular in time, the slope between points isn't a rate. The eye reads it as one.
What is between the points? Twelve monthly findings joined by lines look like a smooth series. The line is an aid. The data is twelve points.
None of these makes a line chart wrong. They're the things a careful reader checks. Knowing them is what lets you draw one that lives the checking.
Line Plots, in summary
ax.plot(x, y) with color, linewidth, linestyle and marker covers the great majority of line charts.
The colour cycle supplies distinct colours on its own and wraps after ten. This Is well past the point where a line chart has stopped being readable.
NaN breaks the line. This Is the honest treatment of a gap.
Markers say where the data really is. Matter most when the points are few. A smooth line through five findings claims a lot.
Later calls draw on top, and zorder replaces it.
And the finishing sequence is the same each time. An aspect ratio suited to the data, a title that says a thing, labelled units, a faint grid on one axis. The top and right spines removed.
Smoothing
A noisy series is often plotted with a smoothed version over it. The pairing is more honest than either alone.
The raw series in a pale colour and the rolling mean in a strong one shows both the variation and the trend. Makes clear that the smooth line is derived rather than measured.
Two cautions worth stating on the chart.
The window is a choice, and a longer one makes a smoother, more lifelike line that is further from the data. Naming it. "7-day mean" — is the min.
A centred window uses future values. This Is fine for showing history and wrong for anything presented as a signal on hand at the time. A trailing window is the honest choice for that case, at the cost of lagging the turns.
Plotting only the smoothed line, without the raw data, is where this turns misleading. The reader has no way to judge how much was smoothed away.
Highlighting within a line chart
A line chart with one key series and a few for context is the most common real case, and the treatment is steady:
for name, y in others.items():
ax.plot(x, y, color="0.85", linewidth=1, zorder=1)
ax.plot(x, focus_y, color="#264653", linewidth=2.5, zorder=2)
ax.text(x[-1], focus_y[-1], " " + focus_name, va="center", color="#264653")
The grey lines give the range and the shape of the group. Nothing is lost. The dark line is unmistakably the subject. The label at the end drops the legend.
zorder ensures the highlighted line is on top even so of drawing order. This Matters when the context lines are drawn in a loop after it.
This pattern is worth having to hand. It converts the hardest kind of line chart. Many series, one message. Into one of the easiest to read.
One more thing
ax.plot accepts a few x/y pairs in one call. ax.plot(x, y1, x, y2). Which draws both with split colours from the cycle.
It's compact and gives no way to label the lines on their own. A loop with label= is often better. Worth recognising in existing code. Here It appears often.
Line Plots: the short version
A line chart is the most common chart there's. The finishing sequence is the same each time. Aspect, colour, weight, title, units, a faint grid, two spines removed.
The line itself is an aid rather than data. This Is worth remembering when the points are few and the line is doing a lot of implying.
Questions people ask about Line Plots
Why does my line chart show 0 to 99 on the x axis? You passed only y values. With one argument, ax.plot uses range(len(y)) for x, so supply the x array (dates, years) as the first argument.
How do I stop a line crossing a gap in my data? Put NaN where the value is missing. matplotlib breaks the line at each NaN instead of drawing through it.
How many lines are too many on one chart? Past about five, readers cannot tell lines apart. Mute most of them in grey and colour only the one or two that carry the message.
What is the difference between ax.plot and ax.step?plot joins points with straight segments, which implies smooth change. step holds each value until the next point, which suits counts and settings that change at moments.
Should I show markers on a line? Show them when there are few points, so readers see where the real observations are. With hundreds of points they only add clutter.
Why do my lines change colour when I add another? Each call takes the next colour from the axes' property cycle. Pass color= explicitly to pin a series to a colour.
Reading the code back
A finished line chart is about a dozen calls. It's worth being able to name what each one is for. The figure size sets the aspect, the plot call sets colour and weight, the title carries the message, the labels carry the units, the margins control the breathing space, the grid supports value reading. The spine removal takes away what carries nothing. Nothing in that list is optional in the sense of being decorative. Each answers a question a reader would else have to ask. Writing them in the same order each time makes the chart quick to give and quick to review. This Is most of why a house function is worth having.
Check yourself
0 of 4
Answer without scrolling back up.
What does `ax.plot(y)` use for the x values?
An axis reading 0 to 99 when the data is dated is usually a forgotten x argument rather than a choice.
Why do four lines drawn in a loop come out in different colours?
The cycle holds ten colours, so the eleventh line repeats the first. Passing an explicit color bypasses it without advancing it.
What does matplotlib do with a NaN in the middle of a line?
The right default - a line drawn through the gap would assert the value moved smoothly across it, which is the thing you do not know.
Two calls draw a fill and a line over the same region. What decides which is visible?
Later calls draw on top. Defaults put patches at 1 and lines at 2, so a line usually sits above a fill drawn before it.
Cheat sheet
Line Plots
Draw single and multiple-line plots with matplotlib's plot function. Set colours, markers, line styles and labels with the arguments that matter.
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.