Fixing a NetCDF Map That Is Shifted, Flipped or Split at 180°

On this page

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).

Three world maps of 2024 mean temperature: one with the data shifted half the globe east so the continents do not match the coastlines, one upside down with the cold Antarctic band at the top, and one correct. Three world maps of 2024 mean temperature: one with the data shifted half the globe east so the continents do not match the coastlines, one upside down with the cold Antarctic band at the top, and one correct.
The two mistakes and the fix, on the file from the code above. Drawn with its 0–360 longitudes on a −180–180 map, the data sits half a globe away from the coastlines — the Pacific’s warmth over Africa. Drawn as if its rows ran south to north, Antarctica’s cold is at the top. Converting and sorting both coordinates, as in the code, puts every cell under its coastline.
Map symptoms from NetCDF coordinates and the fix for each. what is wrong with the mapsymptomfixupside downsortby lat, descendingsplit at Greenwichconvert longitudestripe across the mapsortby lon after convertinghalf a cell offorigin at the outer cornerNCEP/NCAR Reanalysis 1 at 2.5 degrees and NOAA OISST at 0.25 degrees spatialworkflow.io
Each symptom has one cause, and each fix is a line of coordinate handling.

Print the coordinates, then flip, shift or sort

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.

A transform built from the first cell centre compared with one built from the outer corner. where the transform puts the gridorigin at the first centre✕from_origin(-180, 90, 2.5, 2.5)✕grid shifted 1.25 degrees✕71% of 242 points in another cell✕differences up to 23.1 Korigin at the outer corner✓from_origin(-181.25, 91.25, 2.5, 2.5)✓cells where the data are✓samples match sel()NCEP 2024 mean surface air temperature at 242 country points spatialworkflow.io
Half a cell on a 2.5° grid is 139 km: enough to move most coastal points into the neighbouring cell.

Normalising a latitude-longitude grid

The data used on this page

Three public files: NCEP/NCAR Reanalysis 1 daily near-surface air temperature for 2024 from NOAA PSL (2.5°, latitude stored north to south, longitude 0–360), one day of NOAA's OISST v2.1 sea-surface temperature (0.25°, 15 July 2024, latitude south to north), and Natural Earth's 1:50m country outlines.

import os
import subprocess

PSL = "Datasets/ncep.reanalysis/surface/air.sig995.2024.nc"
FILES = {
    "air.sig995.2024.nc": [f"https://downloads.psl.noaa.gov/{PSL}", f"https://psl.noaa.gov/thredds/fileServer/{PSL}"],
    "oisst-avhrr-v02r01.20240715.nc": ["https://www.ncei.noaa.gov/data/sea-surface-temperature-optimum-interpolation/"
                                       "v2.1/access/avhrr/202407/oisst-avhrr-v02r01.20240715.nc"],
    "ne_50m_admin_0_countries.zip": ["https://naciscdn.org/naturalearth/50m/cultural/ne_50m_admin_0_countries.zip"],
}
for name, urls in FILES.items():
    for url in urls * 5:                                 # PSL drops connections: resume and retry
        if os.path.exists(name):
            break
        if subprocess.run(["curl", "-sfL", "-C", "-", "--retry", "3", "-o", name + ".part", url]).returncode == 0:
            os.replace(name + ".part", name)
    if not os.path.exists(name):
        raise RuntimeError(f"could not download {name}")
print({name: os.path.getsize(name) for name in FILES})
{'air.sig995.2024.nc': 22174317, 'oisst-avhrr-v02r01.20240715.nc': 1531844, 'ne_50m_admin_0_countries.zip': 799734}

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

Why nothing raises, and where the stripe comes from

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.

What a converted longitude axis looks like before and after sorting. converting longitude, with and without sortbyconverted, not sortedconverted and sortedthe axis reads0 … 177.5, −180 … −2.5−180 … 177.5largest jump✕357.5°✓one cellslice(-10, 5)✕found nothing✓7 columnsNCEP 2.5 degree grid; edges −181.25 to 178.75 once sorted spatialworkflow.io
Converting changes the labels; sorting puts the columns back in geographic order.

Curvilinear coordinates, Pacific-centred maps and Cartopy

  • 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.plot uses 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.

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.