DNA Molecular Weight in Python: A Runnable ng/µL → nM Workflow
DNA Molecular Weight in Python: A Runnable ng/µL → nM Workflow
There is more than one defensible molecular weight for the same piece of DNA, and the
spread between them is about 7%. Feed one ng/µL reading into two tools that made
different — and equally reasonable — assumptions, and the nanomolar numbers they hand back
will not match. Neither is broken; they are answering slightly different questions, and
most of them do not say which.
This page works the problem end to end on real, publicly fetchable sequences: it computes where the 7% comes from, ranks the variables by how much they actually move the number, and then runs a restriction-cloning calculation to show the case where the disagreement cancels — and the cases where it does not.
Every number below is the output of a script that was run, not an estimate. The environment and the limits of what was verified are stated at the end.
Environment for every number on this page:
| Component | Version |
|---|---|
| Biopython | 1.88 |
| Python | 3.14.2 |
| Sequences | NCBI L09137.2 (pUC19, 2,686 bp) and J01749.1 (pBR322, 4,361 bp) |
| OS | macOS 15, arm64 |
If you only need the number for one sample and do not care why, the Runcell DNA concentration calculator does the conversion in the browser. Read on if you need to defend the number.
The conversion itself is trivial. The molecular weight is not.
Concentration in ng/µL is a mass per volume. Molarity is a count per volume. The
bridge is molecular weight, and the arithmetic is one line:
nM = (ng/µL) × 10⁶ / MW(g/mol)Sanity check on the units: 1 ng/µL = 10⁻⁹ g / 10⁻⁶ L = 10⁻³ g/L. Divide by
MW in g/mol to get mol/L, multiply by 10⁹ to get nanomolar. The 10⁶ is what
survives.
Nothing in that line is controversial. All of the disagreement lives in MW. Plenty of
things can move it — whether you meant one strand or two, linear or circular, average or
monoisotopic mass, any chemical modification you ordered. Hold all of those fixed, ask for
the average mass of an unmodified duplex of a known sequence, and three variables are
left:
- the base composition of the sequence,
- the salt form you assume the DNA is in,
- the terminal chemistry — phosphate or hydroxyl at the ends.
Those three are what this page measures. Most explanations of the topic spend their time on (1). Measured on real sequences, (1) is the one that barely matters.
Setup
python3 -m venv dnaenv
./dnaenv/bin/pip install biopythonIf you are setting up Python on a Mac for the first time, the Jupyter install guide covers the virtual-environment part in more detail.
Fetching the reference sequences — these are the two most-used cloning vectors in molecular biology, and both are public NCBI records:
from Bio import Entrez, SeqIO
from Bio.SeqUtils import molecular_weight, gc_fraction
Entrez.email = "you@example.com" # NCBI requires this
def fetch(acc):
handle = Entrez.efetch(db="nucleotide", id=acc, rettype="gb", retmode="text")
record = SeqIO.read(handle, "genbank")
handle.close()
return record
puc = fetch("L09137.2") # pUC19
pbr = fetch("J01749.1") # pBR322
print(f"pUC19 {len(puc.seq)} bp GC={100*gc_fraction(puc.seq):.2f}%")
print(f"pBR322 {len(pbr.seq)} bp GC={100*gc_fraction(pbr.seq):.2f}%")pUC19 2686 bp GC=50.63%
pBR322 4361 bp GC=53.75%Variable 1: base composition moves the answer by 0.16%
The folklore is that 650 g/mol per bp is “an average that depends on GC content”, so
GC-rich sequences need a different constant. Here is the entire possible range, computed
on 1,000 bp homopolymer duplexes:
from Bio.Seq import Seq
from Bio.SeqUtils import molecular_weight
def ds(s, circular=False):
return molecular_weight(Seq(s), seq_type="DNA",
double_stranded=True, circular=circular)
N = 1000
for label, s in (("poly(A)/poly(T)", "A"*N), ("poly(G)/poly(C)", "G"*N),
("alternating AT", "AT"*(N//2)), ("alternating GC", "GC"*(N//2))):
m = ds(s)
print(f"{label:16} total={m:14,.2f} per bp={m/N:8.3f}")poly(A)/poly(T) total= 617,435.73 per bp= 617.436
poly(G)/poly(C) total= 618,423.73 per bp= 618.424
alternating AT total= 617,435.73 per bp= 617.436
alternating GC total= 618,423.73 per bp= 618.4240% GC to 100% GC is a 0.16% change in mass per base pair. This is not a rounding artifact of the model — it is chemistry. In a duplex you always pay for two bases, and an A·T pair (adenine + thymine) and a G·C pair (guanine + cytosine) happen to weigh almost the same. The heavy purine is paired with the light pyrimidine in both cases.
On real sequence the effect is smaller still, because real sequence never approaches the homopolymer extremes. Sliding a 500 bp window across pUC19:
res = []
for start in range(0, len(puc.seq) - 500, 100):
sub = puc.seq[start:start+500]
exact = molecular_weight(sub, seq_type="DNA", double_stranded=True)
res.append((100*gc_fraction(sub), 100*(650*500 - exact)/exact))
gcs = [r[0] for r in res]; errs = [r[1] for r in res]
print(f"windows={len(res)} GC {min(gcs):.1f}%..{max(gcs):.1f}% "
f"650-rule error {min(errs):+.2f}%..{max(errs):+.2f}%")windows=22 GC 43.4%..57.4% 650-rule error +5.17%..+5.19%Twenty-two real windows spanning 14 percentage points of GC content, and the error of the 650 rule moves by 0.02 percentage points. If your sequence is real DNA, its GC content is not why your calculator disagrees with the next one.
(If you want the GC number itself for a sequence you have on hand, the GC content calculator will give it to you without the venv.)
Variable 2: salt form moves the answer by 7.1%
Here is what Biopython actually returns, per base pair, for a circular duplex — circular so there are no free ends to complicate it:
NA_H = 22.98977 - 1.00794 # a Na⁺ replacing the acidic proton on one phosphate
s = "ATGC" * 250 # 1000 bp
free = ds(s, circular=True) / 1000
print(f"free acid : {free:8.3f} Da/bp")
print(f"+ 2 × (Na − H) per bp : {free + 2*NA_H:8.3f} Da/bp")free acid : 617.894 Da/bp
+ 2 × (Na − H) per bp : 661.857 Da/bpEach base pair carries two phosphate groups, one per strand. In the free acid each
carries an acidic proton; in the sodium salt each proton is replaced by Na⁺, which costs
22.990 − 1.008 = 21.982 Da a time. Two of them per base pair is 43.96 Da, and
617.894 + 43.96 = 661.86.
That lands 0.28% away from the widely used 660 g/mol per bp constant and 1.79% away
from 650. So the rules of thumb are not sloppy averages of the free-acid mass — they are
approximately the sodium salt mass, and Biopython’s molecular_weight returns the
free acid. That is the whole disagreement:
| Da per bp | vs free acid | |
|---|---|---|
Biopython molecular_weight (free acid) | 617.894 | — |
| Free acid + 2 Na⁺ per bp (reconstructed) | 661.857 | +7.12% |
650 × bp rule of thumb | 650 | +5.20% |
660 × bp rule of thumb | 660 | +6.81% |
Ranking the two variables against each other, on 100 kb circular homopolymers so the
terminal correction is out of the picture (the 617.400 baseline is poly(A)/poly(T);
the 617.894 above is an ATGC repeat — that gap is the composition effect):
composition (0% GC → 100% GC): 617.400 → 618.388 Da/bp spread = 0.16%
salt form (free acid → Na salt): 617.400 → 661.363 Da/bp spread = 7.12%
ratio of the two effects: 44xSalt form matters 44× more than the entire GC range. If you take one thing from this page, take that ordering — it is the opposite of how the topic is usually taught.
What this does and does not establish. The
+2 Na⁺reconstruction is a calculation, and the fact that it lands within 0.28% of the conventional660constant is strong evidence for reading650/660as salt-form constants. It is not a citation. This page did not inspect the internals of any commercial calculator, and the “two tools disagree by 7%” framing at the top is a statement about the conventions, not a measured comparison of named products. If you need to reconcile your number with a particular tool, ask that tool’s documentation which form it reports.
Variable 3: terminal chemistry, and why it only bites on short fragments
Before trusting any of this you have to know what Biopython thinks the ends of your molecule look like. The cheapest way to find out is to ask it for a single nucleotide:
print(f"n=1 : {molecular_weight(Seq('A'), seq_type='DNA', double_stranded=False):.4f}")
print(f"per-residue increment : "
f"{molecular_weight(Seq('AAA'), seq_type='DNA', double_stranded=False) - molecular_weight(Seq('AA'), seq_type='DNA', double_stranded=False):.4f}")n=1 : 331.2218
per-residue increment : 313.2065331.22 is dAMP — deoxyadenosine 5′-monophosphate. Deoxyadenosine without the
phosphate is 251.24. So a linear n-mer in this model carries n phosphate groups:
one end is a terminal monophosphate, the other a free hydroxyl.
The circular=True flag corroborates it:
s = "ATGC" * 25 # 100 nt / 100 bp
lin_ss = molecular_weight(Seq(s), seq_type="DNA", double_stranded=False)
cir_ss = molecular_weight(Seq(s), seq_type="DNA", double_stranded=False, circular=True)
lin_ds = ds(s); cir_ds = ds(s, circular=True)
print(f"ssDNA linear={lin_ss:12.4f} circular={cir_ss:12.4f} diff={lin_ss-cir_ss:8.4f}")
print(f"dsDNA linear={lin_ds:12.4f} circular={cir_ds:12.4f} diff={lin_ds-cir_ds:8.4f}")ssDNA linear= 30912.7003 circular= 30894.6850 diff= 18.0153
dsDNA linear= 61825.4006 circular= 61789.3700 diff= 36.0306Exactly one water (18.0153) per strand: circularising joins that terminal 5′-phosphate
to the 3′-OH and releases H₂O. The phosphate count per strand is n either way, which is
also why the sodium correction in the previous section — 2 × 21.982 × bp — is the right
count for a linear duplex as well as a circular one.
Two practical consequences, and they point in opposite directions:
Restriction fragments already match this model. A fragment cut by a restriction enzyme carries a 5′-phosphate, which is exactly what Biopython gives you. No correction needed.
Unmodified synthetic oligos do not. An oligo delivered without a 5′-phosphate
modification has a 5′-OH, so it is HPO₃ = 79.9799 Da lighter than what Biopython
returns. Subtract it. That correction is also where the familiar oligo constant comes
from — starting from the sum of anhydrous residue masses, −79.9799 + 18.0153 = −61.9646,
and −61.96 is the number that shows up in the standard oligo mass formula.
On duplexes, moving both ends from phosphate to hydroxyl:
20 bp linear duplex free acid = 12,393.90 both ends OH instead: -159.96 Da = -1.291%
100 bp linear duplex free acid = 61,825.40 both ends OH instead: -159.96 Da = -0.259%
2686 bp linear duplex free acid = 1,659,697.52 both ends OH instead: -159.96 Da = -0.010%A fixed 160 Da correction is 1.3% of a 20-mer duplex and 0.01% of a plasmid. Terminal chemistry is an oligo problem, not a plasmid problem — which is the reverse of what people usually worry about.
The oligo case: the 330 per nt rule misses twice over
For single-stranded oligos the shortcut is MW ≈ 330 × n. Measured against a real
unmodified 5′-OH oligo — that is, Biopython’s value minus the 79.98 Da terminal
phosphate:
n (nt) Biopython (5-P) 5-OH oligo 330*n 330 vs 5-OH
10 3,106.99 3,027.01 3300 +9.02%
15 4,671.98 4,592.00 4950 +7.80%
20 6,196.95 6,116.97 6600 +7.90%
25 7,745.95 7,665.97 8250 +7.62%
30 9,285.93 9,205.95 9900 +7.54%
40 12,375.89 12,295.91 13200 +7.35%
60 18,554.83 18,474.85 19800 +7.17%
100 30,912.70 30,832.72 33000 +7.03%330 misses on both axes at once. It is a sodium-salt constant — the free-acid
per-nucleotide mass converges to 308.965 for this sequence, and 308.965 + 21.982 = 330.95 — and it assumes a phosphate on every nucleotide including the terminus. That is
why the error is worst on short oligos, precisely where people reach for it: +9.0% on a
10-mer, settling toward +7% by 100 nt.
(These are ATGC-repeat sequences. Unlike the duplex case, single-stranded composition
genuinely does matter, because there is no pairing to average it out — poly(A) is
313.225 Da/nt and poly(C) is 289.200 Da/nt, an 8.3% spread. For a real mixed-base
oligo, compute it; do not use 330 and do not use an average.)
The workflow: pUC19 digest → nM → ligation
Now the part that decides whether any of this changes what you pipette. Real vector, real insert, real cut coordinates.
Cut the vector
from Bio.Restriction import EcoRI, HindIII, BamHI, RestrictionBatch
rb = RestrictionBatch([EcoRI, HindIII, BamHI])
for enz, sites in rb.search(puc.seq, linear=False).items():
print(f"{str(enz):8} site={enz.site:10} cuts at {sites}")EcoRI site=GAATTC cuts at [397]
HindIII site=AAGCTT cuts at [448]
BamHI site=GGATCC cuts at [418]Single cutters, all three, 397 / 418 / 448 — the pUC19 multiple cloning site. Cutting with EcoRI and HindIII drops the 51 bp between them and leaves a 2,635 bp backbone:
cuts = sorted(p for v in RestrictionBatch([EcoRI, HindIII])
.search(puc.seq, linear=False).values() for p in v)
n = len(puc.seq); i0 = cuts[0] - 1
stuffer_bp = (cuts[1] - cuts[0]) % n
vector = (puc.seq + puc.seq)[i0 + stuffer_bp : i0 + n]
print(f"cut positions {cuts} → fragments {sorted([stuffer_bp, n-stuffer_bp], reverse=True)} bp")
print(f"backbone {len(vector)} bp GC={100*gc_fraction(vector):.2f}%")cut positions [397, 448] → fragments [2635, 51] bp
backbone 2635 bp GC=50.47%(For a sequence you have on hand rather than one you are scripting against, the restriction site finder does the same search without the imports.)
Take a real insert
The tet CDS annotated on pBR322 — 1,191 bp, and notably GC-rich at 61.5%, so if
composition were going to matter anywhere it would matter here:
tet = next(f for f in pbr.features
if f.type == "CDS" and f.qualifiers.get("gene") == ["tet"])
insert = tet.extract(pbr.seq)
print(f"{tet.location} {len(insert)} bp GC={100*gc_fraction(insert):.2f}%")[85:1276](+) 1191 bp GC=61.54%(Biopython prints locations 0-based half-open, so [85:1276] is the GenBank record’s
86..1276.)
Convert both to nM, four ways
def mw_free(seq): return molecular_weight(seq, seq_type="DNA", double_stranded=True)
def mw_na(seq): return mw_free(seq) + 2 * NA_H * len(seq)
def nM(ng_per_uL, mw): return ng_per_uL * 1e6 / mw
c_vec, c_ins = 38.5, 21.7 # ng/µL — illustrative readings, see limitationsvector (pUC19 EcoRI/HindIII backbone) 2635 bp GC=50.47%
free acid MW = 1,628,198.3 Na salt MW = 1,744,042.5 650*bp = 1,712,750
insert (pBR322 tet CDS) 1191 bp GC=61.54%
free acid MW = 736,083.3 Na salt MW = 788,444.0 650*bp = 774,150
convention vector nM insert nM insert/vector molar
free acid 23.646 29.480 1.2467
Na salt 22.075 27.523 1.2468
650*bp 22.478 28.031 1.2470
660*bp 22.138 27.606 1.2470Read those last two columns against each other, because this is the point of the whole page:
- The absolute molarity spans
22.075 → 23.646 nM, a 7.11% spread. Which convention you picked is now baked into a number you are about to report. - The molar ratio is
1.2467 / 1.2468 / 1.2470 / 1.2470— a spread of 0.020%, roughly 350× smaller. The convention very nearly cancels, because it applies almost the same multiplicative factor to both species.
“Almost” is doing real work in that sentence, and it is worth knowing why it is not
exactly zero. A convention of the form k × bp is exactly proportional to length, so it
cancels perfectly. The free-acid and sodium-salt masses are not quite proportional:
they carry a per-molecule terminal term and they respond slightly to base composition, and
the vector and insert here differ in both (2,635 bp at 50.5% GC versus 1,191 bp at 61.5%).
That residue is the 0.020%. It is negligible for two fragments of this size; it would not
be for, say, a 20-mer against a plasmid, where the terminal term is 1.3% of one species and
0.01% of the other.
The ligation, and why nobody noticed this before
Set up a 3:1 insert:vector ligation on 50 ng of vector:
free acid need 67.81 ng insert -> 3.13 uL at 21.7 ng/uL
Na salt need 67.81 ng insert -> 3.12 uL at 21.7 ng/uL
650*bp need 67.80 ng insert -> 3.12 uL at 21.7 ng/uL
kb-ratio rule need 67.80 ng insert -> 3.12 uLThe four masses agree to within 0.01 ng (67.81 / 67.81 / 67.80 / 67.80), which
after dividing by the insert concentration is a 0.01 µL difference in what you pipette —
3.13 versus 3.12 µL, i.e. below what any p10 will resolve. The last line is the
back-of-the-envelope
ng_insert = ng_vector × (kb_insert / kb_vector) × ratio that people have been using at
the bench for decades. For two fragments of this size it is not meaningfully worse than
computing every mass explicitly, because molecular weight is very nearly proportional to
length and the constant of proportionality divides out.
So the practical rule is:
| What you are computing | Does the MW convention matter? |
|---|---|
| Insert:vector ratio for a ligation | Effectively no — 0.020% here. Use the kb rule. |
| A ratio between two species of comparable length | Effectively no. |
| A ratio spanning wildly different lengths (oligo vs plasmid) | Some — the terminal term no longer cancels. |
| Absolute nM for NGS library pooling | Yes — 7%. |
| qPCR standard curve, copies/µL | Yes — 7%. |
| Reporting a molarity someone else will act on | Yes. State the convention. |
For the copies case, 1 nM = 6.022141 × 10⁸ copies/µL, so the 7% rides straight through:
2.0 ng/uL of a 500 bp fragment
free acid 6.4729 nM -> 3.8980e+09 copies/uL
Na salt 6.0429 nM -> 3.6391e+09 copies/uL
650*bp 6.1538 nM -> 3.7059e+09 copies/uLReference table: rule-of-thumb error on real pUC19 fragments
Prefix slices of pUC19, treated as linear duplexes, against the two constants:
| Length (bp) | GC% | Exact free-acid MW | 650 × bp error | 660 × bp error |
|---|---|---|---|---|
| 20 | 60.0 | 12,395.9 | +4.87% | +6.49% |
| 25 | 60.0 | 15,485.8 | +4.93% | +6.55% |
| 50 | 58.0 | 30,934.7 | +5.06% | +6.68% |
| 100 | 60.0 | 61,835.3 | +5.12% | +6.74% |
| 200 | 58.5 | 123,631.6 | +5.15% | +6.77% |
| 500 | 56.6 | 309,015.5 | +5.17% | +6.79% |
| 1,000 | 56.1 | 617,990.0 | +5.18% | +6.80% |
| 2,000 | 52.8 | 1,235,879.7 | +5.19% | +6.81% |
| 2,686 | 50.6 | 1,659,715.3 | +5.19% | +6.81% |
Whole circular plasmids, for comparison:
| Plasmid | bp | Exact free-acid MW | 650 × bp error | 660 × bp error |
|---|---|---|---|---|
pUC19 (L09137.2) | 2,686 | 1,659,679.3 | +5.20% | +6.81% |
pBR322 (J01749.1) | 4,361 | 2,694,796.0 | +5.19% | +6.81% |
The error levels off. From about 200 bp upward it sits at +5.2% / +6.8% and stops
moving, across every GC content in the table, because at that point it is almost purely the
salt form — which is length-independent. The drift at the short end (+4.87% at 20 bp) is
the terminal phosphate, whose fixed contribution shrinks as a fraction of the whole.
Checklist
- Ratios very nearly cancel; absolutes do not. Ligation setup, insert:vector — the convention moved the answer 0.020% here, so use the kb rule. NGS pooling, qPCR copies, any nM you hand to someone else — the convention is 7% of your answer.
- When it matters, say which convention you used. “4.2 nM (free acid)” is a number someone can reconcile. “4.2 nM” is not.
- Biopython returns the free acid, with a terminal phosphate. If you are building a
converter on
Bio.SeqUtils.molecular_weightand want it to agree with the660constant, add2 × 21.982 × bp. For ssDNA,21.982 × nt. - Stop worrying about GC content. 0.16% across the entire possible range.
- Do worry about ends on short fragments. Restriction fragments already match
Biopython’s model. An unmodified 5′-OH oligo is
79.98 Dalighter than it per end — 1.29% of a 20-mer single strand, and, at2 × 79.98for the two ends of a duplex, 1.29% of a 20 bp duplex. On a 2,686 bp plasmid the same two ends are 0.01%. - For single-stranded oligos, compute the mass from the sequence.
330 × nruns +9.0% high on a 10-mer against an unmodified 5′-OH oligo, and unlike the duplex case the base composition genuinely moves it — an 8.3% spread between poly(A) and poly(C).
Limitations of what is on this page
These are the things this page did not establish, stated so you do not have to guess:
- No DNA was measured. The
38.5,21.7and2.0 ng/µLreadings are illustrative inputs chosen to make the arithmetic concrete. Every molecular weight, ratio and percentage derived from them was computed; none of them came from a fluorometer. - No commercial calculator was inspected. The claim here is that
650,660and330are numerically consistent with a sodium-salt convention — verified by reconstructing617.894 + 2 × 21.982 = 661.857. This page does not assert which convention any particular vendor’s tool implements. - The sticky ends are approximated away, so the digest masses are not exact. EcoRI and
HindIII leave 4-nt 5′ overhangs, so the products are not blunt duplexes of exactly 2,635
and 1,191 bp. Eight nucleotides of overhang is
≈2,472 Da: 0.15% of the 2,635 bp backbone, 0.34% of the 1,191 bp insert, and 2.0% of a 200 bp fragment. Below a few hundred bp, model the ends explicitly. Everything in the workflow section is accurate to about that tolerance, which is well inside the 7% the page is about — but it is an approximation, not an exact mass. - The
−61.96oligo constant is presented as arithmetic, not as a citation. This page computes−79.9799 + 18.0153 = −61.9646from its own numbers and observes that it matches the constant in common use. It did not check any vendor’s implementation. molecular_weightuses average masses, not monoisotopic. That is the right choice for concentration work. It is the wrong choice if you are interpreting a mass spectrum.
Related reading
- DESeq2 in Python: Step-by-Step PyDESeq2 Tutorial — the downstream end of the workflow, once your libraries are pooled and sequenced.
- Differential Expression Analysis: Guide with R & Python Code — bulk and single-cell DE, with runnable code.
- Install Jupyter Notebook on macOS — for setting up the environment used above.
- nbconvert PDF Export Errors — worth having open when you try to
export this notebook as a PDF for a lab record, because
--to pdffails more often than it works on a fresh machine.