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.
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.
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.
Edge cases or notes
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 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.