Tuples and Immutable Records in Bioinformatics
When to Use
- Representing a fixed genomic record —
(gene_name, chromosome, start, end, strand) or a VCF-style variant (chrom, pos, ref, alt).
- Returning multiple values from a function (e.g.
find_overlap() returning (start, end, length)).
- Using genomic coordinates as dictionary keys (tuples are hashable, lists are not).
- Making a record read-only so downstream code can't accidentally mutate a coordinate or reference allele.
- Debugging a bug where modifying one list unexpectedly changed another (aliasing), or a function's results grow across calls (mutable default argument).
Version Compatibility
Pure Python stdlib — no version-sensitive APIs. Works unchanged on Python ≥3.8. collections.namedtuple and typing.NamedTuple have been stable since Python 3.6+; no external packages required.
Prerequisites
- Basic Python: variables, functions, string slicing, f-strings.
- Genomic coordinate conventions (0-based half-open BED vs 1-based GFF/VCF) — see
bio-genome-intervals-bed-file-basics.
python-bio-lists for the mutable-collection counterpart to the patterns here.
Core Patterns
Goal: parse a fixed-width or delimited genomic record line into an immutable, self-documenting record instead of a raw tuple of positional fields.
Approach: define a namedtuple once, then build instances from parsed text; access fields by name (gene.start) instead of brittle indices (fields[2]).
from collections import namedtuple
Gene = namedtuple("Gene", ["name", "chromosome", "start", "end", "strand"])
def parse_gene_line(line):
"""Parse a tab-separated 'name chrom start end strand' line into a Gene record.
Parameters:
line (str): e.g. "BRCA1\tchr17\t43044295\t43125483\t-"
Returns:
Gene: immutable namedtuple record
"""
name, chrom, start, end, strand = line.strip().split("\t")
return Gene(name, chrom, int(start), int(end), strand)
genes = [
parse_gene_line("BRCA1\tchr17\t43044295\t43125483\t-"),
parse_gene_line("TP53\tchr17\t7661779\t7687538\t-"),
parse_gene_line("EGFR\tchr7\t55019017\t55207337\t+"),
]
by_length = sorted(genes, key=lambda g: g.end - g.start, reverse=True)
for g in by_length:
print(f"{g.name:6s} {g.chromosome}:{g.start}-{g.end} ({g.end - g.start:,} bp)")
Goal: use variant coordinates as dictionary keys so lookups and duplicate-detection are O(1) instead of scanning a list.
Approach: a VCF record's (chrom, pos, ref, alt) is a natural hashable tuple key — lists cannot be used this way because they are unhashable.
def annotate_variants(calls, known_pathogenic):
"""Flag which (chrom, pos, ref, alt) variant calls are in a known-pathogenic set.
Parameters:
calls (list[tuple]): [(chrom, pos, ref, alt), ...] from a VCF
known_pathogenic (set[tuple]): set of (chrom, pos, ref, alt) tuples
Returns:
dict: {variant_tuple: is_pathogenic (bool)}
"""
return {call: call in known_pathogenic for call in calls}
calls = [
("chr7", 55259515, "T", "G"),
("chr17", 43044295, "G", "A"),
]
known_pathogenic = {("chr7", 55259515, "T", "G")}
result = annotate_variants(calls, known_pathogenic)
for variant, is_path in result.items():
print(f"{variant}: {'PATHOGENIC' if is_path else 'benign/unknown'}")
Goal: avoid two classic aliasing bugs — sharing a list across variables, and a mutable default argument that leaks state between calls.
Approach: copy lists explicitly ([:] or .copy(), copy.deepcopy() for nested structures); default to None and build the mutable object inside the function body.
import copy
def add_codon(cds_list, codon, _seen=None):
"""Append a codon to an independent copy of cds_list (no aliasing, no shared default).
Parameters:
cds_list (list[str]): existing codons
codon (str): codon to add
_seen (set, optional): internal dedup set; never share a mutable default
Returns:
list[str]: a NEW list with codon appended (original untouched)
"""
if _seen is None: # correct pattern: never `def f(_seen=set())`
_seen = set()
updated = cds_list[:] # shallow copy -- original list is untouched
updated.append(codon)
_seen.add(codon)
return updated
original = ["ATG", "GCC", "GAT"]
extended = add_codon(original, "TAG")
assert original == ["ATG", "GCC", "GAT"] # unchanged
assert extended == ["ATG", "GCC", "GAT", "TAG"]
# Nested structures need deepcopy: a shallow copy still shares inner lists
exon_sets = [["ATG", "GCC"], ["GAT", "TAG"]]
shallow = exon_sets.copy()
deep = copy.deepcopy(exon_sets)
shallow[0].append("XXX") # mutates exon_sets[0] too (shared inner list)
assert exon_sets[0] == ["ATG", "GCC", "XXX"]
assert deep[0] == ["ATG", "GCC"] # deep copy is fully independent
Pitfalls
- Assignment does not copy:
alias = original makes both names point to the same list; use original[:] or original.copy() for an independent shallow copy.
- Shallow copy ≠ deep copy:
list.copy() only copies the top level — nested lists/dicts are still shared; use copy.deepcopy().
- Tuples are immutable: cannot append, remove, or reassign an element (
snp[2] = "C" raises TypeError); rebuild via slicing/concatenation instead: snp[:2] + ("C",) + snp[3:].
- Single-element tuples need a trailing comma:
("BRCA1",) is a tuple; ("BRCA1") is just a string.
- Mutable default arguments:
def f(x=[]) reuses the same list across every call with no argument — use def f(x=None) and create the object inside the function.
- Lists can't be dict keys or set members (unhashable); tuples can — this is why VCF-style
(chrom, pos, ref, alt) records are typically tuples, not lists.
- Off-by-one on genomic coordinates: Python slicing is half-open
[start, stop); BED is 0-based half-open, GFF/VCF are 1-based inclusive — mixing them silently shifts coordinates by one.
See Also
python-bio-lists — mutable list operations, sorting, and codon/k-mer extraction that pairs with these tuple patterns.
python-bio-dictionaries — using tuple keys in dicts/sets for variant and coordinate lookups.
bio-genome-intervals-bed-file-basics — BED coordinate conventions referenced above.
bio-variant-calling-vcf-basics — VCF record fields behind the (chrom, pos, ref, alt) tuple pattern.
1---2name: python-bio-tuples3description: Build immutable Python tuple/namedtuple records for gene coordinates and SNP tuples (chrom,pos,ref,alt). Use when storing fixed records, returning multiple values, using coords as dict keys, or fixing list-aliasing/mutable-default bugs.4---56# Tuples and Immutable Records in Bioinformatics78## When to Use910- Representing a fixed genomic record — `(gene_name, chromosome, start, end, strand)` or a VCF-style variant `(chrom, pos, ref, alt)`.11- Returning multiple values from a function (e.g. `find_overlap()` returning `(start, end, length)`).12- Using genomic coordinates as dictionary keys (tuples are hashable, lists are not).13- Making a record read-only so downstream code can't accidentally mutate a coordinate or reference allele.14- Debugging a bug where modifying one list unexpectedly changed another (aliasing), or a function's results grow across calls (mutable default argument).1516## Version Compatibility1718Pure Python stdlib — no version-sensitive APIs. Works unchanged on Python ≥3.8. `collections.namedtuple` and `typing.NamedTuple` have been stable since Python 3.6+; no external packages required.1920## Prerequisites2122- Basic Python: variables, functions, string slicing, f-strings.23- Genomic coordinate conventions (0-based half-open BED vs 1-based GFF/VCF) — see `bio-genome-intervals-bed-file-basics`.24- `python-bio-lists` for the mutable-collection counterpart to the patterns here.2526## Core Patterns2728**Goal:** parse a fixed-width or delimited genomic record line into an immutable, self-documenting record instead of a raw tuple of positional fields.29**Approach:** define a `namedtuple` once, then build instances from parsed text; access fields by name (`gene.start`) instead of brittle indices (`fields[2]`).3031```python32from collections import namedtuple3334Gene = namedtuple("Gene", ["name", "chromosome", "start", "end", "strand"])353637def parse_gene_line(line):38 """Parse a tab-separated 'name chrom start end strand' line into a Gene record.3940 Parameters:41 line (str): e.g. "BRCA1\tchr17\t43044295\t43125483\t-"4243 Returns:44 Gene: immutable namedtuple record45 """46 name, chrom, start, end, strand = line.strip().split("\t")47 return Gene(name, chrom, int(start), int(end), strand)484950genes = [51 parse_gene_line("BRCA1\tchr17\t43044295\t43125483\t-"),52 parse_gene_line("TP53\tchr17\t7661779\t7687538\t-"),53 parse_gene_line("EGFR\tchr7\t55019017\t55207337\t+"),54]5556by_length = sorted(genes, key=lambda g: g.end - g.start, reverse=True)57for g in by_length:58 print(f"{g.name:6s} {g.chromosome}:{g.start}-{g.end} ({g.end - g.start:,} bp)")59```6061**Goal:** use variant coordinates as dictionary keys so lookups and duplicate-detection are O(1) instead of scanning a list.62**Approach:** a VCF record's `(chrom, pos, ref, alt)` is a natural hashable tuple key — lists cannot be used this way because they are unhashable.6364```python65def annotate_variants(calls, known_pathogenic):66 """Flag which (chrom, pos, ref, alt) variant calls are in a known-pathogenic set.6768 Parameters:69 calls (list[tuple]): [(chrom, pos, ref, alt), ...] from a VCF70 known_pathogenic (set[tuple]): set of (chrom, pos, ref, alt) tuples7172 Returns:73 dict: {variant_tuple: is_pathogenic (bool)}74 """75 return {call: call in known_pathogenic for call in calls}767778calls = [79 ("chr7", 55259515, "T", "G"),80 ("chr17", 43044295, "G", "A"),81]82known_pathogenic = {("chr7", 55259515, "T", "G")}8384result = annotate_variants(calls, known_pathogenic)85for variant, is_path in result.items():86 print(f"{variant}: {'PATHOGENIC' if is_path else 'benign/unknown'}")87```8889**Goal:** avoid two classic aliasing bugs — sharing a list across variables, and a mutable default argument that leaks state between calls.90**Approach:** copy lists explicitly (`[:]` or `.copy()`, `copy.deepcopy()` for nested structures); default to `None` and build the mutable object inside the function body.9192```python93import copy949596def add_codon(cds_list, codon, _seen=None):97 """Append a codon to an independent copy of cds_list (no aliasing, no shared default).9899 Parameters:100 cds_list (list[str]): existing codons101 codon (str): codon to add102 _seen (set, optional): internal dedup set; never share a mutable default103104 Returns:105 list[str]: a NEW list with codon appended (original untouched)106 """107 if _seen is None: # correct pattern: never `def f(_seen=set())`108 _seen = set()109 updated = cds_list[:] # shallow copy -- original list is untouched110 updated.append(codon)111 _seen.add(codon)112 return updated113114115original = ["ATG", "GCC", "GAT"]116extended = add_codon(original, "TAG")117assert original == ["ATG", "GCC", "GAT"] # unchanged118assert extended == ["ATG", "GCC", "GAT", "TAG"]119120# Nested structures need deepcopy: a shallow copy still shares inner lists121exon_sets = [["ATG", "GCC"], ["GAT", "TAG"]]122shallow = exon_sets.copy()123deep = copy.deepcopy(exon_sets)124shallow[0].append("XXX") # mutates exon_sets[0] too (shared inner list)125assert exon_sets[0] == ["ATG", "GCC", "XXX"]126assert deep[0] == ["ATG", "GCC"] # deep copy is fully independent127```128129## Pitfalls130131- **Assignment does not copy:** `alias = original` makes both names point to the same list; use `original[:]` or `original.copy()` for an independent shallow copy.132- **Shallow copy ≠ deep copy:** `list.copy()` only copies the top level — nested lists/dicts are still shared; use `copy.deepcopy()`.133- **Tuples are immutable:** cannot append, remove, or reassign an element (`snp[2] = "C"` raises `TypeError`); rebuild via slicing/concatenation instead: `snp[:2] + ("C",) + snp[3:]`.134- **Single-element tuples need a trailing comma:** `("BRCA1",)` is a tuple; `("BRCA1")` is just a string.135- **Mutable default arguments:** `def f(x=[])` reuses the same list across every call with no argument — use `def f(x=None)` and create the object inside the function.136- **Lists can't be dict keys or set members** (unhashable); tuples can — this is why VCF-style `(chrom, pos, ref, alt)` records are typically tuples, not lists.137- **Off-by-one on genomic coordinates:** Python slicing is half-open `[start, stop)`; BED is 0-based half-open, GFF/VCF are 1-based inclusive — mixing them silently shifts coordinates by one.138139## See Also140141- `python-bio-lists` — mutable list operations, sorting, and codon/k-mer extraction that pairs with these tuple patterns.142- `python-bio-dictionaries` — using tuple keys in dicts/sets for variant and coordinate lookups.143- `bio-genome-intervals-bed-file-basics` — BED coordinate conventions referenced above.144- `bio-variant-calling-vcf-basics` — VCF record fields behind the `(chrom, pos, ref, alt)` tuple pattern.