Plotting a GeoTIFF with Correct Extent and Axes
Pass the dataset bounds to imshow as an extent, in left-right-bottom-top order:
import matplotlib.pyplot as plt
import rasterio
with rasterio.open("scene.tif") as src:
arr = src.read(1, masked=True)
b = src.bounds # BoundingBox(left, bottom, right, top)
fig, ax = plt.subplots(figsize=(8, 8))
ax.imshow(arr, extent=(b.left, b.right, b.bottom, b.top), interpolation="nearest")
ax.ticklabel_format(style="plain")
The reordering on that third line is the whole trick, and it is where almost every flipped-raster bug comes from. This page belongs to rendering rasters with matplotlib in Visualization, Tiling & Web Delivery.
Why the Default Axes Are Wrong
imshow was designed for images, where row zero is the top and the axis unit is a pixel. A north-up raster also has its highest northing in row zero, so the image orientation happens to be right — but the axis labels are pixel indices, which tell a reader nothing about where they are looking.
The practical payoff is not the labels themselves but what they enable. Once the axes are in map units, a vector layer plots on top with no transformation, a scale bar can be drawn in data coordinates, and a reader can compare two figures of adjacent scenes.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
rasterio |
>=1.3.0 |
Dataset bounds, CRS and masked reads |
matplotlib |
>=3.8 |
imshow, tick formatting, aspect control |
geopandas |
>=0.14 |
Optional vector overlay |
pip install "rasterio>=1.3.0" "matplotlib>=3.8" "geopandas>=0.14"
Complete Working Example
import matplotlib.pyplot as plt
import numpy as np
import rasterio
def plot_geotiff(path: str, band: int = 1, *, target_px: int = 1600,
cmap: str = "viridis", km_axes: bool = False):
"""Plot a band in map coordinates with sane axes."""
with rasterio.open(path) as src:
factor = max(1, int(max(src.width, src.height) / target_px))
arr = src.read(band,
out_shape=(max(1, src.height // factor),
max(1, src.width // factor)),
masked=True)
b = src.bounds
crs = src.crs
# NOTE the reordering: BoundingBox is (left, bottom, right, top),
# imshow wants (left, right, bottom, top)
extent = (b.left, b.right, b.bottom, b.top)
unit = crs.linear_units if crs and crs.is_projected else "degrees"
if km_axes and crs and crs.is_projected:
extent = tuple(v / 1000 for v in extent)
unit = "km"
valid = arr.compressed()
vmin, vmax = np.percentile(valid, [2, 98]) if valid.size else (None, None)
fig, ax = plt.subplots(figsize=(8, 8))
im = ax.imshow(arr, extent=extent, cmap=cmap, vmin=vmin, vmax=vmax,
interpolation="nearest", origin="upper")
ax.set_aspect("equal") # square pixels stay square
ax.ticklabel_format(style="plain") # no 1e6 offset
ax.set_xlabel(f"Easting ({unit})")
ax.set_ylabel(f"Northing ({unit})")
fig.colorbar(im, ax=ax, shrink=0.75, pad=0.02)
return fig, ax
if __name__ == "__main__":
fig, ax = plot_geotiff("S2A_36NYF_20260614_B08.tif", km_axes=True)
fig.savefig("b08.png", dpi=180, bbox_inches="tight")
origin="upper" is the default and is correct here: the array’s first row is the northernmost, and the extent’s top value corresponds to it. Setting origin="lower" without also swapping the extent produces an image that is upside down and mislabelled, which is worse than either alone.
Overlaying Vector Data
import geopandas as gpd
fields = gpd.read_file("field_boundaries.gpkg")
with rasterio.open("scene.tif") as src:
fields = fields.to_crs(src.crs) # essential, and easy to forget
fields.boundary.plot(ax=ax, edgecolor="#b71c1c", linewidth=0.8)
ax.set_xlim(extent[0], extent[1]) # keep the raster's extent
ax.set_ylim(extent[2], extent[3])
Resetting the limits after plotting the vector layer matters when the vector data extends beyond the scene: geopandas expands the axes to fit its own data, which silently zooms the figure out and leaves the raster as a small square in the corner.
Verification
import rasterio
with rasterio.open("scene.tif") as src:
b = src.bounds
assert b.left < b.right and b.bottom < b.top, "unexpected bounds ordering"
assert src.transform.e < 0, "not a north-up raster; extent logic differs"
extent = (b.left, b.right, b.bottom, b.top)
assert extent[0] < extent[1] and extent[2] < extent[3]
The transform.e < 0 assertion is the one worth keeping. A north-up raster has a negative y pixel size, and the rare south-up file needs the opposite origin — silently plotting one upside down is the kind of error that survives a whole report. A quick visual confirmation is to overlay a coastline or a road layer: if it matches, the geometry is right; if it is mirrored about the horizontal axis, the extent’s middle values were swapped. Rotated transforms — where the b and d elements are non-zero — break the extent approach entirely, because imshow can only draw an axis-aligned rectangle; those files must be reprojected onto a north-up grid before they can be plotted this way.
Common Errors
The image is flipped vertically
src.bounds was passed straight through as the extent. Reorder it to (left, right, bottom, top). Unpacking the bounding box into named variables rather than positionally makes the mistake much harder to repeat.
The figure is stretched into a rectangle
The aspect ratio defaults to filling the axes. Call ax.set_aspect("equal") so square ground pixels stay square. If the pixels are genuinely non-square — some resampled products are — pass the ratio of the transform’s a and e magnitudes instead of "equal".
The vector overlay zooms the figure out
geopandas expanded the axes to fit geometry extending beyond the scene. Reset set_xlim and set_ylim to the raster extent after plotting the vector layer, or clip the layer to the raster bounds first.
Axis labels read +3.99e5
Matplotlib’s offset notation. Disable it with ax.ticklabel_format(style="plain"), or work in kilometres.
Frequently Asked Questions
Q: What order does the extent tuple use? Left, right, bottom, top — which is not the order rasterio’s bounds returns them in. BoundingBox gives left, bottom, right and top, so the two middle values must be swapped. Getting it wrong flips the image vertically without any error.
Q: Why do the axes show a scientific offset? Matplotlib factors out a common offset when coordinate values are large, which UTM eastings and northings always are. Call ax.ticklabel_format(style=‘plain’) to disable it, or divide the extent by a thousand and label the axes in kilometres.
Q: How do I overlay a shapefile on the raster? Reproject the vector layer to the raster’s CRS and plot it onto the same axes. Because the axes are already in map coordinates, the geometries land in the right place with no further transformation.
Q: Does this work for a raster in geographic coordinates? Yes, with one caveat: an equal aspect ratio in degrees stretches the map away from the equator, because a degree of longitude is shorter than a degree of latitude there. Either accept the distortion, set the aspect to the secant of the mean latitude, or reproject to a projected CRS before plotting.
Related
- Rendering Rasters with Matplotlib — the parent topic covering stretches, ramps and export.
- Building True Colour Composites with Contrast Stretch — the same axes with three bands stacked.
- Exporting Publication Quality Map Figures — scale bars and annotations that depend on map axes.
- Mastering CRS Transformations in rasterio — reprojecting the vector layer so it can be overlaid.