# How to Visualize Your Bioinformatics Results in Python

The right plot is the one whose shape matches the structure of your data: comparison plots for categories, distribution plots for continuous values, ordination plots for high-dimensional tables, and coordinate-based plots for anything with a genomic position. That single rule of thumb settles most decisions before you open a plotting library. If your result has categories, reach for a bar or box plot. If it has thousands of genes or features, reach for a volcano plot, heatmap, or ordination. If it has genomic coordinates, reach for a track or genome diagram. This guide walks through how to visualize your bioinformatics results in Python, the library options with their pros and cons, worked code examples, and the mistakes that most often ruin a figure.

Python for bioinformatics is a practical choice for visualization because the same environment that runs your analysis (pandas, Biopython, scanpy, scikit-learn) also produces the figures. You never export intermediate files just to make a picture, and every figure can be regenerated from a script.

## Start With the Question Your Plot Must Answer

![Decision path asking whether data has categories, distributions, high-dimensional samples, coordinates or set overlaps, ending at a named plot type.](/article-images/illustrations/choose-right-plot-bioinformatics-python-plot-choice-triage.webp)

Before you choose a library or a color palette, write down the question a reader should answer in five seconds of looking at your figure. Every common bioinformatics figure answers one of seven questions.

1. **Which items differ between groups?** Differential expression, differential abundance, variant allele frequencies. Volcano plots, MA plots, and bar charts.
2. **How do values distribute?** Read lengths, expression levels, coverage, quality scores. Histograms, violin plots, box plots, empirical cumulative distribution function (ECDF) plots.
3. **How do samples or features relate?** Clustering, batch effects, condition separation. Heatmaps with dendrograms, PCA ([principal component analysis](/blog/data-analysis/principal-component-analysis-explained)) scatter plots, UMAP (Uniform Manifold Approximation and Projection) plots.
4. **Where on the genome does something happen?** Coverage peaks, variant positions, gene structures. Genome diagrams, track plots, coverage line plots.
5. **What does a sequence look like?** Conservation, motif composition, alignment structure. Sequence logos, alignment plots.
6. **How many items overlap between sets?** Gene set overlaps, pathway intersections. UpSet plots and Venn diagrams.
7. **How do categories compare?** Counts of taxa, functional annotations, variants per sample. Bar charts, stacked bar charts, dot plots.

If your figure answers none of these questions, it is decoration and should be cut. If it answers two questions at once, split it into two panels or pick the question that matters more.

## How to Choose the Right Plot in Python: A Comparison Table

The table below is the core decision tool for this article. Match your data type to a plot, then check the strengths and trade-offs before you commit.

| Plot type | Question it answers | Primary Python tools | Strengths | Trade-offs |
|--|--|--|--|--|
| Volcano plot | Which genes are both large-effect and significant? | Matplotlib, plus adjustText for labels | Familiar to reviewers, compact, shows two dimensions at once | Encourages overreading of effect size cutoffs, labels overlap badly by default |
| MA plot | Is there systematic intensity-dependent bias? | Matplotlib, statsmodels smoothers | Detects bias that volcano plots hide | Less intuitive for non-specialists |
| Heatmap with dendrogram | Which samples and features cluster together? | Seaborn clustermap, scanpy | Shows thousands of values at once, row and column trees included | Color scale choices can manufacture or destroy patterns, order of rows is arbitrary unless clustered |
| PCA scatter | Do samples separate by condition, and which axis explains most variance? | scikit-learn plus Matplotlib, scanpy | Linear, fast, interpretable axes with loadings | Only captures linear structure, outliers dominate |
| UMAP or t-SNE scatter | What local structure exists in single-cell or high-dimensional data? | scanpy (UMAP), openTSNE | Reveals clusters that PCA misses | Distances between clusters are not meaningful, parameters change the shape |
| Box or violin plot | Does the distribution differ between groups? | Seaborn | Shows spread, robust to outliers when paired with raw points | Hides sample size unless you overlay points |
| Histogram or ECDF | What does one distribution look like? | Matplotlib, Seaborn | Fast, honest, easy to overlay conditions | Bin choice affects appearance, ECDF unfamiliar to some readers |
| Bar chart | How do a few categories compare? | Matplotlib, Seaborn | Universally understood | Wastes space with more than about eight categories, misleading with error bars of unclear meaning |
| Dot plot (enrichment) | Which pathways are enriched and how large are the gene sets? | Matplotlib, adjustText, clusterProfiler-style pipelines in Python | Encodes three variables (fold enrichment, size, significance) cleanly | Requires careful legends or it becomes unreadable |
| UpSet plot | How do three or more gene or variant sets overlap? | upsetplot | Scales where Venn diagrams fail (beyond three sets) | Less familiar to reviewers, needs a legend explaining the bars |
| Genome diagram | Where are genes, features, or hits located? | Biopython GenomeDiagram | Publication-quality circular or linear maps from code | Styling takes effort, not interactive |
| Coverage or depth track | Where does read coverage spike or drop? | Matplotlib, pysam | Simple and precise | Becomes slow with millions of points unless you downsample |
| Sequence logo | Which positions are conserved in a motif? | logomaker | Directly readable, standard for motifs | Only works for aligned sequences of equal length |
| Phylogenetic tree | How are sequences or taxa related? | Bio.Phylo in Biopython | Native tree objects, supports annotation | Layout control is limited compared with dedicated tree viewers |
| Interactive scatter or dashboard | Which point is which gene when a reviewer hovers? | Plotly | Hover identifiers, zoom, export to HTML | Larger files, not always accepted by journals as static figures |

Two plots deserve a note of caution. Venn diagrams are acceptable for two or three sets but collapse visually beyond that, which is why the UpSet plot exists. Pie charts encode area, which humans judge poorly, so a bar chart is almost always the better answer for compositional data.

## The Python Visualization Toolkit for Bioinformatics

You do not need every library. You need a small, reliable set where each tool has one job.

### Matplotlib

Matplotlib is the foundation. Nearly every other plotting library in Python builds on it or imitates its model, and the Matplotlib documentation is the most complete reference in the ecosystem. You control every element of a figure: axes, ticks, fonts, panel labels, and export resolution. The trade-off is verbosity. A publication-quality volcano plot takes twenty to forty lines of code where Seaborn might take five. Learn Matplotlib properly and every other library becomes easier, because concepts like figures, axes, and subplots carry over everywhere.

### Seaborn

Seaborn sits on top of Matplotlib and handles statistical graphics. Its `clustermap` function produces a clustered heatmap with dendrograms in one call, which is the workhorse figure for [transcriptomics](/knowledge/bioinformatics/modern-transcriptomics-bulk-single-cell-spatial) and metabolomics. Its categorical plots (box, violin, strip, swarm) handle grouped comparisons with sensible defaults. The trade-off is reduced control over unusual layouts. When a Seaborn figure needs a nonstandard tweak, you often end up writing Matplotlib code against the Seaborn axes object anyway.

### Plotly

Plotly produces interactive figures that render in a browser. Hover text showing gene symbols is genuinely useful when a reviewer asks which dot is the gene they care about. Export to standalone HTML files so collaborators can explore results without installing anything. The trade-offs are heavier output files, weaker print output than Matplotlib, and the risk that interactivity hides a pattern a static figure would reveal at a glance.

### Biopython

Biopython is the standard library for sequence work in Python, and its official tutorial (the Biopython Tutorial and Cookbook) documents both parsing and visualization. For plotting, two parts matter. `GenomeDiagram` draws circular and linear genome maps from features you parse with `Bio.SeqIO` or `Bio.GenBank`. `Bio.Phylo` reads Newick and other tree formats and draws trees with Matplotlib as the backend. The Biopython tutorial includes worked examples for both, and those examples are a better starting point than anything third party because they stay current with the API.

### Scanpy

Scanpy is the standard analysis and visualization package for single-cell data in Python. It stores data in AnnData objects and produces PCA, UMAP, t-SNE, dot plots, heatmaps, and marker-gene matrices with functions that understand cell annotations. If your result comes out of a single-cell pipeline, stay inside scanpy for figures. Its plot functions carry cluster labels, marker genes, and embeddings consistently, which avoids the most common single-cell figure error, plotting an embedding without the cells labeled.

### Specialist libraries worth knowing

- **adjustText** solves the label-overlap problem on volcano plots and enrichment dot plots by nudging text labels apart algorithmically. One import fixes the single most common visual defect in bioinformatics figures.
- **logomaker** renders sequence logos from position weight matrices.
- **upsetplot** renders UpSet plots directly from membership sets or a pandas DataFrame.
- **pyGenomeTracks** produces genome browser style tracks for coverage, peaks, and annotations, which is the right tool when a Biopython genome diagram is not the shape you need.
- **statsmodels** contributes statistical transformations, such as lowess smoothers, that you may want drawn on top of scatter plots.

## Choosing the Right Plot by Data Type

This section walks through the major result types in bioinformatics and names the plot that answers each question, with the reasoning for the choice.

### Differential expression results

![Worked example clipping an adjusted p-value at 1e-300, taking the negative log10 and setting the y position above the 0.05 threshold line.](/article-images/illustrations/choose-right-plot-bioinformatics-python-neg-log10-padj.webp)

A differential expression table has, for each gene, a log2 fold change, a p-value, and usually an adjusted p-value (an FDR or false discovery rate corrected p-value that controls the expected fraction of false positives across all tests). Two plots dominate.

The **volcano plot** places log2 fold change on the x-axis and the negative log10 of the adjusted p-value on the y-axis. Genes in the upper right and upper left corners are both large-effect and significant. Use it when the audience wants a global picture of how many genes moved and how strongly. The threshold lines you draw are a decision, not a fact, so state them in the caption. Common choices are an absolute log2 fold change of 1 (a twofold change) and an FDR of 0.05.

The **MA plot** places average expression on the x-axis and log ratio on the y-axis. It answers a different question, whether there is intensity-dependent bias, meaning low-count features fanning out with inflated fold changes. Reviewers of RNA-seq work expect to see one or both of these plots, and they show different defects, so neither substitutes for the other.

For a small number of genes of interest, a grouped **bar chart** of normalized counts per condition, one panel per gene, is clearer than either plot, because it shows the actual data rather than a derived statistic.

### Sample relationships and clustering

The **clustered heatmap** (Seaborn's `clustermap`) is the single most informative overview for a normalized feature-by-sample matrix. Rows are genes or taxa, columns are samples, color encodes a normalized value such as z-scored log expression, and dendrograms show the clustering of both dimensions. Its weaknesses are real though. The color scale is doing all the perceptual work, so a diverging colormap centered at zero is essential when values can be above or below the mean. The clustering method and distance metric must be reported in the caption because they change the picture.

The **PCA scatter plot** answers whether samples separate by condition and how much variance each axis explains. Compute it with scikit-learn's `PCA`, then always annotate the explained variance ratio for each component on the axis labels. If component one explains 20 percent of variance and component two explains 10 percent, say so, because that context changes how impressive a separation looks. Loadings (the contribution of each original feature to each component) tell you what drives the separation, and a small bar chart of the top loadings belongs in the same figure or the figure next to it.

**UMAP** is the single-cell and large-dataset option. It preserves local neighborhoods, which makes clusters visible, but it does not preserve global distances, so the spacing between clusters on a UMAP plot carries no quantitative meaning. State this in the caption or a reviewer who works in dimensionality reduction will state it for you.

### Distribution of a single measurement

Read lengths, per-sample mapping rates, coverage depths, and per-cell counts all fall here. A **histogram** is the default, but overlaying conditions on one histogram gets cluttered past two or three groups. A **violin plot** or **box plot paired with overlaid raw points** (a strip or swarm plot) handles the group comparison better and shows whether your groups have different spread, not just different medians. The **ECDF** is the honest option when sample sizes are small, because it plots every point with no binning and no smoothing.

One warning specific to compositional data, such as relative taxon abundances from 16S [amplicon sequencing](/blog/guides/amplicon-sequencing): components sum to a constant, so raw proportions are constrained values. Standard practice is to work with transformed data such as centered log ratio (CLR) for statistics, and to plot proportions only with a clear statement that they are relative, not absolute.

### Genomic coordinates

Anything with a chromosome and position is a different class of problem, because the x-axis is not a value, it is a coordinate system.

For a **genome map** showing genes and features, Biopython's `GenomeDiagram` draws both circular (plasmids, small chromosomes, mitochondrial genomes) and linear maps from parsed GenBank or FASTA input. It is code-driven, which means the figure regenerates exactly when the annotation changes, and that reproducibility is the reason to prefer it over drawing tools operated by hand.

For **coverage and peak tracks**, plot per-base or binned depth as a line or area chart across a locus with Matplotlib, or use pyGenomeTracks when you want several samples stacked in the browser style. With millions of positions, bin the data first. Plotting raw per-base depth for a whole chromosome is the most common cause of figure rendering timeouts in bioinformatics workflows.

For **variants across a genome**, a Manhattan-style scatter (chromosome on the x-axis in blocks, negative log10 p-value on the y-axis) is the standard. Alternate chromosome colors in gray and a darker shade so the eye can separate blocks.

### Sequences and motifs

For a **[multiple sequence alignment](/blog/guides/multiple-sequence-alignment)**, show a logo for conserved regions rather than the raw alignment in a figure. logomaker takes a position weight matrix or counts matrix and renders letters scaled by frequency. The alignment itself is usually better left to a dedicated viewer and referenced in the figure legend.

For **sequence properties** such as length distributions or GC content, treat them as ordinary one-dimensional data and use a histogram or ECDF. A quick Biopython loop over a FASTA file with `Bio.SeqIO` produces the numbers, and a five-line Matplotlib call produces the figure. The Biopython tutorial documents both the parsing and basic statistics steps.

For **phylogenetic trees**, `Bio.Phylo` reads a Newick string into a tree object and draws it through Matplotlib with a choice of layouts. It handles simple publication trees well. If you need heavy annotation, such as mapping trait values onto internal nodes, evaluate whether a dedicated tree viewer serves you better, and keep Python for the upstream analysis.

### Set overlaps

Gene lists from two or more conditions, variant sets across samples, or pathway hits all raise the overlap question. With two or three sets, a **Venn diagram** is readable. With four or more, use an **UpSet plot**, which represents each intersection as a bar connected to a matrix of filled dots showing which sets participate. Reviewers outside single-cell and genomics fields may not know UpSet plots, so a one-sentence caption explaining how to read it saves you a revision.

### Enrichment results

Gene set enrichment output typically has, per pathway, a term name, a fold enrichment or normalized score, a gene set size, and an adjusted p-value. The **dot plot** encodes all of this: dot position for enrichment, dot size for gene count, dot color for significance, with the top ten to fifteen terms on a sorted axis. A **lollipop chart** works well for a single ranked score per term. Avoid horizontal bar charts with forty terms, because they force the reader to compare bar lengths that differ by a fraction of a millimeter.

## Library Options Compared: Pros and Cons in Detail

The table summarized fit. This section expands the reasoning you will use when two options both fit.

**Matplotlib.** Pros: universal, stable API across years, complete control of every visual element, exports PDF, SVG, and PNG at any resolution, and the documentation covers every question. Cons: verbose, default styling looks dated without a style sheet, and complex layouts require understanding its figure and axes model rather than fighting it. Verdict: always in the environment, always the final polish layer.

**Seaborn.** Pros: statistical defaults that save hours, `clustermap` is the fastest route to a clustered heatmap, integration with pandas DataFrames is seamless. Cons: some plot types resist customization, and the abstraction leaks when you need fine control. Verdict: the default for grouped comparisons and clustered heatmaps.

**Plotly.** Pros: interactivity with hover identifiers is a genuine analysis tool, not a gimmick, exports to self-contained HTML, and handles large scatter plots better than Matplotlib in a browser. Cons: static export quality lags Matplotlib, file sizes grow with point counts, and journals that require vector figures may need you to re-render in Matplotlib. Verdict: use it for exploratory dashboards and collaborator-facing HTML, keep Matplotlib for the manuscript figures.

**Scanpy.** Pros: the AnnData object keeps cells, counts, and metadata aligned through every transformation, plotting functions inherit cluster colors and labels automatically, and the scanpy documentation plus its published workflow paper give you reproducible end-to-end examples. Cons: committed to single-cell idioms, and forcing general data into AnnData adds friction. Verdict: mandatory for single-cell, unnecessary elsewhere.

**Biopython.** Pros: it is already your sequence parsing layer, `GenomeDiagram` and `Bio.Phylo` work directly on parsed objects, and the official Biopython tutorial documents both with runnable examples. Cons: visualization is a secondary feature, so styling flexibility is behind dedicated genome and tree viewers. Verdict: use it when the figure lives in the same script as the parsing, which is most of the time.

**adjustText.** Pros: solves label overlap with one function call, dramatic improvement on volcano plots. Cons: results need visual review, since automated label placement is good but not perfect. Verdict: install it, use it on every labeled scatter.

A practical setup covers 95 percent of bioinformatics figures with Matplotlib, Seaborn, adjustText, and one domain package (scanpy for single-cell, Biopython for sequence-centric work, pyGenomeTracks for track-heavy genomic figures).

## Worked Example: A Publication-Quality Volcano Plot

This example uses the standard workflow, pandas for the table, Matplotlib for the axes, adjustText for the gene labels. Assume a differential expression table with columns `gene`, `log2fc`, and `padj`.

```python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from adjustText import adjust_text

de = pd.read_csv("de_results.csv")

# Compute the negative log10 of the adjusted p-value
# Small values are clipped so they do not overflow to infinity
de["neg_log10_padj"] = -np.log10(de["padj"].clip(lower=1e-300))

# Define thresholds as explicit decisions
fc_cutoff = 1.0          # absolute log2 fold change of 1, a twofold change
padj_cutoff = 0.05       # FDR threshold

def category(row):
    if row["padj"] < padj_cutoff and row["log2fc"] >= fc_cutoff:
        return "up"
    if row["padj"] < padj_cutoff and row["log2fc"] <= -fc_cutoff:
        return "down"
    return "ns"

de["category"] = de.apply(category, axis=1)

colors = {"up": "#d1495b", "down": "#2e86ab", "ns": "#bdbdbd"}

fig, ax = plt.subplots(figsize=(6, 5))
for cat, group in de.groupby("category"):
    ax.scatter(group["log2fc"], group["neg_log10_padj"],
               s=6, color=colors[cat], label=cat, linewidths=0)

ax.axhline(-np.log10(padj_cutoff), color="grey", linestyle=":", linewidth=0.8)
ax.axvline(fc_cutoff, color="grey", linestyle=":", linewidth=0.8)
ax.axvline(-fc_cutoff, color="grey", linestyle=":", linewidth=0.8)

# Label only the strongest hits, never every point
sig = de[de["category"].isin(["up", "down"])].nlargest(10, "neg_log10_padj")
texts = [ax.text(r["log2fc"], r["neg_log10_padj"], r["gene"], fontsize=7)
         for _, r in sig.iterrows()]
adjust_text(texts, ax=ax)

ax.set_xlabel("log2 fold change")
ax.set_ylabel("-log10 adjusted p-value")
ax.legend(frameon=False, title="")
fig.savefig("volcano.png", dpi=300, bbox_inches="tight")
```

Three decisions in this script matter more than the rest. Label only a small number of points, because labeling every significant gene produces an unreadable figure. Clip tiny p-values before the log transform so extreme values do not produce infinite y positions. And define thresholds as named variables at the top so they appear once and can be reported in the caption.

## Worked Example: A Clustered Heatmap With Seaborn

Assume a normalized matrix of features by samples, stored in a CSV with features as rows and samples as columns.

```python
import pandas as pd
import seaborn as sns

expr = pd.read_csv("normalized_counts.csv", index_col=0)

# Z-score each row (feature) so color compares relative changes
expr_z = expr.sub(expr.mean(axis=1), axis=0).div(expr.std(axis=1), axis=0)

g = sns.clustermap(
    expr_z,
    cmap="vlag",          # diverging colormap, centered at zero
    center=0,
    row_cluster=True,
    col_cluster=True,
    figsize=(8, 10),
    yticklabels=False,    # hide thousands of feature names
    xticklabels=True,
    cbar_kws={"label": "row z-score"},
)
g.savefig("clustermap.png", dpi=300, bbox_inches="tight")
```

The two lines of z-scoring are the difference between a figure that shows structure and one that shows the most highly expressed genes twice. Without row normalization, the colormap is dominated by absolute abundance, and the visual clustering reflects expression level rather than expression change. Report the clustering method and distance metric (Seaborn's defaults are Ward linkage and Euclidean distance via SciPy) in the caption.

## Worked Example: PCA With Explained Variance

```python
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler

expr = pd.read_csv("normalized_counts.csv", index_col=0).T   # samples by features
meta = pd.read_csv("sample_metadata.csv", index_col=0)

# Scale features to unit variance so no feature dominates by magnitude
X = StandardScaler().fit_transform(expr)

pca = PCA(n_components=2)
coords = pca.fit_transform(X)

fig, ax = plt.subplots(figsize=(5.5, 5))
for condition, group in meta.groupby("condition"):
    idx = group.index
    ax.scatter(coords[expr.index.get_indexer(idx), 0],
               coords[expr.index.get_indexer(idx), 1],
               label=condition, s=40, linewidths=0)

ax.set_xlabel(f"PC1 ({pca.explained_variance_ratio_[0]*100:.1f}%)")
ax.set_ylabel(f"PC2 ({pca.explained_variance_ratio_[1]*100:.1f}%)")
ax.legend(frameon=False)
fig.savefig("pca.png", dpi=300, bbox_inches="tight")
```

Scaling before PCA is not optional when features have different scales. Without `StandardScaler`, the component with the largest numeric range dominates and the plot measures units rather than biology.

## Worked Example: Sequence Statistics With Biopython

A first look at any set of sequences is a length and composition check, and Biopython handles both from one parse. This follows the pattern of the Biopython tutorial, which demonstrates `SeqIO` parsing with FASTA and GenBank files.

```python
import matplotlib.pyplot as plt
from Bio import SeqIO

lengths = []
gc = []

for record in SeqIO.parse("transcripts.fasta", "fasta"):
    seq = str(record.seq).upper()
    lengths.append(len(seq))
    gc_count = seq.count("G") + seq.count("C")
    at_count = seq.count("A") + seq.count("T")
    if (gc_count + at_count) > 0:
        gc.append(100 * gc_count / (gc_count + at_count))

fig, axes = plt.subplots(1, 2, figsize=(9, 3.5))
axes[0].hist(lengths, bins=40, color="#4a6fa5", linewidth=0)
axes[0].set_xlabel("Sequence length (bp)")
axes[0].set_ylabel("Number of sequences")
axes[1].hist(gc, bins=40, color="#7a9e7e", linewidth=0)
axes[1].set_xlabel("GC content (%)")
axes[1].set_ylabel("Number of sequences")
fig.tight_layout()
fig.savefig("seq_stats.png", dpi=300)
```

This is the fastest quality check for a new assembly or transcript set. A bimodal length distribution or an unexpected GC spike appears in seconds and often catches a contaminant or a mixed file before it propagates through a pipeline.

## Worked Example: Drawing a Genome Diagram With Biopython GenomeDiagram

The Biopython tutorial documents this exact workflow. The essentials are a parsed GenBank record, a diagram object, a track, and features.

```python
from Bio import SeqIO
from Bio.Graphics import GenomeDiagram
from reportlab.lib import colors

record = SeqIO.read("plasmid.gbk", "genbank")

gd = GenomeDiagram.Diagram("Plasmid map")
track = gd.new_track(1, greytrack=True)
feature_set = track.new_set()

for feature in record.features:
    if feature.type == "gene":
        color = colors.Color(0.2, 0.5, 0.7)
        feature_set.add_feature(feature, color=color, label=True,
                                label_size=7, label_angle=45)

gd.draw(format="circular", circular=True, pagesize="A4")
gd.write("plasmid_map.pdf", "PDF")
```

Because the figure is generated from the annotation file, regenerating it after re-annotation is a rerun, not a redraw. That property alone justifies code-driven genome figures for anything you will revise more than once.

## Worked Example: Single-Cell Embedding With Scanpy

For data already processed through a scanpy pipeline, visualization is a single call on the AnnData object, with cluster labels carried through automatically.

```python
import scanpy as sc

adata = sc.read_h5ad("processed_adata.h5ad")

# Embeddings are assumed to be computed and stored already
# in adata.obsm["X_pca"] and adata.obsm["X_umap"]
sc.pl.umap(adata, color="leiden", save="_clusters.png")
sc.pl.umap(adata, color=["MKI67", "LYZ"], cmap="viridis", save="_markers.png")
```

The scanpy functions handle point sizing, legend placement, and per-cluster coloring, and their documentation explains the [preprocessing](/blog/data-analysis/what-is-data-preprocessing) steps (normalization, log transformation, scaling, highly variable gene selection) that must happen before an embedding is interpretable.

## Worked Example: An Interactive Scatter With Plotly

For exploratory review by collaborators who will ask which dot is which.

```python
import pandas as pd
import plotly.express as px

de = pd.read_csv("de_results.csv")
de["significance"] = "ns"
de.loc[(de["padj"] < 0.05) & (de["log2fc"] > 1), "significance"] = "up"
de.loc[(de["padj"] < 0.05) & (de["log2fc"] < -1), "significance"] = "down"

fig = px.scatter(
    de, x="log2fc", y="-log10 adjusted p-value".replace("-log10 adjusted p-value", "neg_log10_padj"),
    color="significance", hover_name="gene",
    hover_data={"padj": True, "log2fc": True},
)
fig.write_html("volcano_interactive.html")
```

Share the HTML file directly. It opens in any browser with hover identifiers intact, which answers the reviewer question that static volcano plots cannot.

## Criteria That Decide Real Choices

When two plots both fit, decide on these criteria in this order.

**Permanence of the audience.** Manuscript figures must survive print, grayscale printing, and reviewer scrutiny. That pushes you to Matplotlib and Seaborn. Internal exploration benefits from hover text, which pushes you to Plotly. Many groups maintain both: interactive HTML for the team, static PDF for the paper.

**Data size.** Matplotlib slows visibly past roughly one hundred thousand points on a scatter, and it becomes unusable in the millions. Downsample, bin, or switch to Plotly (which renders to WebGL) for large point clouds. Genome track data should be binned regardless of the library.

**Reproducibility.** A figure that cannot be regenerated from its data is a liability during revision, when a reviewer asks for a color change, a different threshold, or one more panel. Code-driven figures with Matplotlib, Seaborn, Biopython, and scanpy regenerate exactly. This is the strongest general argument for keeping the whole workflow in Python.

**Interpretation limits of the plot itself.** UMAP distances between clusters are not meaningful. PCA captures only linear structure. Volcano plot thresholds are analyst choices. Heatmap clustering changes with the linkage method. Every one of these is a fine plot with a bounded meaning, and every one becomes misleading when the caption does not state its limits.

**Familiarity of the audience.** A figure type your reviewers have never seen costs you attention and goodwill. UpSet plots are worth teaching for four-way overlaps, but a two-way overlap still belongs in a Venn diagram.

## Three Worked Scenarios

**Scenario one, RNA-seq differential expression.** You have a table of twenty thousand genes, six samples in two conditions, and a list of twelve genes you care about. The manuscript figure set is a volcano plot for the global picture, a clustered heatmap of the top fifty genes by adjusted p-value, and a per-gene bar chart of normalized counts for the twelve genes of interest. The supplementary figure set is an MA plot and a PCA scatter with explained variance on the axes. Every figure regenerates from the count matrix and one script, so the reviewer who asks for a different FDR threshold costs you thirty seconds.

**Scenario two, 16S amplicon survey.** You have relative abundances of thirty taxa across twelve samples in three treatment groups. The mistake to avoid is a pie chart per sample. The figure set is a stacked bar chart of the top ten taxa per sample (relative abundance, labeled as relative), a grouped box plot of alpha diversity per treatment group, and a PCA or PCoA scatter of Bray-Curtis or Aitchison distances for beta diversity, with the statistic and its p-value reported in text rather than inferred from the plot. Because the data are compositional, state in every caption that abundances are relative.

**Scenario three, single-cell RNA-seq.** You have eighty thousand cells, twenty clusters, and a marker gene table per cluster. The figure set is a UMAP colored by cluster, a UMAP colored by condition, a dot plot of the top three markers per cluster, and a violin plot of one marker gene per cluster of interest. All four come from scanpy calls on the same AnnData object, which guarantees that the cluster colors match across every panel, and that matching is the detail reviewers notice.

## Common Mistakes and Limitations

**Overplotting.** Ten thousand points in the same pixel region renders as a single blob. Fix it with transparency, point size reduction, hexbinning, or 2D density contours, or subsample for display and say so in the caption.

**Rainbow colormaps on continuous data.** Jet and similar rainbow scales introduce false boundaries because adjacent colors differ in perceived lightness unevenly. Use perceptually uniform colormaps such as viridis for continuous values and a diverging colormap centered at zero for z-scored heatmaps. For accessible figures, avoid red-green pairs, which a substantial fraction of readers cannot distinguish, and check your figures through a colorblind simulation tool before submission.

**Truncated or unbroken axes on bar charts.** A bar chart encodes value as length from zero, so truncating the y-axis exaggerates differences. If you must truncate, use a dot plot or line plot instead, where position encodes value and truncation is legitimate.

**Labeling every point.** On any scatter with hundreds of features, label the top hits only, and use adjustText. A figure with five hundred overlapping gene names communicates nothing.

**Plotting relative abundances as if absolute.** Compositional data are constrained. Report the transformation you used and do not interpret raw proportions as absolute changes.

**Treating UMAP spacing as signal.** Cluster-to-cluster distances on a UMAP plot are not preserved from the original space. The clusters and their internal structure are informative, the gaps between them are not.

**Not reporting the parameters that generate the figure.** Heatmap linkage method and metric, PCA on scaled or unscaled data, UMAP n_neighbors and min_dist, volcano thresholds, and bin sizes all change the picture. Each belongs in the caption.

**Missing normalization before ordination.** PCA on raw counts measures library size. PCA on standardized features measures structure. Same data, opposite conclusions, and only one of them is the plot you intended.

**Default fonts and rasterized text.** Export figures as PDF or SVG for vector graphics, set fonts to match the journal, and check that text is real text rather than baked-in raster. Matplotlib's `pdf.fonttype` and `ps.fonttype` settings of 42 keep text editable in Illustrator.

**No seeds where randomness exists.** UMAP and t-SNE are stochastic. Fix the random seed in the call, or the same script produces a slightly different figure every run and reviewers comparing versions will see the difference.

**The general limit.** A figure presents group-level patterns from the data you ran. It does not establish mechanism, and any biological claim beyond the plotted comparison needs its own evidence.

## Frequently Asked Questions

### Which Python library should I learn first for bioinformatics visualization?

Learn Matplotlib first, because Seaborn, scanpy, and Biopython's plotting all sit on top of it or borrow its concepts. Master figures, axes, subplots, and export options, and every other library in this article becomes a convenience layer rather than a new system.

### How do I choose the right plot for bioinformatics data in Python?

Match the plot to the structure of the data: volcano plots for differential results, clustered heatmaps and PCA for sample relationships, violin or box plots with raw points for group distributions, genome diagrams for coordinates, and UpSet plots for set overlaps with more than three sets.

### Are Seaborn and Matplotlib both necessary?

Yes in practice. Seaborn gives you fast, sensible statistical plots such as clustermaps and violin plots, while Matplotlib gives you the final control over axes, fonts, panels, and export that journal submission requires. Most real workflows use both in the same script.

### Can Biopython make publication figures directly?

Yes for two figure classes. GenomeDiagram produces circular and linear genome maps from parsed GenBank or FASTA records, and Bio.Phylo draws phylogenetic trees with Matplotlib as the backend. Both are documented with examples in the official Biopython tutorial, and both regenerate automatically when the underlying data change.

### What do I do when my scatter plot has too many points to read?

Reduce point size and add transparency first, then bin into a 2D histogram or hexbin if the blob persists, or render through Plotly so the browser handles the points with WebGL. For single-cell data, scanpy's built-in density and point-sizing options handle the problem at the scale of hundreds of thousands of cells.

### Is a volcano plot always the right figure for differential expression?

No. It shows effect size against significance and hides intensity-dependent bias, which the MA plot reveals. For a handful of genes of interest, per-gene bar charts of normalized counts show the actual data rather than a derived statistic. Use the volcano plot as the global overview, not the only figure.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "Which Python library should I learn first for bioinformatics visualization?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Learn Matplotlib first, because Seaborn, scanpy, and Biopython's plotting all sit on top of it or borrow its concepts. Master figures, axes, subplots, and export options, and every other library becomes a convenience layer rather than a new system."
      }
    },
    {
      "@type": "Question",
      "name": "How do I choose the right plot for bioinformatics data in Python?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Match the plot to the structure of the data: volcano plots for differential results, clustered heatmaps and PCA for sample relationships, violin or box plots with raw points for group distributions, genome diagrams for coordinates, and UpSet plots for set overlaps with more than three sets."
      }
    },
    {
      "@type": "Question",
      "name": "Are Seaborn and Matplotlib both necessary?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Yes in practice. Seaborn gives you fast, sensible statistical plots such as clustermaps and violin plots, while Matplotlib gives you the final control over axes, fonts, panels, and export that journal submission requires. Most real workflows use both in the same script."
      }
    },
    {
      "@type": "Question",
      "name": "Can Biopython make publication figures directly?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Yes for two figure classes. GenomeDiagram produces circular and linear genome maps from parsed GenBank or FASTA records, and Bio.Phylo draws phylogenetic trees with Matplotlib as the backend. Both are documented with examples in the official Biopython tutorial, and both regenerate automatically when the underlying data change."
      }
    },
    {
      "@type": "Question",
      "name": "What do I do when my scatter plot has too many points to read?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Reduce point size and add transparency first, then bin into a 2D histogram or hexbin if the blob persists, or render through Plotly so the browser handles the points with WebGL. For single-cell data, scanpy's built-in density and point-sizing options handle the problem at the scale of hundreds of thousands of cells."
      }
    },
    {
      "@type": "Question",
      "name": "Is a volcano plot always the right figure for differential expression?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "No. It shows effect size against significance and hides intensity-dependent bias, which the MA plot reveals. For a handful of genes of interest, per-gene bar charts of normalized counts show the actual data rather than a derived statistic. Use the volcano plot as the global overview, not the only figure."
      }
    }
  ]
}
</script>