How to Add an Inset and Locator Map in Python

On this page

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.

A locator map and a detail inset answer different questions and need different code. two things called an insetlocator β€” where is this?βœ“a wider area, much simplerβœ“the main extent as a rectangleβœ“fig.add_axes, fixed to the figureβœ“needed only if the reader is newdetail β€” what is in that corner?βœ“the same data, larger scaleβœ“connector lines to the source areaβœ“inset_axes + mark_insetβœ“restyled, not enlargedOne message per inset β€” if it needs its own legend it has become a second map spatialworkflow.io
They differ in purpose, in placement API and in how much they should carry.

What you get β€” from The data used on this page

Herefordshire, County of: 2,180 kmΒ²; 179 UK NUTS 3 regions; 1,533 Hereford road ways; locator context 32,638 vertices

Locator or detail, placed in figure coordinates

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. The locator in the Herefordshire example takes 8.7% 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. The locator for Herefordshire is 52 mm wide and spans 381 km β€” about 1:7.3 million, where 0.2 mm on paper is 1.5 km on the ground. Every vertex closer than that is invisible.

Simplify the inset's layers separately. Drawn from the 1:1 million boundaries as they came, the UK-and-Ireland outline added 310 kB to an 853 kB PDF; simplified to 2 km it added 65 kB.

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 locator inset placed over a map, showing the main extent as a red rectangle. a rectangle, not a dotmain map with locatorextentinset near-opaque, thin borderA dot says where the centre is; a rectangle says how much ground the map covers spatialworkflow.io
If the rectangle is too small to see, the locator is at the wrong scale.

A locator for Herefordshire and a detail of Hereford

The data used on this page

Herefordshire, drawn with its neighbours from Eurostat's GISCO NUTS 2021 regions at 1:1 million, and the street network of Hereford from OpenStreetMap (Β© OpenStreetMap contributors, ODbL) β€” the crowded corner a detail inset is for. The roads are Overpass's answer saved on 10 October 2026. The locator's context is the UK and Ireland at country level from the same release. Everything is in the British National Grid.

import json
import os

import geopandas as gpd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import requests
from shapely.geometry import LineString

GISCO = "https://gisco-services.ec.europa.eu/distribution/v2/nuts/geojson/NUTS_RG_01M_2021_4326_LEVL_{}.geojson"
counties = gpd.read_file(GISCO.format(3)).query("CNTR_CODE == 'UK'").to_crs(27700)
countries = gpd.read_file(GISCO.format(0)).query("CNTR_CODE in ['UK', 'IE']").to_crs(27700)
here = counties[counties.NUTS_ID == "UKG11"]                      # Herefordshire

QUERY = """[out:json][timeout:120];
way["highway"~"^(motorway|trunk|primary|secondary|tertiary|unclassified|residential|living_street)$"](52.035,-2.76,52.075,-2.68);
out geom tags;"""
if not os.path.exists("hereford_roads.json"):
    # live: requests.post("https://overpass-api.de/api/interpreter", data={"data": QUERY}, timeout=180,
    #                     headers={"User-Agent": "spatialworkflow.io guide"})
    # read instead as that query answered on 10 October 2026, so the road count stays put
    reply = requests.get("https://www.spatialworkflow.io/data/osm-hereford-roads-2026-10-10.json", timeout=120)
    reply.raise_for_status()
    open("hereford_roads.json", "w").write(reply.text)
ways = json.load(open("hereford_roads.json"))["elements"]
roads = gpd.GeoDataFrame(
    [{"highway": w["tags"]["highway"], "geometry": LineString([(p["lon"], p["lat"]) for p in w["geometry"]])}
     for w in ways if len(w.get("geometry", [])) > 1], crs=4326).to_crs(27700)
print(f"{here.NAME_LATN.iloc[0]}: {here.area.iloc[0] / 1e6:,.0f} kmΒ²; {len(counties)} UK NUTS 3 regions; "
      f"{len(roads):,} Hereford road ways; locator context {countries.get_coordinates().shape[0]:,} vertices")
Herefordshire, County of: 2,180 kmΒ²; 179 UK NUTS 3 regions; 1,533 Hereford road ways; locator context 32,638 vertices

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

The three together, on Herefordshire: drawn at 8 Γ— 6 inches (203 mm wide) and checked for a 90 mm column.

def county_map(simplify_m):
    fig, ax = plt.subplots(figsize=(8, 6))
    counties.plot(ax=ax, facecolor="#f8fafc", edgecolor="#cbd5e1", linewidth=0.5)
    here.plot(ax=ax, facecolor="#e8f4fd", edgecolor="#1a3a6b", linewidth=0.9)
    roads.plot(ax=ax, color="#1e293b", linewidth=0.25)
    minx, miny, maxx, maxy = here.total_bounds
    ax.set_xlim(minx - 20_000, maxx + 5_000)
    ax.set_ylim(miny - 3_000, maxy + 3_000)
    ax.set_aspect("equal")
    ax.set_axis_off()

    loc = None
    if simplify_m is not None:                                  # None: no locator at all
        loc = locator_map(fig, here, countries, rect=(0.72, 0.62, 0.26, 0.34), simplify_m=simplify_m)
        loc.text(0.5, 0.04, "Herefordshire", transform=loc.transAxes, ha="center", fontsize=6)
    det = detail_inset(fig, ax, [(roads, {"color": "#1e293b", "linewidth": 0.5})],
                       roads.total_bounds, size="38%", loc="lower left")
    return fig, loc, det

for simplify_m in (None, 0, 2_000):
    fig, loc, det = county_map(simplify_m)
    fig.savefig(f"hereford_{simplify_m}.pdf")
    label = "no locator" if simplify_m is None else f"locator, simplify {simplify_m:,} m"
    print(f"{label:26} PDF {os.path.getsize(f'hereford_{simplify_m}.pdf') / 1e3:,.0f} kB")
    plt.close(fig)

fig.savefig("hereford.png", dpi=150)
print(f"locator {loc.get_position().width * 203.2:.0f} mm wide covers "
      f"{(loc.get_xlim()[1] - loc.get_xlim()[0]) / 1000:,.0f} km β†’ 1:{(loc.get_xlim()[1] - loc.get_xlim()[0]) / (loc.get_position().width * 0.2032):,.0f}; "
      f"box area {loc.get_position().width * loc.get_position().height:.1%} of the figure")
print(f"detail inset: {(det.get_xlim()[1] - det.get_xlim()[0]) / 1000:.1f} km across")
inset_check(fig, loc, target_width_mm=90)
no locator                 PDF 853 kB
locator, simplify 0 m      PDF 1,163 kB
locator, simplify 2,000 m  PDF 918 kB
locator 52 mm wide covers 381 km β†’ 1:7,347,231; box area 8.7% of the figure
detail inset: 8.4 km across
inset drawn 52 mm wide, placed at 23 mm
  Herefordshire              2.66 pt  <- too small

inset_check flags the only label on the locator: 6 pt drawn, 2.66 pt printed. The detail inset spans 8.4 km of Hereford, where the main map crushes the same streets into a patch a few millimetres across.

Rectangles, scaling and restyled insets

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. The 52 mm locator here, in a figure scaled by 0.443 into a 90 mm column, becomes 23 mm, and its 6 pt label becomes 2.66 pt.

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.

Why the inset is the first element to become unreadable when a figure is scaled. the inset fails before anything elseit starts as the smallest element52 mm wide hereit scales with everything elseΓ—0.443 into a column52 mm becomes 23 mm6 pt labels print at 2.66 ptits type is often set smallerthe wrong instinctnothing warns youit looks fine on screenSet inset type at the same size as the rest of the apparatus, and draw at final size spatialworkflow.io
The temptation is to shrink the inset’s type to fit β€” which guarantees the failure.

Axes types, z-order and the inset's own CRS

  • 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 52 mm locator becomes 23 mm and a 6 pt label 2.66 pt.

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.