GRIB, NetCDF or Zarr: Choosing a Format for Gridded Data

On this page

Problem statement

The same forecast or reanalysis field can arrive as GRIB2, NetCDF or Zarr, and converting between them is routine. The choice decides how large the files are, how fast a map or a time series comes back, whether the data can be read straight from cloud storage, and how much of the meaning travels with the numbers. None of the three is best at everything.

Measured by writing the same GFS 1° upper-air analysis — temperature, wind components and geopotential height on 33 pressure levels, 34.4 MB as 32-bit floats — in each format:

  • GRIB2 was the smallest at 9.4 MB, 27.3% of the raw size, and by far the slowest to open: 85 ms against 3–5 ms for NetCDF and Zarr.
  • Compressed NetCDF was 19.9 MB; with 4 significant digits of quantization, 13.9 MB, at a cost of at most 0.0078 K.
  • Zarr v3 was 18.4 MB spread over 143 files; Zarr v2 with consolidated metadata 24.8 MB over 152.
  • Remotely, a 1,980-month CMIP6 Zarr store on Google Cloud Storage opened in 1.85 s; a 1.53 MB NetCDF file read by 16 KB HTTP byte ranges took 8.56 s just to open.

All three lossless versions decoded to identical values.

Quick answer

situation                                           format
receiving operational forecasts                     GRIB2, as published; convert what you keep
archiving and exchanging files with other tools     NetCDF-4 with compression and CF attributes
analysing large arrays in parallel or from S3/GCS   Zarr with sensible chunks and consolidated metadata
shipping to a GIS                                   GeoTIFF / COG, one variable and time at a time

Read GRIB with cfgrib, then write whatever you will work with:

import xarray as xr

ds = xr.open_dataset("upper.grib2", engine="cfgrib",
                     backend_kwargs={"filter_by_keys": {"typeOfLevel": "isobaricInhPa"}, "indexpath": ""})
ds = ds.drop_vars(["time", "step", "valid_time"])
ds.to_netcdf("upper.nc", encoding={v: {"zlib": True, "complevel": 4, "shuffle": True} for v in ds.data_vars})
ds.chunk({"isobaricInhPa": 1}).to_zarr("upper.zarr", mode="w", consolidated=True)
Size of the same GFS upper-air field set in six storage formats. GFS 1 degree upper air, 33 levelsGRIB29.38 MBNetCDF, plain34.45 MBNetCDF, zlib19.94 MBNetCDF, quantized13.87 MBZarr v318.43 MB, 143 filesZarr v224.81 MB, 152 files036 MBQuantized to 4 significant digits: at most 0.0078 K lost spatialworkflow.io
GRIB's packing beat general-purpose compression; quantization closed most of the gap.

What each format is, then size, speed and chunks

1. Know what each format is

GRIB2 is a sequence of independent messages, one field each, designed by the World Meteorological Organization for transmitting forecasts compactly. NetCDF-4 is a single self-describing file of named arrays and attributes stored in HDF5. Zarr stores each array as a directory of separately compressed chunks plus small JSON metadata files, designed for parallel and cloud access. See NetCDF and gridded data explained and chunked arrays and Zarr explained.

2. Compare size on your own data

GRIB2 packs each field as integers with a per-field scale, using complex spatial differencing: here 9 to 15 bits per value depending on the variable and level, 9,379,220 bytes for the 132 messages. Plain NetCDF stored the 32-bit floats as they were, 34,446,538 bytes. zlib level 4 with the shuffle filter reduced that to 19,936,493; adding significant_digits=4 quantization to 13,871,820. Zarr v3 with its default zstd codec took 18,431,781 bytes; Zarr v2 with consolidated metadata, 24,809,736 in 152 files (Example 4).

3. Compare read speed for the reads you will do

format                 open      one 500 hPa field   all temperature levels
GRIB (cfgrib)         85.0 ms         85.3 ms              119.4 ms
NetCDF, zlib           2.9 ms          3.5 ms               24.4 ms
NetCDF, uncompressed   2.9 ms          2.8 ms                3.6 ms
Zarr v3                4.5 ms          6.8 ms               22.9 ms
Zarr v2, consolidated  3.6 ms          5.6 ms               20.7 ms

Opening GRIB means scanning messages to build the dataset, every time unless an index file is kept. Compressed formats trade decompression time for size; uncompressed NetCDF was fastest to read and 3.7 times the size of GRIB. The times are the best of five runs (three for all levels) from Examples 2 and 4, on a machine shared with other work; single milliseconds move between runs, the gap to GRIB does not.

4. Match chunks to access patterns

NetCDF-4 and Zarr both split arrays into chunks, and a read decompresses every chunk it touches. The files above were chunked one pressure level per chunk, which makes single-level maps cheap and full vertical profiles expensive. GRIB has no chunking beyond one message per field. Choose chunks from the reads you expect — see choosing chunk and tile sizes.

Reading a Zarr store from cloud storage against reading a NetCDF file by HTTP byte ranges. reading without downloadingZarr on Google Cloud Storage✓metadata in one consolidated object✓one request per chunk, in parallel✓1,980 months: opened in 1.85 s✓first month read in 2.90 sNetCDF-4 over HTTP ranges✕HDF5 metadata scattered through the file✕many small range requests to find it✕one 1.53 MB file: opened in 8.56 s✕a 10 degree box: 0.92 s moreSeconds depend on the network; the request count does not spatialworkflow.io
Zarr keeps its metadata where one request finds it; HDF5 makes a remote reader go looking.

5. Decide where the data will live

On local disk all three are practical. From object storage the difference is large. Zarr's metadata is a few small objects and each chunk is one request: a CMIP6 monthly temperature store on Google Cloud Storage, 1,980 months of a 180 × 288 grid, opened in 1.85 s and returned its first month in 2.90 s. A NetCDF file read over HTTP needs many small range requests to find HDF5 metadata scattered through the file: one 1.53 MB daily OISST file took 8.56 s to open with 16 KB reads and 0.92 s more to read a 10° box. Both depend on the network — an earlier run took 2.75 s and 12.90 s to open the two — so the point is how the time scales with the number of requests, not the seconds. GRIB over HTTP is practical only with an index of byte ranges, as in reading GRIB weather data.

6. Consider what metadata travels with the data

NetCDF and Zarr carry arbitrary attributes, and CF conventions give them shared meaning: units, standard names, bounds, calendars. GRIB identifies parameters by numeric codes in WMO and centre-specific tables; cfgrib translates them to names and CF-like attributes, but local parameters and less common levels can decode as unknown. When converting from GRIB, check variable names and units and add what is missing — see CF conventions explained.

7. Convert once, not on every read

Reading GRIB repeatedly pays the scan cost repeatedly and leaves the message-grouping problems of cfgrib in every script. For data you will analyse more than once, convert the variables you need to NetCDF or Zarr in one step, with explicit encoding, and keep the GRIB as the archive of record.

8. Verify that the conversion was lossless — or how lossy

Decode the source and the converted copy and compare. Compressed NetCDF and Zarr matched the GRIB decode exactly, a maximum absolute difference of 0.0. The quantized NetCDF differed by at most 0.0078 K, which is smaller than GRIB's own packing step of 0.01 K for every temperature field (Example 4) but is still a deliberate loss (Example 3).

Milliseconds to open a dataset and to read one field, for five storage formats. milliseconds to read one fieldopenread one fieldGRIB (cfgrib)✕85.0 ms✕85.3 msNetCDF, zlib✓2.9 ms✓3.5 msNetCDF, uncompressed✓2.9 ms✓2.8 msZarr v3✓4.5 ms✓6.8 msZarr v2, consolidated✓3.6 ms✓5.6 msLocal disk; one pressure level per chunk spatialworkflow.io
GRIB's cost is in opening: cfgrib rebuilds the dataset from messages every time.

One GFS field set in three formats

The data used on this page

NOAA's GFS analysis from 10 September 2026, 00 UTC, from the NOAA Open Data Dissemination bucket: the 1° file gfs.t00z.pgrb2.1p00.f000 and its .idx index, from which Example 1 copies the temperature, wind and height messages on pressure levels. Example 4 also reads two public remote datasets: a CMIP6 near-surface temperature store on Google Cloud Storage and one daily NOAA OISST file from NCEI.

import os
import urllib.request

BASE = "https://noaa-gfs-bdp-pds.s3.amazonaws.com/gfs.20260910/00/atmos/"
for name in ("gfs.t00z.pgrb2.1p00.f000", "gfs.t00z.pgrb2.1p00.f000.idx"):
    if not os.path.exists(name):
        urllib.request.urlretrieve(BASE + name, name)
print(f"{os.path.getsize('gfs.t00z.pgrb2.1p00.f000') / 1e6:.1f} MB, "
      f"{len(open('gfs.t00z.pgrb2.1p00.f000.idx').read().splitlines())} messages")
42.4 MB, 696 messages

Example 1 — extract the same field set into three formats

import os
import shutil

import xarray as xr


def extract_messages(grib_path, idx_path, keep, out_path):
    """Copy whole GRIB messages whose index line matches keep(line) into a new file."""
    lines = open(idx_path).read().splitlines()
    starts = [int(line.split(":")[1]) + 0 for line in lines] + [os.path.getsize(grib_path)]
    with open(grib_path, "rb") as src, open(out_path, "wb") as out:
        for i, line in enumerate(lines):
            if keep(line):
                src.seek(starts[i])
                out.write(src.read(starts[i + 1] - starts[i]))


def size_on_disk(path):
    if os.path.isfile(path):
        return os.path.getsize(path), 1
    files = [os.path.join(d, f) for d, _, names in os.walk(path) for f in names]
    return sum(os.path.getsize(f) for f in files), len(files)


def upper_air(line):
    """TMP/UGRD/VGRD/HGT on the 1-1000 hPa levels; the 0.01-0.7 mb ones decode as a separate Pa level type."""
    var, level = line.split(":")[3:5]
    return var in ("TMP", "UGRD", "VGRD", "HGT") and level.endswith(" mb") and float(level.split()[0]) >= 1


extract_messages("gfs.t00z.pgrb2.1p00.f000", "gfs.t00z.pgrb2.1p00.f000.idx", upper_air, "upper.grib2")
ds = xr.open_dataset("upper.grib2", engine="cfgrib",
                     backend_kwargs={"filter_by_keys": {"typeOfLevel": "isobaricInhPa"}, "indexpath": ""})
ds = ds.drop_vars(["time", "step", "valid_time"]).load()
for name in ds.data_vars:
    ds[name].encoding = {}
chunks = {v: (1, ds.sizes["latitude"], ds.sizes["longitude"]) for v in ds.data_vars}
raw_bytes = sum(ds[v].nbytes for v in ds.data_vars)

for path in ("upper.nc", "upper_zlib.nc", "upper_quantized.nc", "upper.zarr"):
    shutil.rmtree(path, ignore_errors=True) if os.path.isdir(path) else None
ds.to_netcdf("upper.nc")
ds.to_netcdf("upper_zlib.nc", encoding={v: dict(zlib=True, complevel=4, shuffle=True, chunksizes=chunks[v]) for v in ds.data_vars})
ds.to_netcdf("upper_quantized.nc", encoding={v: dict(zlib=True, complevel=4, shuffle=True, chunksizes=chunks[v],
                                                     significant_digits=4) for v in ds.data_vars})
ds.chunk({"isobaricInhPa": 1}).to_zarr("upper.zarr", mode="w", zarr_format=3, consolidated=False)

for path in ("upper.grib2", "upper.nc", "upper_zlib.nc", "upper_quantized.nc", "upper.zarr"):
    size, files = size_on_disk(path)
    print(f"{path:20} {size:>11,} bytes  {size / raw_bytes:6.1%} of float32  {files} file(s)")
upper.grib2            9,379,220 bytes   27.3% of float32  1 file(s)
upper.nc              34,446,538 bytes  100.1% of float32  1 file(s)
upper_zlib.nc         19,936,493 bytes   57.9% of float32  1 file(s)
upper_quantized.nc    13,871,820 bytes   40.3% of float32  1 file(s)
upper.zarr            18,431,781 bytes   53.6% of float32  143 file(s)

Example 2 — time the same reads in each format

import time


def fastest(fn, repeats=5):
    best = float("inf")
    for _ in range(repeats):
        start = time.perf_counter()
        fn()
        best = min(best, time.perf_counter() - start)
    return best * 1000


OPENERS = {
    "GRIB (cfgrib)": lambda: xr.open_dataset("upper.grib2", engine="cfgrib", backend_kwargs={
        "filter_by_keys": {"typeOfLevel": "isobaricInhPa", "shortName": "t"}, "indexpath": ""}),
    "NetCDF, zlib": lambda: xr.open_dataset("upper_zlib.nc"),
    "NetCDF, uncompressed": lambda: xr.open_dataset("upper.nc"),
    "Zarr v3": lambda: xr.open_zarr("upper.zarr", consolidated=False),
}
for name, open_ in OPENERS.items():
    t_open = fastest(open_)
    t_level = fastest(lambda: open_()["t"].sel(isobaricInhPa=500).values)
    t_all = fastest(lambda: open_()["t"].values, repeats=3)
    print(f"{name:22} open {t_open:6.1f} ms   500 hPa field {t_level:6.1f} ms   all levels {t_all:6.1f} ms")
GRIB (cfgrib)          open   85.0 ms   500 hPa field   85.3 ms   all levels  119.4 ms
NetCDF, zlib           open    2.9 ms   500 hPa field    3.5 ms   all levels   24.4 ms
NetCDF, uncompressed   open    2.9 ms   500 hPa field    2.8 ms   all levels    3.6 ms
Zarr v3                open    4.5 ms   500 hPa field    6.8 ms   all levels   22.9 ms

These are the rows of the step 3 table; Example 4 adds Zarr v2.

How GRIB2 packed each variable of the GFS upper-air subset. GRIB2 packing, 33 levels per variablevariablebits per valuepacking steptemperature (t)11–130.01 K on every levelu wind9–140.01–0.1 m/sv wind10–130.01–0.1 m/sgeopotential height (gh)11–150.016–0.16 gpm4-significant-digit quantization moved temperature by at most 0.0078 K, under the 0.01 K stepComplex packing with spatial differencing, 132 messages spatialworkflow.io
GRIB is already rounded to the producer’s step; the quantization error is smaller than that rounding, though still a change to the decoded values.

Example 3 — check that the copies decode to the same values

import numpy as np

source = OPENERS["GRIB (cfgrib)"]()["t"].values
for name in ("NetCDF, zlib", "Zarr v3"):
    print(f"{name:14} max abs difference {float(np.nanmax(np.abs(OPENERS[name]()['t'].values - source))):.4f} K")
quantized = xr.open_dataset("upper_quantized.nc")["t"].values
print(f"{'quantized':14} max abs difference {float(np.nanmax(np.abs(quantized - source))):.4f} K")
NetCDF, zlib   max abs difference 0.0000 K
Zarr v3        max abs difference 0.0000 K
quantized      max abs difference 0.0078 K

Example 4 — Zarr v2, GRIB packing and remote reads

import eccodes
import fsspec

shutil.rmtree("upper_v2.zarr", ignore_errors=True)
ds.chunk({"isobaricInhPa": 1}).to_zarr("upper_v2.zarr", mode="w", zarr_format=2, consolidated=True)
size, files = size_on_disk("upper_v2.zarr")
print(f"Zarr v2, consolidated: {size:,} bytes ({size / 1e6:.1f} MB) in {files} files")
v2 = lambda: xr.open_zarr("upper_v2.zarr", consolidated=True)
print(f"  open {fastest(v2):.1f} ms   500 hPa field {fastest(lambda: v2()['t'].sel(isobaricInhPa=500).values):.1f} ms"
      f"   all levels {fastest(lambda: v2()['t'].values, repeats=3):.1f} ms")

bits, step = {}, {}
with open("upper.grib2", "rb") as f:
    while (h := eccodes.codes_grib_new_from_file(f)) is not None:
        name = eccodes.codes_get(h, "shortName")
        bits.setdefault(name, []).append(eccodes.codes_get(h, "bitsPerValue"))
        step.setdefault(name, []).append(2.0 ** eccodes.codes_get(h, "binaryScaleFactor")
                                         * 10.0 ** -eccodes.codes_get(h, "decimalScaleFactor"))
        packing = eccodes.codes_get(h, "packingType")
        eccodes.codes_release(h)
print(f"GRIB, {sum(map(len, bits.values()))} messages, {packing}")
for name in bits:
    print(f"  {name:3} bits per value {min(bits[name])}-{max(bits[name])}   "
          f"packing step {min(step[name]):g}-{max(step[name]):g}")

CMIP6 = "gs://cmip6/CMIP6/CMIP/NOAA-GFDL/GFDL-ESM4/historical/r1i1p1f1/Amon/tas/gr1/v20190726/"
start = time.perf_counter()
cm = xr.open_zarr(fsspec.get_mapper(CMIP6, token="anon"), consolidated=True)
t_open = time.perf_counter() - start
start = time.perf_counter()
cm["tas"].isel(time=0).values
print(f"CMIP6 Zarr on GCS {cm['tas'].shape}: open {t_open:.2f} s, first month {time.perf_counter() - start:.2f} s")

OISST = ("https://www.ncei.noaa.gov/data/sea-surface-temperature-optimum-interpolation/"
         "v2.1/access/avhrr/202409/oisst-avhrr-v02r01.20240901.nc")
fs = fsspec.filesystem("https")
print(f"OISST NetCDF: {fs.size(OISST) / 1e6:.2f} MB")
start = time.perf_counter()
remote = xr.open_dataset(fs.open(OISST, block_size=2**14, cache_type="none"), engine="h5netcdf")
t_open = time.perf_counter() - start
start = time.perf_counter()
remote["sst"].sel(lat=slice(40, 50), lon=slice(0, 10)).values
print(f"  over HTTP ranges: open {t_open:.2f} s, 10° box {time.perf_counter() - start:.2f} s")
Zarr v2, consolidated: 24,809,736 bytes (24.8 MB) in 152 files
  open 3.6 ms   500 hPa field 5.6 ms   all levels 20.7 ms
GRIB, 132 messages, grid_complex_spatial_differencing
  gh  bits per value 11-15   packing step 0.016-0.16
  t   bits per value 11-13   packing step 0.01-0.01
  u   bits per value 9-14   packing step 0.01-0.1
  v   bits per value 10-13   packing step 0.01-0.1
CMIP6 Zarr on GCS (1980, 180, 288): open 1.85 s, first month 2.90 s
OISST NetCDF: 1.53 MB
  over HTTP ranges: open 8.56 s, 10° box 0.92 s

Why GRIB is small and slow, and Zarr suits object storage

Why GRIB is small

GRIB2 does not store floats. Each field is scaled to integers with a precision chosen by the producer, and complex packing with spatial differencing stores the differences between neighbouring values in as few bits as each group needs. It is lossy at the producer's chosen precision, but that loss happened before you received the file; a lossless copy in another format preserves exactly the decoded values.

Why GRIB is slow to open

A GRIB file has no central directory of variables and dimensions. cfgrib reads the header of every message, groups compatible messages, and builds coordinates, which is work proportional to the number of messages. An index file speeds up later opens of the same file; a converted NetCDF or Zarr copy removes the work entirely.

Why Zarr suits object storage

Object stores charge latency per request and have no cheap way to read scattered small pieces of one large object. Zarr puts metadata in a few small objects and each chunk in its own object, so opening is one or two requests and reading is one request per chunk, easily run in parallel. The price is many files on a local disk: 143 for this small store.

Why quantization is worth considering

Most model and reanalysis values carry more digits than their accuracy supports. Rounding to four significant digits before compression let zlib find far more repetition, cutting the NetCDF from 19.9 to 13.9 MB with a maximum error below one hundredth of a kelvin. Whether that is acceptable depends on the variable and the use, and it should be recorded in the file's attributes.

Choosing between GRIB2, NetCDF-4, Zarr and GeoTIFF by how the data will be used. which format?How will the data be used?received as forecastsGRIB2convert what you keeparchived or exchangedNetCDF-4compressed + CFlarge, parallel, cloudZarrchunks, consolidatedhanded to a GISGeoTIFF / COGone variable and timeKeep the original as the archive of record spatialworkflow.io
The format follows the use: receive in GRIB, exchange in NetCDF, compute at scale in Zarr, hand to GIS as GeoTIFF.

NetCDF-3, Zarr versions and virtual references

  • NetCDF-3 has no compression or chunking; files converted to it are always the size of the raw arrays.
  • Zarr v2 and v3 differ in metadata layout and default codecs; not every tool reads v3 yet.
  • Consolidated metadata in Zarr v2 saves one request per variable on open; it must be refreshed after appending.
  • Kerchunk and VirtualiZarr can index existing NetCDF or GRIB files so they read like Zarr without conversion.
  • Many small Zarr chunks are slow on local file systems with high per-file overhead.
  • GRIB parameter tables differ by centre; the same shortName can mean different things in ECMWF and NCEP files.
  • Timings depend on hardware and load; measure on your own storage before committing an archive to a format.

FAQ

Is GRIB or NetCDF smaller?

GRIB2 usually. The GFS upper-air subset was 9.4 MB as GRIB2, 19.9 MB as compressed NetCDF and 13.9 MB as quantized, compressed NetCDF.

Should I convert GRIB to NetCDF?

For data you will analyse repeatedly, yes. Opening the GRIB took 85 ms and required cfgrib's message grouping each time; the compressed NetCDF opened in 2.9 ms with identical values.

When is Zarr better than NetCDF?

When reading from cloud object storage or computing in parallel over many chunks. A 1,980-month CMIP6 Zarr store opened from Google Cloud Storage in 1.85 s, while a 1.53 MB NetCDF file over HTTP range requests took 8.56 s.

Does converting GRIB to NetCDF or Zarr lose precision?

Not with lossless compression: both copies matched the GRIB decode exactly. Quantization is a deliberate loss; four significant digits changed temperatures by at most 0.0078 K.

Why does my Zarr store have so many files?

Each chunk of each variable is a separate object. The small GFS store had 143 files; that suits object storage and can be slow on some local file systems.

Can I read NetCDF directly from S3 or HTTPS?

Yes, with byte-range access, but HDF5 metadata makes it slow. Kerchunk-style references or converting to Zarr give much faster remote reads.