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.
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 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.
Axes types, z-order and the inset's own CRS
fig.add_axesfor a locator,inset_axesfor a detail inset. The first is fixed to the figure, the second to the map.mark_insetdraws both the rectangle and the connectors β writing them by hand usually gets the corners wrong.- Set
zorderabove 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.
Internal links
- How to build a print-ready map layout in Matplotlib β where the inset sits in a layout
- Which map elements are actually required β whether a locator is needed at all
- Map scale explained: how much detail a scale can hold β simplifying for the inset's scale
- Fixing map text that is too small in the exported file β the failure insets hit first
- Visual hierarchy explained: what a map reader sees first β keeping the inset quiet
- How to plot multiple layers in GeoPandas β drawing the inset's content
- How to make a map series with consistent symbology β insets across many panels
- How to add a scale bar and north arrow to a Python map β the other apparatus
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.