Bio.PDB
Bio.PDB
Core Principle:
Bio.PDBparses macromolecular structures into a hierarchical Structure-Model-Chain-Residue-Atom (SMCRA) object model for geometric analysis and spatial measurement.
The SMCRA hierarchy
Structure
└─ Model (NMR ensembles have many; X-ray usually one)
└─ Chain ('A', 'B', ...)
└─ Residue (het_flag, resseq, icode)
└─ Atom (name, coord, bfactor, occupancy, element)
Traceback (most recent call last):
File "/Users/leviosman/Developer/python-for-bio/webapp/scripts/curriculum/generate_cell_outputs.py", line 126, in run_cell_capture
exec(compile(head, "<cell>", "exec"), namespace)
~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<cell>", line 4, in <module>
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/site-packages/Bio/PDB/PDBParser.py", line 85, in get_structure
with as_handle(file) as handle:
~~~~~~~~~^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/contextlib.py", line 141, in __enter__
return next(self.gen)
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/site-packages/Bio/File.py", line 72, in as_handle
with open(handleish, mode, **kwargs) as fp:
~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: '1tup.pdb'
Use mmCIF for anything modern. The legacy PDB format cannot represent structures with more than 62 chains or 99,999 atoms, which excludes large complexes and most cryo-EM structures.
Iterating
Traceback (most recent call last):
File "/Users/leviosman/Developer/python-for-bio/webapp/scripts/curriculum/generate_cell_outputs.py", line 126, in run_cell_capture
exec(compile(head, "<cell>", "exec"), namespace)
~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<cell>", line 1, in <module>
NameError: name 'structure' is not defined
Residue identifiers
Traceback (most recent call last):
File "/Users/leviosman/Developer/python-for-bio/webapp/scripts/curriculum/generate_cell_outputs.py", line 126, in run_cell_capture
exec(compile(head, "<cell>", "exec"), namespace)
~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<cell>", line 1, in <module>
NameError: name 'residue' is not defined
Filter out waters and heteroatoms before geometry:
Traceback (most recent call last):
File "/Users/leviosman/Developer/python-for-bio/webapp/scripts/curriculum/generate_cell_outputs.py", line 132, in run_cell_capture
exec(compile(code, "<cell>", "exec"), namespace)
~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<cell>", line 1, in <module>
NameError: name 'chain' is not defined
The insertion code (icode) exists because antibody numbering schemes insert residues like 100A, 100B. Ignoring it silently merges distinct residues.
Geometry
import numpy as np
from Bio.PDB import calc_angle, calc_dihedral
d = atom1 - atom2 # Atom subtraction gives DISTANCE in Å
calc_angle(a1.get_vector(), a2.get_vector(), a3.get_vector())
calc_dihedral(a1.get_vector(), a2.get_vector(), a3.get_vector(), a4.get_vector())
from Bio.PDB.Polypeptide import PPBuilder
ppb = PPBuilder()
for pp in ppb.build_peptides(chain):
print(pp.get_sequence()) # → a Seq object
phi_psi = pp.get_phi_psi_list() # backbone dihedrals, in radians
atom1 - atom2 returning a distance is a nice piece of operator overloading, and easy to misread as vector subtraction. For the actual displacement vector use atom1.coord - atom2.coord.
Neighbour search
from Bio.PDB import NeighborSearch
atoms = [a for a in structure.get_atoms()]
ns = NeighborSearch(atoms)
ns.search(center_coord, radius=5.0, level="R") # residues within 5 Å
ns.search_all(4.0, level="R") # all residue pairs within 4 Å
NeighborSearch builds a KD-tree, so it is O(log n) per query rather than O(n). For contact maps and binding-site analysis, use it: a nested loop over 5,000 atoms is 25 million distance calculations.
Superposition and RMSD
from Bio.PDB import Superimposer
sup = Superimposer()
sup.set_atoms(fixed_atoms, moving_atoms) # must be the same length, in order
print(sup.rms) # RMSD in Å
sup.apply(moving_structure.get_atoms()) # transform in place
The two atom lists must correspond one-to-one. For structures with different residue numbering, align the sequences first and use the alignment to pair the residues.
Writing
Traceback (most recent call last):
File "/Users/leviosman/Developer/python-for-bio/webapp/scripts/curriculum/generate_cell_outputs.py", line 126, in run_cell_capture
exec(compile(head, "<cell>", "exec"), namespace)
~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<cell>", line 4, in <module>
NameError: name 'structure' is not defined
Biopython 1.86 made PDBIO enforce the wwPDB B-factor field width (max 6 characters). If you have written custom values into bfactor: a common trick for colouring a structure by conservation or by pLDDT: values outside the legal range now raise instead of writing a malformed file.
Downloading structures
Downloading PDB structure '1tup'... Desired structure not found or download failed. '1tup': <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1082)> Downloading PDB structure '2ac0'... Downloading PDB structure '1tup'... Desired structure not found or download failed. '2ac0': <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1082)> Desired structure not found or download failed. '1tup': <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1082)>
WARNING: The default download format has changed from PDB to PDBx/mmCif
For AlphaFold models, fetch directly from the AlphaFold DB:
https://alphafold.ebi.ac.uk/files/AF-P04637-F1-model_v4.cif
In AlphaFold structures the B-factor column holds pLDDT (0–100 confidence), not a temperature factor. Regions below 50 are usually disordered and should not be interpreted structurally.
Bioinformatics examples
# where does a missense variant sit in the structure?
res = structure[0]["A"][175]
print(res.get_resname()) # confirm it matches the expected ref AA
ns = NeighborSearch(list(structure.get_atoms()))
contacts = ns.search(res["CA"].coord, 8.0, level="R")
print(f"{len(contacts)} residues within 8 Å")
# is it buried or exposed? (needs DSSP installed)
from Bio.PDB.DSSP import DSSP
dssp = DSSP(structure[0], "1tup.pdb")
key = ("A", (" ", 175, " "))
ss, rsa = dssp[key][2], dssp[key][3]
print(f"secondary structure {ss}, relative accessibility {rsa:.2f}")
# distance to a bound ligand or DNA
zn = [a for a in structure.get_atoms() if a.element == "ZN"]
print(min(res["CA"] - a for a in zn))
# per-residue B-factor / pLDDT profile
bf = pd.DataFrame([
{"resnum": r.id[1], "resname": r.get_resname(),
"bfactor": np.mean([a.bfactor for a in r])}
for r in structure[0]["A"] if r.id[0] == " "
])
sns.lineplot(data=bf, x="resnum", y="bfactor")
# contact map
ca = [r["CA"] for r in structure[0]["A"] if r.id[0] == " " and "CA" in r]
coords = np.array([a.coord for a in ca])
dist = np.linalg.norm(coords[:, None, :] - coords[None, :, :], axis=-1)
sns.heatmap(dist < 8, cmap="Greys", square=True, cbar=False)
# map a variant onto conservation, then colour the structure
for r in structure[0]["A"]:
for a in r:
a.bfactor = conservation.get(r.id[1], 0.0) # keep within field width!
io.save("coloured.pdb")
The contact-map line uses Broadcasting to compute all pairwise distances in one expression: (n,1,3) - (1,n,3) → (n,n,3). Fine for a few hundred residues; use scipy.spatial.distance for anything larger.
Common mistakes
- Not filtering waters and heteroatoms.
- Ignoring insertion codes, merging distinct residues.
- Assuming one model. NMR ensembles have 20+.
atom1 - atom2misread as vector subtraction. It is a distance.- Missing residues. Crystal structures have disordered gaps; residue numbering is not contiguous and does not necessarily match UniProt numbering.
- PDB numbering ≠ UniProt numbering. Map through SIFTS before placing a variant.
- Using the legacy PDB format for large complexes.
- Treating AlphaFold B-factors as temperature factors. They are pLDDT.
- Nested loops for contacts instead of
NeighborSearch. - Out-of-range B-factor values now raising in 1.86+.
See also
Seq · SeqRecord · Bio.Entrez · Linear Algebra with NumPy · Broadcasting · Applied - Heatmaps and Clustermaps
Exercise
Biopython is not installed here, so instead of
PDBParser you will read the coordinate lines with fixed-column slicing:
which is what the legacy PDB format actually is, and why it cannot hold more
than 99,999 atoms. The columns you need, 0-based and half-open, are: record
name [0:6], atom name [12:16], altLoc [16], residue name [17:20], chain
[21], residue number [22:26], insertion code [26], and the coordinates at
[30:38], [38:46] and [46:54].
Write trace(chain_id, text) returning the report lines for one chain.
Consider only lines whose record name is ATOM, so waters and other
heteroatoms in HETATM records are excluded, and only atoms whose altLoc is
either blank or A, so an alternate conformer is not counted twice. A residue
is identified by its number together with its insertion code; treating the
number alone as the identifier merges residues such as 100 and 100A. Print
residues=<n> counting distinct residues in first-seen order, then
ca_atoms=<m> counting the first CA atom of each residue, then
trace=<x> for the summed distance between consecutive CA atoms and
span=<x> for the straight-line distance from the first CA to the last, both
rounded to two decimals. With fewer than two CA atoms both distances are
0.00. The chain id arrives on the first line of input and the file
follows.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | A HEADER TEST STRUCTURE ATOM 1 N MET A 1 0.000 0.000 0.000 1.00 20.00 N ATOM 2 CA MET A 1 1.000 0.000 0.000 1.00 20.00 C ATOM 3 C MET A 1 2.000 0.000 0.000 1.00 20.00 C ATOM 4 N ALA A 2 3.000 0.000 0.000 1.00 20.00 N ATOM 5 CA ALA A 2 4.000 3.000 0.000 1.00 20.00 C ATOM 6 CA GLY A 3 4.000 3.000 4.000 1.00 20.00 C ATOM 7 CA SER B 1 9.000 9.000 9.000 1.00 20.00 C HETATM 8 O HOH A 101 5.000 5.000 5.000 1.00 20.00 O TER END | residues=3 ca_atoms=3 trace=8.24 span=5.83 |
| 2 | stdin | A ATOM 1 CA MET A 1 0.000 0.000 0.000 1.00 20.00 C ATOM 2 CA AARG A 2 3.000 4.000 0.000 1.00 20.00 C ATOM 3 CA BARG A 2 30.000 40.000 0.000 1.00 20.00 C ATOM 4 CA TRP A 100 3.000 4.000 12.000 1.00 20.00 C ATOM 5 CA SER A 100A 3.000 4.000 24.000 1.00 20.00 C HETATM 6 ZN ZN A 201 0.000 0.000 1.000 1.00 20.00 ZN | residues=4 ca_atoms=4 trace=29.00 span=24.52 |
| 3 | stdin | B HEADER TEST STRUCTURE ATOM 1 N MET A 1 0.000 0.000 0.000 1.00 20.00 N ATOM 2 CA MET A 1 1.000 0.000 0.000 1.00 20.00 C ATOM 3 C MET A 1 2.000 0.000 0.000 1.00 20.00 C ATOM 4 N ALA A 2 3.000 0.000 0.000 1.00 20.00 N ATOM 5 CA ALA A 2 4.000 3.000 0.000 1.00 20.00 C ATOM 6 CA GLY A 3 4.000 3.000 4.000 1.00 20.00 C ATOM 7 CA SER B 1 9.000 9.000 9.000 1.00 20.00 C HETATM 8 O HOH A 101 5.000 5.000 5.000 1.00 20.00 O TER END | residues=1 ca_atoms=1 trace=0.00 span=0.00 |
| 4 | stdin | C HEADER NOTHING ON CHAIN C ATOM 1 CA MET A 1 0.000 0.000 0.000 1.00 20.00 C END | residues=0 ca_atoms=0 trace=0.00 span=0.00 |
Hints · 3
01Hint
line[0:6].strip() == "ATOM" rejects HETATM without also rejecting short lines.
02Hint
Key the residue on (line[22:26].strip(), line[26]) so the insertion code participates.
03Hint
math.sqrt on the summed squared differences gives the distance; there is no numpy needed.