Compression

HDF5 compresses data transparently. Datasets are compressed when they are written and decompressed when they are read, so your R code stays the same and only the file gets smaller. h5lite compresses every dataset by default. Through hdf5lib, it also bundles a full set of codecs: gzip, szip, Zstandard, LZ4, bzip2, LZF, Snappy, Bitshuffle, Blosc, Blosc2, and ZFP. All of them work out of the box, with no system libraries to install.

This vignette covers:

Compression by Default

You don’t have to do anything to get compression:

library(h5lite)
file <- tempfile(fileext = ".h5")

temps <- round(20 + cumsum(rnorm(1e5, sd = 0.05)), 2)
h5_write(temps, file, "temps/default")

h5_inspect(file, "temps/default")
#> <HDF5 Dataset Properties>
#>   Type:    float64             Size:    781.25 KB
#>   Layout:  chunked             Disk:    376.26 KB
#>   Chunks:  [100000]            Ratio:   2.08x
#>   Pipeline: shuffle -> gzip

The default, compress = "gzip", means gzip at level 5, preceded by HDF5’s byte shuffle filter, with the data split into chunks of about 1 MB. Gzip is the default because every HDF5 installation can read it.

To use a different codec, pass a string to compress:

h5_write(temps, file, "temps/zstd", compress = "zstd-5")
h5_write(temps, file, "temps/none", compress = "none")

To control more than the codec, such as the chunk size, a checksum, or lossy pre-filters, build a configuration with h5_compression() and pass that instead. Printing the configuration shows the pipeline it will build:

cmp <- h5_compression("zstd-5", checksum = TRUE, chunk_size = 256 * 1024)
cmp
#> <HDF5 Compression Configuration>
#>   Codec:           zstd-5
#>   Shuffle:         Byte Shuffle (Native HDF5)
#>   Chunk Size:      256.00 KB
#>   Checksum:        Fletcher32

h5_write(temps, file, "temps/configured", compress = cmp)

The same compress argument works with the object-oriented interface (h5$write()). When you write a list, the configuration applies to every dataset created from that list.

What Gets Compressed

HDF5 can only compress chunked datasets, so a few kinds of objects are always stored uncompressed:

Object Compressed? Notes
Vectors, matrices, arrays Yes Numeric, logical, factor, complex, raw, and integer64 data.
Data frames Yes Whole rows (compound records) are compressed together.
Fixed-length strings Yes Often compress extremely well.
Variable-length strings No The text lives outside the dataset’s chunks. See below.
Scalars (written with I()) No A single value is stored as-is.
Attributes No HDF5 does not support filters on attributes.
NULL and zero-length data No There is nothing to compress.

Strings deserve special attention. By default, h5lite stores character vectors as fixed-length strings when they contain no NA and have similar lengths, and as variable-length strings otherwise. Fixed-length strings sit in the chunks and compress like any other data. Variable-length strings are kept in a separate area of the file, so the filter pipeline only ever sees small references to them. If you want text compressed, write it as fixed-length with as = "utf8[]". Fixed-length strings can’t hold NA, though.

words <- sample(c("control", "treatment", "placebo"), 1e4, replace = TRUE)
h5_write(words, file, "strings/fixed")
h5_inspect(file, "strings/fixed")
#> <HDF5 Dataset Properties>
#>   Type:    utf8[9]             Size:    87.89 KB
#>   Layout:  chunked             Disk:    20.87 KB
#>   Chunks:  [10000]             Ratio:   4.21x
#>   Pipeline: shuffle -> gzip

Very small datasets can end up a few bytes larger after compression because of filter overhead. This is harmless and isn’t worth optimizing.

Choosing a Codec

The right codec depends on who will read the file and whether you care most about speed, size, or portability.

If you want… Use Why
Files that any HDF5 software can read "gzip" (the default) Built into every HDF5 library.
Fast I/O with good ratios "zstd-5", "blosc2-zstd-5" Faster than gzip, with similar or better ratios.
The fastest possible I/O "lz4", "bshuf-lz4" Plain LZ4 trades ratio for speed. Bitshuffling often wins the ratio back on numeric data.
The smallest lossless files "bshuf-zstd-19", "zstd-19" Slow to write, but still fast to read.
Integers that span a narrow range int_packing = TRUE with any codec Stores each value in the fewest bits possible.
Numbers where a small error is fine ZFP, float_rounding, blosc2_truncate Lossy. See Lossy Compression.

Every codec except gzip and szip is an HDF5 plugin. Other software needs that plugin installed to read the data. See Sharing Files with Other Software before using these codecs for files that you hand off to others.

Gzip

"gzip-[level]" (levels 1-9, default 5) is the universal standard. It isn’t the fastest or the strongest codec, but any HDF5 reader in any language can open it. Use it for files that you share, publish, or archive. Higher levels are slower and often barely smaller than level 5.

Zstandard and LZ4

Blosc2 and Bitshuffle

Bitshuffle rearranges data at the bit level before handing it to a compressor (see Shuffling). On numeric data, this often beats byte shuffling by a wide margin. You can get bitshuffling in two ways:

Blosc and Blosc2 can compress and decompress the blocks of each chunk in parallel. They use one thread unless you ask for more with h5_threads(), for example h5_threads(4), which sets the thread count for everything in h5lite that runs in parallel. Threads help most when compression is slow, as with high Zstandard levels, and with large chunks, but they speed up reading at the default 1 MB chunk size too. To change the default of one thread without calling h5_threads(), set the H5LITE_NTHREADS environment variable. Threads need hdf5lib 2.2.0.1 or later: when h5lite is built with hdf5lib 2.2.0.0, Blosc always uses one thread.

Szip

"szip-nn" and "szip-ec" are provided by the libaec library and are part of the core HDF5 filter set. Nearest Neighbor (nn) coding suits smooth, correlated data such as time series or gradients. Entropy Coding (ec) suits uncorrelated or categorical integer data. Szip works only on numeric data. For anything else, h5lite falls back to gzip (see Rules and Automatic Adjustments). Modern codecs usually beat szip, but it is still common in some scientific fields, such as Earth observation data.

Legacy Codecs

"blosc1-...", "bzip2-[level]", "lzf", and "snappy" are included mainly so you can read and write files for older pipelines that already use them. They are generally slower or weaker than the codecs above, so they aren’t recommended for new data.

Comparing Codecs on Your Own Data

Codec performance depends heavily on the data, so the most reliable way to choose is to try a few. This small helper writes the same object with each codec and reports the compression ratio:

compression_ratio <- function(x, codecs, ...) {
  tmp <- tempfile(fileext = ".h5")
  on.exit(unlink(tmp))
  sapply(codecs, function(codec) {
    h5_write(x, tmp, "x", compress = h5_compression(codec, ...))
    info <- h5_inspect(tmp, "x")
    round(info$uncompressed_size / info$storage_size, 2)
  })
}

codecs <- c("gzip", "zstd-5", "zstd-19", "lz4", "bshuf-lz4",
            "bshuf-zstd-5", "blosc2-lz4-5", "blosc2-zstd-5")

# A slowly varying sensor signal (float64)
compression_ratio(temps, codecs)
#>          gzip        zstd-5       zstd-19           lz4     bshuf-lz4 
#>          2.08          2.06          2.08          1.29          2.39 
#>  bshuf-zstd-5  blosc2-lz4-5 blosc2-zstd-5 
#>          2.46          1.31          2.47

# Small counts (stored as uint8)
compression_ratio(rpois(1e5, lambda = 3), codecs)
#>          gzip        zstd-5       zstd-19           lz4     bshuf-lz4 
#>          2.38          2.36          2.83          1.38          2.35 
#>  bshuf-zstd-5  blosc2-lz4-5 blosc2-zstd-5 
#>          2.46          2.44          2.49

To compare speed too, wrap the h5_write() and h5_read() calls in system.time(), or use a benchmarking package such as bench. Use realistically large data: on tiny inputs, the timings mostly measure overhead.

Sharing Files with Other Software

h5lite can read every codec it can write, so files compressed with any of them can be read by anyone using h5lite. Other software is a different story. HDF5 filters fall into two groups:

How to get the plugins elsewhere:

A practical rule: use gzip, optionally with int_packing, float_rounding, or checksum, for files you hand off to others. Use the modern codecs for working files that you, or other h5lite users, will read.

Lossy Compression

For large numeric datasets, you can get much higher compression ratios by discarding precision you don’t need. h5lite supports three lossy approaches.

ZFP

ZFP is a compressor built specifically for numeric arrays. It has three lossy modes and one lossless mode:

h5_write(temps, file, "lossy/zfp", compress = "zfp-acc-0.01")

# No value has moved by more than 0.01
max(abs(h5_read(file, "lossy/zfp") - temps))
#> [1] 0.004433594

h5_inspect(file, "lossy/zfp")
#> <HDF5 Dataset Properties>
#>   Type:    float64             Size:    781.25 KB
#>   Layout:  chunked             Disk:    163.39 KB
#>   Chunks:  [100000]            Ratio:   4.78x
#>   Pipeline: zfp

ZFP has requirements that are easy to miss:

Scale-Offset: int_packing and float_rounding

The Scale-Offset filter is built into HDF5, so it’s the one form of lossy compression that every HDF5 reader understands. It works on one chunk at a time and runs before the main compressor.

# Integers in a narrow range: lossless
ids <- sample(100000:100100, 1e5, replace = TRUE)
h5_write(ids, file, "packed/ids", compress = h5_compression("gzip", int_packing = TRUE))
identical(h5_read(file, "packed/ids"), ids)
#> [1] TRUE

# Floats kept to 2 decimal places: lossy
readings <- rnorm(1e5, mean = 98.6, sd = 0.5)
h5_write(readings, file, "packed/readings",
         compress = h5_compression("gzip", float_rounding = 2))
max(abs(h5_read(file, "packed/readings") - readings))
#> [1] 0.004999984

h5_inspect(file, "packed/readings")
#> <HDF5 Dataset Properties>
#>   Type:    float64             Size:    781.25 KB
#>   Layout:  chunked             Disk:    109.92 KB
#>   Chunks:  [100000]            Ratio:   7.11x
#>   Pipeline: scaleoffset -> gzip

A few things to know:

Blosc2 Truncate Precision

With any Blosc2 codec except NDLZ and ZFP, blosc2_truncate = n keeps n bits of each floating-point value’s mantissa and zeroes the rest, which makes the data much more compressible. The relative error is about 2-n. A float64 has 52 mantissa bits and a float32 has 23, so blosc2_truncate = 23 gives float64 data roughly single-precision accuracy while keeping the float64 type. A negative n drops that many bits instead: blosc2_truncate = -29 also keeps 23 bits of a float64.

Truncation only applies to float32 and float64 data. Integers, data frames, and float16/bfloat16 data are written without it, and so is data containing NA or NaN, since zeroing those bits would turn NA into NaN or Inf. So is float32 data when a negative n would drop all 23 of its mantissa bits.

x <- rnorm(1e5)
h5_write(x, file, "lossy/truncated",
         compress = h5_compression("blosc2-zstd-5", blosc2_truncate = 16))

# Largest relative error
max(abs((h5_read(file, "lossy/truncated") - x) / x))
#> [1] 1.523367e-05

h5_inspect(file, "lossy/truncated")
#> <HDF5 Dataset Properties>
#>   Type:    float64             Size:    781.25 KB
#>   Layout:  chunked             Disk:    261.72 KB
#>   Chunks:  [100000]            Ratio:   2.99x
#>   Pipeline: blosc2 [trunc(bits=16) -> bitshuffle -> zstd]

The Filter Pipeline

h5_compression() assembles HDF5’s filter pipeline for you. Data passes through these stages in order:

  1. Scale-Offset (optional): int_packing or float_rounding.
  2. Shuffle: rearranges bytes or bits so that the compressor finds more patterns. Chosen automatically.
  3. Compressor: the codec you chose.
  4. Fletcher32 checksum (optional): checksum = TRUE.

Shuffling

A shuffle filter doesn’t compress anything by itself. It reorders the data so that the compressor can do a better job. The byte shuffle groups the 1st byte of every value together, then the 2nd byte of every value, and so on:

Bitshuffle does the same thing one bit at a time, which exposes even more redundancy in numeric data.

h5lite picks the shuffle for you:

Pipeline Shuffle used
"blosc1-..." or "blosc2-..." Blosc’s internal bitshuffle
"blosc2-ndlz" None. NDLZ works on the raw bytes.
"bshuf-lz4" or "bshuf-zstd" Bitshuffle (built into the codec)
Any other codec (gzip, zstd, lz4, …) HDF5’s byte shuffle
Scale-Offset, szip, or ZFP None. Shuffling would break these filters.
1-byte data (logicals, uint8, int8, small factors) None. There is nothing to shuffle.

Blosc2 Delta

With any Blosc2 codec except NDLZ and ZFP, blosc2_delta = TRUE adds Blosc2’s delta pre-filter, which encodes values relative to a reference block before compression. It can help with slowly varying data, but it can also make things worse. Test it on your data with the helper from Comparing Codecs:

blosc2_codecs <- c("blosc2-zstd-5", "blosc2-lz4-5")
rbind(
  without_delta = compression_ratio(temps, blosc2_codecs),
  with_delta    = compression_ratio(temps, blosc2_codecs, blosc2_delta = TRUE) )
#>               blosc2-zstd-5 blosc2-lz4-5
#> without_delta          2.47         1.31
#> with_delta             2.45         1.44

Checksums

checksum = TRUE appends a Fletcher32 checksum to every chunk. Corrupted chunks are then detected when they are read, instead of silently returning bad values. The checksum is built into HDF5, costs very little, and works with every codec.

Rules and Automatic Adjustments

h5_compression() raises an error for settings that can never work:

Other settings depend on the data being written, so h5_write() quietly adjusts them instead:

When in doubt, h5_inspect() shows the pipeline that was actually applied.

Chunking

HDF5 doesn’t compress a dataset as one block. It splits the dataset into chunks and compresses each one independently. When you read any part of a chunk, HDF5 decompresses the entire chunk.

h5lite picks chunk dimensions automatically. It starts with the full dataset and repeatedly halves its largest dimension until a chunk fits within chunk_size, which defaults to 1 MB. For a matrix with many more rows than columns, this keeps entire rows inside each chunk, which works well with partial reading by row:

mat <- matrix(rnorm(1e6), nrow = 10000, ncol = 100)   # 8 MB of float64

h5_write(mat, file, "chunks/default")
h5_inspect(file, "chunks/default")
#> <HDF5 Dataset Properties>
#>   Type:    float64             Size:    7.63 MB
#>   Layout:  chunked             Disk:    6.73 MB
#>   Chunks:  [1250 x 100]        Ratio:   1.13x
#>   Pipeline: shuffle -> gzip

h5_write(mat, file, "chunks/small", compress = h5_compression(chunk_size = 64 * 1024))
h5_inspect(file, "chunks/small")
#> <HDF5 Dataset Properties>
#>   Type:    float64             Size:    7.63 MB
#>   Layout:  chunked             Disk:    6.82 MB
#>   Chunks:  [79 x 100]          Ratio:   1.12x
#>   Pipeline: shuffle -> gzip

The default is a good choice for most data. Consider changing it when:

Avoid the extremes. Chunks below about 10 KB add a lot of indexing overhead and compress poorly. Chunks of tens of megabytes make every partial read expensive.

Inspecting the Results

h5_inspect() reports how a dataset is stored:

h5_inspect(file, "temps/configured")
#> <HDF5 Dataset Properties>
#>   Type:    float64             Size:    781.25 KB
#>   Layout:  chunked             Disk:    400.67 KB
#>   Chunks:  [25000]             Ratio:   1.95x
#>   Pipeline: shuffle -> zstd -> fletcher32

The returned object is a list, so you can also use the values in code. That’s what the compression_ratio() helper above does:

info <- h5_inspect(file, "temps/configured")
info$storage_size
#> [1] 410290
sapply(info$filters, `[[`, "name")
#> [1] "shuffle"    "zstd"       "fletcher32"

Compression String Reference

The compress argument of h5_write() and h5_compression() accepts the strings below. Strings are case-insensitive. A few shorthands also work: compress = 1 through compress = 9 selects that gzip level, TRUE selects the default ("gzip"), and FALSE selects "none".

Codec Strings Level or parameter
None "none" -
Gzip "gzip", "gzip-[level]" 1-9 (default 5)
Zstandard "zstd", "zstd-[level]" 1-22 (default 3)
LZ4 "lz4", "lz4-[level]" 0-12 (default 0). 0 is LZ4 and 1+ is LZ4-HC.
Bitshuffle + LZ4 "bshuf-lz4" -
Bitshuffle + Zstandard "bshuf-zstd", "bshuf-zstd-[level]" 1-22 (default 3)
Blosc2 "blosc2" (BloscLZ), "blosc2-ndlz" -
Blosc2 + sub-codec "blosc2-lz4-[level]", "blosc2-zstd-[level]", "blosc2-gzip-[level]" Same ranges as the standalone codec
Blosc2 + ZFP "blosc2-zfp-acc-[tol]", "blosc2-zfp-prec-[bits]", "blosc2-zfp-rate-[bits]" Required. float32 and float64 only.
ZFP "zfp-acc-[tol]", "zfp-prec-[bits]", "zfp-rate-[bits]" Required
ZFP (lossless) "zfp-rev" -
Szip "szip-nn", "szip-ec" -
Blosc (v1) "blosc1", "blosc1-snappy", "blosc1-lz4-[level]", "blosc1-zstd-[level]", "blosc1-gzip-[level]" Same ranges as the standalone codec
bzip2 "bzip2", "bzip2-[level]" 1-9 (default 9)
LZF, Snappy "lzf", "snappy" -

Inside Blosc and Blosc2, levels are mapped onto Blosc’s own 0-9 scale. The full range of a sub-codec’s levels is accepted, but some neighboring levels behave the same.

h5_compression() also accepts chunk_size, checksum, int_packing, float_rounding, blosc2_delta, and blosc2_truncate. See ?h5_compression for details.

For filter IDs, parameters, and implementation details of each codec, see the hdf5lib compression guide.