Chatomics Field GuideWhat They Don't Teach You

Conversion · h5ad (AnnData) → SingleCellExperiment (RDS)

How to Convert h5ad to SingleCellExperiment (Without Losing Your Metadata)

readH5AD does the transpose and slot mapping for you, but obs metadata and uns objects can survive broken or vanish silently, so check before you trust the object.

By Ming "Tommy" Tang, Director of Bioinformatics in Big Pharma · Reviewed September 2026 · 3 min read

h5ad (AnnData)
.h5ad · coordinates: n/a
SingleCellExperiment (RDS)
.rds · coordinates: n/a

You end up here when a collaborator hands you an h5ad from a scanpy pipeline and you need to run it through Bioconductor tools built around SingleCellExperiment: scran for normalization, scater for QC plots, or any workflow that expects colData/rowData/reducedDims rather than obs/var/obsm. zellkonverter's readH5AD is the reliable bridge, and it does the mechanical work for you.

What changes on the way over: AnnData's cells-as-rows layout transposes to SingleCellExperiment's genes-as-rows convention, X becomes a named assay, obs and var become colData and rowData, and obsm embeddings land in reducedDims intact. The unstructured uns slot maps to metadata(), but only for objects R can coerce. Python-specific objects in uns get dropped or throw a warning you can easily miss if you're not watching the console output.

The most common way this goes wrong silently is metadata that survives the conversion technically intact but practically broken: obs column names with inconsistent casing or punctuation (paper_expression_subtype vs paper_Expression.Subtype is a real example from public cancer data) carry through unchanged, so a downstream filter on colData(sce) returns zero rows instead of an error. Nothing crashes. You just get the wrong answer and don't find out until much later.

The commands

Type your file names once; every command below updates.

  1. 01zellkonverterv1.20+ (Bioconductor 3.20)

    r
    library(zellkonverter)
    sce <- readH5AD("sample.h5ad")
    saveRDS(sce, "sample.rds")

    readH5AD loads the h5ad through a basilisk-managed Python/anndata backend, transposes X into the primary assay, and maps obs/var to colData/rowData. Assumes the h5ad is a valid AnnData file and that basilisk can provision (or already has) a Python environment on this machine.

  2. 02zellkonverterv1.20+ (Bioconductor 3.20)

    r
    library(zellkonverter)
    sce <- readH5AD("sample.h5ad", use_hdf5 = TRUE)
    saveRDS(sce, "sample.rds")

    use_hdf5 = TRUE keeps assays as on-disk HDF5Array/DelayedArray objects instead of pulling the full matrix into RAM. Use this for h5ad files too large to fit in memory; the saved RDS then still references the original h5ad path, so don't move or delete the source file.

  3. 03zellkonverterv1.20+ (Bioconductor 3.20)

    r
    library(zellkonverter)
    sce <- readH5AD("sample.h5ad", reader = "R")
    saveRDS(sce, "sample.rds")

    The experimental native R reader skips Python/basilisk entirely, which helps when you can't provision a conda environment. It's known to fail on obs columns containing nested lists (zellkonverter issue #102), so check colData(sce) against the original obs before trusting it.

  4. 04zellkonverterv1.20+ (Bioconductor 3.20)

    r
    library(zellkonverter)
    sce <- readH5AD("sample.h5ad", obs = c("cell_type", "batch"), layers = FALSE)
    saveRDS(sce, "sample.rds")

    Restricts conversion to named obs columns and skips layers (replaced with empty sparse placeholders). Assumes you already know which obs columns you need and don't require the extra layers for this analysis; speeds up loading large objects.

  5. 05janitor

    r
    library(janitor)
    colData(sce) <- colData(sce) |> as.data.frame() |> clean_names() |> DataFrame()
    saveRDS(sce, "sample.rds")

    Runs on the sce object already in memory from readH5AD, standardizing colData column names (underscores vs dots, mixed case) that Python and R tolerate differently. Assumes readH5AD has already run in this session; do this before saving so downstream colData() filters don't silently return zero rows.

Coordinates, strand, names, builds

This pair has no genomic coordinates or strand to worry about; what changes is orientation and slot mapping. AnnData stores cells as rows and genes as columns. SingleCellExperiment stores genes as rows and cells as columns. readH5AD handles the transpose for you, but if you ever build an SCE by hand from an AnnData-derived matrix, forgetting this flip is the single fastest way to get nonsense results with no error. Slot by slot: X becomes the primary assay (named by default, or renamed with the X_name argument); other AnnData layers become additional assays, and SingleCellExperiment requires every assay to share identical nrow/ncol, so a layer with a different shape than X will break the conversion. obs and var become colData and rowData respectively, with column names and dtypes carried over verbatim, inconsistencies included. obsm and varm-derived embeddings (PCA, UMAP, t-SNE) land in reducedDims intact. uns, the unstructured slot, maps to metadata(), but only for objects R can coerce; custom Python classes and some nested structures are dropped or throw warnings during the read. There's no genome build or chromosome-naming concern here since this conversion never touches genomic intervals, only expression matrices and their annotations.

Check the output before you trust it

  1. 01Dimensions are transposed correctly

    r
    dim(sce)

    Expected Rows = number of genes/features, columns = number of cells; nrow(sce) should equal the Python file's adata.n_vars and ncol(sce) should equal adata.n_obs.

  2. 02Assay names match expected layers

    r
    assayNames(sce)

    Expected Includes the primary assay from X plus one entry per AnnData layer you expected to carry over; missing names mean layers = FALSE was set or a layer failed to convert.

  3. 03colData columns match the original obs columns

    r
    colnames(colData(sce))

    Expected Same set of column names as adata.obs.columns in Python, same spelling and case; any mismatch means downstream filters on those names will silently fail.

  4. 04metadata() captured what you expect from uns

    r
    names(metadata(sce))

    Expected Contains the keys you care about from adata.uns; anything missing was likely a Python object that failed to coerce and needs to be reattached manually.

  5. 05reducedDims carried over embeddings

    r
    reducedDimNames(sce)

    Expected Lists the embeddings that existed in adata.obsm (e.g. PCA, UMAP); an empty result when you expected embeddings means obsm didn't convert.

  6. 06No conversion warnings were swallowed

    Expected Re-run readH5AD in an interactive session and read the console output line by line; warnings about failed coercions in uns or obs print at read time and are easy to miss in a batch script.

Errors you will see, and what they mean

colData(sce)$paper_Expression.Subtype not found, or a filter on colData returns 0 rows with no error
Cause: AnnData obs column names carry through the conversion exactly as written in Python. If the original obs mixed underscores, dots, and inconsistent casing (a common pattern in public single-cell data), those names land in colData unchanged and don't match what your downstream script expects. Fix: Run colnames(colData(sce)) right after conversion and compare against what you expect. Standardize with janitor::clean_names() before you write any code that references a specific column name.
Warnings during readH5AD about objects in uns failing to coerce, or metadata(sce) missing entries you know were in adata.uns
Cause: uns is unstructured and can hold arbitrary Python objects (custom classes, nested dicts, scipy objects) that have no R equivalent. zellkonverter maps what it can to metadata() and drops or warns on the rest. Fix: Before exporting from Python, strip uns down to plain dicts, strings, numbers, and arrays. Check metadata(sce) after conversion and re-attach anything critical manually if it didn't survive.
Nested or malformed obs metadata after using reader = "R"; columns present in Python but missing or garbled in colData(sce)
Cause: The native R reader (zellkonverter issue #102) can fail to coerce obs columns that contain nested lists or complex Python objects. The default Python-backed reader handles these more reliably. Fix: If you hit this, switch back to the default reader (drop reader = "R") so basilisk's Python/anndata backend handles the coercion, even though that means provisioning a Python environment.
Error indicating assays must have identical nrow/ncol during conversion
Cause: SingleCellExperiment enforces that every assay in the object shares the same dimensions. If a layer in the h5ad has a different shape than X (e.g., a raw counts layer subset to fewer genes), the conversion breaks this constraint. Fix: Check adata.layers[...].shape against adata.X.shape in Python before conversion. Either fix the mismatched layer upstream or convert with layers = FALSE and bring that layer in separately once reshaped.
readH5AD hangs or fails on first run trying to set up a Python environment
Cause: The default reader relies on basilisk to provision a managed conda/Python environment with anndata installed, which requires network access the first time it runs. Fix: Make sure the machine running the conversion has internet access (or a pre-built basilisk cache) before the first readH5AD call, or switch to reader = "R" to avoid the Python dependency altogether.

Questions people ask

Does converting h5ad to SingleCellExperiment transpose the matrix?

Yes. AnnData stores cells as rows and genes as columns; SingleCellExperiment stores genes as rows and cells as columns. zellkonverter's readH5AD performs this transpose automatically, so dim(sce) will show genes first, cells second, the reverse of adata.shape.

Why is my uns metadata missing after converting with zellkonverter?

uns can hold arbitrary Python objects that have no direct R equivalent, such as custom classes or deeply nested structures. zellkonverter maps what it can into metadata() and drops or warns on the rest, so check metadata(sce) against the original adata.uns and re-attach anything important by hand if needed.

Should I use the default reader or reader = "R" in readH5AD?

Start with the default, which routes through a basilisk-managed Python/anndata backend and handles complex obs columns reliably. Use reader = "R" only if you can't provision a Python environment, and then double-check colData(sce) for nested-list columns, which the native R reader (zellkonverter issue #102) can fail to coerce correctly.

Can I convert a huge h5ad file that doesn't fit in memory?

Yes, pass use_hdf5 = TRUE to readH5AD so assays stay as on-disk HDF5Array/DelayedArray objects instead of loading fully into RAM. The resulting SCE (and any RDS you save from it) will still reference the original h5ad file path, so keep that file in place.

Do I need Python installed to convert h5ad to SingleCellExperiment in R?

The default zellkonverter path does, via a basilisk-managed conda environment with anndata installed, provisioned automatically the first time you call readH5AD. If you can't get a Python environment set up, use reader = "R" for the experimental native-R path, or look at the anndataR package, which reads and writes h5ad natively without Python.

Related pages

Related reading on the blog

Sources

  1. An introduction to the SingleCellExperiment class — Defines the assays/colData/rowData/reducedDims/metadata slot structure that readH5AD populates.
  2. Chapter 4 The SingleCellExperiment class | Introduction to Single-Cell Analysis with Bioconductor — Source for the cells-as-rows vs genes-as-rows transpose relationship and the equal-dimension assay constraint.
  3. Bioconductor - zellkonverter — Package page confirming current Bioconductor release/devel versions of zellkonverter.
  4. anndataR improves interoperability between R and Python in single-cell transcriptomics — Source for anndataR as a native-R alternative that avoids the Python dependency.
  5. readH5AD issue #102 · theislab/zellkonverter — Source for the native R reader's failure to coerce nested-list obs columns.