Chatomics Field GuideWhat They Don't Teach You

Conversion · SingleCellExperiment (RDS) → Seurat object (RDS)

How to Convert SingleCellExperiment to Seurat object (Without Losing Your Metadata)

as.Seurat() copies your matrices and cell metadata fine; it quietly leaves your gene annotations and altExps behind.

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

SingleCellExperiment (RDS)
.rds · coordinates: n/a
Seurat object (RDS)
.rds, .RDS · coordinates: n/a

You need this conversion whenever your preprocessing lives in Bioconductor (scran, scater, DropletUtils) but your clustering, integration, or visualization work happens in Seurat, or when you download a public dataset that was released as a SingleCellExperiment and your lab's downstream pipeline expects a Seurat object. It's a one-way trip in practice: nobody round-trips this conversion repeatedly, so getting it right the first time matters.

as.Seurat() moves the counts and logcounts assays into Seurat's counts/data layers and copies colData into @meta.data. What it does not do is copy rowData (your gene symbols, biotypes, chromosome annotations) or altExps (spike-ins, CITE-seq ADT tags). Both of these are documented gaps, not edge cases you'll only hit occasionally, if your SCE has either, they vanish on conversion.

The most common way this goes wrong silently: the conversion "succeeds," dimensions look right, clustering runs fine, and three steps later a feature-level lookup (biotype filter, symbol mapping, antibody panel) returns empty instead of throwing an error. Nothing crashes. You just find out later that your annotation columns were never there.

The commands

Type your file names once; every command below updates.

  1. 01Seuratv5.5.1

    r
    sce <- readRDS("sample.rds")
    seurat_obj <- as.Seurat(sce, counts = "counts", data = "logcounts")
    saveRDS(seurat_obj, "sample.rds")

    as.Seurat() maps the SCE's 'counts' assay to the counts layer and 'logcounts' to the data layer, and copies colData into @meta.data. It assumes your assays are literally named 'counts' and 'logcounts' (the OSCA convention), if they're named differently, pass the real names or rename the assays first. It does not touch rowData or altExps.

  2. 02SingleCellExperiment

    r
    library(Seurat); library(SingleCellExperiment)
    sce <- readRDS("sample.rds")
    seurat_obj <- CreateSeuratObject(counts = counts(sce), meta.data = as.data.frame(colData(sce)))
    saveRDS(seurat_obj, "sample.rds")

    Builds the Seurat object directly from the raw counts matrix and colData instead of going through as.Seurat(). Use this when as.Seurat() throws layer errors on v5-flavored objects or you want explicit control over meta.data column types. You lose logcounts unless you renormalize afterward with NormalizeData().

  3. 03Seurat

    r
    seurat_obj <- readRDS("sample.rds")
    seurat_obj[["RNA"]][[]] <- as.data.frame(rowData(sce))
    saveRDS(seurat_obj, "sample.rds")

    Neither as.Seurat() nor CreateSeuratObject() copies rowData, so gene-level annotations (symbol, biotype, chromosome) have to be reattached by hand to the assay's feature metadata. Assumes rowData(sce) rows are in the same gene order as the assay you already converted, check with identical(rownames(sce), rownames(seurat_obj)).

  4. 04Seurat

    r
    seurat_obj <- readRDS("sample.rds")
    seurat_obj <- JoinLayers(seurat_obj)
    saveRDS(seurat_obj, "sample.rds")

    Rejoins split v5 layers (counts.1, counts.2, ... per batch) back into single counts/data layers. Run this before differential expression or any function that expects one layer per slot; assumes the layers came from the same original feature set and were only split for integration, not created from mismatched objects.

Coordinates, strand, names, builds

Neither object stores genomic coordinates, so there's no 0-based/1-based or chr-naming issue here. What actually changes is the object model and what silently rides along with the conversion. SingleCellExperiment uses named S4 slots (assays, colData, rowData, reducedDims, altExps) accessed with accessor functions; Seurat wraps assay data in layers (counts, data, scale.data in v5) accessed with [[, @meta.data, or GetAssayData(). colData transfers to @meta.data and reducedDims generally carries over as Reductions, but rowData does not transfer and must be reattached manually. altExps (spike-ins, CITE-seq ADT tags) are dropped entirely and have to be rebuilt as separate Seurat assays with CreateAssayObject(). Cell and gene ordering must stay in sync between the counts matrix and the metadata you attach, SCE enforces this automatically on subsetting, Seurat's CreateSeuratObject() does not, so a mismatched row order fails silently instead of erroring. Finally, layer structure is version-dependent: an SCE built to feed a Seurat v3-style assay can land in the wrong layer shape once opened in Seurat v5, which is the root of most "layer-mapping" pitfalls.

Check the output before you trust it

  1. 01Cell and gene counts match the source object

    r
    dim(sce); dim(seurat_obj)

    Expected Same number of genes (rows) and cells (columns) in both objects, in the same order.

  2. 02Cell barcodes are identical and in the same order

    r
    identical(colnames(sce), colnames(seurat_obj))

    Expected TRUE. If FALSE, metadata and expression values are misaligned even though dimensions matched.

  3. 03colData columns landed in meta.data

    r
    setdiff(colnames(colData(sce)), colnames(seurat_obj@meta.data))

    Expected character(0), every colData column should show up as a meta.data column.

  4. 04counts layer actually has non-zero values

    r
    sum(GetAssayData(seurat_obj, layer = "counts")[1:20, 1:20])

    Expected A positive number matching the equivalent slice of counts(sce); zero everywhere means the wrong assay name was passed to as.Seurat().

  5. 05rowData was actually reattached (it isn't by default)

    r
    ncol(seurat_obj[["RNA"]][[]])

    Expected 0 right after as.Seurat()/CreateSeuratObject(); should match ncol(rowData(sce)) only after you run the manual reattachment step.

  6. 06Dimensionality reductions carried over

    r
    Reductions(seurat_obj); reducedDimNames(sce)

    Expected Every reducedDim name in the SCE (e.g. PCA, UMAP) appears as a Reduction in the Seurat object.

Errors you will see, and what they mean

seurat_obj[["RNA"]][[]] is an empty data frame after conversion
Cause: as.Seurat() does not transfer rowData(sce), this is a documented limitation, not a bug in your code. Fix: Manually copy it: seurat_obj[["RNA"]][[]] <- as.data.frame(rowData(sce)), after confirming gene order matches with identical(rownames(sce), rownames(seurat_obj)).
CITE-seq antibody/ADT counts or spike-ins are missing from the Seurat object
Cause: altExps in the SingleCellExperiment (alternative experiments like ADT tags) are not carried over by as.Seurat() at all. Fix: Pull each altExp out and add it as its own assay: seurat_obj[["ADT"]] <- CreateAssayObject(counts = counts(altExp(sce, "ADT"))).
as.Seurat() errors or produces mismatched dimensions between counts and data layers
Cause: The SCE was built against Seurat v5-style layer expectations, but as.Seurat()'s layer mapping doesn't cleanly handle every v5 layer combination (counts, data, scale.data). Fix: Fall back to the manual CreateSeuratObject(counts = counts(sce), ...) path, then add normalized values separately with NormalizeData() or SetAssayData().
as.Seurat() fails when the SCE only has a logcounts assay, no raw counts
Cause: as.Seurat() expects a counts assay by default and errors or produces an empty counts layer when one doesn't exist. Fix: Call as.Seurat(sce, counts = NULL, data = "logcounts") to skip the counts layer explicitly.
Downstream DE or FindMarkers behaves oddly after integration
Cause: Seurat v5 split the assay into per-batch layers during integration and they were never rejoined into single counts/data layers. Fix: Run JoinLayers(seurat_obj) before differential expression or any function expecting one layer per slot.

Questions people ask

Does as.Seurat() preserve rowData from my SingleCellExperiment?

No. This is a documented limitation, not a bug you introduced. You have to reattach rowData yourself after conversion with seurat_obj[["RNA"]][[]] <- as.data.frame(rowData(sce)), after confirming the gene order still matches.

What happens to CITE-seq antibody counts (altExps) when I convert to Seurat?

They're dropped. as.Seurat() only converts the main experiment, not altExps like ADT or spike-in data. Pull each altExp out with altExp(sce, "ADT") and add it as its own Seurat assay with CreateAssayObject().

Should I use as.Seurat() or CreateSeuratObject() to do this conversion?

Start with as.Seurat(), it's less code and handles the standard counts/logcounts mapping. Switch to CreateSeuratObject(counts = counts(sce), meta.data = as.data.frame(colData(sce))) if as.Seurat() errors on layer dimensions, which tends to happen with Seurat v5-style objects.

Why does my Seurat object have mismatched or empty layers after conversion?

Seurat v5 organizes assay data into counts/data/scale.data layers, and the mapping between an SCE's assays and those layers isn't always clean, especially if the SCE was built with v5 layer conventions in mind. If as.Seurat() produces mismatched dimensions, fall back to the manual CreateSeuratObject() path.

My SingleCellExperiment only has logcounts, no raw counts, how do I convert it?

Pass counts = NULL explicitly: as.Seurat(sce, counts = NULL, data = "logcounts"). Without this, as.Seurat() expects a counts assay by default and will misbehave when it doesn't find one.

Related pages

Related reading on the blog

Sources

  1. as.Seurat, Convert objects to Seurat objects
  2. Chapter 4: The SingleCellExperiment class, Introduction to Single-Cell Analysis with Bioconductor
  3. Transfer feature-level metadata from SingleCellExperiment in as.Seurat.SingleCellExperiment function, Seurat GitHub Issue #4205
  4. Seurat v5 Essential Commands
  5. Integrative analysis in Seurat v5
  6. Error converting SingleCellExperiment object to Seurat object using as.Seurat, GitHub Issue #4556