Chatomics Field GuideWhat They Don't Teach You

Conversion · Seurat object (RDS) → CSV/TSV count table

How to Convert Seurat object to CSV/TSV count table (Without Losing Your Metadata)

write.csv() on a full assay tries to densify a matrix that's sparse for a reason, export only what you need or you'll watch R run out of memory on a 40k-cell object.

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

Seurat object (RDS)
.rds, .RDS · coordinates: n/a
CSV/TSV count table
.csv, .tsv, .txt · coordinates: n/a

You need this conversion when the counts matrix has to leave R: handing data to a collaborator on Python, loading it into a tool that only reads plain text, or archiving a GEO submission. The Seurat object itself doesn't survive that handoff intact, it's an S4 container holding the counts/data/scale.data layers, PCA and UMAP embeddings, and per-cell metadata all bundled together, and a CSV of the count matrix carries none of that except the numbers and the row/column names you explicitly write.

What gets lost is structural, not just cosmetic. Metadata (cluster assignments, sample IDs, QC metrics) lives in a separate slot from the assay matrix and has to be exported to its own file; there's no single CSV that holds both a genes-by-cells matrix and a cells-by-covariates table without one truncating the other. Embeddings, graphs, and the normalization history disappear entirely unless you export them on purpose.

The most common way this goes silently wrong is memory, not correctness: the counts layer is stored as a sparse matrix because single-cell data is mostly zeros, and CSV/TSV can only hold dense text. Calling as.matrix() on the full layer before writing can turn a 30 MB in-memory object into 700+ MB dense, and on a full-size dataset that densify step is what actually crashes the R session, not the write.csv() call itself.

The commands

Type your file names once; every command below updates.

  1. 01R/SeuratvSeurat v5

    r
    Rscript -e "obj <- readRDS('sample.rds'); print(Layers(obj[['RNA']]))"

    Loads the RDS and lists the layers present in the default assay (counts, data, scale.data) so you know which one you're about to export before you commit to a giant write. In pre-v5 objects this errors; use slotNames(obj[['RNA']]) instead.

  2. 02R/SeuratvSeurat v5

    r
    Rscript -e "obj <- readRDS('sample.rds'); counts <- GetAssayData(obj, assay='RNA', layer='counts'); write.table(as.matrix(counts), file='sample.csv', sep='\t', quote=FALSE, row.names=TRUE, col.names=NA)"

    Pulls the raw counts layer, densifies it with as.matrix() (this is the step that eats memory), and writes genes-as-rows / cells-as-columns as tab-delimited text. col.names=NA is required so the header row has one fewer field than the data rows, keeping the gene-name column aligned instead of shifting every value one column left.

  3. 03R/Seurat

    r
    Rscript -e "obj <- readRDS('sample.rds'); write.csv(obj@meta.data, file='sample.csv', quote=FALSE, row.names=TRUE)"

    Writes per-cell metadata (cluster ID, sample, nCount_RNA, percent.mt) to its own file. It has to be separate from the counts table: one is genes x cells, the other is cells x covariates, and jamming them into one rectangle either truncates the matrix or duplicates metadata across every gene row.

  4. 04R/Seurat

    r
    Rscript -e "obj <- readRDS('sample.rds'); sub <- FetchData(obj, vars=c('CD3D','CD8A','nCount_RNA','seurat_clusters')); write.csv(sub, file='sample.csv', quote=FALSE, row.names=TRUE)"

    Pulls only the genes and metadata columns you actually need into one already-dense, cell-indexed data frame. Assumes those genes exist in the active assay's feature names; use this instead of densify-then-subset when the full matrix is too big to hold in memory twice.

Coordinates, strand, names, builds

This conversion carries no genomic coordinates and no strand information, so chr-naming and 0-based/1-based questions don't apply here (genes as rows have no positional coordinate in the count table itself). What does change is layer selection, numeric representation, and structure. Seurat v5 stores counts, data, and scale.data as separate layers accessed with layer= or the $ operator (obj[['RNA']]$counts); v3/v4 objects use slot= on the same accessor. Pick the wrong layer and you silently export normalized or scaled values labeled as "counts," which breaks any downstream tool (DESeq2, edgeR) that expects raw integers. The count matrix itself is stored sparse (dgCMatrix) because single-cell data is mostly zeros; as.matrix() forces it dense, which is a memory and disk-size event, not a formatting nicety, and CSV/TSV can only hold dense rectangles. Cell metadata (obj@meta.data) lives in a separate structure from the assay matrix and does not travel with write.csv() on the counts layer, so it has to be exported to its own file and re-joined downstream by matching cell barcodes/column names. Gene identifiers also matter: Seurat's default feature names are usually gene symbols, not Ensembl IDs, and duplicate or make.unique()-mangled symbols in the RDS will carry straight into the CSV row names.

Check the output before you trust it

  1. 01Header field count matches expected cell count

    bash
    awk -F'\t' 'NR==1{print NF}' {output}

    Expected NF equals the number of cells in the object (ncol(counts)); if it's off by one, col.names=NA was omitted and every column is shifted.

  2. 02Row count matches gene count

    bash
    wc -l {output}

    Expected Line count equals nrow(counts) + 1 for the header row.

  3. 03No Excel date corruption in gene names

    bash
    grep -E '\b[0-9]{1,2}-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b' {output} | head

    Expected No matches. Gene symbols like MARCH1, SEPT9, or DEC1 should read as text, not as 1-Mar, 9-Sep, or 1-Dec.

  4. 04Per-cell total matches between R and the written file

    r
    sum(GetAssayData(obj, assay='RNA', layer='counts')[,1]) == sum(read.table('{output}', header=TRUE, row.names=1, sep='\t', check.names=FALSE)[,1])

    Expected TRUE, the summed counts for the first cell match exactly between the in-memory matrix and the written file, confirming nothing was truncated or double-written.

  5. 05Metadata row count matches cell count

    bash
    wc -l metadata.csv

    Expected Line count equals the number of cells + 1 header row, matching the column count of the counts file.

Errors you will see, and what they mean

Error: cannot allocate vector of size 12.3 Gb
Cause: as.matrix() on the full sparse counts layer tries to allocate a dense array; a matrix that is 30 MB sparse can become 700+ MB dense, and a full atlas-scale object can demand tens of gigabytes at once. Fix: Subset to the genes or cells you need with FetchData() or matrix indexing before densifying, or skip CSV entirely and write the sparse matrix as Matrix Market (MTX) format instead.
Error: unused argument (slot = "counts")
Cause: GetAssayData() changed its parameter name between Seurat versions: v3/v4 use slot=, v5 uses layer=. Scripts copied from an older tutorial break on a v5 object and vice versa. Fix: Run packageVersion('Seurat') first, then match the argument name to the version: layer= on v5, slot= on v3/v4.
Error in as.data.frame.default: cannot coerce class structure("dgCMatrix") to a data.frame
Cause: Passing the sparse Matrix object straight to write.csv() or as.data.frame() without densifying it first; write.csv() doesn't know how to serialize a dgCMatrix. Fix: Wrap the extracted layer in as.matrix() before writing, and only do this after confirming the matrix is small enough to fit in memory dense.
Downstream pandas.read_csv() shows every column shifted one to the left, with gene names in the count columns
Cause: write.table() was called with row.names=TRUE but col.names=TRUE (or the default) instead of col.names=NA, so the header row has one fewer entry than the data rows and every reader misaligns the first column. Fix: Use col.names=NA with row.names=TRUE in write.table()/write.csv() so the header lines up with the row-name column, or write the file without row names and keep gene IDs as a separate first column explicitly.
Gene symbols like MARCH1 and SEPT9 show up as 1-Mar and 9-Sep after a colleague sends the CSV back
Cause: Someone opened the CSV in Excel, which auto-converts strings that look like dates, then re-saved it. This is silent: the file still opens and looks fine at a glance. Fix: Never open counts CSV/TSV files in Excel. Inspect them with head, awk, or file instead, and if Excel access is unavoidable, import the column as text rather than double-clicking to open.

Questions people ask

Why does write.csv() run out of memory when I export a Seurat object?

Because write.csv() and write.table() only accept dense rectangles, so any export path forces as.matrix() on the sparse counts layer first. A matrix that's 30 MB sparse can be 700+ MB dense, and a full-size atlas object can demand tens of gigabytes in one allocation. Subset to the genes or cells you actually need before densifying, or export as Matrix Market (MTX) format instead of CSV.

Should I export the counts, data, or scale.data layer?

Export counts (raw integers) if the file is going to a tool like DESeq2, edgeR, or anything expecting raw UMI counts. Export data (log-normalized) only if the downstream step explicitly wants normalized values, and treat scale.data as a plotting/PCA intermediate, not something to hand off as a 'count table.' Check Layers(obj[['RNA']]) before you export so you know which one you're pulling.

How do I keep cell metadata attached to the count table after exporting?

You don't attach it to the same file. Write obj@meta.data to its own CSV, keyed by the same cell barcodes that are the counts matrix's column names, and join them downstream by that key. Cramming metadata columns into the same rectangle as a genes-by-cells matrix either truncates the matrix or duplicates metadata across every gene row.

Can I just open the exported CSV in Excel to sanity check it?

No. Excel auto-converts gene symbols that look like dates, so MARCH1 becomes 1-Mar and SEPT9 becomes 9-Sep, and it will silently resave that corruption if you touch the file. Inspect the output with head, awk, or file instead, or import the column as text explicitly if you must use Excel.

Related pages

Related reading on the blog

Sources

  1. Seurat v5 Essential Commands — Layer vs slot accessors (GetAssayData, $ operator, Layers()) for counts/data/scale.data
  2. Seurat - Guided Clustering Tutorial — Sparse vs dense memory footprint benchmark (29.9 MB vs 709.6 MB)
  3. IntegrateLayers failing due to dense matrix conversion? (Seurat v5) — as.matrix() on large objects allocating tens of gigabytes and triggering memory warnings
  4. Export Seurat Data into 1 csv file and select specific clusters of interest — Common as.data.frame(GetAssayData()) + write.table()/write.csv() export workflow
  5. How to transform Seurat object to csv format — readRDS() and version-dependent object structure