Fixing a NetCDF Map That Is Shifted, Flipped or Split at 180°
Problem statement
The values are right and the map is wrong. It is upside down, or Europe is cut in half down the Greenwich meridian, or a stripe runs across the Pacific, or every coastline sits half a cell away from the data. Each symptom comes from how a grid's coordinates are ordered and interpreted — latitude stored south to north, longitude from 0 to 360, a longitude axis converted but not sorted, a transform built from cell centres as if they were corners — and none of them raises an error.
Measured on the NCEP/NCAR Reanalysis 1 2024 mean surface air temperature (2.5°) and one day of NOAA OISST v2.1 (0.25°):
- Converting longitude without sorting left the axis running 0 … 177.5, −180 … −2.5. A slice from −10° to 5° returned no columns, and the largest jump between neighbouring columns was 357.5° — the stripe across the map.
- OISST stores latitude from −89.875 to 89.875, so row 0 is the southernmost row: drawn as an image, the map is upside down, and written as a GeoTIFF it has a positive pixel height.
- Building a transform from the first cell centre instead of its corner shifted the grid by 1.25°, about 139 km at the equator. Sampling 242 country points, 71% landed in a different cell, with temperature differences up to 23.1 K.
- A 0–360 GeoTIFF is shifted, not wrapped: London at −0.13° sampled correctly, but New York at −74° fell outside the raster.
Quick answer
Normalise the grid once, before plotting, exporting or sampling:
import xarray as xr
air = xr.open_dataset("air.sig995.2024.nc")["air"].mean("time")
air = air.assign_coords(lon=((air.lon + 180) % 360) - 180) # 1. convention
air = air.sortby("lon").sortby("lat", ascending=False) # 2. order: west to east, north to south
assert air.indexes["lon"].is_unique and air.indexes["lon"].is_monotonic_increasing
print(float(air.lon[0]), float(air.lon[-1]), float(air.lat[0]), float(air.lat[-1]))
-180.0 177.5 90.0 -90.0
When you build a geotransform by hand, place the origin half a cell outside the first centre: from_origin(lon[0] - dx / 2, lat[0] + dy / 2, dx, dy).
Step-by-step solution
1. Print the first and last coordinates
lat from 90 to −90 and lon from 0 to 357.5 for NCEP; lat from −89.875 to 89.875 and lon from 0.125 to 359.875 for OISST. Those four numbers tell you which of the problems below apply, before you draw anything.
2. Upside down: sort latitude descending for images and rasters
Image-style plotting and raster formats put row 0 at the top. With OISST's ascending latitude, row 0 is −89.875°, so imshow of the raw array draws Antarctica at the top. xarray's own plotting uses the coordinate values and gets it right; plt.imshow(da.values), a PNG export or a GeoTIFF written as-is does not. sortby("lat", ascending=False) makes the array north-up.
3. Split at Greenwich: convert the longitude convention
On a 0–360 grid the map's edge is the Greenwich meridian, so Europe and Africa appear at both edges. Converting with ((lon + 180) % 360) - 180 moves the edge to the antimeridian. On a Pacific-centred study, keep 0–360 instead — see longitude conventions explained.
4. Stripe across the map: sort after converting
The conversion changes the values, not their order. The NCEP axis became 0, 2.5, … 177.5, −180, … −2.5: not monotonic, with a jump of 357.5° between two neighbouring columns. A plotting routine that infers cell edges from neighbours drew one cell stretching across the whole map, the inferred edges came out as −1.25 to −1.25, and sel(lon=slice(-10, 5)) found nothing. After sortby("lon"), the axis ran from −180 to 177.5, the edges from −181.25 to 178.75, and the same slice returned the seven columns from −10 to 5.
5. Half a cell off: build transforms from edges, not centres
NetCDF coordinates are cell centres; a raster transform's origin is the outer corner of the first cell. from_origin(-180, 90, 2.5, 2.5) treats the first centre as the corner and shifts the whole grid by half a cell east and south. The correct origin is (-181.25, 91.25). With the wrong transform, 71% of 242 country points sampled a different cell; the mean temperature difference was 1.10 K, the median 0.45 K and the largest 23.1 K (Example 3).
6. Shifted, not wrapped: never export 0–360 as a GeoTIFF for vector data
A GeoTIFF of the NCEP grid written at 0–360 spans −1.25° to 358.75°. Points between −1.25° and 0° still work — London sampled correctly — but anything further west falls outside the raster, and GIS software overlays country outlines on the wrong half of the grid. Convert and sort before writing; see converting NetCDF to GeoTIFF.
7. Check duplicates at the seam
A grid that stores both 0° and 360° gains two columns labelled 0 after conversion — or both −180° and 180° after the reverse. Every selection and plot at the seam then has two answers. indexes["lon"].is_unique catches it; drop one column.
8. Verify against known points
Sample a few known places from the prepared grid, or from the exported file, and compare with a direct sel on the original coordinates. The half-cell and 0–360 errors produce plausible-looking maps and are only caught this way.
Code examples
Example 1 — report every orientation problem at once
import numpy as np
import xarray as xr
def grid_report(da, x="lon", y="lat"):
lon, lat = da.indexes[x], da.indexes[y]
dx, dy = np.diff(lon.to_numpy()), np.diff(lat.to_numpy())
problems = []
if lat.is_monotonic_increasing:
problems.append("latitude ascending: row 0 is the south, images draw upside down")
if lon.max() > 180:
problems.append("longitude 0-360: map edge at Greenwich, negative longitudes fall outside")
if not (lon.is_monotonic_increasing or lon.is_monotonic_decreasing):
problems.append(f"longitude not sorted: largest jump {np.abs(dx).max()} degrees")
if not lon.is_unique:
problems.append("duplicate longitudes at the seam")
if np.ptp(np.abs(dx)) > 1e-6 or np.ptp(np.abs(dy)) > 1e-6:
problems.append("irregular spacing: a single affine transform will not fit")
print(f"{y} {lat[0]} -> {lat[-1]}, {x} {lon[0]} -> {lon[-1]}")
for p in problems or ["no orientation problems"]:
print(" -", p)
air = xr.open_dataset("air.sig995.2024.nc")["air"].mean("time")
sst = xr.open_dataset("oisst-avhrr-v02r01.20240715.nc")["sst"].squeeze(drop=True)
grid_report(air)
grid_report(sst)
grid_report(air.assign_coords(lon=((air.lon + 180) % 360) - 180))
lat 90.0 -> -90.0, lon 0.0 -> 357.5
- longitude 0-360: map edge at Greenwich, negative longitudes fall outside
lat -89.875 -> 89.875, lon 0.125 -> 359.875
- latitude ascending: row 0 is the south, images draw upside down
- longitude 0-360: map edge at Greenwich, negative longitudes fall outside
lat 90.0 -> -90.0, lon 0.0 -> -2.5
- longitude not sorted: largest jump 357.5 degrees
- irregular spacing: a single affine transform will not fit
The converted but unsorted axis also fails the spacing test, because one step between neighbouring columns is −357.5°.
Example 2 — normalise a latitude–longitude grid
def normalise_grid(da, x="lon", y="lat"):
"""-180..180 longitude, sorted west to east, latitude north to south, seam duplicates removed."""
da = da.assign_coords({x: ((da[x] + 180) % 360) - 180}).sortby(x).sortby(y, ascending=False)
duplicated = da.indexes[x].duplicated()
if duplicated.any():
da = da.isel({x: ~duplicated})
return da
for grid in (air, sst):
grid_report(normalise_grid(grid))
lat 90.0 -> -90.0, lon -180.0 -> 177.5
- no orientation problems
lat 89.875 -> -89.875, lon -179.875 -> 179.875
- no orientation problems
Example 3 — the cost of a half-cell transform error
import geopandas as gpd
import rasterio.transform as rt
from rasterio.transform import from_origin
def sample_grid(values, transform, xs, ys):
rows, cols = rt.rowcol(transform, xs, ys)
rows = np.clip(np.asarray(rows), 0, values.shape[0] - 1)
cols = np.clip(np.asarray(cols), 0, values.shape[1] - 1)
return values[rows, cols]
grid = normalise_grid(air)
dx, dy = float(grid.lon[1] - grid.lon[0]), float(grid.lat[0] - grid.lat[1])
right = from_origin(float(grid.lon[0]) - dx / 2, float(grid.lat[0]) + dy / 2, dx, dy)
wrong = from_origin(float(grid.lon[0]), float(grid.lat[0]), dx, dy)
points = gpd.read_file("ne_50m_admin_0_countries.zip").representative_point()
a = sample_grid(grid.values, right, points.x.values, points.y.values)
b = sample_grid(grid.values, wrong, points.x.values, points.y.values)
diff = np.abs(a - b)
print(f"origin right {right.c}, {right.f}; wrong {wrong.c}, {wrong.f}")
print(f"{len(diff)} points: {np.mean(diff > 0):.0%} in a different cell, mean |dT| {diff.mean():.2f} K, "
f"median {np.median(diff):.2f} K, max {diff.max():.1f} K")
origin right -181.25, 91.25; wrong -180.0, 90.0
242 points: 71% in a different cell, mean |dT| 1.10 K, median 0.45 K, max 23.1 K
Explanation
Why nothing raises an error
A NetCDF coordinate is just an array of numbers. Nothing requires it to be sorted, to run north to south, or to use one longitude convention, and xarray's label-based tools respect whatever order is stored. Plotting and raster libraries, meanwhile, work in array positions and assume a conventional layout. The mismatch lives between two correct pieces of software.
Why the stripe appears
Plotting a grid as filled cells requires cell edges, which are inferred as midpoints between neighbouring coordinates. Between 177.5 and −180 the midpoint is −1.25, and the cell whose edges are 176.25 and −1.25 covers most of the map. Sorting restores neighbours that are 2.5° apart.
Why half a cell matters so much on coarse grids
A 2.5° cell is about 278 km across at the equator. Moving the grid by half a cell moves every cell boundary past a large share of any set of points, and coastal points in particular cross from ocean to land cells. On a 0.25° grid the same mistake is 14 km — smaller, but still larger than many study areas.
Why a 0–360 raster is not wrong to GDAL
GDAL treats longitude as an ordinary x coordinate. A raster from −1.25 to 358.75 is a valid raster; it just does not overlap vector data west of −1.25°. The transform does not know that 285° and −75° are the same meridian.
Edge cases or notes
- Two-dimensional latitude and longitude on curvilinear grids cannot be sorted this way; plot with the 2-D coordinates or regrid.
- Descending longitude is rare but legal; sort it ascending as well.
- Pacific-centred work is often simpler on 0–360; the fix is to be consistent, not to use −180–180 everywhere.
- Cartopy and similar libraries need the data's CRS passed through
transform=as well as correct coordinates. xarray.plotuses coordinate values, so it draws ascending latitude correctly and still stripes an unsorted axis.- GeoTIFF bounds from a 0–360 grid exceed 180°, which some web viewers reject outright.
- Rotated-pole grids look shifted and skewed on a latitude–longitude map; they need their own projection.
Internal links
- 0–360 or −180–180: longitude conventions in gridded data explained — choosing and converting conventions
- How to convert NetCDF to GeoTIFF in Python — orientation in exports
- How to select a time range and location from an xarray Dataset — descending slices and nearest matches
- Fixing NetCDF values that look wrong: scale_factor, _FillValue and units — when the values, not the positions, are wrong
- Raster and vector do not line up in Python: how to fix it — the same symptom for GeoTIFFs
- The raster data model explained: bands, dtype, NoData and the transform — what a transform stores
- How to clip a NetCDF grid to a polygon in Python — where orientation errors cause empty clips
- NetCDF and gridded data explained: dimensions, variables and attributes — coordinates as variables
FAQ
Why is my NetCDF map upside down?
The file stores latitude from south to north, so row 0 is the southernmost row. Sort latitude descending before using image or raster tools; OISST starts at −89.875°.
Why does my map have a stripe across it after converting longitude?
The converted axis was not sorted. It jumped 357.5° between neighbouring columns, so one plotted cell spanned the map; call sortby("lon") after converting.
Why is my gridded data half a cell away from the coastlines?
The raster transform used the first cell centre as the corner. Shift the origin by half a cell; on the 2.5° NCEP grid the error moved 71% of country points into a different cell.
Why is Europe split between the edges of my map?
The grid uses 0–360 longitude, whose edge is the Greenwich meridian. Convert to −180–180 and sort, or keep 0–360 if your region is the Pacific.
Why does a point at a negative longitude return no data from my GeoTIFF?
The GeoTIFF was written from a 0–360 grid and spans −1.25° to 358.75°. New York at −74° lies outside it; convert longitude before exporting.
How do I check a grid's orientation quickly?
Print the first and last latitude and longitude and check that longitude is sorted and unique. Example 1 reports every orientation problem in one call.