Choose the data
A GSE is a study (Series), a GSM is one sample in it, a GPL is the array or sequencing platform, and a GDS is a dataset GEO curated by hand in earlier years. For differential expression the unit you download is the GSE; platform details are in the GPL.
Pipeline for NCBI-generated counts: HISAT2 alignment to GRCh38 (GCA_000001405.15), featureCounts, gene annotation Annotation Release 109.20190905. Runs with alignment rate below 50% and single-cell data are skipped; counts from several runs of one GSM are summed. GEO2R uses these counts with DESeq2 for RNA-seq.
Species: the official page (revised 2026-07-08) still says mouse data are "expected to become available in 2025". An E-utilities search for "rnaseq counts"[Filter] on 2026-10-11 returned 0 mouse-only Series; about 27,700 records carry the filter, all human.
GDS status: counting by year with E-utilities gives 279 new GDS in 2014, 64 in 2015, 2 in 2016 and 0 from 2018 on. Following old tutorials with getGEO("GDSxxxx") only reaches old data.
| File | Contents | When to use | Watch out |
|---|---|---|---|
| Series Matrix (GSE…_series_matrix.txt.gz) | Submitter-processed expression matrix and sample metadata | Default for microarrays; GEO2R uses it too | The scale is up to the submitter, so check for log2; multi-platform GSEs have several files |
| Raw CEL files (GSE…_RAW.tar) | Raw Affymetrix scan signals | When Series Matrix values are odd or missing, or you want uniform RMA across datasets | Run RMA with oligo or affy; files are large and slow to download |
| NCBI-generated RNA-seq raw counts | Gene × sample integer matrix from NCBI's uniform alignment and counting | Human bulk RNA-seq where the submitter gave no counts or only FPKM | Human only for now; may not match the paper; the bundled FPKM and TPM are not for differential testing |
| Submitter supplementary files (suppl) | Counts, FPKM, TPM or other formats | When you need to match the paper exactly | Formats vary; check column names and whether values are integers before reading |
| GDS | Curated dataset with predefined groups | Reproducing pre-2015 datasets | Essentially no new GDS since 2016; recent data have none |
Download
The code below downloaded the GSE16515 Series Matrix (6.4 MB) in 8.3 s on the test machine. With getGPL = TRUE it also fetches the 85.6 MB GPL570 annotation; on poor networks most failures happen at that step.
A mirror widely used in China is geoChina() from the AnnoProbe package (Biotrainee), which downloads the submitter-processed ExpressionSet (.Rdata) from a Tencent Cloud server. The server was still reachable when tested on 2026-10-11. Its GSE list is built into the package, whose repository was last updated in November 2022, so datasets released after that are not mirrored.
GEOquery versions: this page used GEOquery 2.74.0 (Bioconductor 3.20). The development version on GitHub (2.81.x) plans to change getGEO's return type from ExpressionSet to SummarizedExperiment; code using exprs() and pData() will then need assay() and colData().
library(GEOquery)
options(timeout = 600) # default is 60 s; large files get cut off
Sys.setenv(VROOM_CONNECTION_SIZE = 500000L) # avoids the connection buffer error
dir.create("geo_cache", showWarnings = FALSE)
# Fixed cache directory: the default is tempdir(), which is wiped when R exits
gse <- getGEO("GSE16515", destdir = "geo_cache",
GSEMatrix = TRUE, getGPL = FALSE) # skip the 85.6 MB GPL570 annotation
length(gse) # multi-platform Series return several ExpressionSets
eset <- gse[[1]]
dim(eset) # 54613 probes x 52 samples
annotation(eset) # "GPL570"# FTP path rule: replace the last three digits of the GSE number with nnn
# (GSE16515 -> GSE16nnn; accessions below 1000 use GSEnnn)
# -c resumes partial downloads; rerun the same command after an interruption
wget -c https://ftp.ncbi.nlm.nih.gov/geo/series/GSE16nnn/GSE16515/matrix/GSE16515_series_matrix.txt.gz
# Platform annotation (GPL570 is below 1000, so the folder is GPLnnn)
wget -c https://ftp.ncbi.nlm.nih.gov/geo/platforms/GPLnnn/GPL570/annot/GPL570.annot.gz
# Raw CEL files
wget -c https://ftp.ncbi.nlm.nih.gov/geo/series/GSE16nnn/GSE16515/suppl/GSE16515_RAW.tar
# Read the local file in R without going online
Rscript -e 'library(GEOquery); e <- getGEO(filename = "GSE16515_series_matrix.txt.gz", getGPL = FALSE); print(dim(e))'
| Symptom or error | Cause | Fix |
|---|---|---|
| The downloaded content looks like an HTML page, not GEO data | From late August 2026 NCBI returns a reCAPTCHA page (HTTP 200) to scripted requests under /geo/, and GEOquery parses it as data | Since 2026-09-02 NCBI exempts SOFT text and file downloads that include acc; if it still fails, use getGPL = FALSE or download from FTP and read with filename. FTP is unaffected |
| The size of the connection buffer (131072) was not large enough | vroom's default buffer is too small for very long lines | Sys.setenv(VROOM_CONNECTION_SIZE = 500000L), larger if needed |
| Timeout of 60 seconds was reached / incomplete file | R's default download timeout is 60 s | options(timeout = 600), or wget -c to resume |
| Downloads again every time R restarts | The default cache is tempdir() | Pass destdir to getGEO; "Using locally cached version" then confirms the cache is used |
| Cannot establish a connection to the server | Download method incompatible with the network | Retry after options('download.file.method.GEOquery' = 'libcurl'); download manually if it still fails |
Preprocessing
limma expects log-scale input. Series Matrix values may already be log2 or may be linear, and the sample description is not reliable: the data_processing field of GSE16515 says GC-RMA (which normally outputs log2), yet the matrix is on a linear scale with a maximum of 65045.
GEO2R's auto-detection only looks at samples assigned to groups and transforms the whole matrix or none of it. The log2(dat + 1) line common in shared templates runs unconditionally; on data already in log2 it compresses fold changes to nearly zero.
If a dataset contains negative values or many zeros (common with MAS5 background subtraction or unlogged Illumina data), a direct log2 produces NaN. Decide after the check whether to reprocess from the raw files.
library(limma)
ex <- exprs(eset)
# The rule GEO2R uses
qx <- as.numeric(quantile(ex, c(0, .25, .5, .75, .99, 1), na.rm = TRUE))
LogC <- (qx[5] > 100) || (qx[6] - qx[1] > 50 && qx[2] > 0)
qx; LogC
# GSE16515: 2.28 7.11 18.17 86.87 3283.25 65045 -> TRUE
if (LogC) { ex[ex <= 0] <- NaN; ex <- log2(ex) }
# Check distributions: sample medians in the boxplot should roughly line up
boxplot(ex, las = 2, outline = FALSE)
# Only if they clearly do not: quantile normalization (what GEO2R's Force normalization does)
# ex <- normalizeBetweenArrays(ex, method = "quantile")| Approach | Measured on GSE16515 / GSE15471 (|logFC| > 1, adj.P < 0.05) |
|---|---|
| Log2-transform linear values, then limma (correct) | GSE16515: 1136 up, 399 down |
| Run limma on linear values | GSE16515: 2804 up, 3868 down, median |logFC| 4.51 |
| Apply log2(x+1) to data already in log2 | GSE15471: 0 (correct handling gives 2306 up, 348 down) |
Annotation
The Bioconductor annotation package for a platform is more current than the GPL table. On GPL570 the two disagree for 3874 of 41,039 single-gene probes, mostly because genes were renamed: FAM122C is now PABIR3 and CCDC11 is now CFAP53.
library(hgu133plus2.db) # annotation package for GPL570; see the table for other platforms
pk <- AnnotationDbi::select(hgu133plus2.db, keys = rownames(ex),
columns = "SYMBOL", keytype = "PROBEID")
multi <- names(which(table(pk$PROBEID) > 1)) # probes mapping to several genes: 1528
pk <- pk[!is.na(pk$SYMBOL) & !pk$PROBEID %in% multi, ] # drop unannotated and multi-gene probes
ex <- ex[pk$PROBEID, ]; sym <- pk$SYMBOL # 43112 probes, 20826 genes left
# Several probes per gene: keep the probe with the highest mean (11041 genes have >1 probe)
o <- order(sym, -rowMeans(ex))
keep <- o[!duplicated(sym[o])]
ex_gene <- ex[keep, ]; rownames(ex_gene) <- sym[keep]
# Alternative, averaging: ex_gene <- avereps(ex, ID = sym)
# Platforms without a package: use the GPL table (getGEO("GPLxxx") or .annot.gz on FTP)
# gpl <- getGEO("GPL570", destdir = "geo_cache")
# anno <- Table(gpl)[, c("ID", "Gene Symbol")]
# anno <- anno[anno$`Gene Symbol` != "" & !grepl("///", anno$`Gene Symbol`), ]Multi-probe handling changes the DEG count
Same cutoff on GSE16515: keeping the probe with the highest mean gives 1136 up and 399 down; averaging with avereps gives 834 up and 294 down; keeping the probe with the largest IQR gives 1237 up and 431 down. 450 genes are significant only with the highest-mean method and 43 only with averaging, mainly because low-expressed probes of the same gene pull the average down. State the method in your Methods section.
Recommended approach
Keep the probe with the highest mean (AnnoProbe's filterEM uses the highest median, with similar results). Averaging mixes in non-expressed or non-specific probes and shrinks the differences. Running limma at probe level and then collapsing to genes yields 1771 genes, more than gene-level analysis, because a gene counts if any of its probes is significant.
One probe, several genes
hgu133plus2.db has 1528 such probes; the GPL table has 2796 entries containing " /// ". Shared code often keeps the first gene name, and AnnoProbe's source keeps one as well, with a comment admitting this is a problem. Dropping these probes is safer for differential expression.
Count before and after annotation
Of 54,613 probes, 43,112 remain after removing unannotated and multi-gene probes, covering 20,826 genes; 11,041 genes have more than one probe, up to 15 for a single gene. If your numbers are far off this scale, the platform is usually wrong or the matrix row names are not probe IDs.
| Platform | Annotation package | Notes |
|---|---|---|
| GPL570 HG-U133 Plus 2.0 | hgu133plus2.db | 54,675 probes; the most common |
| GPL96 HG-U133A | hgu133a.db | 22,283 probes |
| GPL571 HG-U133A_2 | hgu133a2.db | |
| GPL6244 HuGene 1.0 ST | hugene10sttranscriptcluster.db | Annotated by transcript cluster |
| GPL6947 / GPL10558 Illumina HumanHT-12 | illuminaHumanv3.db / illuminaHumanv4.db | Matrix row names are ILMN_ probe IDs |
| Agilent and other platforms without a package | GPL table or .annot.gz on FTP | The Gene Symbol column may contain " /// ", meaning one probe maps to several genes |
Groups
In the ExpressionSet returned by getGEO, pData rows are in the same order as the expression matrix columns. Errors arise after sorting pData, merging it with an external clinical table or typing a group vector by hand. R raises no error, yet the results change completely.
If sample counts do not match the paper, check whether the GSE spans several platforms (length(gse) > 1) or mixes in cell lines or technical replicates. The 78 samples of GSE15471 come from 36 patients: the 6 samples with _rep in the title are replicate arrays of normal and tumor tissue from 3 patients. Treating replicate arrays as independent samples overstates sample size; merge them by patient and tissue with avereps, or use duplicateCorrelation with patient as the block.
pd <- pData(eset)
grep(":ch1$", colnames(pd), value = TRUE) # group labels usually live in xxx:ch1 columns
table(pd[["tissue:ch1"]])
# Normal Tissue in Pancreatic Cancer Sample 16 / Tumor Tissue ... 36
group <- factor(ifelse(grepl("Tumor", pd[["tissue:ch1"]]), "Tumor", "Normal"),
levels = c("Normal", "Tumor")) # first level is the reference
# Three checks: sample names match, no mismatches in the cross-table, group sizes match the paper
stopifnot(identical(rownames(pd), colnames(ex_gene)))
table(group, pd[["tissue:ch1"]])
patient <- sub("^Pancreatic Sample ?([0-9]*)-.*$", "\\1", pd$title) # pairing, used later| Mistake | Measured consequence |
|---|---|
| Building the group vector from a sorted pData and analysing it with the original matrix | 18 of 52 labels in GSE16515 are misaligned, and DEGs drop from 1535 to 0 |
| Deriving groups from source_name or title | In GSE15471 source_name is "pancreas" for every sample, so all 78 would be called tumor; groups are in the sample:ch1 column |
| Factor levels in alphabetical order | Normal vs Tumor happens to be right; with control vs case, case comes first and the sign of logFC flips |
| Copying GEO2R results from before 2020 | Since November 2020 GEO2R treats the first-defined group as the test group (previously the reverse), so logFC signs in old tutorial screenshots may be inverted |
Differential expression
A two-group comparison can be written two equivalent ways: ~0 + group with makeContrasts, or ~group and coef = 2. On GSE15471 the largest logFC difference between the two was 8.8 × 10⁻¹⁵, so use whichever you can read. For more than two groups, use ~0 + group and spell out each contrast.
Basis for the cutoffs: adj.P controls the false discovery rate with the BH method, and 0.05 is GEO2R's default. |logFC| > 1 is a convention without statistical grounding. The limma topTable help states that when p-values and fold changes are not highly correlated, filtering by p-value and then fold change can push the false discovery rate above the nominal level, and recommends treat when a fold-change threshold is wanted. If you use the conventional cutoff, state it and provide results for all genes in the supplement.
DEG counts depend strongly on sample size: for the same tissue, GSE15471 (78 samples) gives 2306 up-regulated genes and GSE16515 (52 samples) gives 1136. When up and down counts are unbalanced, treat up- and down-regulated genes separately in heatmaps and enrichment.
design <- model.matrix(~ 0 + group)
colnames(design) <- levels(group) # Normal, Tumor
contr <- makeContrasts(Tumor - Normal, levels = design) # logFC > 0 means up in tumor
fit <- lmFit(ex_gene, design)
fit2 <- eBayes(contrasts.fit(fit, contr), trend = TRUE)
tt <- topTable(fit2, number = Inf) # BH adjustment by default
deg <- subset(tt, adj.P.Val < 0.05 & abs(logFC) > 1)
table(sign(deg$logFC)) # measured here: -1 399, 1 1136
# Build the fold-change threshold into the test (the approach limma recommends)
tr <- topTreat(treat(contrasts.fit(fit, contr), lfc = 1, trend = TRUE), number = Inf)
sum(tr$adj.P.Val < 0.05) # measured here: 166
# Paired samples: if every sample is paired, put patient in the design
# design <- model.matrix(~ patient + group)
# Partly paired (GSE16515 has 16 pairs plus 20 unpaired tumors):
corfit <- duplicateCorrelation(ex_gene, design, block = patient)
fitb <- lmFit(ex_gene, design, block = patient, correlation = corfit$consensus)
ttb <- topTable(eBayes(contrasts.fit(fitb, contr), trend = TRUE), number = Inf)| Cutoff or setting (GSE16515) | Up | Down | Notes |
|---|---|---|---|
| adj.P < 0.05 and |logFC| > 1 | 1136 | 399 | Most common in the literature |
| adj.P < 0.05 and |logFC| > 0.585 (1.5-fold) | 2565 | 1001 | Common with few samples or small effects |
| adj.P < 0.05 only | 4339 | 5992 | With large samples nearly every gene is significant |
| treat(lfc = 1), adj.P < 0.05 | 166 total | Tests whether the fold change is significantly above 2 | |
| eBayes(trend = FALSE), cutoff as row 1 | 1134 | 397 | trend has little effect on arrays |
| Partly paired, duplicateCorrelation, cutoff as row 1 | 1134 | 403 | Within-patient correlation 0.136, 16 pairs |
RNA-seq
RNA-seq differential expression needs raw integer counts. If GEO only has FPKM or TPM, first check whether NCBI generated raw counts; otherwise re-quantify from SRA fastq files.
Measured on 2026-10-11: getRNASeqData("GSE164073") in GEOquery 2.74.0 fails with invalid 'row.names' length. The download link for the human annotation file Human.GRCh38.p13.annot.tsv.gz has no acc parameter and is still blocked by reCAPTCHA, so GEOquery reads the challenge page HTML. The raw count matrix itself downloads fine; annotating by Entrez ID with org.Hs.eg.db maps 37,692 of 39,376 GeneIDs to gene symbols.
This dataset has 18 samples (3 ocular surface tissues × infected/mock × 3 replicates) with design ~ tissue + infection. filterByExpr keeps 16,933 genes, and voom gives 54 up- and 118 down-regulated genes (adj.P < 0.05, |logFC| > 1) in 2.4 s.
library(GEOquery); library(edgeR); library(limma); library(org.Hs.eg.db)
acc <- "GSE164073"
hasRNASeqQuantifications(acc) # TRUE means NCBI-generated counts exist
# Download raw counts directly (this URL form is exempt from reCAPTCHA since 2026-09-02)
url <- paste0("https://www.ncbi.nlm.nih.gov/geo/download/?type=rnaseq_counts&acc=", acc,
"&format=file&file=", acc, "_raw_counts_GRCh38.p13_NCBI.tsv.gz")
f <- file.path("geo_cache", paste0(acc, "_raw_counts.tsv.gz"))
if (!file.exists(f)) download.file(url, f, mode = "wb")
cnt <- as.matrix(read.delim(f, row.names = 1, check.names = FALSE)) # row names are Entrez Gene IDs
sym <- mapIds(org.Hs.eg.db, rownames(cnt), "SYMBOL", "ENTREZID") # replaces the blocked annotation file
pd <- pData(getGEO(acc, destdir = "geo_cache", getGPL = FALSE)[[1]])[colnames(cnt), ]
inf <- factor(ifelse(grepl("SARS", pd[["infection:ch1"]]), "CoV2", "mock"), levels = c("mock", "CoV2"))
tis <- factor(pd[["tissue:ch1"]])
design <- model.matrix(~ tis + inf)
y <- DGEList(cnt)
keep <- filterByExpr(y, design = design) # 39376 -> 16933 genes
y <- normLibSizes(y[keep, , keep.lib.sizes = FALSE])
v <- voom(y, design)
tt <- topTable(eBayes(lmFit(v, design)), coef = "infCoV2", number = Inf)
tt$SYMBOL <- sym[rownames(tt)]| Data | Method | Notes |
|---|---|---|
| Microarray (log2 intensities) | limma | lmFit + eBayes |
| RNA-seq counts, 3–10 samples per group | DESeq2, edgeR or limma-voom | Results overlap heavily; GEO2R uses DESeq2 |
| RNA-seq counts with very different depths or a need for sample weights | limma-voom (voomWithQualityWeights) | Fast; design matrices written as for arrays |
| RNA-seq counts, dozens or more population samples per group | Wilcoxon rank-sum test (after edgeR normalization) | Li et al. 2022 reported excess false positives from DESeq2 and edgeR on such data |
| Only FPKM or TPM | No count-model differential testing | limma-trend on log2(TPM + 1) is exploratory at best; NCBI also notes FPKM and TPM are unsuitable for quantitative cross-sample comparison |
Merging datasets
Before merging, confirm the platforms match and each dataset is on the log2 scale. Batch effects are often larger than the biology: after merging GSE16515 and GSE15471 (both GPL570), PC1 explains 48.9% of the variance and mostly separates the datasets.
The limma help for removeBatchEffect states that the function is intended for plotting and data exploration, not for preparing data for lmFit; for linear modelling the batch should be included in the model so that lmFit can assess standard errors correctly. Correcting first and testing afterwards overstates the residual degrees of freedom and makes p-values too small.
ComBat without mod removes group differences that overlap with batch; in the balanced design here it lost 86 up-regulated genes. Nygaard et al. 2016 showed that with unbalanced batch and group, ComBat with mod makes downstream tests overconfident and increases false positives. The difference was small in the unbalanced case here: with group labels permuted 5 times, ComBat yielded 0 to 1 false-positive genes.
If one dataset contains only tumors and the other only normals, batch and group are completely confounded and no method can separate them. All group differences then come from dataset differences, and such a merged comparison should not be done; a reviewer will spot it from table(batch, group).
library(sva)
# m: merged gene matrix of two same-platform GSEs (each confirmed log2); batch and group are factors
table(batch, group) # check the design first: does every batch contain both groups?
# Differential expression: put batch in the design
design <- model.matrix(~ group + batch)
fit <- eBayes(lmFit(m, design), trend = TRUE)
tt <- topTable(fit, coef = "groupTumor", number = Inf)
# Corrected matrix for PCA and heatmaps only (not fed back into limma)
m_plot <- removeBatchEffect(m, batch = batch, design = model.matrix(~ group))
# If you must use ComBat, pass mod to protect the group effect
m_cb <- ComBat(m, batch = batch, mod = model.matrix(~ group))| Approach | Balanced: both datasets have normal and tumor | Unbalanced: GSE15471 tumors only |
|---|---|---|
| No correction | 1174 / 621 | 3441 / 296 |
| Batch in the design matrix (recommended) | 1149 / 235 | 1117 / 404 |
| ComBat(mod = ~group), then limma | 1144 / 232 | 1108 / 417 |
| removeBatchEffect, then limma | 1149 / 236 | Not tested |
| ComBat without mod, then limma | 1063 / 220 | Not tested |
Plots
Use the same p-value on the volcano y-axis as in the filter (adj.P here). In heatmaps, order samples by group and scale genes by row.
library(ggplot2); library(ggrepel); library(pheatmap)
v <- tt; v$gene <- rownames(v)
v$change <- with(v, ifelse(adj.P.Val < 0.05 & logFC > 1, "Up",
ifelse(adj.P.Val < 0.05 & logFC < -1, "Down", "NS")))
lab <- head(v[v$change != "NS", ][order(v$adj.P.Val[v$change != "NS"]), ], 10)
p <- ggplot(v, aes(logFC, -log10(adj.P.Val), colour = change)) +
geom_point(size = 0.8, alpha = 0.6) +
scale_colour_manual(values = c(Down = "#2b6cb0", NS = "grey75", Up = "#c53030")) +
geom_vline(xintercept = c(-1, 1), linetype = 2) +
geom_hline(yintercept = -log10(0.05), linetype = 2) + # same P on the axis as in the filter
geom_text_repel(data = lab, aes(label = gene), size = 3, colour = "black") +
labs(x = "log2 fold change (Tumor vs Normal)", y = "-log10 adjusted P") + theme_bw()
ggsave("volcano.png", p, width = 6, height = 5, dpi = 300)
# Heatmap: the 25 up- and 25 down-regulated genes with the smallest adj.P
up <- head(rownames(v)[v$change == "Up"][order(v$adj.P.Val[v$change == "Up"])], 25)
dn <- head(rownames(v)[v$change == "Down"][order(v$adj.P.Val[v$change == "Down"])], 25)
o <- order(group)
ann <- data.frame(group = group[o], row.names = colnames(ex_gene)[o])
pheatmap(ex_gene[c(up, dn), o], scale = "row", cluster_cols = FALSE,
annotation_col = ann, show_colnames = FALSE, fontsize_row = 6,
breaks = seq(-2, 2, length.out = 101), # cap z-scores so outliers do not flatten the scale
filename = "heatmap.png", width = 7, height = 8)Pick up- and down-regulated genes separately
Taking the top 50 DEGs by adj.P for the heatmap here gave 50 up-regulated genes, because this dataset has more and stronger up-regulated genes. Taking 25 of each direction shows both.
Cap the z-scores
After scale = "row", some samples reach z-scores above 4, compressing the color scale so most cells look alike. Set breaks from -2 to 2; values beyond are shown in the darkest color.
Labeling genes on the volcano plot
Label only the 10 genes with the smallest adj.P and use ggrepel to avoid overlap. The top genes here are LAMB3, SLC2A1, TMPRSS4, MET and S100P, consistent with known up-regulated genes in pancreatic cancer, which serves as a first sanity check.
Column clustering
With cluster_cols = TRUE, a heatmap of DEGs almost always separates the two groups; this is not evidence that the grouping is sound. Use a PCA of all genes to show group separation.
TCGA comparison
GEO is a collection of independent studies submitted by many labs, with different platforms and processing; TCGA is a uniformly processed cancer cohort with full clinical and survival data but very few normal controls.
GDC changes: HTSeq counts were removed in Data Release 32 (2022-03-29), leaving STAR - Counts; the Legacy Archive was retired on 2023-05-04, so old code using legacy = TRUE and hg19 no longer works. A GDC API query on 2026-10-11 reported Data Release 46.0 (2026-08-10).
Known TCGAbiolinks issues (GitHub issues still open in 2025–2026): GDCprepare in 2.37.x and 2.38.0 fails on some projects with Column `disease_response` doesn't exist, while users report 2.34 works; GDCquery_clinic broke in early 2025 after GDC API changes, and the maintainer suggested the GitHub devel branch; GDCdownload occasionally fails with Error in if (ret == 1) break (issue #656, unresolved), and method = "client", which calls the GDC Data Transfer Tool, is an option to retry. If downloads keep failing, UCSC Xena provides curated GDC TCGA expression and clinical matrices.
Because TCGA has few normal samples, a common approach is to add GTEx normal tissue. The two differ in sequencing and processing, so batch and group are completely confounded after merging, as explained in the previous section; conclusions from such comparisons need validation in GEO datasets or experiments.
library(TCGAbiolinks)
q <- GDCquery(project = "TCGA-PAAD",
data.category = "Transcriptome Profiling",
data.type = "Gene Expression Quantification", # without this line you get twice the files
workflow.type = "STAR - Counts") # HTSeq - Counts no longer exists
GDCdownload(q, files.per.chunk = 20) # chunked; rerunning skips files already downloaded
se <- GDCprepare(q)
cnt <- SummarizedExperiment::assay(se, "unstranded") # use this assay for DESeq2/edgeR
table(se$sample_type) # TCGA-PAAD: Primary Tumor 178, Solid Tissue Normal 4| GEO | TCGA (GDC) | |
|---|---|---|
| Source | Thousands of independent studies, arrays and sequencing | 33 cancer types, uniform sequencing and processing |
| Normal controls | Many datasets include matched normal tissue | Few: TCGA-PAAD has 178 primary tumors and 4 normal tissues |
| Clinical data | Up to the submitter, often only the group | Survival, stage, treatment and more |
| Expression data | Series Matrix, CEL, counts | STAR - Counts (unstranded, TPM, FPKM and other columns) |
| Typical use | Finding DEGs, external validation | Prognostic models, survival analysis, cross-validation with GEO |
Peer review
Most rejections of GEO data-mining papers come down to the points below. Address what you can before submission.
| Objection | Response |
|---|---|
| Small sample size | Report samples per group; use limma's empirical Bayes method (more stable than t-tests with few samples); merge same-platform datasets with batch in the design matrix; frame conclusions as candidate genes |
| No validation set | Analyse an independent GSE separately and report DEG overlap and direction agreement. Here GSE16515 and GSE15471 gave 1507 and 1534 DEGs, with 787 shared, all in the same direction |
| Platforms differ and cannot be merged | Run differential expression per dataset and intersect, or integrate rankings with RobustRankAggreg; merge matrices only within a platform |
| Unclear batch-effect handling | Show PCA colored by batch before and after correction; state that batch is in the design matrix and removeBatchEffect is used only for plots |
| Arbitrary cutoffs | State the adj.P method and the |logFC| cutoff; add treat results or a sensitivity analysis |
| Bioinformatics only | Validate key genes by qPCR, immunohistochemistry or TCGA and HPA data; validate prognostic models in an external cohort |
| Not reproducible | Provide GSE accessions, platform, R and package versions, the full script and results for all genes |
Practice in China
The points below come from posts on CSDN, Jianshu and Biotrainee, each backed by error output, source code or reproduction on this page.
Connection buffer error
A CSDN post shows the error The size of the connection buffer (131072) was not large enough when getGEO read GSE94994, and a successful read after Sys.setenv("VROOM_CONNECTION_SIZE" = 99999999). The "Using locally cached version" path in the output points to an Rtmp temp folder, showing the author had not set destdir.
The geoChina mirror
geoChina() in Biotrainee's AnnoProbe package downloads submitter-processed ExpressionSets and is equivalent to getGEO(gse, getGPL = FALSE). The mirror was still reachable in our test; GSEs released after November 2022 are not in its list and trigger Your GSE may not be expression by array.
AnnoProbe's probe rules
filterEM keeps a single annotation for probes mapping to several genes, and a source comment admits this is a problem; for genes with several probes it keeps the probe with the highest median. If you use idmap + filterEM, describe it in Methods as "keeping the probe with the highest median expression".
Reading GPL tables
A post on GPL570 by the Shengxin Baidu account: skip the description lines starting with # before reading the table; where Gene Symbol contains " /// ", the post keeps the first gene. Some platforms can only be viewed online without a download button; download them from the annot folder on FTP.
log2(dat + 1) in templates
A widely shared template writes dat <- log2(dat + 1) right after the boxplot. The original author's data were linear; copied onto data already in log2, it gave 0 DEGs in our test. Replace that line with the rule above.
Hand off to an agent
Download, annotation, differential expression and plotting follow fixed steps and suit a science agent; choosing datasets, defining groups and interpreting results remain your job.
Example one-line instruction: "Using GSE16515 and GSE15471, compare pancreatic tumor and normal tissue: check each for log2, annotate with hgu133plus2.db keeping the highest-mean probe, run limma on each, then merge the matrices with batch in the design matrix; cutoff adj.P < 0.05, |logFC| > 1; output the DEG overlap between datasets, PCA before and after batch correction, a volcano plot and a heatmap of 25 up- and 25 down-regulated genes."
- 01
Download and check
Download and cache the Series Matrix with GEOquery, switching to FTP if the endpoint is blocked; record sample counts, platform and the data_processing description.
- 02
Preprocess
Apply the GEO2R log2 rule, draw boxplots, annotate probes and report probe and gene counts before and after annotation.
- 03
Test and plot
Extract groups and verify sample order, run limma, and output the all-gene results table, volcano plot and heatmap.
- 04
Merge and validate
Merge datasets with batch in the design matrix; output PCA and the DEG overlap table for the two datasets.
- 05
Adversarial review
Check that groups match the paper, logFC signs are correct, already-logged data were not logged again, and the heatmap includes both up- and down-regulated genes.
- The workspace keeps the cached raw files, R scripts, sessionInfo, results tables and all figures.
- You still need to check that the datasets fit your research question, that groups and sample exclusions match the paper, and that the cutoffs suit your paper.
- Experimental validation such as qPCR and immunohistochemistry is up to you.
References
- NCBI GEO: About GEO2R — log2 auto-detection, the November 2020 group-order change, circular contrasts, 10-minute timeout, DESeq2 for RNA-seq
- NCBI GEO: NCBI-generated RNA-seq count data — Count pipeline, species scope, file types and limitations
- GEOquery GitHub issue #230: NCBI reCAPTCHA blocks programmatic access — The block from August 2026, affected endpoints, NCBI's 2026-09-02 exemptions and the getGPL = FALSE workaround
- GEOquery vignette: RNA-seq quantifications from GEO — Using hasRNASeqQuantifications and getRNASeqData
- limma help pages: topTable, treat, removeBatchEffect — Fold-change filtering not recommended, treat instead; removeBatchEffect not for preparing data for lmFit
- Biostars: GEO2R script for analysing microarray data — The log2 check in the GEO2R script
- Nygaard V, Rødland EA, Hovig E. Methods that remove batch effects while retaining group differences may lead to exaggerated confidence in downstream analyses. Biostatistics, 2016 — ComBat in unbalanced designs
- Li Y, et al. Exaggerated false positives by popular differential expression methods when analyzing human population samples. Genome Biol, 2022 — False positives from DESeq2 and edgeR in large RNA-seq studies; the Wilcoxon test
- Law CW, et al. voom: precision weights unlock linear model analysis tools for RNA-seq read counts. Genome Biol, 2014 — The limma-voom method
- GDC: Why did GDC remove HTSeq gene expression quantification — HTSeq removal and STAR - Counts
- GDC: Legacy Archive retires — Legacy Archive retired on 2023-05-04
- TCGAbiolinks GitHub issues #639, #656, #657 — Current problems and workarounds for GDCquery_clinic, GDCdownload and GDCprepare
- AnnoProbe source (geoChina.R, filterEM.R) — Community source: mirror address and probe de-duplication rules
- CSDN: fixing the getGEO connection buffer error — Community post: original error and VROOM_CONNECTION_SIZE setting, with the author's run output
- CSDN (Shengxin Baidu): probe-to-gene annotation for GEO array platforms — Community post: reading GPL files and handling " /// "
- CSDN repost of a Jianshu post: multi-group differential analysis with limma — Community post: equivalence of two design-matrix forms; counter-example of unconditional log2 in a template
- GSE16515 (pancreatic cancer, GPL570) — Dataset run for this page
- GSE15471 (pancreatic cancer, GPL570) — Dataset used for merging and validation on this page