SeqBench

How to Make a Volcano Plot Without R

9 min read · Updated August 5, 2026

You have a differential expression table with a few thousand rows, and you need the plot everyone expects to see: fold change on the x-axis, significance on the y-axis, the interesting genes in the upper corners. Every tutorial you find assumes you are comfortable in R.

You do not need to be. The plot is a scatter plot of two columns you already have, plus one transformation. This guide covers what the axes actually are, which p-value column to use (this is the part people get wrong), where to put the thresholds, and the four routes to producing the figure.

What the two axes are

A volcano plot is a scatter plot with one point per gene. The x-axis is the log2 fold change between your two conditions. The y-axis is the negative log10 of the p-value. That is the whole construction — there is no fitting, no clustering, no model behind the figure itself.

log2 fold change is a ratio expressed in doublings, and reading it fluently is worth the ten seconds it takes to internalise. A log2FC of 1 means the gene doubled; 2 means it quadrupled; 3 means eight-fold. Negative values are the same in reverse, so −1 means the expression halved. Zero means no change. The symmetry is the reason log2 is used at all: a doubling and a halving sit the same distance from the centre, where a plain ratio would squash all the down-regulated genes between 0 and 1.

The y-axis transformation exists to put the interesting genes at the top. Raw p-values crowd everything meaningful into a sliver near zero. Taking −log10 flips and spreads them: p = 0.05 becomes 1.30, p = 0.01 becomes 2, and p = 1 × 10⁻¹⁰ becomes 10. Bigger is more significant, and each unit up the axis is another factor of ten.

The three columns you need

Whatever produced your results table, you need exactly three columns out of it: a gene identifier, a log2 fold change, and a p-value. Every differential expression tool emits all three; they just disagree about the names.

  • DESeq2, from results(): the gene id is the row name, log2FoldChange is the fold change, and you want padj rather than pvalue.
  • edgeR, from topTags(): logFC for the fold change, and FDR rather than PValue.
  • limma, from topTable(): logFC for the fold change, and adj.P.Val rather than P.Value.

Use the adjusted p-value, not the raw one

This is the mistake that changes conclusions, so it is worth being blunt about. You are testing every gene in the transcriptome, on the order of twenty thousand tests. At a raw p-value cutoff of 0.05 you expect around a thousand genes to clear it by chance alone, with no biology involved. A gene list thresholded on raw p-values is substantially noise, and a volcano plot built on them looks convincing while being wrong.

The adjusted column — padj, FDR, adj.P.Val, depending on your tool — is the raw p-value corrected for multiple testing, almost always by the Benjamini-Hochberg procedure. It is the column to threshold on and the column to plot. The correction is why a gene can have a raw p-value of 0.01 and an adjusted value of 0.4: in the context of twenty thousand simultaneous tests, that result is unremarkable.

There is one legitimate reason to look at raw p-values, which is diagnostic rather than inferential: a histogram of raw p-values across all genes should be roughly flat with a spike near zero, and a strangely shaped one points at a problem with the model or the design. That is a QC check, not the axis of your figure.

Where to put the thresholds

The conventional cutoffs are |log2FC| ≥ 1 and adjusted p ≤ 0.05, and they are conventions rather than statistics. Nothing about a two-fold change is biologically privileged; it is a round number in log2 space that became a habit.

It helps to know what a few alternatives correspond to. A |log2FC| threshold of 0.585 is a 1.5-fold change, which is a defensible choice for a system where you do not expect dramatic effects. A threshold of 0.5 is about 1.41-fold. Going the other way, requiring |log2FC| ≥ 2 restricts you to four-fold changes and will discard real biology in most experiments.

The honest approach is to pick thresholds you can justify for your system, state them in the figure legend, and check that your conclusions do not hinge on the exact values. A tool where you can drag the threshold lines and watch the up/down/not-significant counts move is genuinely useful here, because it makes the sensitivity of your gene list to an arbitrary cutoff visible in a way that re-running a script does not.

Four ways to make the plot

  1. A browser tool, if you want the figure now. Paste the table, and if the columns follow any of the standard naming conventions they are detected automatically. SeqBench's Volcano Plot prefers the adjusted p-value column when both are present, lets you drag the threshold lines directly on the plot, and exports vector SVG or PNG.
  2. R with ggplot2, or EnhancedVolcano. The most flexible route and the standard in the field. Worth learning if you will make many of these or need precise control over labelling; not worth learning to produce one figure under deadline.
  3. Python with matplotlib or seaborn. Equivalent to the R route in capability. Sensible if your pipeline is already Python and you want the figure generated reproducibly alongside it.
  4. Excel, if you have no other option. There is no volcano chart type — you add a helper column computing =-LOG10(padj), make an XY scatter of log2FC against it, and get significance colouring by splitting the data into separate series. It works, it is tedious, and it does not update when your data does.

Five things that go wrong

  • Rows where padj is NA get silently dropped, and this is expected. DESeq2 sets padj to NA for genes removed by independent filtering (too few counts for the test to have power) and both pvalue and padj to NA for genes with extreme count outliers. Those genes were not tested, so they do not belong on the plot — but check how many were removed, because a large fraction means a filtering or design problem.
  • A p-value of exactly 0 produces an infinite y-value. This is floating-point underflow: a genuinely tiny p-value below what a double-precision number can hold gets stored as 0, and −log10(0) is infinity. Those points either vanish or are drawn off the top of the axis. The usual fix is to cap them at a stated value and say so in the legend.
  • The fold-change sign is backwards. log2FC is defined relative to a reference level, and which of your conditions is the reference depends on factor ordering in the software, not on which one you consider the control. Confirm the direction on a gene you already know before you interpret the plot.
  • Raw and adjusted p-values get mixed between the plot and the text. If the figure thresholds on padj but the gene count in your results section came from a filter on pvalue, the two disagree and a reviewer will find it.
  • Nothing is labelled. A volcano plot with no gene names is decorative. Label the handful of genes you are actually going to discuss, not the top fifty, which produces an unreadable cloud of overlapping text.

What comes after the plot

A volcano plot shows you the shape of the result and identifies individual genes. It does not tell you what the changed genes have in common, which is usually the actual question.

The standard next step is over-representation analysis: take the significant genes and test whether any GO terms or pathways appear more often in that list than you would expect by chance. Feed in the gene list your thresholds produced, keep the direction of change in mind (up- and down-regulated genes are often worth testing separately), and use the genes you actually detected as the statistical background rather than the whole genome. A heatmap of the same significant genes across samples is the other common companion figure, and it answers a different question — whether the pattern is consistent across replicates or driven by one outlier.

Frequently asked questions

Can I make a volcano plot without knowing R?

Yes. The plot is a scatter of log2 fold change against −log10 of the adjusted p-value, so anything that draws a scatter plot can produce it. A browser tool that takes a pasted DESeq2, edgeR or limma table and detects the columns is the fastest route; Excel works with a helper column of =-LOG10(padj) and an XY scatter, though colouring by significance means splitting the data into separate series by hand.

Should I use the p-value or the adjusted p-value?

The adjusted one — padj in DESeq2, FDR in edgeR, adj.P.Val in limma. You are running roughly twenty thousand tests at once, so at a raw cutoff of 0.05 about a thousand genes clear it by chance with no biology involved. Threshold and plot the adjusted column; raw p-values are useful only as a diagnostic histogram to check the model.

What does a log2 fold change of 1 actually mean?

The gene doubled. log2 fold change counts doublings: 1 is two-fold, 2 is four-fold, 3 is eight-fold, and −1 is a halving. log2 is used rather than a plain ratio so that a doubling and a halving sit at equal distances from zero. If you want a 1.5-fold threshold, that is a log2FC of about 0.585.

Why does p = 0.05 sit at 1.3 on the y-axis?

Because the axis is −log10 of the p-value, and −log10(0.05) = 1.30. The transformation flips the scale so more significant is higher, and spreads out the small p-values that would otherwise be crushed against zero. Each whole unit up the axis is another factor of ten in significance: p = 0.01 is at 2, p = 1 × 10⁻¹⁰ is at 10.

Why are some genes missing from my volcano plot?

Almost always rows where the adjusted p-value is NA, which get dropped because they cannot be plotted. DESeq2 assigns NA to padj for genes removed by independent filtering (too few counts to have power) and to both pvalue and padj for genes with extreme count outliers. Those genes were never tested, so excluding them is correct — but check what fraction was removed, since a large one points at a filtering or design problem.

Why do some points sit at infinity or vanish off the top?

Floating-point underflow. A p-value smaller than a double-precision number can represent is stored as exactly 0, and −log10(0) is infinity, so those points have no finite y position. Cap them at a stated maximum and note it in the figure legend rather than dropping them silently, since they are usually your strongest hits.

Are |log2FC| ≥ 1 and p ≤ 0.05 the correct thresholds?

They are conventions, not statistical requirements. Nothing privileges a two-fold change biologically — it is a round number in log2 space that became habit. Pick cutoffs you can justify for your system, state them in the legend, and check whether your conclusions survive a modest change to them; a plot with draggable thresholds makes that sensitivity immediately visible.

Related tools

Related guides