Sanity check · Bulk RNA-seq
Why You Must Not Use TPM for Differential Expression in Bulk RNA-seq
TPM already threw away the count magnitude DESeq2 and edgeR need to estimate dispersion, and rounding it to dodge the integer check does not bring that information back.
By Ming "Tommy" Tang, Director of Bioinformatics in Big Pharma · Reviewed September 2026 · 4 min read
You quantified with salmon or kallisto, made a TPM matrix for a heatmap or a marker-gene comparison last week, and now you need a real answer to "which genes changed between control and treatment." The TPM matrix is already sitting in your working directory, so you point DESeqDataSetFromMatrix() at it. Either DESeq2 stops you with a non-integer error, or worse, it does not stop you at all because you rounded the values to get past the check.
The rounding is where this goes wrong quietly. DESeq2 and edgeR are negative binomial models built around the sampling variance of integer read counts. TPM has already divided out gene length and library size, so the magnitude information those models use to estimate dispersion is gone before you ever load the matrix. The result is not "slightly less accurate" p-values, it is dispersion estimates and fold changes computed on the wrong statistical object, and because TPM is compositional (every sample sums to the same 1,000,000), a shift in a few highly expressed genes rescales every other gene's TPM even when its true count did not move.
This page gives you a fast way to tell whether a matrix in front of you is genuinely raw counts or TPM in disguise, and the exact tximport path to get from salmon or kallisto output to something DESeq2 or edgeR can actually model. Budget twenty minutes to run the checks against your current matrix before you trust anything downstream of it.
What it looks like when it's happening
- DESeq2 stops with an error that assay values are not integers when you build the DESeqDataSet
- You rounded TPM to pass the integer check, and now fold changes for low-expression genes look noisy or implausibly large
- A PCA or colSums() check on your matrix shows every sample summing to almost exactly 1,000,000
- plotDispEsts(dds) shows dispersion scattered with no relationship to mean expression, or the fitted trend line does not track the points
- A handful of high-abundance genes (ribosomal, mitochondrial) differ slightly between samples, and unrelated low-expression genes shift in the opposite direction with no biological explanation
- A collaborator handed you a supplementary table with only a TPM or FPKM column, no raw counts anywhere
- sizeFactors(dds) come out close to 1 for every sample despite the samples having clearly different total read counts
Why it happens
DESeq2 and edgeR model the sampling variance of integer read counts with a negative binomial distribution. The mean-variance relationship they fit, and the size factors or TMM normalization factors they compute, both depend on seeing the raw magnitude of counts across genes and samples. TPM is built by dividing counts by gene length first, then rescaling so every sample sums to the same 1,000,000. Both of those divisions remove exactly the information the models need: sequencing depth is gone because every sample is forced to the same total, and gene length is gone because it was divided out before the library-size step ever ran.
TPM's fixed-sum design creates a second, more subtle problem: it is compositional. If a handful of highly expressed genes (ribosomal RNA, mitochondrial transcripts) differ in abundance between two samples for a technical reason like RNA quality, every other gene's TPM shifts to compensate, because the values have to keep summing to a million. A gene whose true count did not change at all can show up with a different TPM purely because its neighbors moved. Raw-count normalization methods like DESeq2's median-of-ratios or edgeR's TMM are specifically designed to be robust to this by estimating a size factor from the bulk of the distribution rather than forcing every sample to an identical total.
The technical cause compounds when quantification comes from salmon or kallisto. Both tools estimate abundance at the transcript level, not the gene level, and both report multiple columns, commonly TPM and NumReads in salmon's quant.sf. Aggregating transcript estimates up to genes is not just a sum: transcripts within a gene differ in length, and if isoform usage differs between your conditions, a naive gene-level sum introduces a length-driven bias that looks like differential expression. tximport exists to solve exactly this, either by producing a gene-level count matrix alongside a length offset matrix (DESeqDataSetFromTximport), or by producing length-corrected pseudo-counts directly (countsFromAbundance = "lengthScaledTPM"). Skipping tximport and reading the TPM column straight into DESeq2 collapses both problems, the normalized-scale issue and the isoform-length issue, into one matrix that no downstream step can recover from.
The checks
Run them in order. Each one tells you what healthy looks like and what the problem looks like.
0/7 checked · saved in this browser
Before loading any matrix, look at the raw quantification output. For salmon, print the header of one sample's quant.sf.
bashhead -n 1 quant.sf # Name Length EffectiveLength TPM NumReads- Healthy
- You can point to the exact column you imported (NumReads for counts, TPM for normalized), and you know which one it was.
- Red flag
- You can't remember, or the matrix you have been handed has no documentation of which column it came from.
On the matrix you're about to feed into DESeqDataSetFromMatrix or DGEList, check for any non-integer entries.
rany(count_matrix %% 1 != 0, na.rm = TRUE)- Healthy
- FALSE for a genuine raw-count matrix (STAR, HTSeq, featureCounts) or for tximport counts fed through DESeqDataSetFromTximport.
- Red flag
- TRUE with values that are small decimals summing to a round number per sample, e.g. 0.02, 4.37, 118.9, which is the signature of TPM or FPKM, not effective counts.
Sum each column of the matrix with colSums() and compare the totals against the known sequencing depth per sample.
rcolSums(count_matrix)- Healthy
- Totals in the tens of millions that vary meaningfully between samples, reflecting real differences in sequencing depth (typically 20-40M reads/sample).
- Red flag
- Every column sums to almost exactly 1,000,000 (TPM) or all columns are suspiciously close to identical despite known differences in raw read yield.
Pull the row for a housekeeping gene like ACTB or GAPDH and look at its values across samples.
rcount_matrix["ACTB", ]- Healthy
- Values in the hundreds to tens of thousands, consistent with raw or length-scaled counts for a moderately-to-highly expressed gene.
- Red flag
- Values under a few hundred with several decimal places, which is TPM/FPKM scale, not count scale.
After running tximport on salmon or kallisto output, look at the object structure and the counts slot directly rather than assuming it matches what you asked for.
rtxi <- tximport(files, type = "salmon", tx2gene = tx2gene) str(txi) head(txi$counts)- Healthy
- txi$counts holds gene-level values on a count-like scale (large, non-round but not TPM-fraction-of-a-million), and txi$countsFromAbundance reports what was actually applied.
- Red flag
- txi$counts looks identical in scale to a TPM matrix you already have, or countsFromAbundance is unset when you meant to request lengthScaledTPM.
Run DESeq() and plot the dispersion estimates against mean expression.
rdds <- DESeq(dds) plotDispEsts(dds)- Healthy
- Gene-wise dispersion estimates decreasing smoothly as mean expression increases, tracking the fitted red trend curve reasonably well.
- Red flag
- Dispersion estimates scattered with no relationship to mean expression, or a fitted curve that clearly does not pass through the bulk of the points, which is consistent with count-scale assumptions being violated by normalized input.
After estimateSizeFactors(), compare the factors to the raw column totals of the same matrix.
rdds <- estimateSizeFactors(dds) sizeFactors(dds) colSums(counts(dds))- Healthy
- Size factors that track real differences in sequencing depth across samples, roughly proportional to colSums when composition is similar.
- Red flag
- Size factors clustered near 1 for every sample even though you know from the sequencing core that depth varied substantially, meaning the depth signal was already stripped out before DESeq2 ever saw it.
What to do about it
Import with tximport and let DESeqDataSetFromTximport build the offset
When: You quantified with salmon or kallisto and still have access to the per-sample quantification files (quant.sf or abundance.h5).
Run tximport(files, type = "salmon", tx2gene = tx2gene) without setting countsFromAbundance, then pass the result straight to DESeqDataSetFromTximport(txi, colData, design). This builds a gene-length offset matrix automatically, correcting for differential isoform usage between conditions.
Caveat: Requires a correct tx2gene mapping built from the same transcriptome index used for quantification. A mismatched or stale tx2gene silently drops or misassigns transcripts.
Use countsFromAbundance = lengthScaledTPM when you need a plain matrix
When: Your downstream tool (edgeR, a custom pipeline) needs a bare counts matrix rather than a tximport/DESeq object.
Run tximport(files, type = "salmon", tx2gene = tx2gene, countsFromAbundance = "lengthScaledTPM"), then feed txi$counts directly into DESeqDataSetFromMatrix or DGEList as if it were a raw count matrix.
Caveat: You lose the automatic isoform-length offset that DESeqDataSetFromTximport applies for you; scaledTPM vs lengthScaledTPM guidance is not settled in the literature, so document which one you used.
Use the aligner's raw counts directly, no TPM step at all
When: You quantified with STAR, HISAT2 + featureCounts, or HTSeq.
Read the integer count matrix straight into DESeqDataSetFromMatrix or DGEList. There is no TPM conversion anywhere in this path, so there is nothing to undo.
Caveat: None, this is the straightforward case; the failure only happens if someone TPM-normalizes the matrix for a plot and then reuses that file for the DE step.
Fall back to limma on log2(TPM+1) only when raw counts are truly gone
When: You only have a TPM or FPKM table, for example from a paper's supplementary file, and the raw counts were never shared or no longer exist.
Run limma on log2(TPM+1) with eBayes(trend=TRUE) and arrayWeights() to partially recover library-size information the model would otherwise be missing.
Caveat: This is a reluctant workaround, not a recommendation. State it explicitly in your methods, because the resulting p-values are still less reliable than a genuine count-based test.
If the integer error came from real tximport counts, don't round, switch import functions
When: DESeqDataSetFromMatrix throws the non-integer error but check 5 confirmed the values are genuine length-scaled tximport counts, not TPM.
Switch to DESeqDataSetFromTximport(txi, colData, design), which handles the non-integer effective counts internally without you touching the values.
Caveat: Rounding is only acceptable here because the values are true length-scaled counts; the same round() call applied to actual TPM values does not fix anything, it just hides the error.
When not to "fix" it
TPM has a real job, and that job is not differential expression. When you are comparing expression of different genes within one sample, comparing the same gene's relative abundance across studies or platforms with different depth and length distributions, or building a cross-tissue heatmap of marker genes the way the TCGA pan-cancer heatmap workflow does, TPM's length and depth normalization is exactly the right tool for that comparison. Converting back to counts for that use would reintroduce the depth and length bias you were trying to remove. The rule is narrow: don't feed TPM or FPKM into a statistical test that assumes count-scale sampling variance. Everything else TPM is good at, keep doing.
Five things experienced analysts do here
- Keep raw counts and TPM as two separate files from the start, named so the scale is obvious (count_matrix.csv vs tpm_matrix.csv), so nobody on the team reaches for the wrong one under deadline pressure.
- When you download a matrix from GEO or a supplementary table, check the column header or the methods section for which value type it is before you touch it; don't assume.
- Read tximport's output structure (str(txi)) once per project so you know exactly what's in $counts, $abundance, and $length before you build a DESeqDataSet from it.
- When reviewing someone else's DE script, the first thing to check is what object was passed to DESeqDataSetFromMatrix or DESeqDataSetFromTximport, not the design formula.
- Treat TPM as a display-and-comparison format for the last mile of an analysis, never as input to a statistical test; if a step needs a p-value, it needs counts.
Questions people ask
- Can I use DESeq2 with TPM or FPKM values?
No. DESeq2's negative binomial model and its median-of-ratios normalization need the sampling variance that is only present in raw counts. TPM and FPKM have already normalized for gene length and library size, so DESeq2 has nothing left to estimate size factors or dispersion from. Feeding it TPM produces wrong p-values even if you round the values to pass the integer check.
- Why does DESeq2 say the count data are not integers?
DESeqDataSet requires the count matrix to contain non-negative integers, because it models discrete read counts. TPM values are continuous fractions of a million, so they fail this check. The fix is not to round TPM, it is to go back to raw counts or to a tximport-derived count matrix that is genuinely count-scale.
- Should I use lengthScaledTPM or scaledTPM in tximport?
Both convert transcript-level abundance back to a count-like scale that corrects for differential isoform usage between samples, and both are acceptable when you are not using DESeqDataSetFromTximport directly. The literature has not settled which is preferable in every scenario, so if you can use DESeqDataSetFromTximport instead, do that: it builds the offset matrix automatically and you avoid the choice entirely.
- What is the actual difference between TPM, FPKM, and raw counts for DE analysis?
Raw counts carry sequencing depth, gene length, and RNA composition all mixed together, which is exactly what DESeq2's and edgeR's normalization steps are designed to separate out correctly. TPM and FPKM normalize for depth and length before you ever see them, which is useful for comparing expression within a sample or across studies, but it removes the information a differential test needs. FPKM additionally normalizes per-sample before the between-sample step, which is documented as the more broken of the two for cross-sample use.
- I only have a TPM table from a supplementary file, no raw counts. What now?
If the raw counts are genuinely gone, the reluctant workaround is a limma analysis on log2(TPM+1) with eBayes(trend=TRUE) and arrayWeights() to partially recover library-size information. Treat this as a fallback, not a recommendation, and say so explicitly when you report the result, since the underlying statistics are still less reliable than a true count-based test.
Related pages
- Guide · How to Detect Batch Effects in Bulk RNA-seq
- Guide · How to Log-Transform Counts Without Fooling Yourself in Bulk RNA-seq
- Compare · DESeq2 vs edgeR: Which One Should You Use?
- Guide · How to Avoid Pseudoreplication in Single-Cell RNA-seq
- Compare · salmon vs kallisto: Which One Should You Use?
- Glossary · False discovery rate (FDR)
- Glossary · Negative binomial model
Related reading on the blog
Sources
- Bioconductor - DESeq2 — DESeq2 is built around raw count data and negative binomial modeling
- Bioconductor - edgeR — edgeR's TMM normalization and quasi-likelihood tests require raw counts, not TPM/FPKM
- Differential expression analysis starting from TPM data — Why TPM discards the information DESeq2/edgeR/limma-voom need, and the reluctant limma-on-log2(TPM) fallback
- Error in DESeqDataSet : some values in assay are not integers — DESeq2's integer requirement and why rounding TPM is not an appropriate workaround
- TPM, FPKM, or Normalized Counts? A Comparative Study of Quantification Measures for the Analysis of RNA-seq Data from the NCI Patient-Derived Models Repository — Benchmarking showing DESeq2/TMM normalized counts outperform TPM in reproducibility, and the compositional distortion mechanism
- Correct way to use DESeq after salmon quantification — The tximport to DESeqDataSetFromTximport workflow, and the countsFromAbundance alternative
- Importing transcript abundance with tximport — countsFromAbundance parameter options: lengthScaledTPM, scaledTPM, dtuScaledTPM
- use tximport's countsFromAbundance when using DESeq2 — DESeqDataSetFromTximport automatically builds the offset matrix, the preferred route over manual countsFromAbundance
Part of the TPM vs counts for differential expression series.