Conversion · SingleCellExperiment (RDS) → h5ad (AnnData)
How to Convert SingleCellExperiment to h5ad (Without Losing Your Metadata)
writeH5AD() decides which assay becomes X almost silently, get that wrong and every downstream scanpy step runs on the wrong matrix.
By Ming "Tommy" Tang, Director of Bioinformatics in Big Pharma · Reviewed September 2026 · 3 min read
- SingleCellExperiment (RDS)
- .rds · coordinates: n/a
- h5ad (AnnData)
- .h5ad · coordinates: n/a
You reach for this conversion when a single-cell analysis crosses the language barrier: your lab did QC, normalization, and clustering in R with SingleCellExperiment, but you, or a collaborator, need the object in scanpy, want to submit it to cellxgene, or have to hand a dataset to a tool that only speaks AnnData. zellkonverter is the Bioconductor-maintained bridge for this, and it does the real work through a basilisk-managed Python environment so you don't have to hand-install anndata yourself.
The conversion is not a bit-for-bit copy. AnnData stores cells as rows and genes as columns; SingleCellExperiment stores it the other way around, so writeH5AD() transposes the matrix for you. Only one assay becomes X, everything else in assays(sce) becomes a named layer, and character or factor columns in colData/rowData get coerced to pandas categorical types on the way over. Sparse assays stay sparse in the output only if they were already a dgCMatrix in R; anything else, including sparse DelayedArray assays, has a real chance of ending up dense or mishandled.
The failure mode that bites people hardest is the assay-to-X mapping. If you don't pass X_name, writeH5AD() picks the first assay in assays(sce), which is often raw counts, not the logcounts your scanpy pipeline expects to find in X. Nothing errors. You get a valid h5ad file, scanpy loads it fine, and your clustering or marker-gene analysis quietly runs on the wrong numbers until someone notices the results don't look normalized.
The commands
Type your file names once; every command below updates.
01zellkonverterv1.23.0
rlibrary(zellkonverter); sce <- readRDS('sample.rds'); writeH5AD(sce, file = 'sample.h5ad')Reads the RDS-serialized SingleCellExperiment and writes it straight to h5ad using zellkonverter's basilisk-managed Python/anndata backend. With no X_name set, writeH5AD picks the first assay in assays(sce) as X and every other assay becomes a layer, so run assayNames(sce) first if the ordering matters to you.
02zellkonverterv1.23.0
rlibrary(zellkonverter); sce <- readRDS('sample.rds'); writeH5AD(sce, file = 'sample.h5ad', X_name = 'logcounts')Pins which assay lands in X. Most scanpy workflows expect normalized or log-transformed data in X and raw counts in a layer (or the reverse), so set X_name explicitly instead of relying on the default assay order, and confirm the remaining assays show up as named layers rather than getting dropped.
03zellkonverterv1.23.0
rlibrary(zellkonverter); sce <- readRDS('sample.rds'); writeH5AD(sce, file = 'sample.h5ad', X_name = 'logcounts', compression = 'gzip')compression controls whether the on-disk X and layer matrices are stored as 'gzip', 'lzf', or 'none'. gzip trades a slower write for a smaller file, which starts to matter once any assay or layer is a dense matrix rather than sparse.
04zellkonverterv1.23.0
rlibrary(zellkonverter); sce_check <- readH5AD('sample.h5ad'); print(assayNames(sce_check)); print(dim(sce_check))Reads the h5ad you just wrote back into R as a fresh SingleCellExperiment so you can diff assayNames, dim(), and colData against the original object. This is the actual verification step, not part of the conversion itself, and it takes {output} as its input because it's checking what you just produced.
Coordinates, strand, names, builds
This is a container-format conversion, not a coordinate conversion: there's no chromosome, position, or strand data at stake, and genome build is whatever annotation was already attached to rowData(sce); zellkonverter doesn't touch or re-annotate it. What does change: matrix orientation flips from genes-as-rows/cells-as-columns in SingleCellExperiment to cells-as-rows (n_obs) and genes-as-columns (n_var) in AnnData, handled automatically by writeH5AD(). Only the assay named in X_name becomes X; every other entry in assays(sce) becomes a layers entry, so five assays in produces one X plus four layers, not five parallel matrices. Sparsity is preserved only for assays stored as dgCMatrix; dense matrices and, currently, sparse DelayedArray assays can lose their sparse representation or need special-cased handling via rhdf5. Character and factor columns in colData/rowData become pandas categorical dtype in obs/var, which changes how downstream code filters or compares them even though the underlying values are identical. reducedDims(sce) entries land in obsm, typically prefixed like X_pca or X_umap, while anything stored in metadata(sce) as an R-specific S4 object may not survive into uns at all.
Check the output before you trust it
01Matrix orientation transposed correctly
pythonimport anndata as ad a = ad.read_h5ad('output.h5ad') print(a.shape)Expected a.shape equals (ncol(sce), nrow(sce)) in R terms: cells as n_obs rows, genes as n_var columns.
02The assay you meant for X actually landed there
pythonimport anndata as ad a = ad.read_h5ad('output.h5ad') print(list(a.layers.keys()))Expected layers.keys() lists every assay from assays(sce) except the one you passed to X_name; that one lives in a.X.
03Sparse assays stayed sparse
pythonimport scipy.sparse as sp print(sp.issparse(a.X), {k: sp.issparse(v) for k, v in a.layers.items()})Expected True for any assay that was a dgCMatrix in R; only assays that were already dense in R (matrix or an unsupported DelayedArray) should show False.
04Character/factor colData columns became categorical
pythonprint(a.obs.dtypes)Expected columns that were character or factor in colData(sce) show up as category dtype in obs, not object; this is expected behavior, not data corruption, but it changes pandas filtering syntax.
05Cell and gene names round-trip in order
pythonprint(a.obs_names[:5].tolist(), a.var_names[:5].tolist())Expected obs_names match colnames(sce) (cell barcodes) and var_names match rownames(sce) (gene IDs or symbols), in the same order as the original object.
06reducedDims made it into obsm
pythonprint(list(a.obsm.keys()))Expected each entry in reducedDims(sce), e.g. PCA or UMAP, appears as a key like X_pca or X_umap; if reducedDims(sce) had entries and obsm is empty, they were dropped before or during conversion.
Errors you will see, and what they mean
- Downstream scanpy clustering or marker-gene results look wrong or unnormalized even though the h5ad loaded without error
- Cause: writeH5AD() defaulted to the first assay in assays(sce), typically raw counts, as X because X_name wasn't set. Fix: Always pass X_name explicitly (e.g. X_name = 'logcounts') and confirm with assayNames(sce) beforehand which assay you actually want in X.
- Conversion runs out of memory or the resulting layer is unexpectedly a dense numpy array instead of sparse
- Cause: Sparsity is only preserved for assays stored as dgCMatrix in R; dense matrices and sparse DelayedArray assays can be converted to dense arrays, and DelayedArray assays get written via rhdf5 directly with known limitations for sparse representations. Fix: Convert the assay to a genuine dgCMatrix with Matrix::Matrix(x, sparse = TRUE) before calling writeH5AD, or use skip_assays = TRUE for assays you don't need in the h5ad at all.
- A metadata column that looked identical before and after conversion fails an identical() or == comparison after reading the h5ad back with readH5AD()
- Cause: anndata coerces character vectors to pandas categorical on save, and the level order or underlying type can differ once it's reconstructed as a factor in R. Fix: Compare colData columns with as.character() on both sides instead of assuming identical() will pass across the round trip, and don't rely on factor level order surviving the conversion.
- The first call to writeH5AD() or readH5AD() in a session hangs, fails, or errors on environment setup
- Cause: zellkonverter provisions a basilisk-managed Python environment on first use, which can fail under restricted filesystem or network permissions (e.g. no write access to the basilisk cache directory, or no network access to fetch the anndata Python package). Fix: Ensure the R session has write access to the basilisk cache directory and network access on first run, or pre-provision the environment in a setup step before running conversions in a restricted environment like a CI job or locked-down cluster node.
Questions people ask
- Does converting SingleCellExperiment to h5ad lose data?
Not entirely, but it's not lossless either. Matrices get coerced into numpy/scipy-compatible formats, character and factor columns in colData/rowData become pandas categorical, and R-specific S4 objects stashed in metadata(sce) may not translate into uns at all. Check assayNames, colData dtypes, and reducedDims on the output before trusting it downstream.
- Which assay becomes adata.X?
Whichever assay you pass to X_name in writeH5AD(); if you don't set it, zellkonverter uses the first assay in assays(sce). Everything else becomes a layer. Name your assays explicitly and always pass X_name for a reproducible pipeline.
- Why are my genes now columns instead of rows?
AnnData's on-disk convention is cells-as-rows, genes-as-columns (n_obs x n_var), the opposite of SingleCellExperiment's genes-as-rows layout. writeH5AD() transposes the matrix automatically; you don't need to transpose anything yourself.
- Can I convert back from h5ad to SingleCellExperiment?
Yes, readH5AD() reads an h5ad file back into R as a SingleCellExperiment. Run it right after writing so you can diff assayNames, colData, and reducedDims against the original object, since factor types and DelayedArray assays are the parts most likely to round-trip differently than expected.
- Do I need Python installed separately to use zellkonverter?
No. zellkonverter provisions its own Python environment through basilisk, so you don't need a separate conda or pip install of anndata. The first writeH5AD() or readH5AD() call in a fresh R session triggers that setup automatically, which can take a minute the first time.
Related pages
- Convert · How to Convert h5ad to SingleCellExperiment (Without Losing Your Metadata)
- Convert · How to Convert h5ad to Seurat object (Without Losing Your Metadata)
- Convert · How to Convert Seurat object to h5ad (Without Losing Your Metadata)
- Convert · How to Convert Seurat object to SingleCellExperiment (Without Losing Your Metadata)
- Convert · How to Convert SingleCellExperiment to Seurat object (Without Losing Your Metadata)
- Glossary · CITE-seq
Related reading on the blog
Sources
- zellkonverter: Conversion Between scRNA-seq Objects — Official vignette describing zellkonverter's purpose and conversion workflow
- On-disk format, anndata — Specification of h5ad file format structure: X, layers, obs, var, obsm, uns
- zellkonverter package reference manual — Details on assay-to-X/layer mapping, sparsity handling, and DelayedArray limitations
- Write H5AD, writeH5AD (rdrr.io) — Parameter reference for X_name, skip_assays, and compression