Chatomics Field GuideWhat They Don't Teach You

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

How to Convert CSV/TSV count table to Seurat object (and Why IDs Go Missing)

The matrix loads without error every time; whether your gene IDs survive the trip is a separate question you have to check yourself.

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

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

You reach for this conversion when a count matrix arrives as plain text instead of coming out of a pipeline with native Seurat support: a collaborator's spreadsheet export, a bulk RNA-seq matrix you're pseudo-bulking cells into, an older dataset that predates 10x's h5/mtx formats. Seurat itself doesn't care where the numbers came from, but CreateSeuratObject() has firm expectations about shape: genes as rows, cells or samples as columns, and gene identifiers living in the matrix rownames, not in a data column.

A CSV has no schema. It doesn't know which column is an identifier and which is data, it doesn't know your matrix should be sparse, and it carries no metadata layers at all. Everything a Seurat object structures for you (the counts layer, cell metadata, later data and scale.data layers in Seurat v5) you're now assembling by hand from a flat file, and the file will let you assemble it wrong without complaint.

The single most common silent failure: the gene ID column stays a regular column instead of becoming rownames. read.csv() without row.names = 1, or fread() without a column_to_rownames() step afterward, leaves gene names as data. CreateSeuratObject() still runs. You get an object with the right dimensions and features named "1", "2", "3", or a "gene" column sitting uselessly among your cells. Nothing errors until you call something that indexes by feature name, like PercentageFeatureSet(), and it fails with a cryptic dimnames error, or worse, it "works" by matching nothing and returning zeros.

The commands

Type your file names once; every command below updates.

  1. 01SeuratvSeurat v5

    r
    df <- read.csv("sample.csv", header = TRUE, row.names = 1, check.names = FALSE)
    seurat_obj <- CreateSeuratObject(counts = as.matrix(df), project = "project", min.cells = 3, min.features = 200)
    saveRDS(seurat_obj, file = "sample.rds")

    row.names = 1 forces the first CSV column to become matrix rownames directly, which is the step that most often gets skipped. check.names = FALSE stops R from mangling cell-barcode column headers. min.cells/min.features drop genes detected in fewer than 3 cells and cells with fewer than 200 detected genes; both assume this is single-cell data with per-cell sparsity, not a bulk matrix, so drop them for bulk counts.

  2. 02data.table + tibble + SeuratvSeurat v5

    r
    library(data.table); library(tibble)
    df <- fread("sample.csv", sep = "\t", data.table = FALSE)
    df <- column_to_rownames(df, colnames(df)[1])
    seurat_obj <- CreateSeuratObject(counts = as.matrix(df), project = "project")
    saveRDS(seurat_obj, file = "sample.rds")

    fread() is far faster than read.csv() on large matrices, but data.table has no native rownames concept, so the ID column comes back as ordinary data; column_to_rownames() moves it explicitly. This assumes the first column is genuinely the gene identifier column and that identifiers are unique.

  3. 03awk

    bash
    awk -F '\t' 'NR==1{print NF}' sample.csv

    Counts the fields in the header row before you load anything into R. Assumes tab-delimited input; swap -F for a comma on a CSV. Catches a header/data column mismatch (a common cause of an off-by-one gene count) in under a second instead of after a 12GB matrix finishes loading.

  4. 04SeuratvSeurat v5

    r
    seurat_obj <- readRDS("sample.rds")
    dim(seurat_obj)
    class(seurat_obj[["RNA"]]$counts)
    head(rownames(seurat_obj), 3)

    Reloads the saved object to verify it independently of the session that built it. dim() should match the source file's gene/cell counts, class() should read dgCMatrix, and head(rownames()) should show real gene identifiers, not integers.

Coordinates, strand, names, builds

Neither side of this conversion carries genomic coordinates or strand: a count matrix is genes-by-cells, not intervals, and none of that survives or needs to. What does carry through, and what breaks silently, is identity metadata instead of positional metadata.

Gene identifiers are the whole ballgame here. The CSV has no schema, so nothing enforces that the first column is an identifier; if it isn't explicitly moved to matrix rownames (via row.names = 1 or column_to_rownames()), Seurat builds a structurally valid object with useless, auto-numbered feature names. Whether those identifiers are Ensembl IDs, gene symbols, or Entrez IDs is also not recorded anywhere in the resulting RDS: the object just holds whatever strings you gave it as rownames, with no annotation of which system or genome build they came from. Write that down somewhere outside the object, because six months later "TP53" and "ENSG00000141510" look equally authoritative and equally undocumented.

Cell or sample identity is the second thing to check: column headers in the CSV become colnames(seurat_obj) verbatim, so any character R's default parsing mangles (spaces, leading numbers, some punctuation) changes your barcodes or sample names on the way in. Storage format changes too: CreateSeuratObject() auto-converts a dense input matrix to sparse dgCMatrix, which is a large memory win but means the object's internal representation no longer matches the flat file it came from. And metadata is a pure loss in the other direction: a CSV carries none of the cell metadata, PCA/UMAP embeddings, or Seurat v5 data/scale.data layers that a full Seurat object supports; you're populating only the counts layer at import time, and every other layer starts empty until you run normalization and scaling yourself.

Check the output before you trust it

  1. 01Dimensions match the source file

    r
    dim(seurat_obj)

    Expected Rows equal the gene count from the CSV (line count minus the header), columns equal the number of sample/cell columns (field count minus the one ID column). A count off by exactly one usually means a header row leaked into the data or vice versa.

  2. 02Rownames are real gene identifiers

    r
    head(rownames(seurat_obj), 5)

    Expected Recognizable symbols or IDs like "TP53" or "ENSG00000141510", not "1", "2", "3" or "V1", "V2". Integer-looking rownames mean the gene column never got moved out of the data and into rownames.

  3. 03Counts matrix is sparse

    r
    class(seurat_obj[["RNA"]]$counts)

    Expected "dgCMatrix". If it prints "matrix", "data.frame", or "array" instead, something densified the matrix and you're paying full memory cost for mostly-zero data.

  4. 04No duplicate feature names

    r
    sum(duplicated(rownames(seurat_obj)))

    Expected 0. A nonzero count usually traces back to converting Ensembl IDs to gene symbols before building the object, collapsing multiple genes into one rowname.

  5. 05Filtering didn't gut the object

    r
    dim(seurat_obj)

    Expected Gene and cell counts drop only modestly from the raw file after min.cells/min.features filtering. If 90%+ of genes or cells vanished, the matrix is probably transposed (cells in rows instead of columns) so the filters are being applied against the wrong axis.

  6. 06Cell barcodes weren't mangled on read-in

    r
    head(colnames(seurat_obj), 3)

    Expected Barcodes matching the source column headers exactly, e.g. "AAACCTGAGAAACCAT-1". If dashes turned into dots, the reader's default name-cleaning altered your sample identifiers.

Errors you will see, and what they mean

Error like "no dimnames[[.]]: cannot use character indexing" when calling PercentageFeatureSet() or similar
Cause: The counts matrix has no rownames because the gene ID column was read in as ordinary data instead of being converted to matrix rownames before CreateSeuratObject() was called. Fix: Fix it at the read step: use row.names = 1 in read.csv(), or column_to_rownames() after fread(), before building the object. Don't try to patch rownames onto the Seurat object after the fact.
Memory allocation failure or a warning like "allocating vector of size 44.0 Gb" during PCA or a merge
Cause: The counts matrix, which CreateSeuratObject() built as a sparse dgCMatrix, got silently coerced to a dense matrix somewhere downstream (a custom transform, certain subsetting operations, or a merge step). Fix: Check class(seurat_obj[["RNA"]]$counts) right after any transform that touches the matrix, and re-sparsify with Matrix::Matrix(m, sparse = TRUE) if it has drifted to dense.
Object builds without error but feature names are "V2", "V3", ... or plain integers
Cause: data.table::fread() has no native rownames concept, so the gene ID column comes back as an ordinary data column named V1 (or whatever header it had), and it gets carried straight into the matrix as a feature unless explicitly moved. Fix: Always run column_to_rownames(df, colnames(df)[1]) on the fread() output before as.matrix() and CreateSeuratObject().
CreateSeuratObject errors on duplicate rownames, or fewer features than expected after building the object
Cause: Ensembl IDs were converted to gene symbols before the matrix was built, and multiple Ensembl IDs mapped to the same symbol, so rows collapsed or conflicted. Fix: Build the Seurat object using the original stable identifiers (Ensembl IDs) as rownames, and keep symbol mappings as a separate lookup table rather than the rownames themselves. If you need unique symbol rownames anyway, run make.unique() first.

Questions people ask

How do I convert a CSV of gene counts into a Seurat object?

Read the file so the gene ID column becomes matrix rownames, not a data column, then pass the result to CreateSeuratObject(counts = as.matrix(df)). With read.csv() that means row.names = 1; with data.table::fread(), which has no native rownames, you have to move the first column over explicitly with tibble::column_to_rownames().

Why does CreateSeuratObject fail with a dimnames or character-indexing error?

That error means the counts matrix has no rownames, so Seurat can't look up genes by name. It happens when the gene ID column was read in as ordinary data instead of being converted to rownames before the matrix was built. Fix it upstream, at the read step, not by patching the Seurat object afterward.

Should my counts matrix be sparse or dense before I build the Seurat object?

CreateSeuratObject() converts a dense input to a sparse dgCMatrix automatically, so you don't have to pre-convert. What you do need to watch is later steps (custom transforms, some merges, some PCA paths) silently coercing it back to dense, which is where single-cell matrices blow past available memory.

How do I fix duplicate gene names when creating a Seurat object?

Duplicates usually come from converting stable Ensembl IDs to gene symbols before building the object; multiple Ensembl IDs can map to the same symbol. Build the object on the original Ensembl IDs and keep symbols as a separate lookup table, or run make.unique() on the symbol vector if you need symbols as rownames specifically.

What changes in Seurat v5 when I load counts from a CSV?

Seurat v5 stores counts, data, and scale.data as separate layers inside the assay rather than fixed slots. A CSV only ever gives you raw counts, so you're populating just the counts layer at import time; normalization and scaling steps fill in data and scale.data later, and there's nothing in the CSV itself that tells you those layers are still empty.

Related pages

Related reading on the blog

Sources

  1. Loading cell counts CSV file into Seurat object (GitHub Issue #2869) — source for the dimnames/character-indexing error and the read.csv + row.names workflow
  2. Problem trying Create Seurat Object (GitHub Discussion #5982) — source for column_to_rownames() as the fix when gene IDs are stuck in a data column
  3. Setting min.cells and min.features in CreateSeuratObject (GitHub Issue #3812) — source for what min.cells and min.features actually filter
  4. Sparse-to-dense conversion warning with PCA on BPcells (GitHub Issue #9271) — source for the 44+ GiB / 400+ GB memory failures from unintended dense coercion
  5. Seurat Guided Clustering Tutorial (PBMC3K) — source for CreateSeuratObject's expected matrix shape, sparse memory savings, and saveRDS/readRDS usage
  6. Seurat v5 Essential Commands — source for v5 counts/data/scale.data layer structure