Plots

You can usually spot a pasted plot in a paper at a glance. Not because the curve looks bad, but because the axis numbers are in the wrong typeface. The LaTeX tool that fixes this at the root is pgfplots: instead of receiving a plot as an image, it builds one from numbers using the same typesetting engine as the surrounding text. This page covers the pgfplots axis environment and \addplot, reading a .dat file, handing the arithmetic to gnuplot, and importing figures drawn in R or Python so the fonts come along. Along the way it quotes the error messages TeX Live 2024 actually produced — most people who search their way here are staring at one of them.

Why a pasted image never quite matches the page

The reason is not resolution but typeface and line weight. A spreadsheet defaults to a sans-serif face while your paper is set in a serif; readers who could not name either font still feel the mismatch. On top of that, scaling an image down to fit the text block scales its strokes down too, so the rules inside the figure end up thinner than the rules outside it. pgfplots takes only the coordinates and sets the tick numbers and the legend in the document’s own font, through the document’s own math typesetting, so neither mismatch can arise. Write $\sin x$ in an axis label and you get exactly the glyphs your body math uses.

The second thing that pays off in practice is separation from the data. Point the plot at a .dat or .csv instead of hard-coding coordinates, and when the measurement is redone you swap the file and recompile — the figure follows. Paper, slides, and appendix all read the same numbers, so transcription errors cannot happen in the first place. The same idea has a sister package on the table side, pgfplotstable. pgfplots itself is built on top of TikZ/PGF, so the whole figure lives inside a tikzpicture and TikZ nodes and decorations mix in freely. TikZ proper — \draw, the coordinate system, why compilation is slow — belongs to the TikZ page.

What happens if you omit \pgfplotsset{compat=1.18}

You get a warning, and your figures keep the old defaults. Compile without compat on the pgfplots 1.18.1 that TeX Live 2024 ships and the log says: "Package pgfplots Warning: running in backwards compatibility mode (unsuitable tick labels; missing features). Consider writing \pgfplotsset{compat=1.18} into your preamble." The warning even names the number to use, so you can simply do as it says.

latex
\usepackage{pgfplots}
\pgfplotsset{compat=1.18}   % pin the release whose defaults you want

The mechanism exists because pgfplots has changed its own defaults from release to release. Alter how tick labels are formatted or how much room an axis reserves, and the same source produces a different figure. compat is the key that declares which release’s conventions to typeset under, and it guarantees that recompiling a five-year-old manuscript will not move the figures. TeX Live 2024 ships pgfplots 1.18.1, dated 15 May 2021, so compat=1.18 is the newest level you can request there. Put the line in every new document — and in an existing one, do not raise it casually: the moment you do, figures may shift.

axis and \addplot: the smallest plot that works

Put one axis environment inside a tikzpicture, and every \addplot inside it lays down one more curve. The division of labour is clean: everything about the frame — labels, ticks, grid, legend position — is an option on axis, and everything about a single curve — colour, markers, dash pattern — is an option on \addplot. Once that two-layer structure clicks, the rest is looking up option names.

latex
\begin{tikzpicture}
  \begin{axis}[
    xlabel = {$x$},
    ylabel = {$f(x)$},
    title  = {A parabola},
    grid   = major,
  ]
    \addplot[blue, domain=-3:3, samples=100] {x^2};
    \addlegendentry{$x^2$}
  \end{axis}
\end{tikzpicture}

This samples the parabola y = x² at 100 points over −3 ≤ x ≤ 3 and draws it as a smooth blue curve; grid=major lays a faint grid along the major ticks and \addlegendentry adds one legend line. Two details worth internalising. Always brace labels and titles — writing xlabel={$x$} keeps a stray comma or ] inside them from breaking option parsing. And legends come two ways: an \addlegendentry{…} after each curve, or one legend entries={A,B,...} in the axis options; position it with legend pos=north west and friends.

OptionWhat it does
xlabel= / ylabel=Axis labels for x and y; brace them as xlabel={$x$} and math is safe
title=A title set above the plot
xmin= xmax= ymin= ymax=Pin the visible range so the axes stay put as data grows
grid=grid=major rules the major ticks, grid=both the minor ones as well
legend pos=Legend position: north west and so on; outer north east moves it outside the frame
xtick=State tick positions (xtick={0,1,2}); xtick=data aligns them to the data points
width= / height=Finished size of the figure; width=\linewidth matches the text block
ybar / xbarTurn it into a vertical / horizontal bar chart; stacked \addplot calls cluster automatically

The defaults for domain and samples, and why sin comes out wrong

The defaults are 25 points over the interval −5:5, and trigonometry in degrees. The first explains at once why a curve looks angular; the second is the single most common cause of “my plot is the wrong shape” in pgfplots. What is charming is that neither default belongs to pgfplots at all — its own /pgfplots/samples/.initial is empty, and the value falls through to the PGF layer underneath. In TeX Live 2024, tikz.code.tex carries the two lines \def\tikz@plot@samples{25} and \def\tikz@plot@domain{-5:5}, and those are the real defaults.

The degrees business is quickest to see in numbers. On TeX Live 2024, \pgfmathparse{sin(1)} evaluates to 0.01746 while \pgfmathparse{sin(deg(1))} gives 0.84143. The first is the sine of one degree, the second the sine of one radian, and deg() is simply the radian-to-degree converter: deg(1) = 57.29578. So writing \addplot {sin(x)} puts sin 1° at x = 1 and yields an unfamiliar wave whose period is 360. The correct form is sin(deg(x)).

latex
% wrong: pgfmath reads the argument as degrees
\addplot[domain=0:2*pi, samples=200] {sin(x)};
% right: convert radians to degrees first
\addplot[red, domain=0:2*pi, samples=200] {sin(deg(x))};

Three ways to feed \addplot: a function, coordinates, or a .dat file

Write an expression in {...}, list points in coordinates {...}, or read a file with table {filename}. The first two appeared above. The third is what you use in practice: given a whitespace-separated text file, pgfplots treats the first line as column names and, by default, plots column 1 as x and column 2 as y.

data.dat
x   y
0   0.0
1   0.8
2   0.9
3   0.1
4  -0.8
5  -1.0
latex
\begin{tikzpicture}
  \begin{axis}[xlabel={$x$}, ylabel={$y$}, grid=major]
    \addplot[mark=square, teal] table {data.dat};
    % naming the columns explicitly is the safer habit:
    % \addplot table[x=x, y=y] {data.dat};
  \end{axis}
\end{tikzpicture}

Column names are case-sensitive. If the header says x but you write table[x=X, y=y], TeX Live 2024 stops with: "! Package pgfplots Error: Sorry, could not retrieve column 'X' from table '...'. Please check spelling (or introduce name aliases)." As failures go this is a kind one — the message tells you to check the spelling or add an alias. For comma-separated CSV, use table[col sep=comma, x=x, y=y] {data.csv}; lines starting with # or % are skipped as comments. Reshaping data or deriving computed columns is the job of the sister package pgfplotstable.

Bar charts, log axes, and 3D with \addplot3

A bar chart is just ybar added to axis; a log axis is just a different environment name in place of axis; and 3D is just \addplot3. Not having to relearn the syntax is a design virtue of pgfplots — how you write \addplot carries over unchanged. In a bar chart, stacking several \addplot calls offsets them into clustered bars automatically, and when the x-axis should carry strings such as years, you pair symbolic x coords with xtick=data.

latex
\begin{tikzpicture}
  \begin{axis}[
    ybar,
    xlabel = {Year}, ylabel = {Count},
    symbolic x coords = {2023, 2024, 2025},
    xtick = data,
  ]
    \addplot coordinates {(2023,40) (2024,55) (2025,72)};
  \end{axis}
\end{tikzpicture}

For log axes, swap the environment name: loglogaxis for log–log, semilogxaxis for a log x only, semilogyaxis for a log y only. For 3D, \addplot3 makes the axis three-dimensional by itself; specify surf for a surface or mesh for a wireframe and write the function in the two variables x and y. Rotate the viewpoint with view={azimuth}{elevation}.

latex
\begin{tikzpicture}
  \begin{loglogaxis}[xlabel={$x$}, ylabel={$y$}]
    \addplot[domain=1:1000, samples=50] {1/x};
  \end{loglogaxis}
\end{tikzpicture}

\begin{tikzpicture}
  \begin{axis}[xlabel={$x$}, ylabel={$y$}, zlabel={$z$}]
    \addplot3[surf, samples=30, domain=-3:3]
      {exp(-x^2 - y^2)};
  \end{axis}
\end{tikzpicture}

gnuplot does not come with TeX Live

This matters enough to say first: using \addplot gnuplot {...} means installing gnuplot yourself, separately. Look inside TeX Live 2024’s binary directory and you will find asy (Asymptote) and mpost (MetaPost) — but no gnuplot. gnuplot is an independent plotting program developed with no connection to TeX, and it has to come from Homebrew or your distribution’s package manager.

So what is it for? The parser built into pgfplots runs on top of TeX, which makes it a poor fit for complicated expressions or very many samples. Writing \addplot gnuplot {...} subcontracts the arithmetic to gnuplot and lets pgfplots draw only the coordinates that come back. The mechanism is startlingly plain: TeX writes a script file for gnuplot, gnuplot runs it and writes a table of numbers to a file, and TeX reads that back. Processing \addplot[blue] gnuplot[domain=0:10] {sin(x)}; on TeX Live 2024, the generated .gnuplot file held these directives (with the real job name in place of job): set table "job.pgf-plot.table"; set format "%.7e"; set samples 25; set dummy x; plot [x=0:10] sin(x);

Two things can be read off that one line. First, the default samples of 25 is handed straight through to gnuplot. Second, the expression goes across in gnuplot’s own syntax — which is why the power operator is gnuplot’s ** rather than pgfplots’s ^, and why trigonometric functions default to radians. The same sine curve is {sin(deg(x))} through the built-in parser but {sin(x)} through gnuplot. And because TeX has to launch an external program, --shell-escape is required (also spelled -write18).

latex
% compile with:  pdflatex --shell-escape document
\begin{tikzpicture}
  \begin{axis}[xlabel={$x$}, ylabel={$y$}]
    % gnuplot syntax: radians, and ** for powers
    \addplot[blue] gnuplot[domain=0:10] {sin(x)};
  \end{axis}
\end{tikzpicture}

Run it where gnuplot is absent and TeX Live 2024 reports: "! Package pgfplots Error: Sorry, the gnuplot-result file 'job.pgf-plot.table' could not be found. Maybe you need to enable the shell-escape feature? For pdflatex, this is '>> pdflatex -shell-escape'." If gnuplot is genuinely missing, the shell’s own "sh: gnuplot: command not found" appears just above it. Notice what the message goes on to say: you may instead run gnuplot <file>.gnuplot by hand on the generated file. All pgfplots really needs is the .table file — and indeed, dropping a hand-written .table in place and recompiling produced the figure with no gnuplot anywhere. On CI where gnuplot cannot be installed, committing the generated .table to the repository is a workable escape hatch.

Bringing R and Python figures over with their fonts

The analysis is already done in R or Python, but you would rather not paste a PNG. The answer is to have each tool emit TikZ/PGF code and \input it: the figure is then typeset as part of the document, so its typeface and its math match the body. Note up front that none of these are part of TeX Live — each has to come from its own language’s package manager.

In R, CRAN’s tikzDevice provides a graphics device that writes R’s standard graphics output — base plots and ggplot2 alike — as TikZ code. Open the device with tikz(), run your usual plotting code, close it with dev.off(), and you have a .tex. Its distinguishing trick is that it asks LaTeX for string widths and font metrics when placing text, which is exactly why the output matches the body typeface and why you can put LaTeX math in an axis label. With standAlone=TRUE it emits a complete, separately compilable document.

R
library(tikzDevice)
tikz("plot.tex", width = 4, height = 3)
plot(cars$speed, cars$dist,
     xlab = "Speed", ylab = "Distance")
dev.off()

On the Python side there are two roads, and they get confused with each other. The first is the pgf backend that ships inside matplotlib — no extra package needed. Call matplotlib.use("pgf") and then savefig("figure.pgf") and out comes a .pgf file. Here is the crucial part: what it emits is plain PGF, not pgfplots code. Inspecting a file actually generated by matplotlib 3.11.0 turns up not one \addplot and not one axis environment; the body is a run of low-level drawing commands such as \pgfqpoint, \pgfpathlineto and \pgfsetstrokecolor. The axes, in other words, are not an axis that pgfplots composed but an axis drawn as lines. And the document needs only \usepackage{pgf}, not \usepackage{pgfplots}.

python
import matplotlib
matplotlib.use("pgf")
import matplotlib.pyplot as plt

matplotlib.rcParams.update({
    "pgf.texsystem": "pdflatex",   # default here is xelatex
    "font.family": "serif",
    "text.usetex": True,
    "pgf.rcfonts": False,
})

fig, ax = plt.subplots(figsize=(4, 3))
ax.plot([0, 1, 2, 3], [0, 1, 4, 9])
ax.set_xlabel(r"$x$")
ax.set_ylabel(r"$x^2$")
fig.savefig("figure.pgf")

There is one trap here. The generated .pgf opens with comments telling you to put \usepackage{pgf} in your preamble — do exactly that, \input{figure.pgf}, and TeX Live 2024 stops with: "! Undefined control sequence. <recently read> \mathdefault". The cause is that matplotlib wraps tick labels in \mathdefault{...} on the way out but does not put the definition into the .pgf. Its backend_pgf.py injects the definition into an internal preamble only when matplotlib itself renders the PDF. The fix is one line: \providecommand{\mathdefault}[1]{#1} in your preamble and it compiles. Note also that pgf.texsystem defaults to xelatex, so if your document is set with pdfLaTeX, state it explicitly as in the example above.

latex
\documentclass{article}
\usepackage{pgf}
\usepackage{lmodern}
% matplotlib wraps tick labels in \mathdefault but never defines it
\providecommand{\mathdefault}[1]{#1}
\begin{document}
\input{figure.pgf}
\end{document}

Python’s other road is tikzplotlib (formerly matplotlib2tikz), which converts a matplotlib figure into pgfplots code. Write it with tikzplotlib.save("figure.tex"), then load \usepackage{pgfplots} and \pgfplotsset{compat=...} and \input{figure.tex}. Because the axes really are an axis environment rather than plain PGF, you can adjust ticks and legends in LaTeX after the fact — the decisive difference from the pgf backend. Be aware, though, that tikzplotlib is no longer maintained; a fork, matplot2tikz, is being developed as its successor with a nearly identical API. Both install from PyPI and neither is part of TeX Live.

When there are too many points and compilation never ends

First, thin the points — this helps most. All of pgfplots’s arithmetic happens through TeX macro expansion, so a scatter of tens of thousands of points is honestly slow and can even hit TeX’s memory ceiling. And on paper, if neighbouring points fall closer together than the printing resolution, those points are not visible anyway. The remedies below are roughly in order of effect.

  • Thin the points. Draw every k-th point with each nth point=k, and discard out-of-range data with filters such as filter discard if not. On a scatter plot the result usually looks the same.
  • Externalization. With \usepgfplotslibrary{external} and \tikzexternalize, each figure is compiled once into its own PDF and thereafter merely included. Recompiling the body becomes dramatically lighter, at the cost of needing --shell-escape.
  • An engine with more memory. lualatex has looser TeX memory limits and is less likely to wedge on a large figure.
  • Render it upstream instead. If a plot stays too heavy, importing a result drawn in R, Python, or gnuplot (previous section) is the more practical route.

Externalization and gnuplot both demand --shell-escape, so if you build in CI or a container you must enable it there too — otherwise a build that passed locally fails on the far side. That failure is extremely common, so it is worth checking the Docker / CI page for the setup.