2025, Oct 28 15:00
How to Remove Extra Whitespace in Matplotlib Cartopy Maps: stop xlim/ylim, use set_global and bbox_inches='tight'
Learn why xlim/ylim create empty margins on Cartopy GeoAxes and how to fix them in Matplotlib: show full globe with set_global, save with bbox_inches='tight'.
When plotting sky maps with matplotlib and cartopy, a common frustration is the amount of empty space around the projection and the colorbar. The map never seems to fill the canvas, and tightening paddings does not help. This guide shows why that happens and how to get compact figures without guessing margins for each projection.
Reproducing the oversized margins
The issue becomes obvious when setting xlim and ylim on map axes and then saving the figure. The map is padded on all sides, and different projections produce different margins, even with the same layout.
import cartopy.crs as ccrs
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.layout_engine import ConstrainedLayoutEngine
def run():
    astro_globe = ccrs.Globe(datum=None, ellipse=None,
                             semimajor_axis=180/np.pi,
                             semiminor_axis=180/np.pi)
    data_crs = ccrs.PlateCarree(globe=astro_globe)
    lon_grid, lat_grid = np.mgrid[-179.5:180:1, -89.5:90:1]
    noise_img = perlin2d(lon_grid.shape, (1, 1))
    for tag, projection_cls in [("ee", ccrs.EqualEarth),
                                ("mw", ccrs.Mollweide),
                                ("lc", ccrs.LambertCylindrical)]:
        try:
            fig_obj, map_ax = plt.subplots(
                figsize=(20, 10),
                layout=ConstrainedLayoutEngine(
                    h_pad=0, w_pad=0, hspace=0, wspace=0
                ),
                subplot_kw={
                    "xlim": (-180, 180),
                    "ylim": (-90, 90),
                    "projection": projection_cls(globe=astro_globe)
                },
            )
            cset = map_ax.contourf(lon_grid, lat_grid, noise_img,
                                   transform=data_crs,
                                   cmap="Greys")
            fig_obj.colorbar(cset, shrink=0.5, pad=0.02)
            fig_obj.savefig(f"layout_test_{tag}.png")
        finally:
            plt.close(fig_obj)
def perlin2d(shape, res):
    def ease(t):
        return 6*t**5 - 15*t**4 + 10*t**3
    delta = (res[0] / shape[0], res[1] / shape[1])
    d = (shape[0] // res[0], shape[1] // res[1])
    grid = np.mgrid[0:res[0]:delta[0],0:res[1]:delta[1]].transpose(1, 2, 0) % 1
    angles = 2*np.pi*np.random.rand(res[0]+1, res[1]+1)
    grads = np.dstack((np.cos(angles), np.sin(angles)))
    g00 = grads[0:-1,0:-1].repeat(d[0], 0).repeat(d[1], 1)
    g10 = grads[1:,0:-1].repeat(d[0], 0).repeat(d[1], 1)
    g01 = grads[0:-1,1:].repeat(d[0], 0).repeat(d[1], 1)
    g11 = grads[1:,1:].repeat(d[0], 0).repeat(d[1], 1)
    n00 = np.sum(grid * g00, 2)
    n10 = np.sum(np.dstack((grid[:,:,0]-1, grid[:,:,1])) * g10, 2)
    n01 = np.sum(np.dstack((grid[:,:,0], grid[:,:,1]-1)) * g01, 2)
    n11 = np.sum(np.dstack((grid[:,:,0]-1, grid[:,:,1]-1)) * g11, 2)
    t = ease(grid)
    n0 = n00*(1-t[:,:,0]) + t[:,:,0]*n10
    n1 = n01*(1-t[:,:,0]) + t[:,:,0]*n11
    return np.sqrt(2)*((1-t[:,:,1])*n0 + t[:,:,1]*n1)
run()
What actually causes the gaps
The core pitfall is that xlim and ylim on a cartopy GeoAxes refer to the axes’ Cartesian coordinates in the projection space, not to longitude and latitude. This is easy to see interactively. Open the figure with plt.show and move the cursor over the canvas. The GUI shows two sets of values: the raw x and y in axes coordinates, and the corresponding longitude and latitude. In QtAgg these appear in the top right, while in TkAgg they are in the bottom right. For EqualEarth specifically, the map footprint spans roughly ±155 in x and ±75 in y. Forcing x to ±180 and y to ±90 does not match the projection’s footprint, which triggers extra spacing to preserve the projected shape. That explains the unwanted horizontal whitespace and the inconsistent margins across projections. The remaining vertical gap comes from the figure bounding box still being larger than the union of all artists on the canvas.
The fix that keeps maps tight
Stop forcing xlim and ylim, ask the axes to show the full globe, and save with a bounding box that hugs the content. Removing the explicit limits eliminates the horizontal padding. Using ax.set_global ensures the projection displays the complete map rather than a slightly flattened view near the equator. Saving with bbox_inches='tight' trims the figure to the artists, which removes the leftover vertical whitespace.
import cartopy.crs as ccrs
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.layout_engine import ConstrainedLayoutEngine
def run():
    astro_globe = ccrs.Globe(datum=None, ellipse=None,
                             semimajor_axis=180/np.pi,
                             semiminor_axis=180/np.pi)
    data_crs = ccrs.PlateCarree(globe=astro_globe)
    lon_grid, lat_grid = np.mgrid[-179.5:180:1, -89.5:90:1]
    noise_img = perlin2d(lon_grid.shape, (1, 1))
    for tag, projection_cls in [("ee", ccrs.EqualEarth),
                                ("mw", ccrs.Mollweide),
                                ("lc", ccrs.LambertCylindrical)]:
        try:
            fig_obj, map_ax = plt.subplots(
                figsize=(20, 10),
                layout=ConstrainedLayoutEngine(
                    h_pad=0, w_pad=0, hspace=0, wspace=0
                ),
                subplot_kw={
                    "projection": projection_cls(globe=astro_globe)
                },
            )
            map_ax.set_global()
            cset = map_ax.contourf(lon_grid, lat_grid, noise_img,
                                   transform=data_crs,
                                   cmap="Greys")
            fig_obj.colorbar(cset, shrink=0.5, pad=0.02)
            fig_obj.savefig(
                f"layout_test_{tag}.png",
                bbox_inches="tight",
                pad_inches="layout",
            )
        finally:
            plt.close(fig_obj)
def perlin2d(shape, res):
    def ease(t):
        return 6*t**5 - 15*t**4 + 10*t**3
    delta = (res[0] / shape[0], res[1] / shape[1])
    d = (shape[0] // res[0], shape[1] // res[1])
    grid = np.mgrid[0:res[0]:delta[0],0:res[1]:delta[1]].transpose(1, 2, 0) % 1
    angles = 2*np.pi*np.random.rand(res[0]+1, res[1]+1)
    grads = np.dstack((np.cos(angles), np.sin(angles)))
    g00 = grads[0:-1,0:-1].repeat(d[0], 0).repeat(d[1], 1)
    g10 = grads[1:,0:-1].repeat(d[0], 0).repeat(d[1], 1)
    g01 = grads[0:-1,1:].repeat(d[0], 0).repeat(d[1], 1)
    g11 = grads[1:,1:].repeat(d[0], 0).repeat(d[1], 1)
    n00 = np.sum(grid * g00, 2)
    n10 = np.sum(np.dstack((grid[:,:,0]-1, grid[:,:,1])) * g10, 2)
    n01 = np.sum(np.dstack((grid[:,:,0], grid[:,:,1]-1)) * g01, 2)
    n11 = np.sum(np.dstack((grid[:,:,0]-1, grid[:,:,1]-1)) * g11, 2)
    t = ease(grid)
    n0 = n00*(1-t[:,:,0]) + t[:,:,0]*n10
    n1 = n01*(1-t[:,:,0]) + t[:,:,0]*n11
    return np.sqrt(2)*((1-t[:,:,1])*n0 + t[:,:,1]*n1)
run()
Why understanding this matters
Cartopy projections live in their own Cartesian coordinate spaces, which do not map linearly to longitude and latitude. Treating xlim and ylim as if they were lon/lat bounds forces extents that do not match the projection’s natural footprint. The result is extra whitespace as the layout engine tries to keep the aspect ratio. Recognizing that difference gives you predictable control over composition. Using the GUI cursor readout to compare x and y versus lon and lat makes this distinction concrete. For EqualEarth, the visible footprint is around ±155 by ±75, so supplying ±180 by ±90 simply cannot be satisfied without padding.
Closing thoughts
To keep sky maps compact across projections, avoid manual xlim and ylim on GeoAxes, request the full globe with ax.set_global, and let bbox_inches="tight" trim any residual space on save. This combination removes the inconsistent margins, works with a colorbar, and avoids tuning per-projection magic numbers. If you need to inspect how axes coordinates relate to lon and lat, open the figure interactively and watch the two readouts as you move the cursor.