Chapter Relevance
Lab Relevance: ★★★ (Essential for loading, formatting, and saving xarray datasets in assignments)
Project Relevance: ★★★ (Core workflow for preparing climate/temporal data cubes for analysis)
Foundation: ★★★ (The practical foundation for interacting with multidimensional data)
Time to Read: 15 minutes
In a nutshell: Learn the standard, reliable workflow for reading, inspecting, cleaning, and exporting multidimensional data cubes using xarray.
Skip this if: You are already fully comfortable manipulating xarray.Dataset objects, editing coordinates and attributes, using the .rio accessor, and exporting to NetCDF or Zarr.
In the previous chapter, you learned that a data cube is a multidimensional matrix paired with explicit spatial and temporal labels. Understanding this structure conceptually is the first step. The next crucial skill is learning how to practically interact with these objects in Python.
Before you begin applying map algebra or calculating time series trends, you must know exactly what your data contains. This chapter provides a standard, reliable workflow for ingesting, inspecting, formatting, processing geospatial metadata, and exporting any multidimensional dataset using xarray.
1. Opening the cube¶
The function you use to open a data cube depends entirely on the storage format of the file on your hard drive or in the cloud. xarray acts as a universal reader, but you must choose the right tool for your specific file.
xr.open_dataset(): This is the standard function for reading native multidimensional formats like NetCDF (.nc) or HDF5 (.h5). It reads the file and returns a Dataset container, which can hold multiple aligned variables (like temperature and precipitation).
ds = xr.open_dataset("climate_data.nc")
xr.open_dataarray(): Use this when you are reading a NetCDF file that you know contains only one single variable. It skips theDatasetcontainer and directly returns the DataArray.
da = xr.open_dataarray("single_variable.nc")
xr.open_zarr(): This is the dedicated function for reading cloud optimized Zarr stores (.zarr), allowing you to stream chunked data directly into memory.
ds_zarr = xr.open_zarr("cloud_data.zarr")
The GeoTIFF bridge: If you are reading a standard GIS raster (
.tif), standardxarraywill struggle to understand its spatial projection. By installing therioxarrayextension, you unlock a new reading engine. You can then load GeoTIFFs while perfectly preserving their Coordinate Reference System (CRS).
# !pip install rioxarray
import rioxarray
# Method 1: Using the xarray engine
da_tif = xr.open_dataarray("satellite_image.tif", engine="rasterio")
# Method 2: Using the rioxarray wrapper
da_tif = rioxarray.open_rasterio("satellite_image.tif")
For this chapter, we will use a built-in tutorial dataset to practice reading and inspecting these structures.
import xarray as xr
# Load a standard NetCDF tutorial dataset
ds = xr.tutorial.open_dataset("air_temperature")
2. Reading the xarray display¶
When you evaluate an xarray object in a Jupyter Notebook, you are presented with an interactive HTML summary. Learning to read this summary is an important debugging skill you can develop for multidimensional analysis.
ds
If you run the cell above, you will see a structured output. Notice the interactive icons: you can click the arrows (▶) to expand sections, and the document icons (📄 or ≡) to peek at the actual data values and metadata without printing massive arrays to your screen.
Dimensions: This shows the shape of your cube. You will see
lat: 25,lon: 53, andtime: 2920. This instantly tells you the matrix has 25 rows, 53 columns, and 2920 time steps. Because dimensions are named, their physical order in the array does not matter.Coordinates: These are the labels for the dimensions. Notice that the names are printed in bold font. This indicates they are “dimension coordinates” backed by an index, allowing for lightning fast data selection.
Data variables: This lists the actual physical measurements stored inside the container. Here, there is only one variable called
air. It also shows the specific dimensions that variable relies on(time, lat, lon)and its data type (e.g.,float64).Indexes: This section confirms which coordinates have an underlying search index built in. In most cases, these are backed by a standard Pandas index.
Attributes: This section holds the global metadata. Expanding this reveals the dataset history, conventions, and units, providing the necessary context to trust the data you are analyzing.
Extracting a single variable¶
The display above shows the entire Dataset container. To actually analyze the temperature data, you need to extract the specific DataArray. xarray allows you to do this using standard dictionary syntax or a convenient dot notation shortcut.
# Extracting using dictionary syntax (safest)
da = ds["air"]
# Extracting using dot notation (fastest for typing)
da = ds.air
da
If you print this new da object, you will see a very similar HTML display, but it now represents just the single variable rather than the whole container.
Interactive Explorer: Dataset Anatomy Visualizer.
Hover your mouse over the different rows in the simulated Jupyter HTML display to instantly reveal how the text-based summary maps directly to the physical axes, labels, data volume, and metadata of a 3D Data Cube. For improved visibility of the explorer, follow this link.
Concept Check: The Container vs. The Variable¶
Scenario: You have a NetCDF file containing daily measurements for both temperature and precipitation. You load it using data = xr.open_dataset("weather.nc"). If you want to check the specific units (e.g., Celsius vs. Kelvin) for the temperature data, where should you look?
A) In the global data.attrs dictionary.
B) In the data.coords list.
C) In the data["temperature"].attrs dictionary.
Check your understanding
Answer: C
Because xr.open_dataset() loads a Dataset (a container for multiple variables), the global .attrs dictionary will only contain metadata for the entire file (like the author or project name). To find specific units for a specific physical measurement, you must extract the individual DataArray first and check its specific attributes!
3. Renaming and formatting¶
Scientific datasets can come with obscure variable names, missing metadata, or poorly formatted coordinates. Before you start writing complex analysis code, it is highly recommended to clean your dataset. This makes your code self documenting and prevents errors later on.
Renaming variables and dimensions
If a temperature variable is awkwardly named t2m or simply air, you can rename it to something descriptive. You use the .rename() method, passing a dictionary where the key is the old name and the value is the new name. This exact same approach works for renaming dimensions.
# Rename the variable 'air' to 'temperature'
ds_clean = ds.rename({"air": "temperature"})
# Verify the change
print(list(ds_clean.data_vars))
['temperature']
Modifying and assigning coordinates Sometimes, a dataset will load with plain integer indices instead of real world coordinates, or the coordinates might be in the wrong format (like strings instead of proper datetime objects). You can overwrite or assign entirely new coordinate arrays using dictionary syntax.
import pandas as pd
# Example: Overwriting the time coordinate with a newly generated Pandas date range
ds_clean.coords["time"] = pd.date_range("2013-01-01", "2014-12-31 18:00", freq="6h")
Updating attributes and metadata
Attributes are dictionaries that hold arbitrary metadata. You might want to add missing information, like the physical units of your new temperature variable, or add a descriptive note to the entire dataset container.
# Add metadata to a specific variable
ds_clean["temperature"].attrs["units"] = "Kelvin"
# Add global metadata to the entire dataset container
ds_clean.attrs["project_author"] = "Spatial Data Science Lab"
# Check the new variable attributes
print(ds_clean["temperature"].attrs)
{'long_name': '4xDaily Air temperature at sigma level 995', 'units': 'Kelvin', 'precision': np.int16(2), 'GRIB_id': np.int16(11), 'GRIB_name': 'TMP', 'var_desc': 'Air temperature', 'dataset': 'NMC Reanalysis', 'level_desc': 'Surface', 'statistic': 'Individual Obs', 'parent_stat': 'Other', 'actual_range': array([185.16, 322.1 ], dtype=float32)}
By taking a few lines of code to rename variables, fix coordinates, and assign units, you transform a confusing raw file into a clean, professional data structure ready for analysis.
4. First checks for a cube¶
While the interactive HTML display is fantastic for visual inspection, you will often need to write automated scripts. Every time you load a new dataset, you should run a mental checklist of its properties using the following programmatic commands.
Standard array properties:
# Check the dimensions and their specific lengths
print(ds_clean.sizes)
# Check the available variables
print(list(ds_clean.data_vars))
# Check the global dataset attributes (e.g., to find the author or conventions)
print(ds_clean.attrs)
Frozen({'time': 2920, 'lat': 25, 'lon': 53})
['temperature']
{'Conventions': 'COARDS', 'title': '4x daily NMC reanalysis (1948)', 'description': 'Data is from NMC initialized reanalysis\n(4x/day). These are the 0.9950 sigma level values.', 'platform': 'Model', 'references': 'http://www.esrl.noaa.gov/psd/data/gridded/data.ncep.reanalysis.html', 'project_author': 'Spatial Data Science Lab'}
Memory and safety checks: Data cubes can easily consume all your computer’s RAM. Before running heavy calculations, you should always check the size of your object.
# Calculate total memory size in Megabytes
memory_mb = ds_clean.nbytes / (1024**2)
print(f"Total size: {memory_mb:.2f} MB")
Total size: 29.54 MB
5. Geospatial operations¶
xarray is easily extensible. Third party packages can plug directly into xarray using a feature called accessors. Accessors allow you to use familiar dot notation (like .plot() or .dt()) to access entirely new toolkits.
The rioxarray package provides the .rio accessor. It acts as a bridge between the multidimensional power of xarray and the foundational geospatial algorithms of the rasterio and GDAL libraries.
Loading geospatial metadata¶
Let us load a new dataset to explore these features: a Copernicus Digital Elevation Model (DEM) subset over New Zealand. You can download the DEM here. We will use the rasterio engine to open the GeoTIFF.
import xarray as xr
import rioxarray
filepath = "data/Copernicus_DEM_NZ_subset.tif"
da_dem = xr.open_dataarray(filepath, engine="rasterio")
When rioxarray opens a file, it automatically parses the spatial metadata embedded in the GeoTIFF. You unlock a powerful suite of GIS metadata checks using the .rio accessor.
# Check the Coordinate Reference System (CRS)
print(da_dem.rio.crs)
# Check the spatial resolution (pixel width and height)
print(da_dem.rio.resolution())
# Check the geographic bounding box (xmin, ymin, xmax, ymax)
print(da_dem.rio.bounds())
# Check the designated 'nodata' value used for missing pixels
print(da_dem.rio.nodata)
EPSG:32759
(30.0, -30.0)
(399660.0, 5147640.0, 464760.0, 5208780.0)
None
The output for the CRS typically reveals an EPSG code (European Petroleum Survey Group). Understanding your resolution and bounding box is critical before merging this DEM with other datasets, as they must align perfectly in space.
Reprojection¶
If you want to map this data alongside standard web maps or datasets in a different coordinate system, you must reproject the dataset into a new CRS. For example, converting it to Web Mercator (EPSG:3857).
# Reproject to Web Mercator
da_reprojected = da_dem.rio.reproject("EPSG:3857")
print(da_reprojected.rio.crs)
EPSG:3857
If you print the sizes of da_dem and da_reprojected, you will notice that the dimensions have changed. This is an important rule of spatial data science: reprojection resamples your data. The original grid must be warped and interpolated to fit the newly defined spatial grid.
Handling NoData values¶
Elevation models and satellite imagery frequently contain missing pixels around the edges of the swath or over the ocean. These are recorded using a designated NoData value. You can assign and clean these values so they do not skew your mathematical calculations or color ramps later.
# Explicitly assign a NoData value to the dataset memory
da_clean = da_reprojected.rio.set_nodata(-9999)
# Mask the NoData values (writes them safely to the object attributes)
da_clean = da_clean.rio.write_nodata(-9999, inplace=True)
6. Exporting data¶
Once you have renamed your variables, reprojected your coordinates, and cleaned your NoData values, you will likely want to save the processed data back to disk. Xarray makes writing data just as easy as reading it.
Exporting to multidimensional formats¶
The recommended way to store standard xarray data structures (like the climate datasets from earlier in the chapter) is NetCDF. This format guarantees that all your variables, coordinates, and metadata attributes are perfectly preserved in a single file.
If you are working with massive datasets or cloud environments, you should export to Zarr instead. Zarr implements chunked, compressed arrays that stream beautifully from services like Amazon S3 or Google Cloud Storage.
# Save a cleaned Dataset container to a local NetCDF file
ds_clean.to_netcdf("processed_climate_data.nc")
# Save to a local Zarr store (directory)
ds_clean.to_zarr("processed_climate_data.zarr", mode="w")
/Users/wulf/miniconda3/envs/sds210/lib/python3.12/site-packages/IPython/core/interactiveshell.py:3701: SerializationWarning: saving variable temperature with floating point data as an integer dtype without any _FillValue to use for NaNs
exec(code_obj, self.user_global_ns, self.user_ns)
---------------------------------------------------------------------------
ModuleNotFoundError Traceback (most recent call last)
File ~/miniconda3/envs/sds210/lib/python3.12/site-packages/xarray/core/utils.py:1379, in attempt_import(module)
1378 try:
-> 1379 return importlib.import_module(module)
1380 except ImportError as e:
File ~/miniconda3/envs/sds210/lib/python3.12/importlib/__init__.py:90, in import_module(name, package)
89 level += 1
---> 90 return _bootstrap._gcd_import(name[level:], package, level)
File <frozen importlib._bootstrap>:1387, in _gcd_import(name, package, level)
File <frozen importlib._bootstrap>:1360, in _find_and_load(name, import_)
File <frozen importlib._bootstrap>:1324, in _find_and_load_unlocked(name, import_)
ModuleNotFoundError: No module named 'zarr'
The above exception was the direct cause of the following exception:
ImportError Traceback (most recent call last)
Cell In[13], line 5
2 ds_clean.to_netcdf("processed_climate_data.nc")
4 # Save to a local Zarr store (directory)
----> 5 ds_clean.to_zarr("processed_climate_data.zarr", mode="w")
File ~/miniconda3/envs/sds210/lib/python3.12/site-packages/xarray/core/dataset.py:2377, in Dataset.to_zarr(self, store, chunk_store, mode, synchronizer, group, encoding, compute, consolidated, append_dim, region, safe_chunks, align_chunks, storage_options, zarr_version, zarr_format, write_empty_chunks, chunkmanager_store_kwargs)
2194 """Write dataset contents to a zarr group.
2195
2196 Zarr chunks are determined in the following way:
(...) 2373 The I/O user guide, with more details and examples.
2374 """
2375 from xarray.backends.writers import to_zarr
-> 2377 return to_zarr( # type: ignore[call-overload,misc]
2378 self,
2379 store=store,
2380 chunk_store=chunk_store,
2381 storage_options=storage_options,
2382 mode=mode,
2383 synchronizer=synchronizer,
2384 group=group,
2385 encoding=encoding,
2386 compute=compute,
2387 consolidated=consolidated,
2388 append_dim=append_dim,
2389 region=region,
2390 safe_chunks=safe_chunks,
2391 align_chunks=align_chunks,
2392 zarr_version=zarr_version,
2393 zarr_format=zarr_format,
2394 write_empty_chunks=write_empty_chunks,
2395 chunkmanager_store_kwargs=chunkmanager_store_kwargs,
2396 )
File ~/miniconda3/envs/sds210/lib/python3.12/site-packages/xarray/backends/writers.py:773, in to_zarr(dataset, store, chunk_store, mode, synchronizer, group, encoding, compute, consolidated, append_dim, region, safe_chunks, align_chunks, storage_options, zarr_version, zarr_format, write_empty_chunks, chunkmanager_store_kwargs)
770 if encoding is None:
771 encoding = {}
--> 773 zstore = get_writable_zarr_store(
774 store,
775 chunk_store=chunk_store,
776 mode=mode,
777 synchronizer=synchronizer,
778 group=group,
779 consolidated=consolidated,
780 append_dim=append_dim,
781 region=region,
782 safe_chunks=safe_chunks,
783 align_chunks=align_chunks,
784 storage_options=storage_options,
785 zarr_version=zarr_version,
786 zarr_format=zarr_format,
787 write_empty_chunks=write_empty_chunks,
788 )
790 dataset = zstore._validate_and_autodetect_region(dataset)
791 zstore._validate_encoding(encoding)
File ~/miniconda3/envs/sds210/lib/python3.12/site-packages/xarray/backends/writers.py:666, in get_writable_zarr_store(store, chunk_store, mode, synchronizer, group, consolidated, append_dim, region, safe_chunks, align_chunks, storage_options, zarr_version, zarr_format, write_empty_chunks)
663 already_consolidated = False
664 consolidate_on_close = consolidated or consolidated is None
--> 666 return backends.ZarrStore.open_group(
667 store=mapper,
668 mode=mode,
669 synchronizer=synchronizer,
670 group=group,
671 consolidated=already_consolidated,
672 consolidate_on_close=consolidate_on_close,
673 chunk_store=chunk_mapper,
674 append_dim=append_dim,
675 write_region=region,
676 safe_chunks=safe_chunks,
677 align_chunks=align_chunks,
678 zarr_version=zarr_version,
679 zarr_format=zarr_format,
680 write_empty=write_empty_chunks,
681 **kwargs,
682 )
File ~/miniconda3/envs/sds210/lib/python3.12/site-packages/xarray/backends/zarr.py:714, in ZarrStore.open_group(cls, store, mode, synchronizer, group, consolidated, consolidate_on_close, chunk_store, storage_options, append_dim, write_region, safe_chunks, align_chunks, zarr_version, zarr_format, use_zarr_fill_value_as_mask, write_empty, cache_members)
688 @classmethod
689 def open_group(
690 cls,
(...) 707 cache_members: bool = True,
708 ):
709 (
710 zarr_group,
711 consolidate_on_close,
712 close_store_on_close,
713 use_zarr_fill_value_as_mask,
--> 714 ) = _get_open_params(
715 store=store,
716 mode=mode,
717 synchronizer=synchronizer,
718 group=group,
719 consolidated=consolidated,
720 consolidate_on_close=consolidate_on_close,
721 chunk_store=chunk_store,
722 storage_options=storage_options,
723 zarr_version=zarr_version,
724 use_zarr_fill_value_as_mask=use_zarr_fill_value_as_mask,
725 zarr_format=zarr_format,
726 )
728 return cls(
729 zarr_group,
730 mode,
(...) 739 cache_members=cache_members,
740 )
File ~/miniconda3/envs/sds210/lib/python3.12/site-packages/xarray/backends/zarr.py:1819, in _get_open_params(store, mode, synchronizer, group, consolidated, consolidate_on_close, chunk_store, storage_options, zarr_version, use_zarr_fill_value_as_mask, zarr_format)
1817 import zarr
1818 else:
-> 1819 zarr = attempt_import("zarr")
1821 # zarr doesn't support pathlib.Path objects yet. zarr-python#601
1822 if isinstance(store, os.PathLike):
File ~/miniconda3/envs/sds210/lib/python3.12/site-packages/xarray/core/utils.py:1381, in attempt_import(module)
1379 return importlib.import_module(module)
1380 except ImportError as e:
-> 1381 raise ImportError(
1382 f"The {install_name} package is required {reason}"
1383 " but could not be imported."
1384 " Please install it with your package manager (e.g. conda or pip)."
1385 ) from e
ImportError: The zarr package is required for working with Zarr stores but could not be imported. Please install it with your package manager (e.g. conda or pip).Exporting to geospatial formats¶
If you isolated a single 2D spatial variable (like our New Zealand DEM) and need to share it with a colleague who uses standard GIS software like QGIS or ArcGIS, you should export it as a GeoTIFF.
Because standard xarray does not understand GeoTIFF formatting natively, you must use the .rio accessor to handle the export.
# Save the reprojected and cleaned DEM back to a GeoTIFF file
da_clean.rio.to_raster("reprojected_nz_dem.tif")
7. Exercise¶
It is time to practice the complete ingestion, inspection, and export workflows for both multidimensional climate data and geospatial raster data.
Task A: The Climate Workflow (NetCDF)
Open: Load the
rasmtutorial dataset usingxr.tutorial.open_dataset().Rename: Inspect the data variables. Rename
Tairtoair_temp.Check memory: Calculate and print the total memory size of your renamed dataset in Megabytes (MB).
Export: Save your cleaned dataset to a new NetCDF file named
rasm_cleaned.nc.
Task B: The Geospatial Workflow (GeoTIFF)
Open: Load the New Zealand DEM (
data/Copernicus_DEM_NZ_subset.tif) using therasterioengine.Inspect CRS: Programmatically print the original Coordinate Reference System of the DEM.
Reproject: Reproject the data to WGS 84 (
EPSG:4326).Export: Save the reprojected array to a new GeoTIFF named
nz_dem_wgs84.tif.
Starter code:
# Install required libraries
!pip install cftime rioxarray
import xarray as xr
import rioxarray
# --- TASK A: Climate Workflow ---
# 1. Open the 'rasm' dataset
# ...
# 2. Rename 'Tair' to 'air_temp'
# ...
# 3. Print the memory size in MB
# ...
# 4. Export to NetCDF
# ...
# --- TASK B: Geospatial Workflow ---
filepath = "data/Copernicus_DEM_NZ_subset.tif"
# 1. Open the DEM
# ...
# 2. Print the CRS
# ...
# 3. Reproject to EPSG:4326
# ...
# 4. Export to GeoTIFF
# ...
Sample solution
!pip install cftime rioxarray
import xarray as xr
import rioxarray
# --- TASK A: Climate Workflow ---
# 1. Open the dataset
rasm_ds = xr.tutorial.open_dataset("rasm")
# 2. Rename the variable
rasm_clean = rasm_ds.rename({"Tair": "air_temp"})
# 3. Check memory size
memory_mb = rasm_clean.nbytes / (1024 ** 2)
print(f"Task A - Total size: {memory_mb:.2f} MB")
# 4. Export to NetCDF
rasm_clean.to_netcdf("rasm_cleaned.nc")
print("Task A - Exported to rasm_cleaned.nc")
# --- TASK B: Geospatial Workflow ---
filepath = "data/Copernicus_DEM_NZ_subset.tif"
# 1. Open the DEM
da_dem = xr.open_dataarray(filepath, engine="rasterio")
# 2. Print the CRS
print(f"Task B - Original CRS: {da_dem.rio.crs}")
# 3. Reproject to EPSG:4326
da_dem_wgs84 = da_dem.rio.reproject("EPSG:4326")
print(f"Task B - New CRS: {da_dem_wgs84.rio.crs}")
# 4. Export to GeoTIFF
da_dem_wgs84.rio.to_raster("nz_dem_wgs84.tif")
print("Task B - Exported to nz_dem_wgs84.tif")8. Summary¶
Whenever you receive a new dataset, you should apply a standardized workflow to safely ingest, clean, format, and export your data cubes before diving into deep analysis:
Ingest: Load the file using the appropriate tool (
xr.open_datasetfor NetCDF/HDF5,xr.open_zarrfor cloud stores, or therasterioengine for GeoTIFFs).Inspect: Explore the interactive HTML display to understand the shape, dimension coordinates, physical variables, and underlying metadata. Always check the memory footprint (
.nbytes) to ensure your computer can handle the arrays.Format: Clean up the dataset by renaming messy variables with
.rename(), reassigning coordinate values, and updating missing attributes (like physical units).Process Geospatial Data: Use the
.rioaccessor to inspect spatial properties, mask NoData pixels, and use.rio.reproject()to warp grids into matching Coordinate Reference Systems.Export: Save your verified and cleaned data safely to disk using
.to_netcdf(),.to_zarr(), or.rio.to_raster().
With a clean, memory-safe, and spatially aligned dataset sitting on your hard drive, you are now ready to start slicing, aggregating, and analyzing the cube in the upcoming chapters.