Saving Bioconductor Objects for Sharing

Michael Love

2026-09-22

Introduction

Bioconductor objects are useful for their shared structure within the project, and that they enable rich metadata.

How then should one save a Bioconductor object so that a collaborator can load and use it? Or so that it persists reliably across time?

The answer depends on several factors:

This vignette walks through the main options, their trade-offs, and some recommendations for common scenarios.

Acknowledgments

The content of this vignette has been informed by discussions on the Bioconductor community Zulip, including contributions from Kevin Rue-Albrecht, Johannes Rainer, Lori Shepherd, Jayaram Kancherla, Aaron Lun, Hervé Pagès, Laurent Gatto, Vince Carey, Sean Davis, Robert Castelo, Hugo Gruson, and Luke Zappia.

Version Info

R version: R version 4.6.1 (2026-06-24)
Bioconductor version: 3.24
Package version: 0.99.1

R serialization

saveRDS and readRDS

The simplest approach is R’s built-in binary serialization. saveRDS() saves a single object to an RDS file (the acronym is not defined in the man pages but likely stands for “R Data Serialization”); save() bundles one or more named objects into an .RData (or .rda) file.

library(SummarizedExperiment)

se <- SummarizedExperiment(
  assays = list(counts = matrix(1:12, nrow = 3)),
  colData = DataFrame(
    condition = c("A", "A", "B", "B"), 
    row.names=1:4
    ),
  rowData = DataFrame(
    gene = c("gene1","gene2","gene3"), 
    row.names=1:3
    )
)

# Single object
tmp_rds <- tempfile(fileext = ".rds")
saveRDS(se, file = tmp_rds)
se_from_rds <- readRDS(tmp_rds)
se_from_rds
class: SummarizedExperiment 
dim: 3 4 
metadata(0):
assays(1): counts
rownames(3): 1 2 3
rowData names(1): gene
colnames(4): 1 2 3 4
colData names(1): condition
# Multiple objects in one file
tmp_rda <- tempfile(fileext = ".RData")
save(se, file = tmp_rda)
load(tmp_rda)  # restores 'se' by name into the current environment

Prefer saveRDS() for most use cases. save() / load() silently overwrites any object in the calling environment that shares a name, which is a common source of confusion. The main remaining use case for save() is .rda data files shipped inside R packages (under data/).

Advantages:

Disadvantages:

When to use it: quick sharing between R users on the same project, saving intermediate objects in a pipeline, anything under ~1 GB.

If your workflow downloads RDS files from a remote URL, consider using BiocFileCache to cache them locally so they are only fetched once.

Reading RDS files in Python

BiocPy is a Python ecosystem that brings Bioconductor’s core data structures to Python, including BiocFrame, IRanges, GenomicRanges, SummarizedExperiment, SingleCellExperiment, and MultiAssayExperiment.

Python users can read RDS files with the rds2py package from the BiocPy ecosystem. Standard R types map to NumPy/SciPy equivalents (e.g. numeric vectors become numpy.ndarray), and Bioconductor classes such as SummarizedExperiment, SingleCellExperiment, GRanges, and MultiAssayExperiment are converted to their BiocPy counterparts. For unrecognised S4 classes the object falls back to a dictionary so no data is lost. Support for writing RDS files from Python is also in development.

Cross-release stability

There is no guarantee that an S4 object serialized today will work with older versions of Bioconductor. The most common reasons are that the S4 class is not defined at all in the earlier version, or that it exists but its definition has changed — new slots added, slots renamed or removed, or infrastructure moved between packages.

A concrete example: in Bioconductor 3.22, Seqinfo was moved out of GenomicRanges into its own package. A GRanges serialized under BioC ≥ 3.22 can still be loaded on a machine running BioC < 3.22, and many basic operations work — show(), seqnames(), ranges(), mcols(), and [ among them. But operations that require the Seqinfo package (such as shift() or reduce()) will fail with a confusing “package not available” error. The practical advice is to upgrade to BioC ≥ 3.22. The reverse was also true: older GRanges objects loaded into a newer session sometimes required updateObject() to migrate the internal representation:

# not evaluated — requires an object saved under an older Bioconductor release
gr <- readRDS("old_granges.rds")
gr <- updateObject(gr, verbose = TRUE)

The general lesson is: if you can save your data in a format that is not tied to a particular version of Bioconductor — or better, not tied to R at all — you should. For a GRanges, for instance, a simple TSV is often enough:

library(GenomicRanges)

gr <- GRanges(
  seqnames = "chr1",
  ranges = IRanges(start = c(100, 200, 300), width = 50),
  seqinfo = Seqinfo(seqnames = "chr1", seqlengths = 248956422,
                    isCircular = FALSE, genome = "hg38")
)
names(gr) <- c("peak1", "peak2", "peak3")
gr$score  <- c(500, 800, 300)   # standard BED score column
gr$log2fc <- c(1.2, -0.5, 2.1) # extra metadata column

tmp_tsv <- tempfile(fileext = ".tsv")
write.table(as.data.frame(gr), tmp_tsv, sep = "\t", quote = FALSE)

gr_from_tsv <- makeGRangesFromDataFrame(
  read.table(tmp_tsv, header = TRUE, sep = "\t"),
  keep.extra.columns = TRUE
)
gr_from_tsv
GRanges object with 3 ranges and 3 metadata columns:
      seqnames    ranges strand |       names     score    log2fc
         <Rle> <IRanges>  <Rle> | <character> <integer> <numeric>
  [1]     chr1   100-149      * |       peak1       500       1.2
  [2]     chr1   200-249      * |       peak2       800      -0.5
  [3]     chr1   300-349      * |       peak3       300       2.1
  -------
  seqinfo: 1 sequence from an unspecified genome; no seqlengths

This round-trip survives any Bioconductor version and is readable from Python or the command line. The alabaster ecosystem (described below) applies the same principle more systematically and with better support for complex objects, using HDF5 and JSON as the underlying storage formats.

HDF5-backed storage

Saving with HDF5Array

For large assay matrices (e.g., single-cell count matrices with millions of cells), it is impractical to hold the entire object in RAM. The HDF5Array package provides array classes backed by HDF5 files, enabling lazy loading and out-of-memory computation.

library(HDF5Array)

tmp_hdf5 <- tempfile()
saveHDF5SummarizedExperiment(se, dir = tmp_hdf5, replace = TRUE)

# Assay data remains on disk until accessed
se_from_hdf5 <- loadHDF5SummarizedExperiment(tmp_hdf5)
se_from_hdf5
class: SummarizedExperiment 
dim: 3 4 
metadata(0):
assays(1): counts
rownames(3): 1 2 3
rowData names(1): gene
colnames(4): 1 2 3 4
colData names(1): condition

The saved directory contains an HDF5 file with the assay data and an RDS file for the non-assay metadata.

Advantages:

Disadvantages:

When to use it: large single-cell or spatial datasets where you want on-disk access; workflows shared between R users who need memory efficiency.

SummarizedExperiment and AnnData

For single-cell workflows that move between R and Python, the .h5ad format used by scanpy and related tools is often the most convenient path when collaborators are working in Python. Like HDF5Array, .h5ad is an HDF5-based format. The trade-off relative to alabaster is that .h5ad is AnnData-specific rather than a general Bioconductor serialization format.

The recommended starting point is the anndataR package, a more recent and complete implementation that is actively maintained as part of the scverse ecosystem:

library(anndataR)
library(SingleCellExperiment)

sce <- as(se, "SingleCellExperiment")

tmp_h5ad <- tempfile(fileext = ".h5ad")
write_h5ad(sce, path = tmp_h5ad)

sce_from_h5ad <- read_h5ad(tmp_h5ad, as = "SingleCellExperiment")
sce_from_h5ad
class: SingleCellExperiment 
dim: 3 4 
metadata(0):
assays(1): counts
rownames(3): 1 2 3
rowData names(1): gene
colnames(4): 1 2 3 4
colData names(1): condition
reducedDimNames(0):
mainExpName: NULL
altExpNames(0):

The zellkonverter package is an alternative that also converts directly between SingleCellExperiment and .h5ad. It remains actively maintained and has advantages in some cases, though at the cost of managing a Python environment via basilisk.

# not evaluated — zellkonverter installs a full Python environment via basilisk
# on first use, which takes too long in CI
library(zellkonverter)

writeH5AD(sce, file = "sce.h5ad")

sce_from_h5ad <- readH5AD("sce.h5ad")
sce_from_h5ad

The alabaster ecosystem

saveObject and readObject

The alabaster family of packages is part of the broader ArtifactDB project, which provides a multi-language system for storing and retrieving analysis-ready Bioconductor objects. The core idea is to save objects as directories of standard files (HDF5, JSON, CSV) whose format is defined by explicit, versioned specifications — meaning the saved form is readable without R, and can evolve over time without breaking previously saved objects.

library(alabaster.base)
library(alabaster.se)

tmp_alabaster <- tempfile()
saveObject(se, path = tmp_alabaster)

se_from_alabaster <- readObject(tmp_alabaster)
se_from_alabaster
class: SummarizedExperiment 
dim: 3 4 
metadata(0):
assays(1): counts
rownames(3): 1 2 3
rowData names(1): gene
colnames(4): 1 2 3 4
colData names(1): condition

The alabaster umbrella package pulls in support for the most common Bioconductor classes. Individual sub-packages cover specific classes: alabaster.se for SummarizedExperiment, alabaster.sce for SingleCellExperiment, and so on.

Validation with takane

A key part of the ArtifactDB design is that saved directories can be independently validated against the format specification. This is handled by takane, a C++ library that maintains separate, versioned specifications for 30+ Bioconductor object types. Calling takane::validate() on a saved directory checks that all files conform to the expected layout and types, which means a collaborator or downstream tool can verify the integrity of a saved object without needing to load it into R. This makes alabaster directories suitable for deposition in data repositories where format conformance needs to be auditable.

The Python counterpart to alabaster is the dolomite family of packages, which reads and writes the same on-disk format. An object saved with alabaster in R can be read with dolomite in Python, and vice versa, with no conversion step.

Advantages:

Disadvantages:

When to use it: archival storage, data portal submissions, cross-language workflows, or any situation where you want the saved format to be readable without R.

BED format for ranges

Writing BED files

When the object is a GRanges or similar ranges object and the goal is interoperability with other tools (genome browsers, Python, command-line utilities), exporting to BED format is often more useful than R-specific serialization. Our gr has range names, a standard BED score column, and an extra metadata column log2fc:

gr
GRanges object with 3 ranges and 2 metadata columns:
        seqnames    ranges strand |     score    log2fc
           <Rle> <IRanges>  <Rle> | <numeric> <numeric>
  peak1     chr1   100-149      * |       500       1.2
  peak2     chr1   200-249      * |       800      -0.5
  peak3     chr1   300-349      * |       300       2.1
  -------
  seqinfo: 1 sequence from hg38 genome

Both rtracklayer and plyranges can write BED files:

library(rtracklayer)
library(plyranges)

tmp_bed <- tempfile(fileext = ".bed")
export(gr, tmp_bed)

tmp_bed2 <- tempfile(fileext = ".bed")
write_bed(gr, tmp_bed2)

When reading back, the standard score column is preserved, but log2fc is silently dropped — BED has no mechanism to carry arbitrary metadata columns. Range names are stored in the BED name field but come back as a $name metadata column rather than as R names on the object; restore them manually:

gr_rtracklayer <- import(tmp_bed)
names(gr_rtracklayer) <- gr_rtracklayer$name
gr_rtracklayer$name <- NULL
gr_rtracklayer
GRanges object with 3 ranges and 1 metadata column:
        seqnames    ranges strand |     score
           <Rle> <IRanges>  <Rle> | <numeric>
  peak1     chr1   100-149      * |       500
  peak2     chr1   200-249      * |       800
  peak3     chr1   300-349      * |       300
  -------
  seqinfo: 1 sequence from an unspecified genome; no seqlengths
gr_plyranges <- read_bed(tmp_bed2)
names(gr_plyranges) <- gr_plyranges$name
gr_plyranges$name <- NULL
gr_plyranges
GRanges object with 3 ranges and 1 metadata column:
        seqnames    ranges strand |     score
           <Rle> <IRanges>  <Rle> | <numeric>
  peak1     chr1   100-149      * |       500
  peak2     chr1   200-249      * |       800
  peak3     chr1   300-349      * |       300
  -------
  seqinfo: 1 sequence from an unspecified genome; no seqlengths

Saving metadata columns

If preserving all metadata columns is the priority and BED compatibility is not required, the simplest approach is the TSV round-trip shown earlier: write.table(as.data.frame(gr), ...) followed by makeGRangesFromDataFrame(..., keep.extra.columns = TRUE) restores all mcols in one step without a sidecar.

When you do need a BED file (e.g. for a genome browser or a tool that expects BED input), extra mcols can be preserved by writing them to a sidecar file. Here using plyranges to read the BED back, then reattaching from the sidecar:

tmp_meta <- tempfile(fileext = ".tsv")
write.table(
  data.frame(name = names(gr), log2fc = gr$log2fc),
  tmp_meta, sep = "\t", quote = FALSE, row.names = FALSE
)

gr_restored <- read_bed(tmp_bed2)
meta <- read.table(tmp_meta, header = TRUE, sep = "\t")
gr_restored$log2fc <- meta$log2fc
gr_restored
GRanges object with 3 ranges and 3 metadata columns:
      seqnames    ranges strand |        name     score    log2fc
         <Rle> <IRanges>  <Rle> | <character> <numeric> <numeric>
  [1]     chr1   100-149      * |       peak1       500       1.2
  [2]     chr1   200-249      * |       peak2       800      -0.5
  [3]     chr1   300-349      * |       peak3       300       2.1
  -------
  seqinfo: 1 sequence from an unspecified genome; no seqlengths

Saving Seqinfo separately

BED files do not store chromosome lengths or genome build information, so Seqinfo is silently dropped on export. To preserve it, write it out alongside the BED file and restore it on load:

tmp_seqinfo <- tempfile(fileext = ".csv")
write.csv(as.data.frame(seqinfo(gr)), tmp_seqinfo)

df <- read.csv(tmp_seqinfo, row.names = 1)
si <- Seqinfo(
  seqnames   = rownames(df),
  seqlengths = as.integer(df$seqlengths),
  isCircular = as.logical(df$isCircular),
  genome     = as.character(df$genome)
)
si
Seqinfo object with 1 sequence from hg38 genome:
  seqnames seqnames seqlengths isCircular genome
  1               1  248956422      FALSE   hg38

Propagating object metadata

Object-level metadata stored in metadata(object) — things like processing parameters, provenance notes, or experiment descriptors — is lost in any format that only encodes the ranges or assay data. This applies whether you are writing a BED file, an HDF5 matrix, or any other non-R format. Write it to a JSON sidecar file so it travels with the data:

library(jsonlite)

metadata(se) <- list(
  timestamp = as.POSIXct("2020-01-01 12:00:00", tz = "UTC"),
  pipeline = "v2.1",
  n_samples = 4L
)

tmp_json <- tempfile(fileext = ".json")
writeLines(toJSON(metadata(se), pretty = TRUE, auto_unbox = TRUE), tmp_json)

metadata(se_from_rds) <- fromJSON(tmp_json)
metadata(se_from_rds)
$timestamp
[1] "2020-01-01 12:00:00"

$pipeline
[1] "v2.1"

$n_samples
[1] 4

toJSON handles simple R types (lists, vectors, data frames) well, but complex objects (S4 instances, environments) need to be simplified or omitted before serializing.

Summary and recommendations

Scenario Recommended approach
Quick sharing between R users, same Bioc release saveRDS()
Loading an object from an older Bioc release updateObject() after readRDS()
Large assay matrices, R-only saveHDF5SummarizedExperiment()
SingleCellExperiment ↔︎ Python AnnData anndataR::write_h5ad() / read_h5ad() (or zellkonverter for Python-env integration)
Cross-language (R + Python), general alabaster::saveObject()
Long-term archive / data portal alabaster::saveObject()

In most new projects we recommend defaulting to saveRDS() for convenience and upgrading to alabaster when cross-language access or archival stability becomes a priority. Be aware that any R-serialized Bioconductor object may require updateObject() when loaded under a different Bioconductor release.

Session info

sessionInfo()
R version 4.6.1 (2026-06-24)
Platform: x86_64-pc-linux-gnu
Running under: Ubuntu 24.04.5 LTS

Matrix products: default
BLAS:   /home/biocbuild/bbs-3.24-bioc/R/lib/libRblas.so 
LAPACK: /usr/lib/x86_64-linux-gnu/lapack/liblapack.so.3.12.0  LAPACK version 3.12.0

locale:
 [1] LC_CTYPE=en_US.UTF-8       LC_NUMERIC=C              
 [3] LC_TIME=en_GB              LC_COLLATE=C              
 [5] LC_MONETARY=en_US.UTF-8    LC_MESSAGES=en_US.UTF-8   
 [7] LC_PAPER=en_US.UTF-8       LC_NAME=C                 
 [9] LC_ADDRESS=C               LC_TELEPHONE=C            
[11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C       

time zone: America/New_York
tzcode source: system (glibc)

attached base packages:
[1] stats4    stats     graphics  grDevices utils     datasets  methods  
[8] base     

other attached packages:
 [1] jsonlite_2.0.0              plyranges_1.33.2           
 [3] dplyr_1.2.1                 rtracklayer_1.73.0         
 [5] alabaster.se_1.13.1         alabaster.base_1.13.4      
 [7] SingleCellExperiment_1.35.2 anndataR_1.3.2             
 [9] HDF5Array_1.41.3            h5mread_1.5.3              
[11] rhdf5_2.57.17               DelayedArray_0.39.6        
[13] SparseArray_1.13.2          S4Arrays_1.13.0            
[15] abind_1.4-8                 Matrix_1.7-6               
[17] SummarizedExperiment_1.43.0 Biobase_2.73.2             
[19] GenomicRanges_1.65.4        Seqinfo_1.3.2              
[21] IRanges_2.47.5              S4Vectors_0.51.10          
[23] BiocGenerics_0.59.12        generics_0.1.4             
[25] MatrixGenerics_1.25.0       matrixStats_1.5.0          
[27] BiocManager_1.30.27        

loaded via a namespace (and not attached):
 [1] rjson_0.2.23             xfun_0.61                lattice_0.23-1          
 [4] rhdf5filters_1.25.4      vctrs_0.7.3              tools_4.6.1             
 [7] bitops_1.1-0             curl_8.0.0               parallel_4.6.1          
[10] tibble_3.3.1             pkgconfig_2.0.3          BiocBaseUtils_1.15.1    
[13] cigarillo_1.3.1          lifecycle_1.0.5          compiler_4.6.1          
[16] Rsamtools_2.29.0         Biostrings_2.81.9        codetools_0.2-20        
[19] htmltools_0.5.9          RCurl_1.98-1.20          alabaster.matrix_1.13.2 
[22] yaml_2.3.12              pillar_1.11.1            crayon_1.5.3            
[25] BiocParallel_1.47.0      tidyselect_1.2.1         digest_0.6.39           
[28] purrr_1.2.2              restfulr_0.0.17          fastmap_1.2.0           
[31] grid_4.6.1               cli_3.6.6                magrittr_2.0.5          
[34] XML_3.99-0.24            withr_3.0.3              rmarkdown_2.32          
[37] XVector_0.53.0           httr_1.4.9               otel_0.2.0              
[40] reticulate_1.47.0        png_0.1-9                evaluate_1.0.5          
[43] knitr_1.52               BiocIO_1.23.3            rlang_1.3.0             
[46] Rcpp_1.1.2               glue_1.8.1               alabaster.ranges_1.13.1 
[49] alabaster.schemas_1.13.0 R6_2.6.1                 Rhdf5lib_2.1.0          
[52] GenomicAlignments_1.49.2