Converting NetCDF to Cloud-Optimized GeoTIFF
Turning a model output or a gridded product into something a web map or a tile server can read a window of is a routine step, and rioxarray makes it look like one line. The line works, and it silently produces a transposed, upside-down, CRS-less raster often enough that the conversion deserves a procedure. This page is that procedure, within the NetCDF versus GeoTIFF routing topic.
Root cause: NetCDF describes axes, GeoTIFF assumes them
A CF NetCDF variable carries named dimensions in any order, with coordinate variables that say what each one means and which way it runs. A GeoTIFF carries a geotransform: an origin and a pixel size, with the implicit convention that rows increase southward from a north-west origin. The conversion has to turn the first description into the second, and four pieces of information have no default that is right more often than it is wrong.
import xarray as xr
ds = xr.open_dataset("hycom_ssh.nc")
print(ds["surf_el"].dims) # ('time', 'lat', 'lon') — three dims, not two
print(float(ds.lat[0]), float(ds.lat[-1])) # 25.0 45.0 — ascending, i.e. south-up
print(ds["surf_el"].attrs.get("grid_mapping")) # 'crs' — the CRS is in another variable
print(ds["surf_el"].attrs.get("_FillValue")) # -30000 — packed, not NaN
Every one of those is a decision the writer must be told about. Left alone, the time dimension becomes bands in an arbitrary order, the ascending latitude produces a vertically mirrored image, the CRS defaults to whatever the reader guesses, and the fill value becomes a real elevation of −30000.
Step-by-step fix with production code
1. Reduce to a 2D or banded array, deliberately
import logging
import rioxarray # noqa: F401 — registers the .rio accessor
import xarray as xr
logger = logging.getLogger(__name__)
def select_slice(ds: xr.Dataset, var: str, **selectors) -> xr.DataArray:
"""One variable, reduced to (y, x) by explicit selection on every other dim."""
da = ds[var]
if selectors:
da = da.sel(**selectors, method="nearest")
extra = [d for d in da.dims if d not in ("y", "x", "lat", "lon", "latitude", "longitude")]
if extra:
raise ValueError(
f"{var} still has non-spatial dimensions {extra}; select on them "
"explicitly rather than letting the writer choose a band order")
logger.info("selected %s -> dims %s shape %s", var, da.dims, da.shape)
return da
Raising on leftover dimensions rather than squeezing them is the point. A four-dimensional variable silently written as bands produces a file whose band 7 means something nobody recorded.
2. Orient the grid, then attach the CRS
def orient_north_up(da: xr.DataArray, y: str = "lat", x: str = "lon") -> xr.DataArray:
"""Transpose to (y, x) and flip so y descends, as a geotransform expects."""
da = da.rename({y: "y", x: "x"}) if y in da.dims else da
da = da.transpose("y", "x")
if float(da.y[0]) < float(da.y[-1]):
logger.info("y ascends; flipping so the origin is north-west")
da = da.isel(y=slice(None, None, -1))
return da
def attach_crs(da: xr.DataArray, ds: xr.Dataset) -> xr.DataArray:
"""Read the CF grid_mapping variable and write it as the raster CRS."""
gm = da.attrs.get("grid_mapping")
if gm and gm in ds.variables:
wkt = ds[gm].attrs.get("crs_wkt") or ds[gm].attrs.get("spatial_ref")
if wkt:
return da.rio.write_crs(wkt)
epsg = ds[gm].attrs.get("epsg_code")
if epsg:
return da.rio.write_crs(epsg)
raise ValueError(
"no usable CRS: the variable has no grid_mapping, or the mapping "
"variable carries no crs_wkt/epsg_code. Do not default to EPSG:4326 — "
"a model grid on a rotated pole will be silently wrong.")
The refusal to default to EPSG:4326 is deliberate. Most CF marine products are on a plain geographic grid and the default would usually be right, which is precisely what makes the exceptions — rotated-pole atmospheric grids, polar stereographic ice products — so damaging.
3. Write with the COG driver, and validate
def write_cog(da: xr.DataArray, path: str, *, nodata: float, blocksize: int = 512) -> None:
"""Write a valid COG: tiled, with overviews, IFDs before the image data."""
if da.rio.crs is None:
raise ValueError("refusing to write a COG with no CRS")
da = da.rio.write_nodata(nodata, encoded=True)
da.rio.to_raster(
path, driver="COG", compress="DEFLATE", predictor=2,
blocksize=blocksize, overview_resampling="average",
BIGTIFF="IF_SAFER",
)
logger.info("wrote %s (%s, %s)", path, da.rio.crs.to_string(), da.shape)
The COG driver rather than GTiff with manual overview building is what guarantees the header-first layout that makes range requests cheap — the property described in setting COG overviews and blocksize for S3. Building overviews after writing with GTiff produces a file that is tiled, has overviews, and is not a COG.
4. Carry the metadata that the format cannot hold
A CF NetCDF variable arrives with units, a standard name, a long name, a cell-methods string and a history. A GeoTIFF has none of those as first-class fields. Everything except the CRS and the nodata value has to be written into free-text tags or lost, and by default it is lost.
The practical minimum is a small, fixed set of tags written on every conversion, so a consumer opening the COG alone can tell what the numbers are:
CARRY = ("units", "standard_name", "long_name", "cell_methods", "source", "references")
def carry_cf_metadata(da: xr.DataArray, ds: xr.Dataset, extra: dict[str, str]) -> dict[str, str]:
"""CF attributes worth writing into the COG's metadata tags."""
tags = {k: str(da.attrs[k]) for k in CARRY if k in da.attrs}
for k in ("title", "institution", "Conventions"):
if k in ds.attrs:
tags[f"global_{k}"] = str(ds.attrs[k])
if "time" in da.coords:
tags["time"] = str(da.coords["time"].values)
missing = [k for k in ("units", "standard_name") if k not in tags]
if missing:
raise ValueError(
f"refusing to write a COG with no {', '.join(missing)}: a raster of "
"unnamed numbers in unknown units is not a product")
return tags | extra
Refusing to write without units and standard_name looks strict and is the cheapest possible defence against the most common downstream question, which is what the values mean. A CF NetCDF always has them; a COG converted from one has them only if somebody carried them across.
The time tag deserves particular attention. Once a single timestep is extracted, nothing in the raster records which one, and a directory of forty COGs distinguished only by filename is exactly the arrangement in which two files get swapped. Writing the timestamp into the file’s own metadata makes the filename a convenience rather than the sole record.
Verification and acceptance test
Round-tripping a known cell is the test that catches all four failure modes at once, because a transpose, a flip, a wrong CRS or a fill-as-data all move or change the value at a given coordinate.
import numpy as np
import pytest
import rasterio
def test_cog_round_trips_a_known_cell(tmp_path, ds):
da = attach_crs(orient_north_up(select_slice(ds, "surf_el", time="2026-03-14")), ds)
out = tmp_path / "ssh.tif"
write_cog(da, str(out), nodata=float("nan"))
lon, lat = -75.0941, 38.7870
expected = float(da.sel(x=lon, y=lat, method="nearest"))
with rasterio.open(out) as src:
assert src.crs is not None, "COG has no CRS"
assert src.profile["blockxsize"] == 512, "not tiled at the requested block size"
assert src.overviews(1), "no overview pyramid — this is not a COG"
got = float(next(src.sample([(lon, lat)]))[0])
assert np.isclose(got, expected, equal_nan=True), (
f"value at ({lon}, {lat}) is {got}, expected {expected} — "
"the raster is transposed, flipped, or in the wrong CRS")
Sampling by coordinate rather than by pixel index is what makes this test meaningful. Comparing array[10, 20] before and after would pass on a flipped raster.
Edge cases and gotchas
- Packed integers. A variable with
scale_factorandadd_offsetis stored as an integer and must be unpacked before the nodata value means what you think.xr.open_dataset(..., mask_and_scale=True)is the default and does this, butdecode_cf=False— used to inspect raw values — turns it off, and code written under that flag carries the raw fill value forward. - Longitude on 0–360. A model grid running 0 to 360 produces a COG whose western hemisphere is on the right-hand side. Roll the array and rewrite the coordinate before writing, and verify with a coordinate sample rather than a bbox check.
- Time as bands is legal and rarely wanted. A COG can hold a time series as bands, but nothing in the format records what each band’s timestamp is beyond a free-text description. For anything more than a handful of steps, the Zarr route preserves the time coordinate properly.
Related
- Understanding NetCDF vs GeoTIFF for Marine Data — the routing decision this conversion implements
- Zarr vs NetCDF for Cloud-Native Coastal Pipelines — when the answer is neither of these two formats
- Setting COG Overviews and Blocksize for S3 — choosing the block size this writer is given