GRanges and GRangesList: Genomic Intervals in R with Bioconductor

Represent, subset, and overlap genomic ranges with the GenomicRanges package

Bioinformatics

Represent genomic intervals in R with the Bioconductor GenomicRanges package — build GRanges and GRangesList objects, attach metadata, find and count overlaps, and summarize coverage with Rle and Views. The core interval data structure that the rest of Bioconductor is built on.

Published

July 8, 2026

Modified

July 9, 2026

TipKey takeaways
  • A GRanges (genomic ranges) object stores a set of intervals on a genome: for each one a chromosome (seqnames), a start–end range, and a strand. It is the data structure the rest of Bioconductor speaks.
  • Attach any per-interval data — scores, gene names, p-values — as metadata columns with mcols(), then subset the object like a vector and every column follows.
  • A GRangesList groups several GRanges under one object — the natural shape for exons grouped by gene or peaks grouped by sample.
  • The workhorse operation is finding overlaps: findOverlaps(), countOverlaps(), and the %over% shortcut answer “which of these intervals hit those?” — the basis of peak annotation and read counting.
  • Coverage turns a pile of intervals into a per-base depth track, stored compactly as an Rle (run-length encoding); Views let you read summaries back out of any region.

Introduction

Almost everything in genomics is an interval on a chromosome: a gene runs from one coordinate to another, a sequencing read aligns to a stretch of bases, a ChIP-seq peak marks a bound region, a variant sits at a single position. The recurring questions are all about those intervals — which peaks fall inside a gene? how many reads cover this exon? where do two annotation sets agree?

You could hold intervals in a plain data frame with chrom, start, end columns, but then every overlap query is a hand-rolled comparison and every subset risks misaligning the coordinates from their metadata. GenomicRanges — the Bioconductor package built for exactly this — gives you a purpose-built container, the GRanges object, plus fast, correct operations on it. It is the foundation the analysis packages sit on: a DESeqDataSet, a SummarizedExperiment, an annotation TxDb all carry GRanges inside. Learn it once and the genome-scale side of Bioconductor stops feeling like ad-hoc bookkeeping.

This lesson builds GRanges and GRangesList objects from scratch, attaches metadata, filters them, finds overlaps, and computes coverage — all on small synthetic intervals you can print and read, so every result is visible on the page and reproducible on your machine. See the GenomicRanges package page for the full reference.

Build a GRanges

A GRanges object represents a set of genomic intervals. Construct one with the GRanges() constructor: give it the chromosome names (seqnames), an IRanges of start/end coordinates, the strand, and — optionally — any per-interval columns (here a score). IRanges() is the integer-range type from the companion IRanges package; GRanges adds the chromosome and strand on top.

library(GenomicRanges)

gr <- GRanges(
  seqnames = "chr1",
  ranges   = IRanges(start = c(5, 10, 25), end = c(15, 20, 40)),
  strand   = c("+", "+", "-"),
  score    = c(10L, 25L, 7L)
)
gr
GRanges object with 3 ranges and 1 metadata column:
      seqnames    ranges strand |     score
         <Rle> <IRanges>  <Rle> | <integer>
  [1]     chr1      5-15      + |        10
  [2]     chr1     10-20      + |        25
  [3]     chr1     25-40      - |         7
  -------
  seqinfo: 1 sequence from an unspecified genome; no seqlengths

Read the print-out top to bottom. The header says 3 ranges and 1 metadata column: three intervals, each with a score. The columns left of the | are the interval itself — seqnames (all chr1), ranges (the start–end you gave), strand — and the columns right of the | are your metadata. The angle-bracket line names each column’s type (<Rle>, <IRanges>, <integer>); <Rle> is the compressed encoding GenomicRanges uses for seqnames and strand, covered at the end. The footer seqinfo records the chromosomes — here one, with no declared length.

Reach into any component with its accessor. Each returns a plain vector you can compute on:

library(GenomicRanges)
gr <- GRanges("chr1", IRanges(c(5, 10, 25), c(15, 20, 40)),
              strand = c("+", "+", "-"), score = c(10L, 25L, 7L))

seqnames(gr)   # chromosome of each interval
factor-Rle of length 3 with 1 run
  Lengths:    3
  Values : chr1
Levels(1): chr1
start(gr)      # 5 10 25
[1]  5 10 25
end(gr)        # 15 20 40
[1] 15 20 40
width(gr)      # end - start + 1  ->  11 11 16
[1] 11 11 16
strand(gr)     # + + -
factor-Rle of length 3 with 2 runs
  Lengths: 2 1
  Values : + -
Levels(3): + - *

Note that width is end - start + 1 — GenomicRanges uses 1-based, inclusive coordinates (the same convention as Ensembl and the SAM/VCF formats), so a range from 5 to 15 is 11 bases wide, not 10.

Attach and use metadata columns

The columns to the right of the | are metadata columns — arbitrary per-interval data — and you reach the whole table with mcols(). Add a column by assigning to mcols(gr)$name; it lines up with the intervals position by position.

library(GenomicRanges)
gr <- GRanges("chr1", IRanges(c(5, 10, 25), c(15, 20, 40)),
              strand = c("+", "+", "-"), score = c(10L, 25L, 7L))

mcols(gr)                        # the metadata table: one column so far (score)
DataFrame with 3 rows and 1 column
      score
  <integer>
1        10
2        25
3         7
mcols(gr)$gene <- c("GENA", "GENB", "GENC")   # add a gene label per interval
gr
GRanges object with 3 ranges and 2 metadata columns:
      seqnames    ranges strand |     score        gene
         <Rle> <IRanges>  <Rle> | <integer> <character>
  [1]     chr1      5-15      + |        10        GENA
  [2]     chr1     10-20      + |        25        GENB
  [3]     chr1     25-40      - |         7        GENC
  -------
  seqinfo: 1 sequence from an unspecified genome; no seqlengths

The object now carries 2 metadata columns, score and gene. This is why a GRanges beats three loose vectors: the coordinates and their annotation live in one object and move together.

That pays off when you subset. Index a GRanges like a vector — with positions, or with a logical test on any column — and the ranges and every metadata column are filtered in one move:

library(GenomicRanges)
gr <- GRanges("chr1", IRanges(c(5, 10, 25), c(15, 20, 40)),
              strand = c("+", "+", "-"), score = c(10L, 25L, 7L),
              gene = c("GENA", "GENB", "GENC"))

gr[strand(gr) == "+"]            # keep plus-strand intervals
GRanges object with 2 ranges and 2 metadata columns:
      seqnames    ranges strand |     score        gene
         <Rle> <IRanges>  <Rle> | <integer> <character>
  [1]     chr1      5-15      + |        10        GENA
  [2]     chr1     10-20      + |        25        GENB
  -------
  seqinfo: 1 sequence from an unspecified genome; no seqlengths
gr[mcols(gr)$score > 8]          # keep intervals scoring above 8
GRanges object with 2 ranges and 2 metadata columns:
      seqnames    ranges strand |     score        gene
         <Rle> <IRanges>  <Rle> | <integer> <character>
  [1]     chr1      5-15      + |        10        GENA
  [2]     chr1     10-20      + |        25        GENB
  -------
  seqinfo: 1 sequence from an unspecified genome; no seqlengths

The first keeps the two plus-strand intervals; the second drops GENC (score 7). You never touched the gene column, yet it came along correctly — the guarantee that makes the container trustworthy.

Transform ranges

GenomicRanges ships interval arithmetic that respects strand. Three you will reach for constantly: shift() slides an interval, resize() sets its width from a fixed anchor, and flank() grabs the region beside it (a common way to build promoter windows upstream of a gene’s start).

library(GenomicRanges)
gr <- GRanges("chr1", IRanges(c(5, 10, 25), c(15, 20, 40)),
              strand = c("+", "+", "-"), score = c(10L, 25L, 7L))

shift(gr, 100)                   # move every interval 100 bp to the right
GRanges object with 3 ranges and 1 metadata column:
      seqnames    ranges strand |     score
         <Rle> <IRanges>  <Rle> | <integer>
  [1]     chr1   105-115      + |        10
  [2]     chr1   110-120      + |        25
  [3]     chr1   125-140      - |         7
  -------
  seqinfo: 1 sequence from an unspecified genome; no seqlengths
resize(gr, width = 5)            # first 5 bp, anchored at each interval's START
GRanges object with 3 ranges and 1 metadata column:
      seqnames    ranges strand |     score
         <Rle> <IRanges>  <Rle> | <integer>
  [1]     chr1       5-9      + |        10
  [2]     chr1     10-14      + |        25
  [3]     chr1     36-40      - |         7
  -------
  seqinfo: 1 sequence from an unspecified genome; no seqlengths
flank(gr, width = 3)             # the 3 bp immediately upstream of each interval
GRanges object with 3 ranges and 1 metadata column:
      seqnames    ranges strand |     score
         <Rle> <IRanges>  <Rle> | <integer>
  [1]     chr1       2-4      + |        10
  [2]     chr1       7-9      + |        25
  [3]     chr1     41-43      - |         7
  -------
  seqinfo: 1 sequence from an unspecified genome; no seqlengths

resize() and flank() are strand-aware: “start” and “upstream” mean the 5′ end, so for the minus-strand GENC (25–40) the anchor is the right coordinate, not the left. That is exactly what you want for genomic features — a promoter is upstream in transcription direction regardless of strand.

Group intervals: GRangesList

A GRangesList groups several GRanges objects into one — the natural representation when intervals belong to a hierarchy. The textbook case is exons grouped by gene (or by transcript): each list element is one gene’s set of exons.

library(GenomicRanges)

exons_geneA <- GRanges("chr1", IRanges(c(100, 200, 300), c(150, 250, 360)), strand = "+")
exons_geneB <- GRanges("chr1", IRanges(c(500, 700), c(600, 800)), strand = "-")

grl <- GRangesList(geneA = exons_geneA, geneB = exons_geneB)
grl
GRangesList object of length 2:
$geneA
GRanges object with 3 ranges and 0 metadata columns:
      seqnames    ranges strand
         <Rle> <IRanges>  <Rle>
  [1]     chr1   100-150      +
  [2]     chr1   200-250      +
  [3]     chr1   300-360      +
  -------
  seqinfo: 1 sequence from an unspecified genome; no seqlengths

$geneB
GRanges object with 2 ranges and 0 metadata columns:
      seqnames    ranges strand
         <Rle> <IRanges>  <Rle>
  [1]     chr1   500-600      -
  [2]     chr1   700-800      -
  -------
  seqinfo: 1 sequence from an unspecified genome; no seqlengths

grl has two elements — geneA with three exons, geneB with two. Query the list itself, or pull out one element and get an ordinary GRanges back:

library(GenomicRanges)
exons_geneA <- GRanges("chr1", IRanges(c(100, 200, 300), c(150, 250, 360)), strand = "+")
exons_geneB <- GRanges("chr1", IRanges(c(500, 700), c(600, 800)), strand = "-")
grl <- GRangesList(geneA = exons_geneA, geneB = exons_geneB)

length(grl)          # number of genes: 2
[1] 2
elementNROWS(grl)    # exons per gene: geneA 3, geneB 2
geneA geneB 
    3     2 
grl[["geneA"]]       # one element -> a plain GRanges
GRanges object with 3 ranges and 0 metadata columns:
      seqnames    ranges strand
         <Rle> <IRanges>  <Rle>
  [1]     chr1   100-150      +
  [2]     chr1   200-250      +
  [3]     chr1   300-360      +
  -------
  seqinfo: 1 sequence from an unspecified genome; no seqlengths
range(grl)           # the full span (min start to max end) of each gene
GRangesList object of length 2:
$geneA
GRanges object with 1 range and 0 metadata columns:
      seqnames    ranges strand
         <Rle> <IRanges>  <Rle>
  [1]     chr1   100-360      +
  -------
  seqinfo: 1 sequence from an unspecified genome; no seqlengths

$geneB
GRanges object with 1 range and 0 metadata columns:
      seqnames    ranges strand
         <Rle> <IRanges>  <Rle>
  [1]     chr1   500-800      -
  -------
  seqinfo: 1 sequence from an unspecified genome; no seqlengths

elementNROWS() counts the intervals inside each element, and range() collapses each gene’s exons to its full genomic span (100–360 for geneA) — a one-liner for turning exon models into gene bodies.

Find overlaps

The single most common genomics operation is asking which intervals in one set overlap which in another — annotate peaks by the genes they hit, count reads per exon, intersect two feature sets. Set up a small example: four peaks and three genes on chr1.

library(GenomicRanges)

peaks <- GRanges("chr1", IRanges(c(8, 30, 120, 900), c(18, 45, 140, 950)))
genes <- GRanges("chr1", IRanges(c(1, 25, 500), c(20, 60, 800)),
                 strand = "+", gene = c("GENA", "GENB", "GENC"))

findOverlaps(query, subject) returns a Hits object: a two-column map of which query interval overlaps which subject interval, by position.

library(GenomicRanges)
peaks <- GRanges("chr1", IRanges(c(8, 30, 120, 900), c(18, 45, 140, 950)))
genes <- GRanges("chr1", IRanges(c(1, 25, 500), c(20, 60, 800)),
                 strand = "+", gene = c("GENA", "GENB", "GENC"))

hits <- findOverlaps(query = peaks, subject = genes)
hits
Hits object with 2 hits and 0 metadata columns:
      queryHits subjectHits
      <integer>   <integer>
  [1]         1           1
  [2]         2           2
  -------
  queryLength: 4 / subjectLength: 3
queryHits(hits)      # peaks that overlapped:   1 2
[1] 1 2
subjectHits(hits)    # the genes they hit:      1 2
[1] 1 2

Read it as pairs: peak 1 overlaps gene 1, peak 2 overlaps gene 2. Peaks 3 (120–140) and 4 (900–950) match no gene, so they never appear. queryHits()/subjectHits() give the two columns as integer vectors you can index with — e.g. genes$gene[subjectHits(hits)] labels each overlapping peak with its gene.

When you only need a count or a yes/no rather than the full pairing, two shortcuts are lighter:

library(GenomicRanges)
peaks <- GRanges("chr1", IRanges(c(8, 30, 120, 900), c(18, 45, 140, 950)))
genes <- GRanges("chr1", IRanges(c(1, 25, 500), c(20, 60, 800)),
                 strand = "+", gene = c("GENA", "GENB", "GENC"))

countOverlaps(peaks, genes)      # how many genes each peak hits: 1 1 0 0
[1] 1 1 0 0
peaks %over% genes               # logical: does each peak hit ANY gene?
[1]  TRUE  TRUE FALSE FALSE
peaks[peaks %over% genes]        # keep only the peaks that overlap something
GRanges object with 2 ranges and 0 metadata columns:
      seqnames    ranges strand
         <Rle> <IRanges>  <Rle>
  [1]     chr1      8-18      *
  [2]     chr1     30-45      *
  -------
  seqinfo: 1 sequence from an unspecified genome; no seqlengths

countOverlaps() is the read-counting primitive — swap “peaks” for aligned reads and “genes” for exons and you have per-gene counts. %over% returns a logical vector, so peaks[peaks %over% genes] filters in one line. To keep the annotated side instead — the genes that any peak hit, with their metadata — use subsetByOverlaps():

library(GenomicRanges)
peaks <- GRanges("chr1", IRanges(c(8, 30, 120, 900), c(18, 45, 140, 950)))
genes <- GRanges("chr1", IRanges(c(1, 25, 500), c(20, 60, 800)),
                 strand = "+", gene = c("GENA", "GENB", "GENC"))

subsetByOverlaps(genes, peaks)   # genes covered by >= 1 peak, columns intact
GRanges object with 2 ranges and 1 metadata column:
      seqnames    ranges strand |        gene
         <Rle> <IRanges>  <Rle> | <character>
  [1]     chr1      1-20      + |        GENA
  [2]     chr1     25-60      + |        GENB
  -------
  seqinfo: 1 sequence from an unspecified genome; no seqlengths

By default, nofindOverlaps() and friends ignore strand, so a plus-strand peak can overlap a minus-strand gene. That is usually what you want for position-based questions. When strand does matter (e.g. counting stranded RNA-seq reads), pass ignore.strand = FALSE; two intervals then overlap only if they also share a strand. Always be explicit about which you mean.

Coverage: per-base depth

Coverage answers “how many intervals cover each position?” — the depth track behind every read pileup. coverage() takes a GRanges of reads and returns, per chromosome, the depth at every base.

library(GenomicRanges)

reads <- GRanges("chr1", IRanges(start = c(1, 5, 10, 10), width = 8))
cov <- coverage(reads)
cov
RleList of length 1
$chr1
integer-Rle of length 17 with 5 runs
  Lengths: 4 4 1 3 5
  Values : 1 2 1 3 2
as.numeric(cov$chr1)             # depth at each position, expanded
 [1] 1 1 1 1 2 2 2 2 1 3 3 3 2 2 2 2 2

The four reads span positions 1–17. Reading the expanded vector: positions 1–4 are covered once, 5–8 twice, position 9 once, positions 10–12 by three reads (two reads start at 10 on top of a third), and 13–17 twice. That per-base depth is what a genome browser draws as a coverage track.

The compact printout — integer-Rle of length 17 with 5 runs — is the key to why this scales to whole genomes.

Rle: run-length encoding

Coverage over a chromosome is a vector billions of positions long, but it changes value only at interval boundaries — long stretches repeat. An Rle (run-length encoding) stores such a vector as its distinct values plus each value’s run length, instead of every element. A 3-billion-base track becomes a few million runs.

library(GenomicRanges)

r <- Rle(c(0, 0, 0, 2, 2, 2, 2, 1, 1, 0, 0))
r                    # 11 values stored as 4 runs
numeric-Rle of length 11 with 4 runs
  Lengths: 3 4 2 2
  Values : 0 2 1 0
runValue(r)          # the distinct values:  0 2 1 0
[1] 0 2 1 0
runLength(r)         # how long each runs:    3 4 2 2
[1] 3 4 2 2
as.numeric(r)        # expand back to the full vector
 [1] 0 0 0 2 2 2 2 1 1 0 0

Eleven positions compress to four runs; a real coverage track compresses far more. An Rle behaves like a normal vector — arithmetic (mean(r), sum(r)) and element-wise comparison all work — while staying compressed, which is how GenomicRanges manipulates genome-length signals without exhausting memory.

Views: read summaries out of a region

A Views object lets you peer into specified windows of an Rle (or any long vector) without copying the data — each view is just a start/end into the underlying subject. Summarize many regions at once with viewMeans(), viewMaxs(), and their relatives — the pattern behind “average coverage per exon”.

library(GenomicRanges)
r <- Rle(c(0, 0, 0, 2, 2, 2, 2, 1, 1, 0, 0))

v <- Views(r, start = c(1, 7), end = c(6, 11))
v
Views on a 11-length Rle subject

views:
    start end width
[1]     1   6     6 [0 0 0 2 2 2]
[2]     7  11     5 [2 1 1 0 0]
viewMeans(v)         # mean signal in each window: 1.0 0.8
[1] 1.0 0.8
viewMaxs(v)          # peak signal in each window: 2 2
[1] 2 2

Two windows, two summaries — computed straight off the compressed Rle. Point the same idea at a coverage track with your exons as the windows and you have per-feature coverage statistics in one call.

Common issues

“cannot coerce” or a strand error when building a GRanges. strand accepts only +, -, or * (unspecified) — anything else errors. Give one value to recycle across all intervals (strand = "+") or a vector the same length as the ranges. Similarly, every metadata column must match the number of intervals.

Overlaps come back empty when you expect hits. Two things bite here: your intervals may be on different chromosomes (an overlap needs the same seqnames), or you set ignore.strand = FALSE and the strands disagree. Print both objects and check the seqnames and strand columns before assuming the ranges are wrong.

A GRanges prints seqinfo: … no seqlengths. That is only a note — you never declared chromosome lengths. It is harmless for overlap and coverage work; it matters only when an operation needs the chromosome bounds (like trimming ranges to the chromosome end), which you can set later with seqlengths().

Frequently asked questions

A GRanges (from the Bioconductor GenomicRanges package) is a container for genomic intervals: each interval carries a chromosome (seqnames), a start–end range, and a strand, plus any number of metadata columns (scores, gene names, …). It is the standard data structure for representing genes, reads, peaks, and variants in Bioconductor.

A GRanges is one flat set of intervals. A GRangesList groups several GRanges objects under one container — one list element per group. The canonical use is exons grouped by gene: each element holds a single gene’s exons, and list-level operations like range() or elementNROWS() act per gene.

Use findOverlaps(query, subject) for the full pairing (a Hits object), countOverlaps() for a count per query interval, or x %over% y for a TRUE/FALSE vector you can subset with. subsetByOverlaps(x, y) returns the elements of x that overlap y, metadata intact.

1-based and inclusive, like Ensembl, GFF, and VCF — a range from 5 to 15 is 11 bases wide (width = end - start + 1). This differs from BED and BAM files, which are 0-based half-open; importers such as rtracklayer::import() convert BED coordinates to the 1-based convention for you.

An Rle (run-length encoding) stores a long vector as its distinct values plus each value’s run length. A genome-length coverage track changes value only at interval edges, so it compresses to a handful of runs — letting GenomicRanges hold and compute on per-base signal across a whole chromosome without storing every position.

Test your understanding

Using the peaks and genes objects from the overlaps section, build a character vector that labels each overlapping peak with the gene name it hits. (Peaks that hit nothing should not appear.)

findOverlaps() gives you queryHits() (which peak) and subjectHits() (which gene). Use subjectHits() to index into genes$gene.

library(GenomicRanges)
peaks <- GRanges("chr1", IRanges(c(8, 30, 120, 900), c(18, 45, 140, 950)))
genes <- GRanges("chr1", IRanges(c(1, 25, 500), c(20, 60, 800)),
                 strand = "+", gene = c("GENA", "GENB", "GENC"))

hits <- findOverlaps(peaks, genes)
labels <- genes$gene[subjectHits(hits)]
labels                          # "GENA" "GENB"

# tie each label back to its peak:
data.frame(peak = queryHits(hits), gene = labels)

subjectHits(hits) (1 2) indexes genes$gene, giving the gene each overlapping peak hit; pairing it with queryHits(hits) shows peak 1 → GENA and peak 2 → GENB.

A. 10 B. 11 C. 20

B — 11. GenomicRanges uses 1-based, inclusive coordinates, so width = end - start + 1 = 20 - 10 + 1 = 11. This trips up anyone coming from 0-based half-open BED/BAM coordinates, where the same span would be 10.

Conclusion

GRanges is the interval object the genome-scale side of Bioconductor is built on: a chromosome, a range, and a strand per interval, plus metadata columns that subset in lockstep with the coordinates. You built one with GRanges(), reached into it with start()/end()/width()/strand() and mcols(), filtered it like a vector, and transformed intervals with strand-aware shift()/resize()/ flank(). You grouped intervals into a GRangesList (exons by gene), answered the workhorse question with findOverlaps()/countOverlaps()/%over%, and summarized signal with coverage(), Rle, and Views. The practical rules: coordinates are 1-based inclusive, overlaps ignore strand by default, and coverage is stored run-length-encoded so it scales to whole genomes. Every analysis container you meet next — SummarizedExperiment, DESeqDataSet, annotation TxDb objects — carries these ranges inside.

References

  • Lawrence, M., Huber, W., Pagès, H., Aboyoun, P., Carlson, M., Gentleman, R., Morgan, M. T., & Carey, V. J. (2013). Software for computing and annotating genomic ranges. PLoS Computational Biology, 9(8), e1003118.
  • Morgan, M., Obenchain, V., Lang, M., Thompson, R., & Pagès, H. GenomicRanges: Representation and manipulation of genomic intervals. Bioconductorpackage page.

Reuse

Citation

BibTeX citation:
@online{2026,
  author = {},
  title = {GRanges and {GRangesList:} {Genomic} {Intervals} in {R} with
    {Bioconductor}},
  date = {2026-07-08},
  url = {https://www.datanovia.com/learn/bioinformatics/genomics/granges-and-granges-list},
  langid = {en}
}
For attribution, please cite this work as:
“GRanges and GRangesList: Genomic Intervals in R with Bioconductor.” 2026. July 8. https://www.datanovia.com/learn/bioinformatics/genomics/granges-and-granges-list.