Making Plots with Julia

Introduction

There are several ways to make plots in Julia. The two most widely-used graphing ecosystems are:

  • Plots.jl — a high-level interface that can target many different rendering backends. You write the same Plots.jl code regardless of which backend you choose.
  • Makie.jl — a modern, GPU-accelerated plotting system that gives you very fine control over every visual element. Its most popular backend for publication-quality vector output is CairoMakie.jl.

Other notable libraries include VegaLite.jl (a declarative grammar-of-graphics inspired by ggplot2) and Gadfly.jl.

Plots.jl and Its Backends

Plots.jl itself does not draw anything — it hands off the rendering to a backend. Several backends are available: 1

1 This tutorial focuses on GR and PythonPlot; see the Plots.jl backend gallery for the full list.

  • GR — the default backend. Fast, simple, and works out of the box.
  • PythonPlot — uses Python’s matplotlib via PythonCall.jl. If you already know matplotlib, this backend may feel the most familiar.
  • PlotlyJS — interactive, web-based plots using plotly.js.
  • PGFPlotsX — high-quality LaTeX/TikZ output for publications.
  • UnicodePlots — plots directly in the terminal using Unicode characters.

This tutorial will give examples of making plots using Plots.jl (with the GR backend), and will show how the PythonPlot backend and Makie.jl produce equivalent results. Relevant documentation resources are listed below.

Some Resources

Plots.jl

Makie.jl

PythonPlot

Color and styling

Setup

Since we’ll be generating random numbers, load the packages and set a seed for reproducibility.

using Plots
using StatsPlots   # for distribution plots; re-exports Plots
using CairoMakie   # the vector-graphics Makie backend
using LaTeXStrings
using Measures
using Random
Random.seed!(1);
NotePlots.jl Backends and Makie

Plots.jl is more of a common interface for several different plotting ecosystems (called backends) than a self-contained plotting package. By default, Plots.jl uses the GR backend, which is pretty basic and fast; I will use this in class for simplicity. You switch backends at any time by calling their name, e.g. gr() or pythonplot().

You can and should feel free to use other backends if you find them more intuitive or useful for the plot(s) you are trying to make. In particular, you might find PythonPlot simple to use if you feel comfortable with matplotlib in Python. The downside to PythonPlot is that it relies on Python to render the plots; if you don’t have Python installed, CondaPkg.jl will install a private copy automatically (which can take a few minutes the first time).

Below, each plot example is shown in a tabset for three approaches: the GR backend, the PythonPlot backend, and Makie.jl.

TipMakie Function Names in This Tutorial

Because this document has both Plots.jl and Makie loaded at once, a few function names that exist in both packages (such as scatter and heatmap) are written as Makie.scatter and Makie.heatmap in the Makie tabs. In your own scripts, where you would normally load only one of the two, you can drop the Makie. prefix.

Making a Basic Plot

Let’s walk through making a basic line plot.

First, to generate a basic line plot, use plot():

x = 1:5
y = rand(length(x))
plot(x, y, label="Original Data", legend=:topleft)
1
This creates x as a range of integers between 1 and 5 (inclusive of both endpoints). You can use arbitrary steps with syntax like x = 1:0.1:5. To turn x into a vector instead of a range, you can use collect(x), but this is not needed for plotting (Julia does this under the hood).
2
y is a vector of random values (uniformly distributed between 0 and 1) with the same length as x. This type of syntax is better than hard-coding the length into y, since you might want to use different lengths.
3
We use two different arguments related to constructing the legend: label sets the text associated with the line we just created (to not include an element in the legend, use label=false), and legend either sets the position of the legend (in this case, at the top left) or can be used to turn off the legend (with label=false). But there are many others you could use to customize the plot.

Here, we explicitly passed in x to provide the \(x\) coordinates for the plotted values. If only one array is passed2, Plots.jl will interpret the values as \(y\) coordinates and use their indices for the \(x\) positions.

2 This syntax might look like plot(y, ...) instead of plot(x, y, ...).

Now add more series and axis labels.

TipMutating Function

The exclamation mark says that plot!() is a mutating function, which changes an existing plot instead of creating a new one. Try removing the ! — you will get a new plot that no longer contains the old elements.

y2 = rand(5)
y3 = rand(5)
5-element Vector{Float64}:
 0.5710874493423871
 0.4528085872833483
 0.30232547191787174
 0.0013502779247226426
 0.5670236732404312
gr()
plot(x, y, label="Original Data")
plot!(x, y2, color=:red, linewidth=2, linestyle=:dot, label="New Data")
scatter!(x, y3, markercolor=:black, markershape=:square, markersize=5, label="Point Data")
xlabel!("Regular String (days)")
ylabel!(L"LaTeX String $x_2$ (m$^3$)")
pythonplot()
plot(x, y, label="Original Data")
plot!(x, y2, color=:red, linewidth=2, linestyle=:dot, label="New Data")
scatter!(x, y3, markercolor=:black, markershape=:square, markersize=5, label="Point Data")
xlabel!("Regular String (days)")
ylabel!(L"LaTeX String $x_2$ (m$^3$)")
2026-07-30T13:51:50.997898 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/
fig = Figure()
ax = Axis(fig[1, 1], xlabel="Regular String (days)", ylabel=L"LaTeX String $x_2$ (m$^3$)")
lines!(ax, x, y, label="Original Data")
lines!(ax, x, y2, color=:red, linewidth=2, linestyle=:dot, label="New Data")
Makie.scatter!(ax, x, y3, color=:black, marker=:rect, markersize=10, label="Point Data")
axislegend(ax, position=:lt)
fig

Removing Plot Elements

Sometimes we want to remove legends, axes, grid lines, and/or ticks.

plot!(legend=false, axis=false, grid=false, ticks=false)
2026-07-30T13:51:55.053052 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/

Notice that this unintentionally modified the image dimensions to move the axis labels off the page. If we wanted to keep them, we could modify the dimensions with plot!(size=...).

plot!(size=(400, 400))
2026-07-30T13:51:55.178309 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/

The lesson is that sometimes the Plots.jl defaults don’t look ideal, and we need to adjust sizes and margins. Don’t shy away from these tweaks if they make your figures easier to read or interpret!

Aspect Ratio

For a square aspect ratio, Plots.jl uses ratio = 1; Makie.jl uses aspect = AxisAspect(1).

v = rand(5)
5-element Vector{Float64}:
 0.6159379234562881
 0.19573857852575793
 0.012461945950411835
 0.3119923865097316
 0.11479916823306191
gr()
plot(v, ratio=1, legend=false)
scatter!(v)
pythonplot()
plot(v, ratio=1, legend=false)
scatter!(v)
2026-07-30T13:51:56.175731 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/
fig = Figure()
ax = Axis(fig[1, 1], aspect=AxisAspect(1))
lines!(ax, v)
Makie.scatter!(ax, v)
fig

Plot Demos

This section includes some examples of how to make other types of plots.

Heatmaps

A heatmap is a plotted matrix whose cells are colored by value. Use clim (Plots) or colorrange (Makie) to fix the color scale.

A = rand(10, 10)
1
Create a random 10×10 matrix, but this could come from actual data.
10×10 Matrix{Float64}:
 0.742414  0.108674  0.0909513  0.818597   …  0.239896   0.96551   0.229329
 0.65913   0.265374  0.914553   0.859535      0.327377   0.81217   0.575938
 0.360275  0.699443  0.590472   0.375186      0.109344   0.430081  0.243464
 0.892222  0.036808  0.967915   0.947293      0.51529    0.599123  0.342309
 0.337329  0.633275  0.673935   0.373811      0.0913972  0.82805   0.835509
 0.671766  0.295391  0.228969   0.0223076  …  0.585548   0.592562  0.0590473
 0.534097  0.348069  0.46191    0.110385      0.120938   0.982151  0.551654
 0.655354  0.427535  0.577283   0.226571      0.628025   0.439115  0.139791
 0.404693  0.60398   0.139681   0.857178      0.59816    0.938309  0.540769
 0.703556  0.641963  0.129069   0.0568809     0.717561   0.593831  0.775324
gr()
heatmap(A, clim=(0, 1))
pythonplot()
heatmap(A, clim=(0, 1))
2026-07-30T13:51:58.634760 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/
fig = Figure()
ax = Axis(fig[1, 1])
hm = Makie.heatmap!(ax, A, colorrange=(0, 1))
Colorbar(fig[1, 2], hm)
fig
M = [ 0 1 0; 0 0 0; 1 0 0]
whiteblack = [RGBA(1,1,1,0), RGB(0,0,0)]
heatmap(M, c=whiteblack, aspect_ratio = 1, ticks=.5:3.5, lims=(.5,3.5), gridalpha=1, legend=false, axis=false, ylabel="i", xlabel="j")
1
This creates a vector of colors, so 0 (the lower value) will map to whiteblack[1] (which is white) and 1 will map to whiteblack[2] (black). The specific 0 and 1 values don’t matter for this syntax, just that there are two distinct values; the lower one will always be mapped to white. Try changing the values of M to 1 and 2!
2026-07-30T13:51:59.314390 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/

Custom Colormaps

Use a custom set of colors instead of the default gradients.

using Colors

mycolors = [colorant"lightslateblue",colorant"limegreen",colorant"red"]
A2 = [i for i=50:300, j=1:100]
1
Colors.jl provides many named colors. The colorant function converts a name to an RGB value.
2
This comprehension creates a 251×100 array where each row’s value increases from 50 to 300.
251×100 Matrix{Int64}:
  50   50   50   50   50   50   50   50  …   50   50   50   50   50   50   50
  51   51   51   51   51   51   51   51      51   51   51   51   51   51   51
  52   52   52   52   52   52   52   52      52   52   52   52   52   52   52
  53   53   53   53   53   53   53   53      53   53   53   53   53   53   53
  54   54   54   54   54   54   54   54      54   54   54   54   54   54   54
  55   55   55   55   55   55   55   55  …   55   55   55   55   55   55   55
  56   56   56   56   56   56   56   56      56   56   56   56   56   56   56
  57   57   57   57   57   57   57   57      57   57   57   57   57   57   57
  58   58   58   58   58   58   58   58      58   58   58   58   58   58   58
  59   59   59   59   59   59   59   59      59   59   59   59   59   59   59
   ⋮                        ⋮            ⋱              ⋮                 
 292  292  292  292  292  292  292  292     292  292  292  292  292  292  292
 293  293  293  293  293  293  293  293     293  293  293  293  293  293  293
 294  294  294  294  294  294  294  294     294  294  294  294  294  294  294
 295  295  295  295  295  295  295  295  …  295  295  295  295  295  295  295
 296  296  296  296  296  296  296  296     296  296  296  296  296  296  296
 297  297  297  297  297  297  297  297     297  297  297  297  297  297  297
 298  298  298  298  298  298  298  298     298  298  298  298  298  298  298
 299  299  299  299  299  299  299  299     299  299  299  299  299  299  299
 300  300  300  300  300  300  300  300  …  300  300  300  300  300  300  300
gr()
heatmap(A2, c=mycolors, clim=(50, 300))
pythonplot()
heatmap(A2, c=mycolors, clim=(50, 300))
2026-07-30T13:52:01.398415 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/
fig = Figure()
ax = Axis(fig[1, 1])
hm = Makie.heatmap!(ax, A2, colormap=mycolors, colorrange=(50, 300))
Colorbar(fig[1, 2], hm)
fig

Area Under a Curve

areaplot() fills the region between a curve and the x-axis. The Makie equivalent is band! with a zero lower bound.

xa = -3:0.01:3
fa = exp.(-xa.^2/2) / (2π)
601-element Vector{Float64}:
 0.0044318484119380075
 0.004566589954670145
 0.004704957526933979
 0.004847032905978945
 0.004992899213612376
 0.005142640923053939
 0.00529634386531102
 0.005454095235056545
 0.005615983595990969
 0.005782098885669473
 ⋮
 0.005615983595990969
 0.005454095235056545
 0.00529634386531102
 0.005142640923053939
 0.004992899213612376
 0.004847032905978945
 0.004704957526933979
 0.004566589954670145
 0.0044318484119380075
gr()
areaplot(xa, fa, alpha=0.25, legend=false)
pythonplot()
areaplot(xa, fa, alpha=0.25, legend=false)
2026-07-30T13:52:06.884419 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/
fig = Figure()
ax = Axis(fig[1, 1])
band!(ax, xa, fill(0.0, length(xa)), fa, color=(:blue, 0.25))
fig

Stacked Area Plots

We can stack multiple area series on top of each other by passing a matrix to areaplot(), where each column (or row) represents one layer. The layers are stacked cumulatively. A practical example: visualize the monthly energy use of four sources over three years.

years = ["2021", "2022", "2023"]
# Each row is a source; each column is a year.
# Sources: Solar, Wind, Hydro, Coal (values in GWh)
energy = [
    120  180  260;    # Solar (growing)
    200  220  240;    # Wind
    150  140  130;    # Hydro (declining)
     80   60   40     # Coal (declining)
]
sources = ["Solar", "Wind", "Hydro", "Coal"]

p = areaplot(1:3, energy',               # transpose so each column = layer
    seriescolor = [:orange :skyblue :steelblue :gray],
    fillalpha   = [0.6, 0.5, 0.4, 0.5],
    labels      = reshape(sources, 1, :), # one label per layer
    legend      = :topleft,
    xticks      = (1:3, years),
    xlabel      = "Year",
    ylabel      = "Energy Use (GWh)"
)
2026-07-30T13:52:09.757890 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/

When you pass a matrix, each column becomes its own stacked layer. The seriescolor and fillalpha vectors control the color and transparency per layer. The labels must be a row matrix (reshape(sources, 1, :)) to match the matrix orientation. Try experimenting — change the values or colors to see how the plot adjusts.

The fillrange option lets us color the area between two arbitrary lines/curves if we only want to treat one of those curves as a boundary. fillcolor and fillalpha let you change the color and transparency of the filled area.

y = rand(10)
plot(y, fillrange= y.*0 .+ .5, label= "above/below 1/2", fillcolor=:red, legend =:top)
2026-07-30T13:52:10.242234 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/

Confidence Band

Use fillrange to color the region between two curves, e.g. to show a confidence band.

xb = LinRange(0, 2, 100)
y1 = exp.(xb)
y2 = exp.(1.3 .* xb)
1
LinRange creates a range from 0 to 2 with 100 evenly spaced points.
100-element Vector{Float64}:
  1.0
  1.0266105279586968
  1.0539291761156344
  1.0819747879231458
  1.1107667082677797
  1.1403247968137291
  1.1706694417013364
  1.2018215736101217
  1.233802680196039
  1.2666348209129108
  ⋮
 10.912391625589951
 11.20277612803896
 11.500887915409168
 11.806932614831997
 12.121121325285433
 12.443670763202702
 12.774803411955727
 13.11474767531643
 13.463738035001692
gr()
plot(xb, y1, fillrange=y2, fillalpha=0.35, c=1, label="Confidence band", legend=:topleft)
pythonplot()
plot(xb, y1, fillrange=y2, fillalpha=0.35, c=1, label="Confidence band", legend=:topleft)
2026-07-30T13:52:11.567667 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/
fig = Figure()
ax = Axis(fig[1, 1])
band!(ax, xb, y1, y2, color=(:blue, 0.35), label="Confidence band")
lines!(ax, xb, y1, label="Estimate")
axislegend(ax, position=:lt)
fig

We can also get more creative and color different parts of a curve differently. Here, we divide a normal distribution into 100 quantiles and alternate red and blue stripes. We’ll do this using the erfinv() function from SpecialFunctions.jl to calculate the quantiles using the inverse cumulative distribution function, but there are other approaches using Distributions.jl.

using SpecialFunctions

# write a function for the normal distribution density
f(x) = exp(-x^2/2)/(2π) 
# get the edges of the quantiles
δ = .01
x =2 .* erfinv.(2 .*/2 : δ : 1) .- 1)
# make the plot and draw the density line in black
areaplot(x, f.(x), seriescolor=[ :red,:blue], legend=false)
plot!(x, f.(x),c=:black)
2026-07-30T13:52:13.422776 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/

Plotting Shapes

We can also draw shapes more directly, such as rectangles and circles.

rectangle(w, h, x, y) = Shape(x .+ [0,w,w,0], y .+ [0,0,h,h])
circle(r,x,y) == LinRange(0,2π,500); (x.+r.*cos.(θ), y.+r.*sin.(θ)))
plot(circle(5,0,0), ratio=1, c=:red, fill=true)
plot!(rectangle(5*2,5*2,-2.5*2,-2.5*2),c=:white,fill=true,legend=false)
2026-07-30T13:52:16.717875 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/

Plotting Distributions

StatsPlots.jl adds distribution-plotting recipes. Makie.jl requires computing the PDF explicitly with Distributions.jl.

using Distributions
d = Normal(2, 5)
xs_dist = range(-15, 20, length=300)
-15.0:0.11705685618729098:20.0
gr()
plot(d)
pythonplot()
plot(d)
2026-07-30T13:52:18.171305 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/
fig = Figure()
ax = Axis(fig[1, 1], xlabel="x", ylabel="pdf(x)")
lines!(ax, xs_dist, pdf.(d, xs_dist))
fig

Kernel Density Estimate

samples = rand(d, 2000)
2000-element Vector{Float64}:
  1.5245899213514686
  4.24310012818435
  3.4922212945438504
  4.857580339253022
 -1.0847650373991686
  0.34742627716202557
 -3.8740430084774573
  0.6981636365869883
 -1.7086625454229645
  0.5531704345757209
  ⋮
  2.519087067670294
  0.2482312995844831
  9.895844005945166
  6.946334488918343
  9.527402479478546
  3.6776118640373947
  9.825093587382582
  3.1282935080762977
 -1.4111479173941746
gr()
density(samples, label="KDE", legend=:topleft)
pythonplot()
density(samples, label="KDE", legend=:topleft)
2026-07-30T13:52:19.549562 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/
fig = Figure()
ax = Axis(fig[1, 1], xlabel="Value", ylabel="Density")
Makie.density!(ax, samples)
fig

DataFrame Density

StatsPlots.jl also integrates with DataFrames.jl:

using DataFrames
dat = DataFrame(a = 1:10, b = 10 .+ rand(10), c = 10 .* rand(10))
@df dat density([:b :c], color=[:black :red])
1
@df is an example of a macro, which modifies the subsequent function. There are many other examples of macros in Julia.
2026-07-30T13:52:22.181011 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/

Log-Scaled Axes

xx = 0.1:0.1:10
0.1:0.1:10.0
gr()
plot(xx, xx.^2, xaxis=:log, yaxis=:log, legend=false)
pythonplot()
plot(xx, xx.^2, xaxis=:log, yaxis=:log, legend=false)
2026-07-30T13:52:23.550930 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/
fig = Figure()
ax = Axis(fig[1, 1], xscale=log10, yscale=log10)
lines!(ax, xx, xx .^ 2)
fig
plot(exp.(x), yaxis=:log)
2026-07-30T13:52:23.920234 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/

Editing Plots Manually

Now let’s look at how to modify plot attributes directly.

pl = plot(1:4,[1, 4, 9, 16])
2026-07-30T13:52:24.104491 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/

To get a list of properties we can modify, use p.attr:

pl.attr
RecipesPipeline.DefaultsDict with 30 entries:
  :dpi                      => 96
  :background_color_outside => :match
  :plot_titlefontvalign     => :vcenter
  :warn_on_unsupported      => true
  :background_color         => RGBA{Float64}(1.0, 1.0, 1.0, 1.0)
  :inset_subplots           => nothing
  :size                     => (672, 480)
  :display_type             => :auto
  :overwrite_figure         => true
  :html_output_format       => :auto
  :plot_titlefontfamily     => :match
  :plot_titleindex          => 0
  :foreground_color         => RGB{N0f8}(0.0, 0.0, 0.0)
  :window_title             => "Plots.jl"
  :plot_titlefontrotation   => 0.0
  :extra_plot_kwargs        => Dict{Any, Any}()
  :pos                      => (0, 0)
  :plot_titlefonthalign     => :hcenter
  :tex_output_standalone    => false
  ⋮                         => ⋮

We can also directly access properties of the plotted series.

pl.series_list[1]
Plots.Series(RecipesPipeline.DefaultsDict(:plot_object => Plot{Plots.PythonPlotBackend() n=1}, :subplot => Subplot{1}, :label => "y1", :serieshandle => Any[<py [<matplotlib.lines.Line2D object at 0x33c625290>]>], :fillalpha => nothing, :linealpha => nothing, :linecolor => RGBA{Float64}(0.0, 0.6056031704619725, 0.9786801190138923, 1.0), :x_extrema => (NaN, NaN), :series_index => 1, :markerstrokealpha => nothing…))
pl[:size]=(300,200)
1
This is a little contrived: we could just use plot!(pl, size=(300, 200)) to do the same thing.
(300, 200)
pl
2026-07-30T13:52:24.860486 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/

Saving Plots

To save plots to a file for inclusion in a report writeup:

save("figure.png", p)