Why UltraPlot?#

Matplotlib is an extremely versatile plotting package, but it is cumbersome and repetitive for users who make complex figures with many subplots, finely tune their annotations, or need new figures nearly every day. UltraPlot’s core mission is to smooth out the plotting experience for matplotlib’s most demanding users. It does this by expanding upon matplotlib’s object-oriented interface with changes that would be hard to justify inside matplotlib itself.

The sections below pair the “before” – a plain matplotlib result – with the “after” – the same plot made with UltraPlot – so you can see the difference at a glance. For the full user guide, see the usage introduction and the user guide.

Less typing, more plotting#

In matplotlib, changing many plot settings at once means calling a series of one-liner setter methods – and it is often unclear whether a property lives on the Axes, the XAxis, a Spine, or tick_params(). UltraPlot replaces all of this with the format() command: an expanded, thoroughly documented version of update() that can also apply rc settings and integrate with the constructor functions. The figure-level format() and format() commands format several subplots at once.

import ultraplot as uplt
fig, axs = uplt.subplots(ncols=2)
axs.format(color='gray', linewidth=1)
axs.format(xlim=(0, 100), xticks=10, xtickminor=True, xlabel='foo', ylabel='bar')

is much more succinct than…

import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
import matplotlib as mpl
with mpl.rc_context(rc={'axes.linewidth': 1, 'axes.edgecolor': 'gray'}):
    fig, axs = plt.subplots(ncols=2, sharey=True)
    axs[0].set_ylabel('bar', color='gray')
    for ax in axs:
        ax.set_xlim(0, 100)
        ax.xaxis.set_major_locator(mticker.MultipleLocator(10))
        ax.tick_params(width=1, color='gray', labelcolor='gray')
        ax.tick_params(axis='x', which='minor', bottom=True)
        ax.set_xlabel('foo', color='gray')

Class constructor functions#

Matplotlib and cartopy define verbose class names like MultipleLocator and LambertAzimuthalEqualArea, and keep them out of the top-level namespace. UltraPlot instead “registers” tick locators, tick formatters, axis scales, property cycles, colormaps, normalizers, and cartopy projections, so you can refer to them with constructor functions and short names:

  • A scalar passed to Locator returns a MultipleLocator; a list of strings passed to Formatter returns a FixedFormatter.

  • Colormap and Cycle accept registered names, individual colors, and lists of colors.

  • Every registered class is also available directly in the top-level namespace, e.g. uplt.MultipleLocator(...) or uplt.LogNorm(...).

The table below lists the constructor functions and the keyword arguments that use them.

Links#

Automatic dimensions and spacing#

Matplotlib

import matplotlib.pyplot as plt
import numpy as np

x = np.arange(10)
y = np.random.default_rng(0).normal(size=(4, 10))
fig, axs = plt.subplots(2, 2)
for i in range(2):
   for j in range(2):
      axs[i, j].plot(x, y[i, :] * (i + 1) + j + 0.25 * x)
      axs[i, j].set_title(
         "A long title that collides with neighboring labels",
         fontsize=10,
      )
      axs[i, j].set_xlabel("x axis", fontsize=10)
      axs[i, j].set_ylabel("y axis", fontsize=10)

(Source code, svg)

_images/why-1.svg

The figure size is fixed, the margins are tuned by hand, and the bottom-row titles collide with the tick labels above them. Add a subplot or change the font size and the whole thing needs retuning.

UltraPlot

import numpy as np
import ultraplot as uplt

x = np.arange(10)
y = np.random.default_rng(1).normal(size=(4, 10))
fig, axs = uplt.subplots(
   nrows=2,
   ncols=2,
)
for ix in range(2):
   for jx in range(2):
      axs[ix, jx].plot(x, y[ix, :] * (ix + 1) + jx + 0.25 * x)

axs.format(
   xlabel="x",
   ylabel="y",
   title="Automatic spacing across subplot groups",
   abc="A.",
   abcloc="ul",
)

(Source code, svg)

_images/why-2.svg

UltraPlot fixes the physical dimensions of a reference subplot (refwidth, refheight, refaspect) instead of the figure, so subplot size – and the apparent size of text – stays constant no matter how many subplots you add. Its own tight layout algorithm then handles the spacing.

Links#

Working with multiple subplots#

Matplotlib

import matplotlib.pyplot as plt
import numpy as np

x = np.arange(5)
fig, axs = plt.subplots(2, 2)
for i in range(2):
   for j in range(2):
      axs[i, j].plot(x, x * (i + 1) + j, label=f"line_{i}_{j}")
      axs[i, j].set_xlabel("x")
      axs[i, j].set_ylabel("y")
      axs[i, j].set_title("Panel label")
fig.legend(loc="upper right", ncol=1, frameon=False)

(Source code, svg)

_images/why-3.svg

Every subplot repeats its own tick labels and axis labels, wasting page space. Adding “a-b-c” labels – required for most publications – is entirely manual.

UltraPlot

import numpy as np
import ultraplot as uplt

x = np.arange(5)
fig, axs = uplt.subplots(nrows=2, ncols=2)
for idx, ax in enumerate(np.ravel(axs), start=1):
   ax.plot(x, x * ((idx % 2) + 1), label=f"line_{idx}")
axs.format(xlabel="x", ylabel="y", abc=True)
fig.legend(loc="r", ncol=1, frameon=False)

(Source code, svg)

_images/why-4.svg

Tick labels and axis labels are shared and aligned automatically (sharex, sharey, spanx, spany), and a-b-c labels are added with a single rc.abc setting, e.g. axs.format(abc='A.').

Links#

Simpler colorbars and legends#

Matplotlib

import matplotlib.pyplot as plt
import numpy as np

data = np.linspace(0, 1, 200).reshape(20, 10)
fig, axs = plt.subplots(1, 2)
for i in range(2):
   m = axs[i].imshow(data * (i + 1), aspect="auto")
   axs[i].set_title("Left" if i == 0 else "Right")
   if i == 0:
      fig.colorbar(m, ax=axs[i])

(Source code, svg)

_images/why-5.svg

Drawing a colorbar with fig.colorbar(m, ax=ax) steals space from the parent subplot – here the left panel is visibly narrower than the right one. Outer legends have to be positioned by hand and tweaked to fit.

UltraPlot

import numpy as np
import ultraplot as uplt

data = np.linspace(0, 1, 200).reshape(20, 10)
fig, axs = uplt.subplots(ncols = 2)
for idx, ax in enumerate(np.ravel(axs), start=1):
   m = ax.imshow(data * idx)
   fig.colorbar(m, ax=ax, loc="r", width="6mm")
axs.format(title=["Left", "Right"])

(Source code, svg)

_images/why-6.svg

Colorbars and legends get their own space in the GridSpec – the subplots keep their exact dimensions. Outer (loc='l') and inset (loc='ur') locations work for both, and colorbar widths are specified in physical units.

Links#

  • For more on single-subplot colorbars and legends, see this page.

  • For more on multi-subplot colorbars and legends, see this page.

  • For new colorbar features, see this page.

  • For new legend features, see this page.

Improved plotting commands#

Matplotlib

import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(0, 2 * np.pi, 200)
y = np.sin(x)
fig, ax = plt.subplots()
ax.fill_between(x, 0, y)
ax.plot(x, y, color="black")
ax.set_title("Single fill color for pos/neg regions")

(Source code, svg)

_images/why-7.svg

Filling under a curve means writing fill_between() yourself, and the obvious call paints the positive and negative regions the same color. Differentiating the sign requires where= bookkeeping by hand.

UltraPlot

import numpy as np
import ultraplot as uplt

x = np.linspace(0, 2 * np.pi, 200)
y = np.sin(x)
fig, ax = uplt.subplots()
ax.area(x, y, negpos=True)
ax.format(title="Automatic negative/positive fills")

(Source code, svg)

_images/why-8.svg

ax.area(x, y, negpos=True) colors the positive and negative regions automatically. The PlotAxes commands bundle many such seaborn- and xarray-style conveniences, including standardized data arguments, on-the-fly colorbars and legends, and error bars and shading.

Links#

  • For the 1D plotting features, see this page.

  • For the 2D plotting features, see this page.

  • For treatment of 1D data arguments, see this page.

  • For treatment of 2D data arguments, see this page.

Cartopy and basemap integration#

Matplotlib

import matplotlib.pyplot as plt
import numpy as np

x = np.arange(0, 360, 10)
y = np.arange(-90, 100, 10)
X, Y = np.meshgrid(x, y)
Z = np.sin(np.deg2rad(X)) * np.cos(np.deg2rad(Y))
fig, ax = plt.subplots()
pcm = ax.pcolormesh(X, Y, Z, cmap="viridis")
plt.colorbar(pcm, ax = ax, location = "top")
ax.set_title("Hand-built pseudo map with manual gridline work")

(Source code, svg)

_images/why-9.svg

Building a map with cartopy or basemap means importing a separate package, configuring the projection, and adding gridlines and labels line by line. Longitude-latitude (“Plate Carrée”) data has to be converted to map coordinates by hand.

UltraPlot

import numpy as np
import ultraplot as uplt

x = np.arange(0, 360, 10)
y = np.arange(-90, 100, 10)
X, Y = np.meshgrid(x, y)
Z = np.sin(np.deg2rad(X)) * np.cos(np.deg2rad(Y))
fig, ax = uplt.subplots(proj="pcarree")
ax.pcolormesh(X, Y, Z,
       cmap="batlow",
       colorbar = "r",
       colorbar_kw = dict(label = "Value"),
)
ax.format(lonlabels="b", latlabels="l")

(Source code, svg)

_images/why-10.svg

A geographic plot is uplt.subplots(proj='pcarree'). The GeoAxes subclass unifies cartopy and basemap, defaults to longitude-latitude coordinates, and exposes gridlines, labels, coastlines, and borders through the same format() command used elsewhere.

Links#

Pandas and xarray integration#

Matplotlib

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

rng = np.random.RandomState(0)
data = (rng.normal(size=(12, 18)).cumsum(axis=1).cumsum(axis=0))
df = pd.DataFrame(
   (data - data.min()) / (data.max() - data.min()),
   index=pd.date_range("2026-01-01", periods=12, freq="MS"),
   columns=np.arange(18),
)

fig, ax = plt.subplots()
image = ax.imshow(df.to_numpy(), cmap="viridis", aspect="auto")
fig.colorbar(image, ax=ax)
ax.set_title("Matplotlib treats metadata as plain arrays")
ax.set_xlabel("generic x")
ax.set_ylabel("generic y")

(Source code, svg)

_images/why-11.svg

Matplotlib treats a DataArray or DataFrame as a plain array and ignores its metadata – so no legend, no title, and generic axis labels. Getting the metadata into the figure means switching to the .plot methods and learning a second syntax.

UltraPlot

import pandas as pd
import numpy as np
import ultraplot as uplt

rng = np.random.RandomState(0)
df = pd.DataFrame(
   (rng.normal(size=(12, 18)).cumsum(axis=1).cumsum(axis=0) - 1),
   index=pd.date_range("2026-01-01", periods=12, freq="MS"),
   columns=np.arange(18),
)
df.name = "temperature (\N{DEGREE SIGN}C)"
df.index.name = "month"
df.columns.name = "variable"

fig, ax = uplt.subplots()
cs = ax.contourf(df, cmap="batlow", colorbar="t")
fig.colorbar(cs, ax=ax, loc="r", width="6mm")

(Source code, svg)

_images/why-12.svg

The same data plotted with UltraPlot is labeled automatically: the axis labels, subplot title, and colorbar and legend labels are all taken from the metadata. Quantity units are handled too. Disable with autoformat=False.

Links#

Aesthetic colors and fonts#

Matplotlib

import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(-4, 4, 180)
y = np.linspace(-4, 4, 180)
X, Y = np.meshgrid(x, y)
Z = np.exp(-(X**2 + Y**2) / 4) * np.cos(X * 2) * np.sin(Y * 2)

fig, ax = plt.subplots()
ax.pcolormesh(X, Y, Z, cmap="jet")
ax.set_title("A misleading 'jet' colormap")
ax.set_xlabel("x")
ax.set_ylabel("y")

(Source code, svg)

_images/why-13.svg

“Misleading” colormaps like 'jet' have jarring jumps in hue, saturation, and luminance that can trick the eye into seeing patterns that are not there (rainbow). The default DejaVu font is functional but not particularly elegant.

UltraPlot

import numpy as np
import ultraplot as uplt

x = np.linspace(-4, 4, 180)
y = np.linspace(-4, 4, 180)
X, Y = np.meshgrid(x, y)
Z = np.exp(-(X**2 + Y**2) / 4) * np.cos(X * 2) * np.sin(Y * 2)

fig, ax = uplt.subplots()
ax.pcolormesh(X, Y, Z, cmap="batlow")
ax.format(title="Perceptually uniform batlow colormap")

(Source code, svg)

_images/why-14.svg

UltraPlot ships “perceptually uniform” colormaps from the seaborn, cmocean, SciVisColor, and Scientific Colour Maps projects (here, 'batlow'), plus the TeX Gyre font series, the open color palette, and filtered XKCD color survey names.

Links#

  • For more on colormaps, see this page.

  • For more on color cycles, see this page.

  • For more on fonts, see this page.

  • For importing custom colormaps, colors, and fonts, see this page.

Manipulating colormaps#

Matplotlib implements colormaps with LinearSegmentedColormap and ListedColormap, which are cumbersome to modify or create from scratch. UltraPlot makes colormaps and property cycles easy to work with:

  • All colormaps are replaced with the ContinuousColormap and DiscreteColormap subclasses, adding the features used by the Colormap and Cycle constructor functions.

  • Colormap can merge, truncate, and modify existing colormaps, or generate brand-new ones – including PerceptualColormaps with linear transitions in hue, saturation, and luminance rather than red, green, and blue.

  • Cycle can build property cycles from scratch, from registered DiscreteColormap instances, or by splitting up the colors from continuous colormaps.

  • Colormap and cycle names are case-insensitive, and appending '_r' or '_s' reverses or cyclically shifts them.

Links#

  • For making new colormaps, see this page.

  • For making new color cycles, see this page.

  • For merging colormaps and cycles, see this page.

  • For modifying colormaps and cycles, see this page.

Physical units engine#

Matplotlib expresses margins in figure-relative units and spacing in axes-relative units, so changing the figure size forces you to re-tune the numbers. UltraPlot instead uses physical units everywhere:

  • The GridSpec keywords left, right, top, bottom, wspace, hspace, pad, outerpad, and innerpad accept physical units, defaulting to em-widths – plot text is a useful “ruler” for spacing.

  • The Figure keywords figsize, figwidth, figheight, refwidth, and refheight accept arbitrary string units such as inches, centimeters, millimeters, pixels, points, and picas (see the units table).

  • This is powered by the units() engine, which also translates rc settings assigned to rc_matplotlib() and rc_UltraPlot.

Links#

Flexible global settings#

In matplotlib, several rcParams are only useful if changed all at once – like spine and label colors – and they cannot be changed for individual subplots. UltraPlot provides a single rc object for both native matplotlib settings (rc_matplotlib) and UltraPlot’s own settings (rc_UltraPlot):

  • Assigned settings are always validated, and “meta” settings like meta.edgecolor and meta.linewidth update many settings at once.

  • Settings can be changed with uplt.rc.key = value, uplt.rc[key] = value, uplt.rc.update(key=value), format(), or the context() context manager.

  • Settings changed during a session can be saved with save() and loaded with load().

Links#

Loading stuff#

Matplotlib makes persistent configuration awkward, and there is no built-in way to register your own colormaps, color cycles, or fonts. UltraPlot turns this into dropping files into folders:

  • Edit the default ultraplotrc file (usually $HOME/.ultraplot/ultraplotrc) or add loose ultraplotrc files to the current directory or a parent directory to change settings persistently.

  • Colormaps, color cycles, colors, and fonts stored in subfolders named cmaps, cycles, colors, and fonts inside user_folder() (usually $HOME/.ultraplot) are registered automatically – as are loose ultraplot_cmaps, ultraplot_cycles, ultraplot_colors, and ultraplot_fonts folders in the current or a parent directory.

  • Pass save=True to Colormap and Cycle to save new colormaps and cycles, or use register_cmaps(), register_cycles(), register_colors(), and register_fonts() to register arbitrary inputs during a session.

Links#