Python Comprehensions for Bioinformatics
When to Use
- Refactoring an explicit
forloop that builds a list/dict/set of sequences, positions, or stats into a single expression. - Filtering a FASTA dict (name → sequence) by length, GC content, or motif presence.
- Computing per-sequence stats (GC%, k-mer spectrum, codon counts) across a collection without intermediate loops.
- Streaming a genome-scale file (FASTQ, VCF, large FASTA) where materializing a full list would blow up memory — use a generator expression instead.
- Generating combinatorial sequences (all k-mers, all codons, reading frames) for enumeration or lookup tables.
Version Compatibility
Pure standard library — Python ≥3.8 (walrus operator := in comprehensions requires ≥3.8; everything else works on ≥3.6). No third-party dependencies.
Prerequisites
- Comfortable with plain
forloops,dict/setliterals, and basic string slicing in Python. - No packages to install — only
itertoolsandcollectionsfrom the standard library are used below.
Core Patterns
Goal: turn a for-loop that builds a list/dict/set into a single comprehension, and know when a filter (if after for) is not the same as a transform (if/else in the expression).
Approach: [expr for item in iterable if condition] filters (drops items); [expr_if_true if condition else expr_if_false for item in iterable] transforms (keeps every item, changes its value). Nest by writing the outer for first; use [[...] for outer in ...] when you need a list-of-lists instead of a flattened one.
def gc_content(seq: str) -> float:
"""Fraction of a sequence that is G or C (0.0-1.0)."""
return (seq.upper().count('G') + seq.upper().count('C')) / len(seq)
def classify_bases(dna: str) -> list[str]:
"""Filter vs transform, side by side."""
gc_only = [nt for nt in dna if nt in 'GC'] # filter: drops A/T
purine_map = ['purine' if nt in 'AG' else 'pyrimidine' for nt in dna] # transform: keeps all, relabels
return gc_only, purine_map
def filter_fasta(fasta: dict[str, str], min_len: int = 100,
gc_range: tuple[float, float] = (0.4, 0.6)) -> dict[str, str]:
"""Keep only sequences that are long enough and within a GC window."""
return {name: seq for name, seq in fasta.items()
if len(seq) > min_len and gc_range[0] <= gc_content(seq) <= gc_range[1]}
Goal: process genome-scale data (FASTQ reads, chromosome-length sequences) without materializing an intermediate list in memory.
Approach: swap [...] for (...) — a generator expression is lazily evaluated and single-pass. Feed it straight into sum, max, any, all, or a for loop; never call list() on it unless you actually need to index or iterate it twice.
def high_gc_fraction(sequences, threshold: float = 0.55) -> float:
"""Fraction of sequences above a GC threshold, computed with zero
intermediate list — only one sequence is in memory at a time.
"""
total = len(sequences)
# generator expression: not materialized, single-pass, consumed once by sum()
n_high_gc = sum(1 for seq in sequences if gc_content(seq) > threshold)
return n_high_gc / total
def average_gc(sequences) -> float:
"""sum()/max()/any()/all() all accept a generator directly -- no [] needed."""
return sum(gc_content(s) for s in sequences) / len(sequences)
Goal: generate k-mers, codons, and reading frames for enumeration, spectra, or lookup tables.
Approach: use itertools.product for combinatorial generation, nested for clauses for reading frames (list-of-lists), and collections.Counter — not a comprehension calling .count() — for k-mer tallies.
from itertools import product
from collections import Counter
GENETIC_CODE = { # abbreviated; use Bio.Seq.Seq.translate() for the full table in practice
'ATG': 'M', 'TAA': '*', 'TAG': '*', 'TGA': '*', 'TTT': 'F',
}
def all_kmers(k: int, alphabet: str = 'ATGC') -> list[str]:
"""All possible k-mers over an alphabet (4**k of them for DNA)."""
return [''.join(c) for c in product(alphabet, repeat=k)]
def kmer_spectrum(seq: str, k: int) -> Counter:
"""Counter, not {kmer: kmers.count(kmer) for kmer in set(kmers)} -- that
dict comprehension is O(n^2): it rescans the list once per unique key.
"""
kmers = [seq[i:i + k] for i in range(len(seq) - k + 1)]
return Counter(kmers)
def reading_frames(dna: str) -> list[list[str]]:
"""Three forward reading frames as a list of codon lists (nested comprehension)."""
return [[dna[i:i + 3] for i in range(frame, len(dna) - 2, 3)] for frame in range(3)]
def find_orfs(dna: str, starts=('ATG',), stops=('TAA', 'TAG', 'TGA')) -> list[tuple[int, int, str]]:
"""Pair each start codon with the nearest downstream in-frame stop codon."""
start_pos = [i for i in range(len(dna) - 2) if dna[i:i + 3] in starts]
stop_pos = [i for i in range(len(dna) - 2) if dna[i:i + 3] in stops]
orfs = []
for s in start_pos:
for e in stop_pos:
if e > s and (e - s) % 3 == 0:
orfs.append((s, e + 3, dna[s:e + 3]))
break
return orfs
if __name__ == '__main__':
assert round(gc_content('GGCC'), 2) == 1.0
assert filter_fasta({'a': 'AT' * 60, 'b': 'GC' * 60}) == {'b': 'GC' * 60}
assert kmer_spectrum('ATGATG', 3) == Counter({'ATG': 2, 'TGA': 1})
assert reading_frames('ATGCGATCG')[0] == ['ATG', 'CGA', 'TCG']
assert find_orfs('ATGAAATAG') == [(0, 9, 'ATGAAATAG')]
print('all checks passed')
Pitfalls
- Nested comprehension loop order: in a flat nested comprehension the outer
forcomes first —[expr for outer in outer_list for inner in inner_list]flattens; use[[expr for inner in ...] for outer in ...]to keep a list-of-lists. iffilter vsif/elsetransform:ifafterforis a filter (removes items);if/elsein the expression is a conditional transform (keeps every item, maps it differently). Mixing them up silently drops data.- Generator expressions are single-pass: once consumed (
sum(...),list(...), oneforloop), the generator is exhausted — a second iteration silently yields nothing. Use a list comprehension when you need multiple passes, indexing, orlen(). - Dict comprehension with
.count()is O(n²):{kmer: kmers.count(kmer) for kmer in set(kmers)}rescans the full list once per unique key. Usecollections.Counter(kmers)instead. - Off-by-one errors: Python ranges/slices are half-open
[start, stop), but bioinformatics coordinates (GFF, 1-based FASTA positions) are often 1-based inclusive — convert explicitly at the I/O boundary, not inside the comprehension. - Deep vs shallow copy:
list.copy()and[:]only copy the top level; nested structures (list of lists, dict of dicts) still share inner references. Usecopy.deepcopy()when you need full independence.
See Also
bio-sequence-manipulation-sequence-properties— GC content, molecular weight, and other per-sequence metrics via Biopython.bio-sequence-io-read-sequences— reading FASTA/FASTQ into the dicts/iterables these comprehensions operate on.bio-sequence-manipulation-codon-usage— codon/amino-acid frequency tables built on the same comprehension patterns.biopython— reach forBio.Seq/Bio.SeqUtilsonce a one-off comprehension becomes a repeated, validated operation.