AboutBlogMediaTags

DNA Molecular Weight in Python: A Runnable ng/µL → nM Workflow

Runcell Team,

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:

ComponentVersion
Biopython1.88
Python3.14.2
SequencesNCBI L09137.2 (pUC19, 2,686 bp) and J01749.1 (pBR322, 4,361 bp)
OSmacOS 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:

  1. the base composition of the sequence,
  2. the salt form you assume the DNA is in,
  3. 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 biopython

If 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.424

0% 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/bp

Each 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 bpvs free acid
Biopython molecular_weight (free acid)617.894
Free acid + 2 Na⁺ per bp (reconstructed)661.857+7.12%
650 × bp rule of thumb650+5.20%
660 × bp rule of thumb660+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: 44x

Salt 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 conventional 660 constant is strong evidence for reading 650/660 as 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.2065

331.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.0306

Exactly 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.95and 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 limitations
vector (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.2470

Read those last two columns against each other, because this is the point of the whole page:

“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 uL

The 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 computingDoes the MW convention matter?
Insert:vector ratio for a ligationEffectively no — 0.020% here. Use the kb rule.
A ratio between two species of comparable lengthEffectively no.
A ratio spanning wildly different lengths (oligo vs plasmid)Some — the terminal term no longer cancels.
Absolute nM for NGS library poolingYes — 7%.
qPCR standard curve, copies/µLYes — 7%.
Reporting a molarity someone else will act onYes. 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/uL

Reference 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 MW650 × bp error660 × bp error
2060.012,395.9+4.87%+6.49%
2560.015,485.8+4.93%+6.55%
5058.030,934.7+5.06%+6.68%
10060.061,835.3+5.12%+6.74%
20058.5123,631.6+5.15%+6.77%
50056.6309,015.5+5.17%+6.79%
1,00056.1617,990.0+5.18%+6.80%
2,00052.81,235,879.7+5.19%+6.81%
2,68650.61,659,715.3+5.19%+6.81%

Whole circular plasmids, for comparison:

PlasmidbpExact free-acid MW650 × bp error660 × bp error
pUC19 (L09137.2)2,6861,659,679.3+5.20%+6.81%
pBR322 (J01749.1)4,3612,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

  1. 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.
  2. When it matters, say which convention you used. “4.2 nM (free acid)” is a number someone can reconcile. “4.2 nM” is not.
  3. Biopython returns the free acid, with a terminal phosphate. If you are building a converter on Bio.SeqUtils.molecular_weight and want it to agree with the 660 constant, add 2 × 21.982 × bp. For ssDNA, 21.982 × nt.
  4. Stop worrying about GC content. 0.16% across the entire possible range.
  5. Do worry about ends on short fragments. Restriction fragments already match Biopython’s model. An unmodified 5′-OH oligo is 79.98 Da lighter than it per end — 1.29% of a 20-mer single strand, and, at 2 × 79.98 for 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%.
  6. For single-stranded oligos, compute the mass from the sequence. 330 × n runs +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:


© Runcell.RSS