Control Flow for Bioinformatics
When to Use
- Iterating over a DNA/RNA/protein string codon-by-codon or residue-by-residue.
- Scanning a sequence or reading frame for a stop codon, start codon, or arbitrary motif.
- Classifying sequences (DNA vs RNA vs protein, GC-content bucket, purine/pyrimidine).
- Filtering a batch of reads/sequences by length, GC%, or composition using
if/elif/continue. - Debugging an off-by-one codon slice, a
breakthat only exits one loop, or awhilethat never terminates.
Version Compatibility
Pure Python standard library only — Python ≥3.8 (walrus operator and f-strings used below need ≥3.8). No third-party packages required.
Prerequisites
- Comfortable with Python strings, slicing (
seq[i:i+3]),set, anddict. - Know basic sequence concepts: codon = 3 nt, reading frame, GC content, purine (A/G) vs pyrimidine (C/T/U).
Goal: iterate a DNA sequence in complete, non-overlapping codons.
Approach: use range(0, len(dna) - 2, 3) (not range(len(dna))) so the loop never starts a codon it can't finish, and translate using a codon table with an explicit stop-codon break.
CODON_TABLE = {
'TTT': 'F', 'TTC': 'F', 'TTA': 'L', 'TTG': 'L',
'CTT': 'L', 'CTC': 'L', 'CTA': 'L', 'CTG': 'L',
'ATT': 'I', 'ATC': 'I', 'ATA': 'I', 'ATG': 'M',
'GTT': 'V', 'GTC': 'V', 'GTA': 'V', 'GTG': 'V',
'TCT': 'S', 'TCC': 'S', 'TCA': 'S', 'TCG': 'S',
'CCT': 'P', 'CCC': 'P', 'CCA': 'P', 'CCG': 'P',
'ACT': 'T', 'ACC': 'T', 'ACA': 'T', 'ACG': 'T',
'GCT': 'A', 'GCC': 'A', 'GCA': 'A', 'GCG': 'A',
'TAT': 'Y', 'TAC': 'Y', 'TAA': '*', 'TAG': '*',
'CAT': 'H', 'CAC': 'H', 'CAA': 'Q', 'CAG': 'Q',
'AAT': 'N', 'AAC': 'N', 'AAA': 'K', 'AAG': 'K',
'GAT': 'D', 'GAC': 'D', 'GAA': 'E', 'GAG': 'E',
'TGT': 'C', 'TGC': 'C', 'TGA': '*', 'TGG': 'W',
'CGT': 'R', 'CGC': 'R', 'CGA': 'R', 'CGG': 'R',
'AGT': 'S', 'AGC': 'S', 'AGA': 'R', 'AGG': 'R',
'GGT': 'G', 'GGC': 'G', 'GGA': 'G', 'GGG': 'G',
}
def translate(dna: str) -> str:
"""Translate a DNA coding sequence into a protein sequence, stopping at the first stop codon."""
dna = dna.upper()
protein = []
# step 3, and stop 2 short of the end so every slice is a full codon
for i in range(0, len(dna) - 2, 3):
codon = dna[i:i + 3]
amino_acid = CODON_TABLE.get(codon, '?')
if amino_acid == '*':
break
protein.append(amino_acid)
return ''.join(protein)
assert translate("ATGGCCGATCGTTAG") == "MADR"
Goal: find the first in-frame stop codon, and separately find every occurrence of an arbitrary motif (which may overlap and isn't frame-locked).
Approach: a frame-locked scan advances the index by 3 each step with while; a motif scan advances by 1 using str.find's start argument so overlapping hits aren't missed.
def first_stop_codon(dna: str, frame: int = 0) -> int | None:
"""Return the 0-based position of the first in-frame stop codon, or None if none found."""
stop_codons = {"TAA", "TAG", "TGA"}
pos = frame
while pos <= len(dna) - 3:
if dna[pos:pos + 3] in stop_codons:
return pos
pos += 3 # step by whole codon, not by 1
return None
def find_motif_positions(seq: str, motif: str) -> list[int]:
"""Return every 0-based start position of motif in seq, including overlaps."""
positions = []
pos = seq.find(motif)
while pos != -1:
positions.append(pos)
pos = seq.find(motif, pos + 1) # +1, not +len(motif), to catch overlaps
return positions
assert first_stop_codon("ATGGCCGATCGATAGCCATAGTTAACG") == 15
assert find_motif_positions("ATGCGATGATCGATGCATG", "ATG") == [0, 6, 12, 16]
Goal: classify a sequence's identity and GC-content bucket, and validate that every character is a legal base — three related if/elif and for-else patterns bioinformatics code uses constantly.
Approach: subset tests (unique <= set("ATGC")) for identity; a sorted threshold table for GC bucketing; for...else to report "all valid" only when no break fired.
def detect_sequence_type(sequence: str) -> str:
"""Classify a sequence as DNA, RNA, protein, or Unknown from its alphabet."""
unique = set(sequence.upper())
if unique <= set("ATGC"):
return "DNA"
elif unique <= set("AUGC"):
return "RNA"
elif unique <= set("ACDEFGHIKLMNPQRSTVWY"):
return "Protein"
return "Unknown"
GC_CLASSES = [(30, "AT-rich"), (50, "Moderate"), (60, "High GC")]
def classify_gc(sequence: str) -> tuple[float, str]:
"""Return (gc_percent, label); label is the first bucket whose threshold isn't exceeded."""
s = sequence.upper()
gc = (s.count('G') + s.count('C')) / len(s) * 100
for threshold, label in GC_CLASSES:
if gc < threshold:
return gc, label
return gc, "Very high GC"
def validate_sequence(sequence: str, valid_bases: set[str] = frozenset("ATGC")) -> bool:
"""Print the first invalid position found, or confirm validity via the for-else 'no break' branch."""
for i, nuc in enumerate(sequence):
if nuc not in valid_bases:
print(f"Invalid '{nuc}' at position {i + 1}") # +1 for 1-based reporting
return False
else:
print("Sequence is valid")
return True
assert detect_sequence_type("ATGCGATCGATCG") == "DNA"
assert classify_gc("GCGCGCGCGC")[1] == "Very high GC"
Pitfalls
- Off-by-one in codon loops:
range(0, len(seq) - 2, 3)stops so the last slice is still a full 3-char codon;range(0, len(seq), 3)produces an incomplete final codon whenlen(seq) % 3 != 0. elifvs separateif: useeliffor mutually exclusive classification (one GC bucket, one sequence type); use separateifstatements for independent filters (length check AND GC check).breakexits only the innermost loop: in nested frame-scanning loops (e.g. looping over 3 reading frames, each scanning codons), abreakin the inner loop does not stop the outer one — use a flag,return, or restructure into a function.whilewithout progress: everywhileloop must modify its condition variable or hit abreak; forgettingpos += 3(or using+= 1in a frame-locked scan) either loops forever or misses the frame.- Motif scan off-by-N:
seq.find(motif, pos + 1)finds overlapping motifs;seq.find(motif, pos + len(motif))silently skips overlaps — pick deliberately. passdoes nothing: it's a syntactic placeholder only — don't use it where you meancontinue(skip this iteration) orbreak.- Mutable default arguments:
def f(bases=[])shares one list across every call; usedef f(bases=None)and initialize inside, or afrozensetdefault as shown above. - 1-based vs 0-based reporting: Python indices are 0-based; bioinformatics coordinates (VCF, GenBank, user-facing reports) are conventionally 1-based — add 1 only when printing, keep 0-based internally for slicing.
See Also
bio-sequence-manipulation-codon-usage— codon frequency/usage bias tables built on the same iteration pattern.bio-sequence-manipulation-transcription-translation— full transcription/translation pipeline (BiopythonSeq.translate).bio-sequence-manipulation-motif-search— regex- and Biopython-based motif finding beyond plainstr.find.bio-sequence-io-filter-sequences— filtering FASTQ/FASTA records by length/quality at scale.