How to Save a Map as an Image in Python with Matplotlib

On this page

Problem statement

You already have a map plotted in Python and need to export it for a report, slide deck, web page, or automated GIS workflow. This is a common task when working with GeoPandas and Matplotlib: create a spatial plot, then save it to PNG, JPG, SVG, or PDF output.

The main problem is usually not plotting the data. It is exporting the map cleanly with the right size, resolution, and layout so the saved file is usable outside Python. The examples below export real maps of Rutland β€” its wards from the ONS, and its roads and land use from OpenStreetMap β€” and measure what each setting does to the file.

Quick answer

To save a map as an image in Python with Matplotlib, plot your spatial data on a Matplotlib figure, then export it with fig.savefig().

Saving a GeoPandas map to an image file. plot, tidy, then savefig, ax =subplotsfigsize=gdf.plot(ax=ax)every layertidy the axesset_axis_off()savefig()dpi, bbox_inchescloseplt.close(fig)savefig() must come before show() β€” showing a figure clears it spatialworkflow.io
Calling show() first is why saved images sometimes come out blank.
fig, ax = plt.subplots(figsize=(8, 6))
wards.plot(ax=ax, color="lightblue", edgecolor="black")

ax.set_axis_off()
fig.savefig(output_dir / "rutland_wards.png", dpi=300, bbox_inches="tight")
plt.close(fig)
print(png_size(output_dir / "rutland_wards.png"), "pixels")
1463 x 1446 pixels

Use:

  • dpi for resolution
  • bbox_inches="tight" to reduce whitespace β€” which is why the file is not 2400 Γ— 1800
  • plt.close(fig) in scripts or loops to avoid memory issues

Plot, save, then size, DPI and whitespace

The savefig arguments that decide output quality. the four arguments that matterdefaultuse insteaddpi!100 β€” screen onlyβœ“300 for printbbox_inches!wide white marginβœ“"tight"facecolorwhitematch the reporttransparentoffTrue for overlaysFigure size sets the layout; dpi sets the pixels β€” set both deliberately spatialworkflow.io
bbox_inches="tight" removes the margin that makes exported maps look small.

1. Load spatial data with GeoPandas

GeoPandas reads common GIS formats such as shapefiles and GeoJSON. It uses Matplotlib for plotting, so a GeoPandas map can be exported with normal Matplotlib save methods. If you are combining layers, make sure they use the same CRS before plotting:

roads = gpd.read_file(f"{OSM}gis_osm_roads_free_1.shp", where="fclass IN ('primary', 'secondary', 'tertiary')")
print("wards:", wards.crs.to_epsg(), "| roads:", roads.crs.to_epsg())

if roads.crs != wards.crs:
    roads = roads.to_crs(wards.crs)
print(len(wards), "wards,", len(roads), "main road segments, both in EPSG:", roads.crs.to_epsg())
wards: 27700 | roads: 4326
15 wards, 417 main road segments, both in EPSG: 27700

Without the to_crs, the roads β€” in degrees β€” would be drawn as a speck near the grid's origin, hundreds of kilometres from wards drawn in metres.

2. Create a Matplotlib figure and plot the map

Create an explicit fig, ax pair so you control the exact figure that will be exported:

fig, ax = plt.subplots(figsize=(10, 8))
wards.plot(ax=ax, color="#d9e8fb", edgecolor="#444444", linewidth=0.6)
roads.plot(ax=ax, color="red", linewidth=1)
ax.set_title("Rutland: wards and main roads")

Using fig.savefig() is usually clearer than relying on implicit plotting because it saves the exact figure you created.

3. Save the map to a file

PNG is the most common output format for GIS maps used in documents, presentations, and web pages. To avoid path errors, create the output folder first:

output_dir = Path("output")
output_dir.mkdir(exist_ok=True)
fig.savefig(output_dir / "wards_roads.png")
print(png_size(output_dir / "wards_roads.png"), "pixels at the default", plt.rcParams["savefig.dpi"], "dpi")
1000 x 800 pixels at the default figure dpi

savefig.dpi defaults to "figure", the figure's own dpi β€” 100 unless something changed it β€” so a 10 Γ— 8 inch figure becomes 1000 Γ— 800 pixels.

4. Control output quality with figure size and DPI

DPI controls export resolution. Figure size and DPI work together β€” pixels are inches times dpi:

for dpi in (100, 150, 300):
    fig.savefig(output_dir / f"wards_roads_{dpi}.png", dpi=dpi)
    print(f"figsize (10, 8) at {dpi:>3} dpi -> {png_size(output_dir / f'wards_roads_{dpi}.png')} pixels, "
          f"{(output_dir / f'wards_roads_{dpi}.png').stat().st_size / 1024:,.0f} kB")
figsize (10, 8) at 100 dpi -> 1000 x 800 pixels, 122 kB
figsize (10, 8) at 150 dpi -> 1500 x 1200 pixels, 208 kB
figsize (10, 8) at 300 dpi -> 3000 x 2400 pixels, 490 kB

Practical values:

  • dpi=100 to 150: screen use
  • dpi=200 to 300: reports and print
  • above 300: large-format print

Raising dpi does not make the text or lines any bigger relative to the map β€” they are sized in points, which scale with the figure. It only adds pixels.

5. Remove extra whitespace

Maps often export with more empty space than needed. bbox_inches="tight" trims the saved canvas to what was drawn:

ax.set_axis_off()
for bbox in (None, "tight"):
    fig.savefig(output_dir / "trim_test.png", dpi=100, bbox_inches=bbox)
    print(f"bbox_inches={bbox!s:6} -> {png_size(output_dir / 'trim_test.png')} pixels")
plt.close(fig)
bbox_inches=None   -> 1000 x 800 pixels
bbox_inches=tight  -> 696 x 657 pixels

This is useful when placing the image into a document or slide β€” but it changes the image's size, so do not use it when you need exact pixel dimensions.

6. Add only the map elements you need

If the map is going into a report, include only the elements needed to read it:

landuse = gpd.read_file(f"{OSM}gis_osm_landuse_a_free_1.shp").to_crs(wards.crs)
main = landuse[landuse["fclass"].isin(landuse["fclass"].value_counts().index[:6])]

fig, ax = plt.subplots(figsize=(10, 8))
main.plot(ax=ax, column="fclass", legend=True, cmap="Set2", edgecolor="none",
          legend_kwds={"loc": "lower left", "fontsize": 8})
wards.boundary.plot(ax=ax, color="#444444", linewidth=0.4)
ax.set_title("Rutland land use (OpenStreetMap, six most common classes)")
ax.set_axis_off()
fig.savefig(output_dir / "land_use_map.png", dpi=300, bbox_inches="tight")
plt.close(fig)
print(len(main), "land-use polygons in", sorted(main["fclass"].unique()))
2153 land-use polygons in ['farmland', 'farmyard', 'forest', 'grass', 'residential', 'scrub']

Formats, batches, exact pixels and blank exports

The data used on this page

Rutland's wards from the ONS December 2024 boundaries (OGL), and its roads and land use from Geofabrik's OpenStreetMap extract of 1 January 2026 (Β© OpenStreetMap contributors, ODbL).

import io
import struct
from pathlib import Path

import geopandas as gpd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import requests

Path("data").mkdir(exist_ok=True)
if not Path("data/rutland.shp.zip").exists():
    Path("data/rutland.shp.zip").write_bytes(requests.get(
        "https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip", timeout=300).content)
if not Path("data/wards.geojson").exists():
    Path("data/wards.geojson").write_bytes(requests.get(
        "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
        "Wards_December_2024_Boundaries_UK_BFC/FeatureServer/0/query",
        params={"where": "LAD24CD='E06000017'", "outFields": "WD24NM", "outSR": 27700, "f": "geojson"},
        timeout=120).content)
OSM = "/vsizip/data/rutland.shp.zip/"
wards = gpd.read_file("data/wards.geojson")
output_dir = Path("output")
output_dir.mkdir(exist_ok=True)


def png_size(path):
    """Width x height from a PNG's header."""
    width, height = struct.unpack(">II", Path(path).read_bytes()[16:24])
    return f"{width} x {height}"

print(len(wards), "wards, CRS EPSG:", wards.crs.to_epsg())
15 wards, CRS EPSG: 27700

Example 1: Save in different output formats

Matplotlib chooses the output format from the file extension:

fig, ax = plt.subplots(figsize=(8, 6))
wards.plot(ax=ax, color="#f2efe9", edgecolor="#555555", linewidth=0.4)
roads.plot(ax=ax, color="#c0392b", linewidth=0.8)
ax.set_axis_off()
for suffix, options in (("png", {"dpi": 300}), ("jpg", {"dpi": 300}), ("svg", {}), ("pdf", {})):
    fig.savefig(output_dir / f"map.{suffix}", **options)
    print(f"{suffix:4} {(output_dir / f'map.{suffix}').stat().st_size / 1024:7,.0f} kB")

png = plt.imread(output_dir / "map.png")[..., :3]
jpg = plt.imread(output_dir / "map.jpg")[..., :3] / 255
print("distinct colours: PNG", len(np.unique((png * 255).round().reshape(-1, 3), axis=0)),
      "| JPG", len(np.unique((jpg * 255).round().reshape(-1, 3), axis=0)))
plt.close(fig)
png      315 kB
jpg      153 kB
svg      788 kB
pdf      280 kB
distinct colours: PNG 2017 | JPG 38496

The PNG holds 2,017 distinct colours β€” a few fills plus the anti-aliased edges between them. The JPEG of the same map holds 38,496: the extras are compression noise around every line and boundary. It is half the size of the PNG, and the SVG is the largest of the four, because it stores every vertex of every ward and road as text. Use these formats based on the final use:

  • PNG: best general-purpose raster format for maps
  • JPG: smaller for photographs, but it smears labels, boundaries and linework
  • SVG: vector output for web or design tools
  • PDF: vector output for reports and print
File size of the same Rutland wards-and-roads map saved as PNG and JPEG at 300 dpi, and as SVG and PDF. one 8 Γ— 6 inch map, four formatsPNG, 300 dpi315 kB Β· 2,017 coloursJPEG, 300 dpi153 kB Β· 38,496 coloursSVG788 kBPDF280 kB0400 kB800 kBThe JPEG is smallest because it throws detail away β€” 36,000 of its colours are noise spatialworkflow.io
Example 1, measured: the smallest file is the damaged one.

Example 2: Save maps in a batch workflow

Batch export is common when you need one image per dataset, region, or category β€” here, one map per ward with the main roads that cross it:

batch_dir = output_dir / "wards"
batch_dir.mkdir(parents=True, exist_ok=True)

for name, ward in wards.groupby("WD24NM"):
    fig, ax = plt.subplots(figsize=(6, 6))
    ward.plot(ax=ax, color="lightyellow", edgecolor="black")
    roads.clip(ward).plot(ax=ax, color="#c0392b", linewidth=1)
    ax.set_title(name)
    ax.set_axis_off()
    fig.savefig(batch_dir / f"{name.lower().replace(' ', '_').replace('&', 'and')}.png", dpi=150, bbox_inches="tight")
    plt.close(fig)

saved = sorted(batch_dir.glob("*.png"))
print(len(saved), "maps, e.g.", [p.name for p in saved[:3]], "| open figures:", len(plt.get_fignums()))
15 maps, e.g. ['barleythorpe.png', 'braunston_and_martinsthorpe.png', 'cottesmore.png'] | open figures: 0

plt.close(fig) inside the loop is what keeps the open-figure count at zero; without it Matplotlib warns after twenty figures and memory grows with every map.

Example 3: An exact pixel size, and a transparent background

fig, ax = plt.subplots(figsize=(19.2, 10.8), dpi=100)
wards.plot(ax=ax, color="#1a3a6b", edgecolor="white")
ax.set_axis_off()
fig.savefig(output_dir / "slide.png")
print("slide:", png_size(output_dir / "slide.png"))
fig.savefig(output_dir / "slide_transparent.png", transparent=True)
alpha = plt.imread(output_dir / "slide_transparent.png")[..., 3]
print(f"transparent: {(alpha == 0).mean():.0%} of pixels fully transparent")
plt.close(fig)
slide: 1920 x 1080
transparent: 84% of pixels fully transparent

Pixel size is inches times dpi, so 19.2 Γ— 10.8 inches at 100 dpi is exactly 1920 Γ— 1080 β€” as long as bbox_inches="tight" is left out. With transparent=True, everything but the wards is see-through, ready to lay over a slide background.

Example 4: Why a saved map comes out blank

fig, ax = plt.subplots(figsize=(8, 6))
wards.plot(ax=ax, color="lightblue", edgecolor="black")
plt.close(fig)                                    # what plt.show() does to the figure in a script
plt.savefig(output_dir / "blank.png")             # saves the *current* figure β€” a new, empty one
fig.savefig(output_dir / "not_blank.png")         # the figure object still has the map

for name in ("blank.png", "not_blank.png"):
    pixels = plt.imread(output_dir / name)[..., :3]
    print(f"{name:14} {len(np.unique(pixels.reshape(-1, 3), axis=0)):>5} distinct colours")
plt.close("all")
blank.png          1 distinct colours
not_blank.png    908 distinct colours

plt.savefig saves whichever figure is current. Once the map's figure has been closed β€” by plt.show() returning in a script, or explicitly β€” the current figure is a brand-new empty one, and that is what gets written. Saving through the fig object you drew on avoids the problem entirely.

A map is still a Matplotlib figure

A GeoPandas plot is still a Matplotlib figure and axes object. Saving a map figure with Matplotlib works the same way for spatial plots as for any other plot. Once your map is drawn on ax, fig.savefig() writes that figure to a file.

Which image format to save a map in, and what each one is for. which format for which useformatkindbest forPNGrasterweb, slides, most usesSVGvectorediting later, infinite zoomPDFvectorprint and publicationJPEGlossy rasteravoid β€” artefacts on linesa vector format keeps a map crisp at any size β€” until it holds a million featuresJPEG compresses exactly the sharp edges a map is made of spatialworkflow.io
JPEG compression puts halos around every line β€” never use it for maps.

In practice, the workflow is:

  1. read spatial data
  2. plot it on a Matplotlib axis
  3. adjust figure size and layout
  4. save with fig.savefig()

For already-plotted static maps, this is the standard export method in Python. If your output looks wrong, the issue is usually one of these:

  • figure size is too small
  • DPI is too low
  • axes or whitespace were not removed
  • layers use different CRS values
  • the wrong figure was saved, or the figure was closed before plt.savefig

CRS mismatch, transparency and plt.show

  • CRS mismatch: If layers do not share the same CRS, they may plot in the wrong place or appear missing. Check gdf.crs and use .to_crs(...) before plotting.
  • Invalid geometries: Broken polygons can cause odd rendering or missing features. Check with gdf.is_valid if the output looks incomplete.
  • Transparent background: For overlays or slides, use:
    fig.savefig("output/map_transparent.png", dpi=300, transparent=True, bbox_inches="tight")
  • Overwrite behavior: Saving to the same filename replaces the old file unless you add versioning logic.
  • Blank exports: Save through the figure object, after plotting β€” plt.savefig after the figure is closed writes an empty one (example 4).
    fig.savefig("output/map.png")
    plt.close(fig)
  • plt.show() in scripts: If you use plt.show(), save before closing the window and keep a reference to fig. In scripts, fig.savefig() is usually the safer pattern.

For a broader plotting workflow, see How to Reproject Spatial Data in Python (GeoPandas).

If you need to start from vector data, read How to Read a Shapefile in Python with GeoPandas.

If you need to write processed data before mapping, see How to Export GeoJSON in Python with GeoPandas.

FAQ

How do I save a GeoPandas map as a PNG in Python?

Plot the GeoDataFrame on a Matplotlib axis, then save the figure with fig.savefig("map.png"). For better output, add dpi=300 and bbox_inches="tight".

What DPI should I use when exporting a map image with Matplotlib?

Use 100 to 150 DPI for screen use and 200 to 300 DPI for reports or print. If the image looks blurry, increase DPI and possibly figure size too.

How do I remove axes and whitespace before saving a map?

Turn off axes with:

ax.set_axis_off()

Then save with:

fig.savefig("map.png", bbox_inches="tight")

Can I save a Python map as SVG or PDF instead of PNG?

Yes. Change the file extension in savefig():

fig.savefig("map.svg")
fig.savefig("map.pdf")

Use SVG or PDF when you need vector output.

Why does my saved Matplotlib map look blurry or cut off?

Blurry maps usually come from low DPI or a small figure size. Cut-off labels or legends usually come from layout issues. Increase figsize, raise dpi, and use bbox_inches="tight" where needed.

How do I make the saved map's background transparent except for the data?

Pass transparent=True to savefig() and avoid setting a figure or axes face color. Note that JPG does not support transparency, so save as PNG, SVG, or PDF when you need a transparent background.

Why is there still white space around my map even with bbox_inches="tight"?

Extra margin often comes from the axes data limits, not the figure padding. Set the axis extent tightly with ax.set_xlim() and ax.set_ylim() to the layer's bounds, and add pad_inches=0 to savefig() to remove the remaining border.

How do I save the map at an exact pixel size, like 1920x1080?

Pixel size is figsize Γ— dpi, so choose values that multiply to your target, for example figsize=(19.2, 10.8) with dpi=100 for 1920x1080. Avoid bbox_inches="tight" in this case, since trimming changes the final pixel dimensions.

Why are the colors or basemap in my saved PNG different from what plt.show() displayed?

This usually happens when a contextily basemap or tile layer is added in a CRS that differs from the data, or when an interactive backend renders differently from the file backend. Make sure all layers share one CRS before plotting, and save the same fig object you drew on rather than relying on the current figure.

How do I add a north arrow or scale bar before saving the map?

Matplotlib has no built-in map elements, so add a scale bar with the matplotlib-scalebar package (ax.add_artist(ScaleBar(1)) for a layer in meters) and draw a north arrow with ax.annotate() or an image. Add them to the ax before calling fig.savefig() so they appear in the exported file.