
Chris Parmer
November 3, 2026
Volcano Plots - A Complete Guide
TL;DR
- A volcano plot is a scatter plot of thousands of statistical tests at once, one dot per gene, protein or variant: the size of the change on the x axis (usually log2 fold change) and the strength of the evidence on the y axis (−log10 p-value).
- Volcano plots are similar to MA plots, which put mean expression on the x axis instead of the p-value, and Manhattan plots, which put genome position on it.
- The dots that matter sit in the top left and top right corners, past two thresholds that every published volcano should state on the chart: a fold change (often 2-fold) and an adjusted p-value (often q < 0.05).
- They are the standard differential expression plot for RNA-seq and proteomics, and they also summarize drug screens, GWAS results and adverse events in clinical trials.
- You can make a volcano plot with Plotly in Python from px.scatter with three dashed threshold lines, or with Dash Bio's VolcanoPlot, which draws the lines and highlights the hits for you.
What is a volcano plot?
Volcano plots look like this:

Data: Himes et al. (2014), Cuffdiff results deposited in NCBI GEO, series GSE52778.
How do you read a volcano plot? In the example above, notice these things:
- Each dot is one gene, tested for a difference between airway smooth muscle cells treated with the steroid dexamethasone and untreated cells from the same four donors.
- The x axis is the log2 fold change, so 0 means no change, +1 means twice as much and −1 means half as much. Genes that went up are on the right, like FKBP5 at +3.95 (15.5 times as much), and genes that went down are on the left, like KCTD12.
- The y axis is −log10 of the p-value, so higher means stronger evidence. The dashed horizontal line is where the adjusted p-value (q) reaches 0.05, at p = 0.0012, so every dot above it passes after correcting for 13,370 tests.
- Only the dots past both dashed lines count as hits, colored red (217 up) and blue (99 down). The gray cloud near 0 is the thousands of genes that didn't change, which gives the chart its volcano shape.
- Watch the edges. The triangles at the top are 10 genes whose p-value the software reported as exactly 0, so their height is a cap rather than a value, and ZBTB16 far right went from almost no expression to some, which makes its fold change large.
It's often used for charts like these:

Proteomics. 24 hours after SARS-CoV-2 infection, the nucleoprotein was 63 times more abundant than in mock-infected cells, yet only 3 of the 9 viral proteins passed q < 0.05, because three replicates per group give noisy p-values. Data: Bojkova et al. (2020), Nature, Supplementary Table 02 on Zenodo.

Drug screens. Across 194,860 drug and mutation tests in the GDSC cancer cell line screen, BRAF-mutant lines needed 36 times less dabrafenib than other lines, one of 414 hits past both thresholds. Data: Wellcome Sanger Institute, GDSC2 ANOVA results, release 8.4 (Iorio et al. 2016).

Genetic association studies. In a GWAS of about 700,000 people, a rare MC4R variant carried on 2% of chromosomes had the largest effect on BMI of the 41,103 genome-wide significant SNPs, while common FTO variants had the smallest p-values. Data: GIANT consortium and UK Biobank, Yengo et al. (2018).
A short history
Volcano plots came out of the microarray era, when a single experiment produced a fold change and a t-test for thousands of genes. Wikipedia's volcano plot article cites Jin et al. (2001) and Cui and Churchill (2003), who described "the 'volcano plot'" as "an effective and easy-to-interpret graph that summarizes both fold-change and t-test criteria". Their figure plotted −log10 p against log2 fold change with threshold bars, the same layout used today. We couldn't find their data in a public form to redraw it, so example 6 below draws the same kind of chart on Golub's 1999 leukemia microarrays. The chart later moved to RNA-seq, proteomics, genetic association studies and clinical safety tables (Zink et al. 2013).
Alternatives to a volcano plot
Here's the same airway data drawn five ways. Some are better for the question, some worse, some just different:

- MA plot (different, better for checking normalization). Log fold change on y against mean expression on x shows whether low-count genes swing more than high-count ones, which a volcano hides, but you can't see the p-values. DESeq2's plotMA draws this one.
- Manhattan-style plot (different). P-values by genome position show whether hits cluster on a chromosome. It is the standard chart for GWAS, shown below, but for gene expression the position rarely matters.
- Ranked dot plot (better for a short list). The 10 largest increases and decreases with their names are easier to read in a slide, but the other 13,000 genes and the evidence are gone.
- Heatmap (works too, for the top hits). Each sample's values show whether the change holds in every sample, which a volcano can't show, but it only fits a few dozen genes.
- Table sorted by p-value (worse for exploring). It's exact, but it hides the shape of the result: how many genes moved, in which direction and by how much.
The Manhattan plot is the volcano plot's closest relative in genetics. It draws every SNP at its position in the genome, so each associated region rises as a tower:

Data: GIANT consortium and UK Biobank, Yengo et al. (2018); the same results as the GWAS volcano above.
How to make a volcano plot
With AI
ChatGPT, Claude Code, GitHub Copilot, Cursor and other AI tools are excellent at writing Plotly code. Name Plotly in the prompt, and give it your thresholds so they end up on the chart:
Make a plotly volcano plot from deseq2_results.csv (columns: gene, log2FoldChange, pvalue, padj).- x = log2FoldChange, y = -log10(pvalue), one dot per gene, WebGL for speed.- Hits: padj < 0.05 and |log2FoldChange| >= 1. Up in red, down in blue, the rest light gray.- Dashed vertical lines at -1 and +1 and a dashed horizontal line at the largest pvalue with padj < 0.05,each labeled with its threshold.- Gene name on hover, labels with leader lines on the 10 genes with the smallest padj.- Genes with pvalue 0: draw them at the top as triangles and say so on the chart.- Check: print the number of genes up and down.
With Plotly Studio
Plotly Studio is a desktop app where you point an AI at a dataset and it builds charts and a Dash app around your questions. Ask for a volcano plot the same way:
Show a volcano plot of the DESeq2 results with log2 fold change on x and −log10 p-value on y, hits at padj below 0.05 and at least 2-fold in red and blue, dashed threshold lines, and sliders to change both thresholds.
Make a volcano plot in Python or JavaScript
This is the code AI tools write under the hood. In Python, a volcano plot is px.scatter with a status column for the colors and three dashed lines. It takes the columns that DESeq2 and PyDESeq2 write, so it works on an RNA-seq result as it is:
import numpy as npimport pandas as pdimport plotly.express as pxdf = pd.read_csv("deseq2_results.csv") # gene, log2FoldChange, pvalue, padj (DESeq2 or PyDESeq2 output)FC, ALPHA = 1, 0.05 # thresholds: at least 2-fold, adjusted p below 0.05df["neg_log10_p"] = -np.log10(df.pvalue)hit = df.padj < ALPHAdf["status"] = np.select([hit & (df.log2FoldChange >= FC), hit & (df.log2FoldChange <= -FC)],["Up", "Down"], "Not significant")fig = px.scatter(df, x="log2FoldChange", y="neg_log10_p", color="status", hover_name="gene",color_discrete_map={"Up": "#e34948", "Down": "#2a78d6", "Not significant": "#c3c2bc"},labels={"log2FoldChange": "log2 fold change", "neg_log10_p": "−log10 p-value"},render_mode="webgl")fig.add_vline(x=-FC, line_dash="dash", annotation_text="2-fold down", annotation_position="top left")fig.add_vline(x=FC, line_dash="dash", annotation_text="2-fold up")fig.add_hline(y=-np.log10(df[hit].pvalue.max()), line_dash="dash", annotation_text=f"padj = {ALPHA}")fig.show()
The horizontal line sits at the largest p-value whose adjusted p-value is below 0.05, so the dots above it are exactly the ones that pass. If you'd rather put the adjusted p-value on the y axis, use -np.log10(df.padj) and draw the line at -np.log10(0.05). The Plotly scatter plot documentation covers hover text and styling.
In JavaScript, plotly.js draws the same thing from a scatter trace and three line shapes:
<div id="chart"></div><script src="https://cdn.plot.ly/plotly-4.1.1.min.js"></script><script>// 17 genes from Himes et al. (2014), dexamethasone vs untreated (GEO GSE52778): log2 FC, p, qconst genes = [["TSC22D3", 3.27, 2.22e-16, 2.5e-13], ["CRISPLD2", 2.70, 6.66e-16, 6.92e-13], ["PER1", 3.20, 3.77e-15, 3.64e-12],["KCTD12", -2.52, 1.24e-14, 1.12e-11], ["ERRFI1", 2.43, 1.28e-13, 1.08e-10], ["STEAP4", 4.99, 6.67e-13, 5.3e-10],["MT2A", 2.23, 3.41e-12, 2.56e-9], ["SOX4", -2.41, 7.19e-12, 5.11e-9], ["ZBTB16", 7.56, 1.31e-9, 5.23e-7],["VCAM1", -3.58, 1.0e-7, 2.02e-5], ["ACTB", 0.97, 0.0943, 0.998], ["TSPAN6", -0.41, 0.225, 1],["NFYA", -0.44, 0.235, 1], ["CFH", 0.43, 0.243, 1], ["FUCA2", -0.28, 0.366, 1], ["DPM1", 0.20, 0.581, 1],["GAPDH", -0.06, 0.887, 1]];const FC = 1, ALPHA = 0.05, P_AT_ALPHA = 0.00115; // p-value where q crosses 0.05 in the full tableconst color = ([, fc, , q]) => q < ALPHA && fc >= FC ? "#e34948" : q < ALPHA && fc <= -FC ? "#2a78d6" : "#c3c2bc";const dashed = {color: "#52514e", dash: "dash", width: 1};Plotly.newPlot("chart", [{type: "scatter", mode: "markers+text", textposition: "top center",x: genes.map(g => g[1]), y: genes.map(g => -Math.log10(g[2])),text: genes.map(g => g[3] < ALPHA ? g[0] : ""), marker: {size: 10, color: genes.map(color)}}], {width: 800, height: 520, showlegend: false,xaxis: {title: {text: "log2 fold change"}, range: [-8, 8]},yaxis: {title: {text: "−log10 p-value"}},shapes: [{type: "line", x0: -FC, x1: -FC, yref: "paper", y0: 0, y1: 1, line: dashed},{type: "line", x0: FC, x1: FC, yref: "paper", y0: 0, y1: 1, line: dashed},{type: "line", xref: "paper", x0: 0, x1: 1, y0: -Math.log10(P_AT_ALPHA), y1: -Math.log10(P_AT_ALPHA), line: dashed}]});</script>
Make a volcano plot with Dash Bio
Dash Bio is Plotly's open-source library of bioinformatics charts for Dash, and its VolcanoPlot returns a Plotly figure with the threshold lines drawn and the hits highlighted. Its defaults come from genetics: effect_size_line=[-1, 1] and genomewideline_value=-log10(5e-8), given on the −log10 scale. For an RNA-seq result, set the line to your own cut:
import dash_bioimport numpy as npimport pandas as pddf = pd.read_csv("airway.csv") # gene, log2fc, p_value, q_valueline = -np.log10(df[df.q_value < 0.05].p_value.max()) # the p-value where q = 0.05df = df[df.p_value > 0].reset_index(drop=True) # -log10(0) is infinite; VolcanoPlot expects a 0..n-1 indexfig = dash_bio.VolcanoPlot(dataframe=df, effect_size="log2fc", p="p_value", snp="gene", gene="gene",effect_size_line=[-1, 1], genomewideline_value=line)fig.show()

In a Dash app, put the figure in a dcc.Graph and connect a dcc.RangeSlider to effect_size_line with a callback, as the Dash Bio example does, so readers can move the fold change threshold and watch the hits change.
Share it
Every Plotly chart has a Share chart button (the cloud icon in its toolbar) that uploads it to Plotly Cloud and gives you a link. Only you can see it until you change its visibility.
For a live app, put the chart in a Dash app and publish it with the Plotly Cloud CLI. Coding agents can run these commands for you:
pip install "dash[cloud]"plotly user loginplotly app publish
If your data has to stay in your own cloud or data center, publish to Dash Enterprise instead.
Dos and don'ts
Most of the work in a published volcano plot is choosing the axes, showing the thresholds and labeling only a few points. Here are the airway results both ways, in the same colors.
Don't plot the fold change on a linear scale, which squeezes every gene that went down between 0 and 1 and lets two outliers stretch the axis to 4,130, leave out the threshold lines, label 40 genes on top of each other, or use a default serif font and raw column names as axis titles:

Do use log2 fold change so ups and downs are symmetric around 0, draw both thresholds as dashed lines with their values, make the genes that miss small and light, label a handful of genes with leader lines, and write axis titles in words:

Checklist
Do
- Put log2 fold change on x and −log10 p-value (or adjusted p-value) on y.
- State both thresholds on the chart: the fold change and the adjusted p-value, with their values.
- Color only the dots past both thresholds, with up and down in two colors that work for color-blind readers (red and blue, not red and green).
- Label 5 to 15 dots with leader lines, and put every name in the hover text.
- Say in the subtitle what was compared, how many tests there were and how p-values were adjusted.
- Mark capped values: p-values reported as 0 and fold changes from a zero count.
Don't
- Use unadjusted p < 0.05 as the threshold for thousands of tests.
- Read a dot's height as effect size, or its distance from 0 as evidence.
- Let a few extreme fold changes set the axis range for the whole chart.
Options to customize
A volcano plot in Plotly is px.scatter (or go.Scattergl) plus lines and annotations, so these are the options that matter most. The full lists are in the px.scatter reference and the scattergl trace reference.
- x, y: log2 fold change and −log10 p-value (compute them as columns first).
- color with color_discrete_map: a status column (up, down, not significant) mapped to fixed colors, so the colors don't change between charts.
- render_mode="webgl" or go.Scattergl: WebGL markers, which you need above about 10,000 points.
- hover_name, hover_data, hovertemplate: the gene or protein name, fold change and adjusted p-value on hover.
- add_vline, add_hline with line_dash="dash" and annotation_text: the threshold lines and their labels.
- add_annotation with ax, ay: labels with leader lines for the points the story needs.
- marker.size, marker.opacity: 4 to 5 px and 0.5 opacity for the gray cloud, larger and opaque for the hits.
- marker.symbol: triangles for capped points, such as p-values reported as 0.
- yaxis.type="log": a log axis for −log10 p when the top values run into the hundreds or thousands, as in GWAS and pharmacovigilance.
- xaxis.range: a symmetric range around 0, so a couple of extreme fold changes don't flatten the rest.
- facet_col or make_subplots(shared_yaxes=True): one volcano per time point or contrast with the same axes.
- dash_bio.VolcanoPlot(effect_size_line=..., genomewideline_value=..., highlight_color=...): the same chart with built-in threshold lines and highlighting.
More volcano plot examples
Six more volcano plots built from real public data, each with its source, the technique that makes it work and a prompt to rebuild it.
1. DESeq2 in Python on the classic pasilla dataset

Knocking down the splicing factor pasilla changed 234 genes at least 2-fold, and pasilla itself fell 3.7-fold, which confirms the knockdown worked. This is the dataset the DESeq2 tutorials use, analyzed here with PyDESeq2 in Python. Data: Brooks et al. (2011), counts from the Bioconductor pasilla package.
Tip: circle the gene you perturbed in its own color; if it isn't on the left, check the experiment before the biology.
Get the data from the Bioconductor pasilla package (inst/extdata/pasilla_gene_counts.tsv andpasilla_sample_annotation.csv, Brooks et al. 2011) and run PyDESeq2 with design ~ type + condition,treated vs untreated, giving a table (columns: gene_id, gene, log2FoldChange, pvalue, padj).Build a plotly volcano plot: hits at padj < 0.05 and at least 2-fold in red and blue, dashed thresholdlines with labels, the gene ps (FBgn0261552) circled in orange, and the five smallest p-values labeled.
2. Adverse events in the SELECT semaglutide trial

Of 1,761 serious adverse event terms in the 17,604-patient SELECT trial, 26 differed between semaglutide and placebo at p < 0.05, while a 5% false-positive rate alone would allow 88, and none passed q < 0.05. The lowest, acute myocardial infarction (209 vs 293), has q = 0.25. Data: ClinicalTrials.gov results for NCT03574597; method from Zink et al. (2013).
Tip: size each bubble by the number of participants with the event, so a term with 6 cases doesn't look as important as one with 500.
Get the data from the ClinicalTrials.gov API v2 (study NCT03574597, resultsSection.adverseEventsModule.seriousEvents) and shape it into a table with one row per MedDRA term (columns: term, organ_system,n_sema, at_risk_sema, n_placebo, at_risk_placebo).Build a plotly volcano plot: x = risk difference in percentage points, y = -log10 Fisher's exact p,bubble size by participants with the event, a dashed line at p = 0.05, Benjamini-Hochberg q in the hover,and the subtitle stating how many terms pass q < 0.05.
3. Semaglutide's safety signals in FAERS

Impaired gastric emptying was reported with semaglutide at 99 times the odds of other drugs in 11.4 million FDA adverse event reports, one of 72 reactions past both thresholds. With this many reports nearly every p-value is tiny, so the fold change threshold does most of the work. Data: FDA Adverse Event Reporting System via openFDA, reports received 2018 to 2025.
Tip: when −log10 p runs into the thousands, use a log y axis.
Get the data from the openFDA drug adverse event API (FAERS, reports received 2018-2025): counts forsemaglutide's 200 most-reported reactions and the same reactions across all reports, and shape it into atable (columns: reaction, a, b, c, d) for the 2x2 table of each reaction.Build a plotly volcano plot: x = log2 reporting odds ratio, y = -log10 p on a log axis, bubbles sized bysemaglutide reports, dashed lines at ROR 2 and 1/2 and at q = 0.05, and the strongest signals labeled.
4. B-cell marker genes in single-cell RNA-seq

127 genes are at least 2-fold higher in the 341 B cells of 10x Genomics' PBMC 3k dataset than in the other 2,297 cells, led by CD74 and the B-cell receptor gene CD79A. With thousands of cells, p-values reach 10⁻¹⁸³. Data: 10x Genomics PBMC 3k, analyzed with Scanpy.
Tip: single-cell fold changes run to ±27 for genes almost absent in one group, so pin them to the axis edge as triangles instead of stretching the axis.
Get the data from 10x Genomics' "3k PBMCs from a Healthy Donor" filtered matrices, follow the ScanpyPBMC 3k clustering tutorial, and test the B-cell cluster against all other cells with rank_genes_groups(Wilcoxon), giving a table (columns: gene, log2fc, pvals, pvals_adj, pct_b, pct_rest).Build a plotly volcano plot: x clipped to -8..8 with clipped genes as triangles, hits at q < 0.05 and atleast 2-fold, dashed threshold lines, and CD79A, MS4A1, CD74 and LYZ labeled.
5. Proteomics over time as small multiples

The median viral protein went from 1.0 times its background level at 2 hours to 23.5 times at 24 hours, and no host protein passed q < 0.05 until the 24-hour time point, when 17 changed at least 2-fold. Data: Bojkova et al. (2020), Supplementary Table 02.
Tip: give every panel the same axis ranges, or the cloud will look the same size at every time point.
Get the data from Zenodo record 3820973 (Bojkova et al. 2020, Supplementary Table 02: proteome ofSARS-CoV-2-infected Caco-2 cells at 2, 6, 10 and 24 h) and shape it into a long table (columns: accession,gene, viral, hours, log2fc, p_value) with a Benjamini-Hochberg q per time point.Build plotly volcano plots as four panels in one row with shared axes, viral proteins in purple, hosthits in red and blue, and each panel's title giving its number of hits.
6. Golub's leukemia microarrays, the classic two-group comparison

313 genes differ at least 2-fold between acute myeloid and acute lymphoblastic leukemia in Golub's 1999 training set of 38 microarrays, with zyxin the most significant. This is the kind of t-test and fold change comparison the first volcano plots summarized. Data: Golub et al. (1999), training set from the Broad Institute.
Tip: use the same colors, thresholds and axis titles across experiments, so old and new results can be compared at a glance.
Get the data from the Broad Institute's Golub leukemia training set (data_set_ALL_AML_train.txt, samples1-27 ALL and 28-38 AML), floor at 100 and cap at 16,000, keep genes with max/min > 5 and max - min > 500,and log2 it, then run a Welch t-test per gene (columns: probe, description, log2fc, p_value, q_value).Build a plotly volcano plot: x = AML minus ALL in log2, hits at q < 0.05 and at least 2-fold, dashedthreshold lines, and zyxin, CST3, MYB and TCF3 labeled.