How to Add an Inset and Locator Map in Python

Problem statement

Two different jobs get called "an inset", and they need different code:

  • A locator map answers "where in the world is this?" It shows a wider area with the main map's extent marked on it. It is needed when the audience does not already know the area, and it is noise when they do.
  • A detail inset answers "what is happening in that crowded corner?" It shows a zoomed-in part of the same map, usually with a rectangle on the main map showing where it came from.

Both are extra axes placed on top of a finished figure, and both fail the same way: they are drawn at a size that made sense on screen, then the figure is scaled into a column and the inset becomes an unreadable smudge with 3 pt labels.

Quick answer

An inset is an axis with its own extent, its own layers and its own โ€” much simpler โ€” styling:

def add_locator(fig, ax_main, world, extent_gdf,
                rect=(0.72, 0.66, 0.26, 0.30), highlight="#ef4444"):
    """A small wider-area map with the main extent marked on it."""
    ax = fig.add_axes(rect)
    world.plot(ax=ax, facecolor="#eef2f7", edgecolor="white", linewidth=0.3)

    minx, miny, maxx, maxy = extent_gdf.total_bounds
    ax.add_patch(plt.Rectangle((minx, miny), maxx - minx, maxy - miny,
                               facecolor="none", edgecolor=highlight,
                               linewidth=1.0, zorder=5))
    ax.set_aspect("equal")
    ax.set_axis_off()
    for spine in ax.spines.values():
        spine.set_visible(False)
    ax.patch.set_alpha(0.92)          # readable over the main map
    return ax

The rule for both kinds: an inset carries one message. No legend, no labels beyond one or two, no graticule. If it needs its own key, it has become a second map and belongs beside the first, not on top of it.

Two panels contrasting a locator map with a detail inset.
They differ in purpose, in placement API and in how much they should carry.

Step-by-step solution

1. Decide which kind you need, and whether you need one

A locator is for an audience that does not know the area. A detail inset is for a map with a crowded region that cannot be resolved at the main scale.

Neither is a default. Both consume roughly a tenth of the figure, and on a map already carrying a legend and a title that is a meaningful share.

2. Place it in figure coordinates, not axes coordinates

fig.add_axes([left, bottom, width, height]) uses figure-relative coordinates, which stay put when the main map's extent changes. inset_axes on the main axes ties the inset to the map, which is what you want for a detail inset and not for a locator.

from mpl_toolkits.axes_grid1.inset_locator import inset_axes, mark_inset

ax_detail = inset_axes(ax_main, width="34%", height="34%", loc="lower right")
mark_inset(ax_main, ax_detail, loc1=2, loc2=4, fc="none", ec="#64748b", lw=0.6)

mark_inset draws both the source rectangle and the connector lines, which is the part people write by hand and get wrong.

3. Give the inset a matching CRS and an equal aspect

An inset in a different projection from the main map is disorienting, and one without set_aspect("equal") is distorted. Reproject the inset's layers to the main map's CRS unless there is a deliberate reason not to โ€” a global locator for a national map is the usual exception, and there a world-scale projection is the right choice.

4. Simplify the inset's content aggressively

An inset is small, so its scale is small, so the detail it can show is small. A locator at 30 mm wide showing a continent is at roughly 1:60,000,000, where the resolvable ground distance is about 12 km โ€” every vertex closer than that is invisible.

Simplify the inset's layers separately. It is the cheapest way to stop a locator from tripling the figure's file size.

5. Make it legible over what is underneath

An inset sits on top of the map, so it needs to separate itself:

ax.patch.set_facecolor("white")
ax.patch.set_alpha(0.92)
for spine in ax.spines.values():
    spine.set_visible(True)
    spine.set_linewidth(0.5)
    spine.set_edgecolor("#cbd5e1")

A thin border and a near-opaque background. A fully transparent inset reads as part of the main map; a fully opaque one with a heavy border reads as a sticker.

6. Check the sizes after placement

The inset's type is the smallest on the figure and therefore the first to fail. If the figure is drawn at 203 mm and placed at 90 mm โ€” a factor of 0.443 โ€” an inset label at 6 pt arrives at 2.66 pt, which is invisible.

Draw the figure at its final size, and set inset type at the same absolute size as the rest of the apparatus, not smaller.

A map with a small locator inset in the corner showing the main extent as a rectangle.
If the rectangle is too small to see, the locator is at the wrong scale.

Code examples

Example 1 โ€” a locator map with an extent rectangle

import matplotlib.pyplot as plt
import geopandas as gpd


def locator_map(fig, main_gdf, context_gdf, rect=(0.70, 0.64, 0.27, 0.32),
                highlight="#ef4444", simplify_m=20_000, pad_factor=6.0):
    """A small map of the wider area, with the main extent boxed."""
    ax = fig.add_axes(rect, zorder=8)

    context = context_gdf.to_crs(main_gdf.crs)
    if simplify_m:
        context = context.assign(
            geometry=context.geometry.simplify(simplify_m, preserve_topology=True))

    minx, miny, maxx, maxy = main_gdf.total_bounds
    cx, cy = (minx + maxx) / 2, (miny + maxy) / 2
    reach = max(maxx - minx, maxy - miny) * pad_factor / 2

    context.plot(ax=ax, facecolor="#eef2f7", edgecolor="white", linewidth=0.3)
    ax.add_patch(plt.Rectangle((minx, miny), maxx - minx, maxy - miny,
                               facecolor="none", edgecolor=highlight,
                               linewidth=1.1, zorder=5))
    ax.set_xlim(cx - reach, cx + reach)
    ax.set_ylim(cy - reach, cy + reach)
    ax.set_aspect("equal")
    ax.set_xticks([]); ax.set_yticks([])
    ax.patch.set_alpha(0.94)
    for spine in ax.spines.values():
        spine.set_linewidth(0.5)
        spine.set_edgecolor("#cbd5e1")
    return ax

pad_factor decides how much wider the locator is than the main map. Six is a reasonable default: enough context to orient, not so much that the rectangle becomes a dot.

Example 2 โ€” a detail inset with connector lines

from mpl_toolkits.axes_grid1.inset_locator import inset_axes, mark_inset


def detail_inset(fig, ax_main, layers, bounds, size="36%", loc="lower right",
                 connector_colour="#64748b"):
    """Zoom into `bounds` on the same data, with lines back to the source area.

    layers: [(gdf, style_dict), ...] โ€” the same layers as the main map, drawn
    again at the inset's scale, usually with thicker lines and more labels.
    """
    ax = inset_axes(ax_main, width=size, height=size, loc=loc, borderpad=0.6)
    for gdf, style in layers:
        gdf.plot(ax=ax, **style)

    minx, miny, maxx, maxy = bounds
    ax.set_xlim(minx, maxx)
    ax.set_ylim(miny, maxy)
    ax.set_aspect("equal")
    ax.set_xticks([]); ax.set_yticks([])
    for spine in ax.spines.values():
        spine.set_linewidth(0.6)
        spine.set_edgecolor(connector_colour)

    mark_inset(ax_main, ax, loc1=2, loc2=4, fc="none",
               ec=connector_colour, linewidth=0.6, zorder=7)
    return ax

Redraw the layers rather than copying the main axis. A detail inset exists because the main scale cannot show something, so it should use thicker lines, larger markers and more labels โ€” not the same styling enlarged.

Example 3 โ€” checking the inset will still be readable

def inset_check(fig, ax_inset, target_width_mm=None, min_pt=6.0):
    """The inset's type is the smallest on the figure โ€” check it first."""
    fig_width_mm = fig.get_size_inches()[0] * 25.4
    scale = (target_width_mm / fig_width_mm) if target_width_mm else 1.0

    box = ax_inset.get_position()
    inset_mm = box.width * fig_width_mm * scale
    print(f"inset drawn {box.width * fig_width_mm:.0f} mm wide, "
          f"placed at {inset_mm:.0f} mm")

    if inset_mm < 20:
        print("  ! under 20 mm โ€” a locator this small cannot carry any labels")

    import matplotlib.text as mtext
    for text in ax_inset.findobj(mtext.Text):
        if text.get_text().strip():
            effective = text.get_fontsize() * scale
            flag = "  <- too small" if effective < min_pt else ""
            print(f"  {text.get_text()[:24]:26} {effective:4.2f} pt{flag}")
    return inset_mm

Explanation

Why a locator needs a rectangle and not a dot

A dot says where the centre is; a rectangle says how much of the world the main map covers. Readers use the second to calibrate everything else on the page โ€” whether they are looking at a city, a county or a country.

When the main extent is genuinely too small to draw as a rectangle at the locator's scale, that is a signal that the locator is at the wrong scale. Reduce pad_factor until the rectangle is visible, or accept two levels of locator, which is almost always more apparatus than the map deserves.

Why insets are the first thing to break in a scaled figure

Everything on the figure scales together, but the inset starts smallest. A 30 mm locator in a figure scaled by 0.443 becomes 13 mm, at which point its coastline is a blur and any label is unreadable.

This is why insets belong in a figure drawn at its final size. It is also why the inset's type should be the same size as the rest of the apparatus, not one point smaller โ€” the temptation is to shrink it to fit, and the result is a component nobody can read.

Why a detail inset should be restyled, not enlarged

An inset showing a city centre at five times the main map's scale can carry five times the detail: street names, individual buildings, thicker symbols. Reusing the main map's styling wastes that.

The practical rule: pick the styling for the inset's own scale, using the same 0.2 mm rule that governs the main map. At the inset's larger scale that tolerance is a smaller ground distance, so more geometry is legitimately visible.

Why one message per inset

An inset with its own legend, its own labels and its own graticule is a second map competing with the first. Readers then have to decide which one is the subject.

The discipline that works: a locator shows context and a rectangle, nothing else; a detail inset shows one crowded area at a scale where it resolves. Anything more belongs beside the map as a panel, where it can have its own apparatus honestly.

Checklist of five reasons an inset becomes unreadable after a figure is scaled.
The temptation is to shrink the insetโ€™s type to fit โ€” which guarantees the failure.

Edge cases or notes

  • fig.add_axes for a locator, inset_axes for a detail inset. The first is fixed to the figure, the second to the map.
  • mark_inset draws both the rectangle and the connectors โ€” writing them by hand usually gets the corners wrong.
  • Set zorder above the main map, or the inset draws underneath.
  • Reproject the inset's layers to the main CRS unless the inset is deliberately global.
  • Simplify the inset's geometry separately. Its scale is much smaller than the main map's.
  • Insets over a choropleth need an opaque background, or the colours behind them read as data.
  • Avoid two locators. If one level of context is not enough, the main map's extent is wrong.
  • Check the inset after export โ€” it fails before anything else does.

FAQ

What is the difference between a locator map and an inset?

A locator shows a wider area with the main extent marked, answering "where is this?". A detail inset zooms into part of the same map, answering "what is in that crowded corner?".

Do I always need a locator map?

No. It is needed when the audience does not already know the area. On a map for readers who know the region it is apparatus competing with the data.

How do I mark the main map's extent on the locator?

Draw a rectangle from total_bounds of the main layer. A dot tells the reader where the centre is; a rectangle tells them how much ground the map covers.

How do I connect a detail inset to the area it came from?

mark_inset from mpl_toolkits.axes_grid1.inset_locator draws both the source rectangle and the connector lines.

Why is my inset unreadable in the final document?

Because it is the smallest element and the figure was scaled. An 8-inch figure placed in a 90 mm column scales by 0.443, so a 30 mm inset becomes 13 mm.

Should the inset use the same styling as the main map?

A locator should be much simpler. A detail inset should be restyled for its own, larger scale โ€” thicker lines, more labels โ€” rather than being the main styling enlarged.