The "Tm = 4(G+C) + 2(A+T)" rule is easy to remember, but it ignores sequence context, salt, and primer concentration. For the primer Tm calculator on my side project, I implemented the nearest-neighbor (NN) model from SantaLucia (1998) and added the magnesium correction from Owczarzy et al. (2008). This post covers the model, the TypeScript, and how I test it.
The two-state model
We treat duplex formation as a two-state reaction: each strand is either fully single-stranded or fully paired in a duplex. With that assumption, the melting temperature is the temperature where the duplex is half formed:
Tm = ΔH° / (ΔS° + R · ln(C_T / x))
ΔH° (cal/mol) and ΔS° (cal/(K·mol)) are the total enthalpy and entropy of duplex formation, and R = 1.987 cal/(K·mol). The term x depends on how much of each strand is present (more on that below).
Unified NN parameters
The NN model assumes duplex stability is the sum of contributions from adjacent base-pair stacks. SantaLucia (1998) combined several earlier datasets into one "unified" set of 10 Watson–Crick stacks. Each stack is keyed by its top-strand 5'→3' dinucleotide. The reverse complement of a key (for example, CA and TG) is the same stack read from the other strand, so I store both keys:
type Thermo = { dH: number; dS: number }; // kcal/mol, cal/(K·mol)
// SantaLucia 1998, unified parameters (1 M NaCl)
const NN: Record = {
AA: { dH: -7.9, dS: -22.2 }, TT: { dH: -7.9, dS: -22.2 },
AT: { dH: -7.2, dS: -20.4 },
TA: { dH: -7.2, dS: -21.3 },
CA: { dH: -8.5, dS: -22.7 }, TG: { dH: -8.5, dS: -22.7 },
GT: { dH: -8.4, dS: -22.4 }, AC: { dH: -8.4, dS: -22.4 },
CT: { dH: -7.8, dS: -21.0 }, AG: { dH: -7.8, dS: -21.0 },
GA: { dH: -8.2, dS: -22.2 }, TC: { dH: -8.2, dS: -22.2 },
CG: { dH: -10.6, dS: -27.2 },
GC: { dH: -9.8, dS: -24.4 },
GG: { dH: -8.0, dS: -19.9 }, CC: { dH: -8.0, dS: -19.9 },
};
AT, TA, CG and GC are their own reverse complements, so each appears only once.
Initiation and terminal terms
The stacks alone don't account for the cost of starting a duplex. The unified set adds an initiation term at each end, and its value depends on whether the terminal pair is G·C or A·T. Self-complementary sequences also get a symmetry correction to ΔS.
const INIT_GC: Thermo = { dH: 0.1, dS: -2.8 };
const INIT_AT: Thermo = { dH: 2.3, dS: 4.1 };
const SYMMETRY_DS = -1.4;
const COMP: Record = { A: "T", T: "A", G: "C", C: "G" };
const revComp = (s: string) => [...s].reverse().map((b) => COMP[b]).join("");
function sumThermo(seq: string): Thermo {
let dH = 0;
let dS = 0;
for (let i = 0; i < seq.length - 1; i++) {
const p = NN[seq.slice(i, i + 2)];
dH += p.dH;
dS += p.dS;
}
for (const end of [seq[0], seq[seq.length - 1]]) {
const t = end === "G" || end === "C" ? INIT_GC : INIT_AT;
dH += t.dH;
dS += t.dS;
}
if (seq === revComp(seq)) dS += SYMMETRY_DS;
return { dH, dS };
}
Monovalent salt correction
The parameters are for 1 M Na⁺. SantaLucia applies the salt effect to entropy only:
ΔS°[Na⁺] = ΔS°[1 M] + 0.368 · (N − 1) · ln[Na⁺]
Here N is the oligo length and [Na⁺] is in molar. ΔH° doesn't change.
The primer concentration term: why C_T, not C_T/4
The textbook formula for two non-self-complementary strands at equal concentration uses ln(C_T/4). At the Tm, half of each strand is paired, so [duplex] = [A] = [B] = C_T/4. The association constant is then K = (C_T/4) / (C_T/4)² = 4/C_T.
That's not what happens in PCR. The primer is in large excess over its template. At the Tm, half of the template is bound, and the free primer concentration has barely changed from C_T. The constant becomes K = [D] / ([primer][template]) ≈ 1/C_T, so the term is ln(C_T).
Biopython's Tm_NN has a two-concentration convention for this: dnac1 is the primer and dnac2 is the complement. Its effective concentration is dnac1 − dnac2/2. With dnac2 = 0 that reduces to C_T, which is the model I use. The default is 250 nM primer.
const R = 1.987; // cal/(K·mol)
function tmNa(seq: string, naM = 0.05, primerM = 250e-9): number {
const { dH, dS } = sumThermo(seq);
const dSsalt = dS + 0.368 * (seq.length - 1) * Math.log(naM);
return (dH * 1000) / (dSsalt + R * Math.log(primerM)) - 273.15;
}
Calling tmNa(seq, 1) gives the 1 M Na⁺ Tm (ln 1 = 0). The Mg²⁺ correction below starts from that value.
Mg²⁺ correction (Owczarzy 2008)
PCR buffers contain Mg²⁺, and Mg²⁺ stabilizes duplexes much more strongly than Na⁺ does. Owczarzy et al. (2008) fit a correction to 1/Tm, starting from the 1 M Na⁺ Tm:
1/Tm(Mg) = 1/Tm(1 M Na⁺) + a + b·ln[Mg] + f_GC·(c + d·ln[Mg])
+ (e + f·ln[Mg] + g·ln²[Mg]) / (2(N − 1))
The constants are a = 3.92×10⁻⁵, b = −9.11×10⁻⁶, c = 6.26×10⁻⁵, d = 1.42×10⁻⁵, e = −4.82×10⁻⁴, f = 5.25×10⁻⁴, g = 8.31×10⁻⁵. f_GC is the GC fraction and N is the length in base pairs.
dNTPs bind Mg²⁺ first. What matters is free Mg²⁺. With Ka = 3×10⁴ M⁻¹ for Mg·dNTP, the binding equilibrium becomes a quadratic in free Mg:
function freeMg(mgTotal: number, dntp: number, ka = 3e4): number {
if (dntp >= mgTotal) return 0; // fallback: treat Mg2+ as fully chelated
const b = ka * dntp - ka * mgTotal + 1;
return (-b + Math.sqrt(b * b + 4 * ka * mgTotal)) / (2 * ka);
}
The fallback is a deliberate simplification. If dNTP ≥ Mg²⁺, almost all of the magnesium is chelated, so I treat free Mg²⁺ as negligible and use the monovalent-only path.
Which ion dominates? Owczarzy uses the ratio R = √[Mg²⁺] / [Mon⁺]:
R < 0.22: monovalent ions dominate, so use the monovalent correction.
0.22 ≤ R < 6: both matter, so a, d and g are adjusted using [Mon⁺].
R ≥ 6: Mg²⁺ dominates, so use the constants unchanged.
function tmMg(tm1M: number, seq: string, mg: number, mon: number): number {
const lnMg = Math.log(mg);
const n = seq.length;
const fGC = [...seq].filter((b) => b === "G" || b === "C").length / n;
let a = 3.92e-5, d = 1.42e-5, g = 8.31e-5;
const b = -9.11e-6, c = 6.26e-5, e = -4.82e-4, f = 5.25e-4;
if (mon > 0 && Math.sqrt(mg) / mon < 6) {
// the caller has already sent R < 0.22 to the monovalent path
const l = Math.log(mon);
a = 3.92e-5 * (0.843 - 0.352 * Math.sqrt(mon) * l);
d = 1.42e-5 * (1.279 - 4.03e-3 * l - 8.03e-3 * l * l);
g = 8.31e-5 * (0.486 - 0.258 * l + 5.25e-3 * l ** 3);
}
const inv =
1 / (tm1M + 273.15) + a + b * lnMg + fGC * (c + d * lnMg) +
(e + f * lnMg + g * lnMg * lnMg) / (2 * (n - 1));
return 1 / inv - 273.15;
}
All concentrations are in molar. The default monovalent concentration is 50 mM.
Testing
Floating-point thermodynamics is easy to get slightly wrong: a swapped sign, a missing ×1000, or the wrong log base. These mistakes still give plausible-looking numbers. So the tests do two things.
Pin a published value. Owczarzy et al. (2008) give a worked example: AAGGCGAGTCAGGCTCAGTG has a Tm of 76.3 °C at 1 M Na⁺, which corrects to 67.9 °C at 1.5 mM Mg²⁺. That makes a clean test of the Mg step on its own:
it("matches Owczarzy 2008 worked example", () => {
expect(tmMg(76.3, "AAGGCGAGTCAGGCTCAGTG", 1.5e-3, 0)).toBeCloseTo(67.9, 1);
});
Check directions and invariants. Tm should rise with [Na⁺] and with [Mg²⁺], and fall when dNTPs bind Mg. A sequence and its reverse complement should give the same Tm. Input validation rejects anything other than A/C/G/T and anything shorter than 10 bases.
For primer pairs, the calculator suggests an anneal temperature of the lower Tm − 5 °C. This is only a starting point for a gradient, not an optimized value.
Limits
This model covers perfectly matched, non-degenerate DNA duplexes under the two-state assumption. It does not handle:
degenerate bases (N, R, Y, …)
mismatches, dangling ends, or hairpins
polymerase-specific buffers (additives like DMSO, betaine, or proprietary salts)
If your polymerase vendor publishes its own Tm calculator for its buffer, use that one for that buffer.
References
SantaLucia J. Jr. (1998). A unified view of polymer, dumbbell, and oligonucleotide DNA nearest-neighbor thermodynamics. PNAS 95:1460–1465.
Owczarzy R., Moreira B.G., You Y., Behlke M.A., Walder J.A. (2008). Predicting stability of DNA duplexes in solutions containing magnesium and monovalent cations. Biochemistry 47:5336–5353. DOI: 10.1021/bi702363u
If you'd like to try it, the calculator is at molicheck.com/primer-tm. It runs in the browser and puts your inputs in a shareable URL. The formulas will be published at https://github.com/volvetech/molicheck-formulas.
Top comments (0)