Skip to content

After Import: Controls and First Diagnostics

What your positive and negative controls actually tell you, and the first things to check once your data is imported.

Before you look at a single biological result, look at your controls. They're the fastest, most direct evidence for whether the rest of the dataset can be trusted, and checking them early means catching a problem while it's still cheap to deal with.

A Starting Point, Not a Protocol to Follow Blindly

Nothing on this page is a formula to apply mechanically to your data. Every project's controls, contamination sources, and risk tolerance differ, and a pattern that's reassuring in one dataset can be a real problem in another. Treat what follows as starting questions to adapt, not a checklist that hands you a conclusion. Every dataset is unique and deserves to be treated that way.

Import
   │
   ▼
Verify metadata (Step 1)
   │
   ▼
Examine positive controls (Step 2)
   │
   ▼
Examine negative controls (Step 3)
   │
   ▼
General diagnostics
   │
   ▼
Filtering, normalisation, and beyond
   (project-specific, not covered here)

Statistical contaminant removal (decontam) is a further, optional step past this page, see Identifying Contaminants.

Why Bother With Controls At All

A positive control (a mock community of known composition) tells you what your pipeline does with a sample where you already know the right answer. If it doesn't come back looking roughly like what you put in, something is wrong, and it's wrong for your real samples too, not just the mock.

A negative control (extraction blanks, library prep blanks, no-template PCRs) should come back essentially empty. It exists to show that contamination and cross-talk aren't happening, or to tell you how much they are. See the OTU/Count Table page's note on why Mock and Negative aren't regular samples, and Data Prep Output on cross-talk between samples on the same run.

Step 1: Identify Your Controls in the Map File, From the Start

Record which samples are controls, and what kind, when you design your sampling scheme and build your map file, not after the fact in R. Add a column like SampleGroup with consistent values (Sample, ExtractionNegative, LibraryPrepNegative, LibraryPrepPositive), and it's simply there, correct, from the moment you import.

Keep the Naming Consistent

negative, Negative, and NEG are three different strings to R. A filter looking for "Negative" silently skips "negative", no warning, no error, just a sample quietly landing in the wrong bucket. Decide on a controlled vocabulary once and use it exactly, every time, across every project if you can.

If a map file didn't record this, you can still derive it in R from whatever fields exist:

# Recovery, if control type wasn't recorded directly in the map file.
sample_data(ps)$ControlType <- "Sample"

sample_data(ps)$ControlType[
  sample_data(ps)$SampleCategory == "Negative" &
  sample_data(ps)$ExperimentType == "Extraction"
] <- "ExtractionNegative"

sample_data(ps)$ControlType[
  sample_data(ps)$SampleCategory == "Negative" &
  sample_data(ps)$ExperimentType == "LibraryPrep"
] <- "LibraryPrepNegative"

sample_data(ps)$ControlType[
  sample_data(ps)$SampleCategory == "Positive" &
  sample_data(ps)$ExperimentType == "LibraryPrep"
] <- "LibraryPrepPositive"

Adjust the field names and values to your own map file, the pattern matters, not the exact names. Treat this as a repair, not a substitute for recording it properly next time.

Avoid Hard-Coded Sample ID Lists

Selecting samples by listing specific IDs (X.SampleID != "ctrl1" & ...) doesn't survive a rerun, is easy to get wrong silently, and becomes unreadable months later. A proper metadata column is self-documenting and keeps working as your dataset changes.

Step 2: Look at Your Positive Controls First

With controls labeled, check whether your mock community looks like what you expect.

Positive <- subset_samples(ps, ControlType == "LibraryPrepPositive")
Positive <- prune_taxa(taxa_sums(Positive) > 0, Positive)
Positive_relabund <- microbiome::transform(Positive, "compositional")

df_positive <- psmelt(Positive_relabund)

ggplot(df_positive, aes(x = OTU, y = Abundance)) +
  geom_col() +
  geom_hline(yintercept = 0.01, col = "red", linetype = 2) +
  facet_wrap(~Sample, scales = "free_y") +
  theme(legend.position = "none")

The 1% line is a practical noise floor, not a hard rule. Anything unexpected sitting above it in a mock sample is worth a second look, that's the signature of cross-contamination or tag-switching rather than genuine mock composition.

Step 3: Look at Your Negative Controls

There Is No Standard Protocol Here, Only Careful Judgment

Read count alone is not enough. A negative control with a high total count is not automatically worse than one with a low count, what matters is where those reads came from.

Two negative controls can both have unexpectedly high counts for very different reasons. Reads spread thinly across dozens of OTUs at low abundance looks like broad cross-contamination from many samples. Reads dominated by a single OTU looks like one trackable source, a reagent, a water system, an extraction kit lot, and is usually easier to resolve, even though the raw count might look just as bad. Diversity, not just count, is what tells these apart.

# Check composition, not just total count, for each negative control.
neg_samples <- sample_names(ps)[
  sample_data(ps)$ControlType %in% c("ExtractionNegative", "LibraryPrepNegative")
]

for (s in neg_samples) {
  neg_otu <- as.vector(otu_table(ps)[, s])
  names(neg_otu) <- taxa_names(ps)
  neg_otu <- sort(neg_otu[neg_otu > 0], decreasing = TRUE)
  total <- sum(neg_otu)
  top_share <- if (total > 0) neg_otu[1] / total else NA

  cat(s,
      ": total reads =", total,
      ", OTUs present =", length(neg_otu),
      ", top OTU share =", round(top_share * 100, 1), "%\n")
}

A high top-OTU share with few OTUs present points to a single, trackable source. A high total spread thinly across many OTUs points to something more systemic. Neither has a fixed next step, that depends on your project, but knowing which pattern you're looking at is the difference between a five-minute explanation and a real problem.

Read depth. Negatives should sit distinctly lower than your real samples. Comparable depth is a direct red flag.

depth_df <- as.data.frame(sample_data(ps))
depth_df$LibrarySize <- sample_sums(ps)
depth_df <- depth_df[order(depth_df$LibrarySize), ]
depth_df$Index <- seq(nrow(depth_df))

ggplot(depth_df, aes(x = Index, y = LibrarySize, color = ControlType)) +
  geom_point()

Composition. If negatives do carry reads, does that signal resemble your real samples? An ordination colored by control type makes this visible at a glance:

Negative <- subset_samples(ps, ControlType %in% c("Sample", "ExtractionNegative"))

ord_negative <- ordinate(Negative, "NMDS", "jaccard")
plot_ordination(Negative, ord_negative, type = "samples", color = "ControlType") +
  geom_point(size = 2)

Negatives clustering apart from your real samples is reassuring. Negatives clustering with, or inside, your real sample cloud is worth investigating before you trust anything downstream.


First Diagnostics After Import

Once your controls look reasonable, a quick general health check on the whole dataset is worth doing before any real analysis.

cat("Number of samples:", nsamples(ps), "\n")
cat("Number of taxa (zOTUs):", ntaxa(ps), "\n")
cat("Total counts:", sum(otu_table(ps)), "\n")

sample_depths <- sample_sums(ps)
cat("Mean counts:  ", mean(sample_depths), "\n")
cat("Median counts:", median(sample_depths), "\n")
cat("Range counts: ", range(sample_depths), "\n")

# Read depth per sample, sorted, so you can see the shape of the distribution at a glance
plot(sort(sample_depths), type = "h",
     xlab = "Size-sorted samples", ylab = "Read counts",
     main = "Read counts per sample (sorted)")

# Percentile summary, useful for choosing a depth-filtering or rarefaction threshold later
quantile(sample_depths, probs = c(0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95))

Mean vs. Median Tells You Something

Mean noticeably above median means right-skewed: a few high-depth samples pulling the average up. Median above mean means the opposite. Either is common, but know which one you have before picking a filtering or rarefaction threshold, "the mean depth" can be a misleading anchor either way.

Filtering and normalisation from here (removing low-depth samples, rarefying, abundance or prevalence thresholds) depend heavily on your specific project and go beyond what this page covers. What matters at this stage is knowing what you're starting from, and knowing your controls checked out first.