Peptidoforms¶
A mass spectrometrist looking at a protein usually wants one thing: which peptides come off it, and which fragments would I see for each? pyMzLib answers that in one call. Give it a UniProt accession and it fetches the annotated entry, applies the modifications UniProt records, digests the protein, and fragments every peptide. The digestion, the modification handling and the fragment masses are the ones MetaMorpheus uses. pyMzLib can also rewrite a MetaMorpheus full sequence in Unimod or ProForma notation, so it can be handed to another tool (see Converting full sequences).
| You want to know | Call | mzLib does it with |
|---|---|---|
| Which peptides and fragments a protein gives | fragments() |
Protein.Digest, PeptideWithSetModifications.Fragment |
| Which UniProt modifications were used, and which were not | Digest.modification_census |
the UniProt XML reader and its PTM list |
| A peptide's m/z at a charge | Peptide.mz() |
ClassExtensions.ToMz, with fixed charges accounted for |
| Whether the list is complete | Digest.truncated |
DigestionParams.MaxModificationIsoforms |
| A MetaMorpheus full sequence in Unimod accessions | convert() |
SequenceConversionService.Convert, Unimod serializer |
| The same sequence in ProForma | convert(target="ProForma") |
SequenceConversionService.Convert, ProForma serializer |
| Which sequences mzLib could not convert, and why | SequenceConversions.not_converted |
ConversionWarnings |
| Which notations mzLib can read and write | SequenceConversions.source_formats and .target_formats |
SequenceConversionService.AvailableSourceFormats and AvailableTargetFormats |
Every >>> example on this page runs in CI against output recorded from the real bridge, so the
numbers you see are the numbers pyMzLib gives. Blocks titled Not run say why they are not.
>>> import pymzlib
>>> digest = pymzlib.peptidoform.fragments("P02768") # human serum albumin
>>> digest.full_name, digest.organism, digest.sequence_length
('Albumin', 'Homo sapiens', 609)
>>> len(digest.peptides), len(digest.modified_peptides)
(303, 108)
The defaults are opinions, not placeholders¶
The one-argument call made several decisions. They are the choices this lab makes when it has no reason to choose otherwise, and each is a parameter you can change:
| Default | Meaning |
|---|---|
protease="trypsin|P" |
Tryptic with the Keil rule: cleave after K/R except before proline. |
dissociation="ETD" |
c and z• fragment ions. |
modifications=True |
Apply UniProt's annotated modifications. |
missed_cleavages=2 |
Up to two missed cleavage sites per peptide. |
min_length=7 |
Discards peptides shorter than seven residues. See the trap below. |
max_modifications=2 |
Consider up to two modifications per peptide. |
max_isoforms=1024 |
Cap on modification isoforms per peptide. See the trap below. |
terminus="Both" |
Fragment from both ends. |
The result echoes the settings it ran with, so a saved Digest says how it was made:
>>> digest.protease, digest.dissociation, digest.max_modifications, digest.max_isoforms
('trypsin|P', 'ETD', 2, 1024)
Reading the result¶
fragments() returns a Digest. Its peptides are
Peptide objects in digestion order, each carrying its own
Fragment ions. Here is the first modified one:
>>> p = digest.modified_peptides[0]
>>> p.base_sequence
'TCVADESAENCDK'
>>> p.full_sequence # modifications inline, as mzLib writes them
'TCVADES[UniProt:Phosphoserine on S]AENCDK'
>>> round(p.monoisotopic_mass, 4) # neutral monoisotopic mass, modifications included
1463.4946
>>> p.missed_cleavages, p.is_modified
(0, True)
Each fragment carries a neutral mass, not an m/z:
>>> f = p.fragments[0]
>>> f.product_type, f.fragment_number, round(f.neutral_mass, 4)
('c', 1, 118.0742)
fragment_number counts residues from the fragment's own terminus: c1 is the first residue from
the N-terminus, z•1 the first from the C-terminus.
Modifications: what was annotated, and what could be used¶
This is the part a hand workflow gets wrong most often, so pyMzLib refuses to hide it.
mzLib loads only UniProt's modified residue and lipid moiety-binding region annotations. It
drops every other feature type, such as glycosylation sites, on the feature type alone, before
any mass lookup. That is usually right: a glycation or glycosylation annotation describes a labile,
heterogeneous adduct, and giving it one exact mass would describe a species you cannot observe. The
problem is that without being told, you cannot tell an exhaustive answer from a filtered one
(smith-chem-wisc/mzLib#1112).
Every Digest carries a ModificationCensus that says
what happened:
>>> c = digest.modification_census
>>> c.annotated, c.applied, c.excluded, c.sites
(38, 14, 24, 14)
>>> c.by_type
[{'type': 'glycosylation site', 'count': 24, 'loaded': False}, {'type': 'modified residue', 'count': 14, 'loaded': True}]
>>> print(c.explain()) # doctest: +ELLIPSIS
14 of 38 annotated modifications were applied, across 14 residue positions. Excluded by type: 24 × glycosylation site ...
sites is not a modification count
A histone lists several alternatives at one residue: K9me1, K9me2, K9me3 and K9ac are four
modifications at one site. sites is the number of residue positions, so on a histone it
is the smaller number.
unresolved catches the quietest failure. It lists modification names UniProt annotates that are
absent from UniProt's own modification list, so no mass can be assigned and mzLib drops them. On
histone H3.1, seven N6-lactoyllysine sites land here instead of vanishing. Albumin has none:
m/z, and why trimethyllysine needs care¶
Peptide.mz(charge) converts a peptide's neutral mass to an m/z, and it handles two conventions
that are invisible in the answer if you get them wrong:
It adds the proton mass (1.007276 Da), not the hydrogen atom (1.007825 Da). The difference is 0.55 mDa, about 1.1 ppm at m/z 500. On an Orbitrap that is the difference between a match and a miss. Libraries differ on this and rarely say which they used.
It does not double-count fixed charges. Some modifications leave a residue permanently charged.
Trimethylation of a lysine ε-amine gives a quaternary ammonium, and UniProt records its delta as
43.054227 Da (C₃H₇ minus an electron) rather than the neutral 43.054775 Da. The peptide's mass
already carries that charge, so mz() adds only charge − fixed_charges protons. No albumin
peptide carries a fixed charge:
On a trimethylated histone peptide, fixed_charges is 1, and mz(2) adds one proton, not two.
Adding a full complement would put a 2+ trimethylated peptide half a Thomson too high, on one of the
most studied histone modifications there is. A peptide with a fixed charge is observable at that
charge with no protonation at all, so charge may not be below fixed_charges.
Fragments deliberately have no mz()
Converting a fragment correctly needs the fixed charge within that fragment's span: a c or z
ion carries only the charged modifications on the residues it contains, not the whole peptide's
fixed_charges. That per-fragment accounting is not provided yet, so Fragment has
neutral_mass only. For an unmodified or neutrally modified peptide,
(neutral_mass + z × 1.007276) / z is correct.
Two defaults that bite silently¶
Both produce a short answer that looks exactly like a complete one, so they are worth knowing before you trust a count.
min_length=7 drops short peptides¶
The default digest discards every peptide shorter than seven residues. On a histone that is about a
third of the tryptic peptides, including short, heavily modified ones you may care about most. If
you asked for "every peptide", pass min_length=1:
digest = pymzlib.peptidoform.fragments("P68431", min_length=1) # histone H3.1, every peptide
The isoform cap truncates silently¶
Modification isoforms are enumerated combinatorially, and the count grows fast: histone H3.1 gives
49 bare tryptic peptides, 2,563 peptidoforms at two modifications, and 7,040 at three.
max_isoforms=1024 caps the isoforms considered per peptide position, and when it binds mzLib
discards the excess without an error. On H3.1 at four modifications it drops about 30% of the
peptidoforms.
Digest says when this happened. Albumin at the defaults is complete:
When truncated is true, raise the cap and digest again:
digest = pymzlib.peptidoform.fragments("P68431", max_modifications=4)
if digest.truncated:
print(f"{digest.peptides_at_cap} peptide positions hit the isoform cap")
digest = pymzlib.peptidoform.fragments("P68431", max_modifications=4, max_isoforms=20000)
Check digest.truncated before you treat a peptidoform list as exhaustive.
Protease naming: read this if you come from MaxQuant or Mascot¶
The default protease="trypsin|P" means the reverse of what the same-looking name means
elsewhere:
| pyMzLib and mzLib | Behaviour | The MaxQuant and Mascot name for it |
|---|---|---|
"trypsin|P" |
Cleave after K/R except before proline (the Keil rule) | plain Trypsin |
"trypsin" |
Cleave after K/R, including before proline | Trypsin/P |
The difference is real. On serum albumin, without modifications, "trypsin|P" gives 195 distinct
peptides and "trypsin" gives 202. The counts differ by 7, but the lists differ by 37: 15 peptides
are only in the first and 22 only in the second, because every K/R before a proline moves a
cleavage site. "trypsin|P" is the default because it is what a mass spectrometrist usually means
by "trypsin". If you expected MaxQuant's Trypsin/P (ignore the proline rule), pass "trypsin".
Tuning the digest¶
Every default can be changed. The common adjustments:
# The bare sequence, no modifications: a clean control
pymzlib.peptidoform.fragments("P02768", modifications=False)
# HCD or CID b and y ions instead of ETD c and z•
pymzlib.peptidoform.fragments("P02768", dissociation="HCD")
# A tighter length window and no missed cleavages
pymzlib.peptidoform.fragments("P02768", missed_cleavages=0, min_length=6, max_length=40)
# Only N-terminal fragments
pymzlib.peptidoform.fragments("P02768", terminus="N")
modifications=False is a clean control. It keeps UniProt's signal-peptide and propeptide
boundaries, which mzLib digests at, so the bare digest has the same distinct backbones and only
the modified variants go away.
max_modifications is the setting that most affects run time, because isoforms are combinatorial:
three modifications per peptide is much more work than two.
Converting full sequences to Unimod or ProForma¶
A MetaMorpheus result names each modification the way mzLib's databases do:
[UniProt:N-acetylserine on S], [Common Variable:Oxidation on M]. Most other tools want a Unimod
accession instead, such as [UNIMOD:1]. convert() hands each
sequence to mzLib's SequenceConversionService and returns one row per input, in order. pyMzLib
maps no modification itself; every output is mzLib's.
This example uses BottomUpExample.psmtsv, a MetaMorpheus search result that ships with mzLib's
tests. Read its full sequences, then convert them:
>>> psms = pymzlib.readers.read_results("BottomUpExample.psmtsv")
>>> unimod = pymzlib.peptidoform.convert(psms.columns["full_sequence"])
>>> unimod.source_format, unimod.target_format, unimod.mode
('mzLib', 'Unimod', 'ReturnNull')
>>> for row in unimod.sequences[:5]:
... print(row.status, row.output)
converted YPIEH[UNIMOD:34]GIVTNWDDMEK
converted VAPEEHPVLLTEAPLNPK
converted AYHEQLSVAEITNAC[UNIMOD:4]FEPANQMVK
converted VEDMAELTC[UNIMOD:4]LNEASVLHNLK
converted YPIEH[UNIMOD:34]GIVTNWDDM[UNIMOD:35]EK
>>> unimod.converted_count == unimod.record_count
True
UniProt's tele-methylhistidine became UNIMOD:34 (Methyl). Carbamidomethyl became UNIMOD:4, and
oxidation became UNIMOD:35. Unmodified sequences pass through unchanged. unimod.columns is
already the shape pandas.DataFrame wants, if you want a table instead of a loop.
Every result lists the notations mzLib has registered, so you never need to guess a name:
>>> unimod.source_formats
['MassShift', 'Modomics', 'ProForma', 'mzLib']
>>> unimod.target_formats
['Chronologer', 'Essential', 'MassShift', 'ProForma', 'Unimod', 'mzLib']
ProForma does not resolve UniProt modifications yet¶
Ask for ProForma and the same file gives a different answer. Carbamidomethyl and oxidation become
UNIMOD accessions. The UniProt modification is written back under its mzLib name, and the row
still says converted:
>>> proforma = pymzlib.peptidoform.convert(psms.columns["full_sequence"], target="ProForma")
>>> proforma.outputs[0]
'YPIEH[UniProt:Tele-methylhistidine on H]GIVTNWDDMEK'
>>> proforma.outputs[4]
'YPIEH[UniProt:Tele-methylhistidine on H]GIVTNWDDM[UNIMOD:35]EK'
>>> proforma.sequences[0].status
'converted'
The cause is in mzLib, not in pyMzLib. mzLib's ProForma serializer looks modifications up only in
MetaMorpheus's own list, which has no UniProt entries. The Unimod serializer looks them up in every
list mzLib loads. Until mzLib fixes this, convert to Unimod when your sequences carry UniProt
modifications. In ProForma output, treat any bracket that is not a UNIMOD: term as unresolved.
pyMzLib does not patch around the gap. A patch here would leave the same gap in mzLibRust, mzLibR
and MetaMorpheus. The pro_forma column that readers.read_records() gives a
.psmtsv comes from the same serializer, so it has the same gap.
When mzLib cannot convert a sequence¶
What happens to a modification the target cannot write depends on mode, which is mzLib's
SequenceConversionHandlingMode:
mode |
The row | output |
|---|---|---|
"ReturnNull" (default) |
failed |
None |
"RemoveIncompatibleElements" |
converted_with_warnings |
the sequence without that modification |
"UsePrimarySequence" |
converted_with_warnings |
the sequence without that modification |
"ThrowException" |
no rows: the call raises UsageError naming the first such sequence |
none |
status is mzLib's own verdict:
converted: mzLib returned an output and recorded nothing against it.converted_with_warnings: mzLib returned an output but noted something, such as a dropped modification or a skipped character.failed: mzLib returned nothing.
These four sequences include one modification that has no Unimod accession:
>>> result = pymzlib.peptidoform.convert([
... "[UniProt:N-acetylserine on S]SEQK",
... "PEPK[UniProt:N6,N6-dimethyllysine on K]R",
... "PEPM[Common Variable:Oxidation on M]K",
... "PEPK[Made Up:Not a modification on K]R",
... ])
>>> result.outputs
['[UNIMOD:1]SEQK', 'PEPK[UNIMOD:36]R', 'PEPM[UNIMOD:35]K', None]
>>> (bad,) = result.not_converted
>>> bad.input, bad.status, bad.failure_reason
('PEPK[Made Up:Not a modification on K]R', 'failed', None)
>>> bad.incompatible_items
['Made Up:Not a modification on K @3(K)']
failure_reason is None here. Under ReturnNull, mzLib's Unimod serializer records the
incompatible modification but no reason code, so read incompatible_items to see what failed.
Split ambiguous sequences first. When MetaMorpheus cannot tell candidates apart, it joins their
full sequences with |. mzLib's parser does not refuse this. It skips each | with a warning and
joins the candidates into one sequence. The row is converted_with_warnings, but the output is not
a real peptide. Split on | before you convert.
The full list of parameters, fields and caveats is in the
peptidoform convert reference.
Errors you might hit¶
| Exception | Means |
|---|---|
UsageError |
fragments(): the accession, protease, dissociation type or terminus is not recognised, or a number is negative; raised before any network call. convert(): no sequences, a blank one, a format name mzLib has not registered (the message lists them), an unknown mode, or, under mode="ThrowException", a sequence mzLib could not convert. |
ServiceUnavailableError |
UniProt was down, rate-limiting, or timed out. Not your bug: retry later. |
BridgeError |
UniProt answered, but something about the request was rejected. |
A malformed accession is refused before anything is fetched:
>>> pymzlib.peptidoform.fragments("not-an-accession") # doctest: +ELLIPSIS
Traceback (most recent call last):
...
pymzlib._bridge.UsageError: 'not-an-accession' is not a valid UniProtKB accession. ...
ServiceUnavailableError is a subclass of BridgeError, so catching BridgeError catches both.
Separate them when you want to retry an outage but report everything else:
try:
digest = pymzlib.peptidoform.fragments("P02768")
except pymzlib.ServiceUnavailableError:
... # UniProt's problem; worth a retry
except pymzlib.BridgeError as e:
print(f"{e.error_type}: {e}")
Every error each verb can raise is listed on one page.
Cite¶
If this guide's results go into a paper, cite mzLib (see Citing) and the sources the digest reads:
- doi:10.1002/pmic.200300744: Unimod, the modification database whose accessions the Unimod target writes (Creasy and Cottrell 2004, Proteomics)
- doi:10.1021/acs.jproteome.1c00771: ProForma 2.0, the ProForma target's notation (LeDuc et al. 2022, J Proteome Res)
- doi:10.1093/nar/gkae1010: UniProt, the source of the entry, its sequence and its annotated modifications (UniProt Consortium 2025, NAR)