PCR & qPCR Primer Design
When to Use
- Designing a forward/reverse primer pair for cloning a target region, with restriction sites to append
- Building a qPCR (SYBR or TaqMan) assay that needs a small amplicon and tightly matched primer Tms
- Designing genotyping primers (knockout/knock-in confirmation, allele-specific PCR, colony PCR screening)
- Cross-checking a candidate primer's Tm against a second model before ordering
- Screening a primer pair for hairpins, self-dimers, cross-dimers, or off-target genome binding
Version Compatibility
primer3-py>= 2.0 (primer3.bindings.design_primers,calc_tm/calc_hairpin/calc_homodimer/calc_heterodimer); wraps Primer3 core 2.6.1. The olderdesignPrimers/camelCase API is deprecated — use the snake_case bindings.biopython>= 1.83 (Bio.SeqUtils.MeltingTempfor an independent nearest-neighbor Tm cross-check)blastn(BLAST+ >= 2.15) for specificity screening against a local or NCBI database- Python >= 3.9
Prerequisites
pip install primer3-py biopython
# for local specificity screening:
makeblastdb -in genome.fa -dbtype nucl -out genome_db
- Familiarity with primer Tm/GC/amplicon-size tradeoffs and IUPAC/restriction-site basics
- Related skills:
bio-applied-genetic-engineering-in-silico(from-scratch NN Tm math, restriction-site appending),bio-core-blast-searching(full BLAST workflow)
Cloning / Standard PCR Primers
Goal: design a primer pair flanking a target region with explicit Tm/GC/size constraints, then optionally append restriction sites for directional cloning.
Approach: call primer3.bindings.design_primers with SEQUENCE_TEMPLATE + SEQUENCE_TARGET (region that must be inside the amplicon) and a PRIMER_PRODUCT_SIZE_RANGE; read back PRIMER_PAIR_NUM_RETURNED ranked candidates.
import primer3
def design_cloning_primers(template: str, target_start: int, target_len: int,
product_size_range: tuple = (150, 600),
re_site_fwd: str = '', re_site_rev: str = '',
target_tm: float = 60.0) -> list:
"""Design ranked forward/reverse primer pairs that amplify a product containing
template[target_start:target_start+target_len], appending restriction sites for cloning.
"""
seq_args = {
'SEQUENCE_ID': 'insert',
'SEQUENCE_TEMPLATE': template,
'SEQUENCE_TARGET': [target_start, target_len],
}
global_args = {
'PRIMER_TASK': 'generic',
'PRIMER_PICK_LEFT_PRIMER': 1,
'PRIMER_PICK_RIGHT_PRIMER': 1,
'PRIMER_OPT_SIZE': 20, 'PRIMER_MIN_SIZE': 18, 'PRIMER_MAX_SIZE': 27,
'PRIMER_OPT_TM': target_tm, 'PRIMER_MIN_TM': target_tm - 3, 'PRIMER_MAX_TM': target_tm + 3,
'PRIMER_MIN_GC': 40.0, 'PRIMER_MAX_GC': 60.0,
'PRIMER_MAX_POLY_X': 4,
'PRIMER_PRODUCT_SIZE_RANGE': [list(product_size_range)],
'PRIMER_NUM_RETURN': 5,
}
result = primer3.bindings.design_primers(seq_args, global_args)
pairs = []
for i in range(result.get('PRIMER_PAIR_NUM_RETURNED', 0)):
pairs.append({
'forward': re_site_fwd + result[f'PRIMER_LEFT_{i}_SEQUENCE'],
'reverse': re_site_rev + result[f'PRIMER_RIGHT_{i}_SEQUENCE'],
'product_size': result[f'PRIMER_PAIR_{i}_PRODUCT_SIZE'],
'fwd_tm': result[f'PRIMER_LEFT_{i}_TM'],
'rev_tm': result[f'PRIMER_RIGHT_{i}_TM'],
})
return pairs
if __name__ == '__main__':
tmpl = 'GCTTGCATGCCTGCAGGTCGACTCTAGAGGATCCCCCTACATTTTAGCATCAGTGAGTACAGCATGCTTACTGGAAGAGAGGGTCATGCAACAGATTAGGAGGTAAGTTTGCAAAGGCAGGCTAAGGAGGAGACGCACTGAATGCCATGGTAAGAACTCTGGAC'
pairs = design_cloning_primers(tmpl, target_start=40, target_len=60, re_site_fwd='GGATCC', re_site_rev='GAATTC')
assert len(pairs) > 0
assert pairs[0]['forward'].startswith('GGATCC')
print(f"best pair: fwd={pairs[0]['forward']} rev={pairs[0]['reverse']} product={pairs[0]['product_size']}bp")
qPCR Assay Design (with optional TaqMan probe)
Goal: design a qPCR-appropriate primer pair (small amplicon, narrow Tm window) and, optionally, an internal hydrolysis probe.
Approach: shrink PRIMER_PRODUCT_SIZE_RANGE to 70-150 bp, tighten the Tm window to 59-61C, and set PRIMER_PICK_INTERNAL_OLIGO=1 with a probe Tm ~8-10C above the primers (standard TaqMan design rule) so the probe binds before the primers extend.
import primer3
def design_qpcr_assay(template: str, target_start: int, target_len: int,
max_amplicon: int = 150, want_probe: bool = False) -> list:
"""Design qPCR primer pairs (amplicon <= max_amplicon bp) spanning the target region,
with an optional TaqMan-style internal probe (Tm ~10C hotter than the primers).
"""
seq_args = {
'SEQUENCE_ID': 'qpcr_target',
'SEQUENCE_TEMPLATE': template,
'SEQUENCE_TARGET': [target_start, target_len],
}
global_args = {
'PRIMER_TASK': 'generic',
'PRIMER_PICK_LEFT_PRIMER': 1, 'PRIMER_PICK_RIGHT_PRIMER': 1,
'PRIMER_PICK_INTERNAL_OLIGO': 1 if want_probe else 0,
'PRIMER_OPT_SIZE': 20, 'PRIMER_MIN_SIZE': 18, 'PRIMER_MAX_SIZE': 24,
'PRIMER_OPT_TM': 60.0, 'PRIMER_MIN_TM': 59.0, 'PRIMER_MAX_TM': 61.0,
'PRIMER_MIN_GC': 30.0, 'PRIMER_MAX_GC': 70.0,
'PRIMER_INTERNAL_OPT_TM': 70.0, 'PRIMER_INTERNAL_MIN_TM': 68.0, 'PRIMER_INTERNAL_MAX_TM': 72.0,
'PRIMER_INTERNAL_MIN_SIZE': 18, 'PRIMER_INTERNAL_MAX_SIZE': 27,
'PRIMER_PRODUCT_SIZE_RANGE': [[70, max_amplicon]],
'PRIMER_NUM_RETURN': 3,
}
result = primer3.bindings.design_primers(seq_args, global_args)
assays = []
for i in range(result.get('PRIMER_PAIR_NUM_RETURNED', 0)):
assay = {
'forward': result[f'PRIMER_LEFT_{i}_SEQUENCE'],
'reverse': result[f'PRIMER_RIGHT_{i}_SEQUENCE'],
'product_size': result[f'PRIMER_PAIR_{i}_PRODUCT_SIZE'],
}
if want_probe and f'PRIMER_INTERNAL_{i}_SEQUENCE' in result:
assay['probe'] = result[f'PRIMER_INTERNAL_{i}_SEQUENCE']
assays.append(assay)
return assays
if __name__ == '__main__':
tmpl = 'GCTTGCATGCCTGCAGGTCGACTCTAGAGGATCCCCCTACATTTTAGCATCAGTGAGTACAGCATGCTTACTGGAAGAGAGGGTCATGCAACAGATTAGGAGGTAAGTTTGCAAAGGCAGGCTAAGGAGGAGACGCACTGAATGCCATGGTAAGAACTCTGGAC'
assays = design_qpcr_assay(tmpl, target_start=50, target_len=20, want_probe=True)
assert all(a['product_size'] <= 150 for a in assays)
print(f"qPCR assay: {assays[0]}")
Primer QC: Tm Cross-Check, Dimers/Hairpins, Specificity
Goal: before ordering, verify Tm agreement across models, flag hairpin/dimer risk, and confirm the primers won't bind off-target elsewhere in the genome.
Approach: get a second Tm opinion from Bio.SeqUtils.MeltingTemp.Tm_NN, use calc_hairpin/calc_homodimer/calc_heterodimer (dG in cal/mol — below -9000 cal/mol at 3'-adjacent structures is a common "likely problematic" cutoff), and run blastn -task blastn-short against a local db to count near-perfect hits.
import subprocess
import tempfile
import os
import primer3
from Bio.SeqUtils import MeltingTemp as mt
def qc_primer_pair(fwd: str, rev: str, mv_conc: float = 50.0, dna_conc: float = 250.0,
dg_threshold: float = -9000.0) -> dict:
"""QC a primer pair: NN Tm cross-check, hairpin/homodimer/heterodimer risk (dG in cal/mol)."""
fwd_tm = mt.Tm_NN(fwd, Na=mv_conc)
rev_tm = mt.Tm_NN(rev, Na=mv_conc)
hp = [primer3.bindings.calc_hairpin(p, mv_conc=mv_conc, dna_conc=dna_conc) for p in (fwd, rev)]
homo = [primer3.bindings.calc_homodimer(p, mv_conc=mv_conc, dna_conc=dna_conc) for p in (fwd, rev)]
hetero = primer3.bindings.calc_heterodimer(fwd, rev, mv_conc=mv_conc, dna_conc=dna_conc)
hairpin_risk = any(r.structure_found and r.dg < dg_threshold for r in hp)
homodimer_risk = any(r.structure_found and r.dg < dg_threshold for r in homo)
heterodimer_risk = hetero.structure_found and hetero.dg < dg_threshold
return {
'fwd_tm_nn': fwd_tm, 'rev_tm_nn': rev_tm, 'tm_diff': abs(fwd_tm - rev_tm),
'hairpin_risk': hairpin_risk, 'homodimer_risk': homodimer_risk, 'heterodimer_risk': heterodimer_risk,
'flagged': abs(fwd_tm - rev_tm) > 2.0 or hairpin_risk or homodimer_risk or heterodimer_risk,
}
def check_specificity_blastn(primer_seq: str, db_path: str, word_size: int = 7) -> int:
"""Count blastn-short hits for `primer_seq` against a local nucleotide db with
>=90% identity over the full primer length (>1 hit => off-target binding risk).
Requires `makeblastdb -in genome.fa -dbtype nucl -out db_path` beforehand.
"""
with tempfile.NamedTemporaryFile('w', suffix='.fasta', delete=False) as f:
f.write(f'>primer\n{primer_seq}\n')
query_path = f.name
try:
cmd = ['blastn', '-task', 'blastn-short', '-query', query_path, '-db', db_path,
'-word_size', str(word_size), '-perc_identity', '90', '-outfmt', '6 length']
out = subprocess.run(cmd, capture_output=True, text=True, check=True).stdout
return sum(1 for line in out.strip().splitlines() if int(line) >= 0.9 * len(primer_seq))
finally:
os.remove(query_path)
if __name__ == '__main__':
fwd, rev = 'ATGCGTACGATCGATCGATG', 'CATCGATCGATCGTACGCAT' # deliberately near-perfect revcomp -> flags
report = qc_primer_pair(fwd, rev)
assert report['flagged'] is True and report['heterodimer_risk'] is True
print(report)
Pitfalls
- Primer3 Tm and Biopython Tm rarely match exactly — different NN parameter sets and salt-correction formulas give Tm within ~1-2C of each other, not identical values; treat them as a sanity cross-check, not a contradiction to chase to zero.
SEQUENCE_TARGETis not the amplicon — it marks a region that must fall inside the designed product, not the primer binding sites; forgetting to setPRIMER_PRODUCT_SIZE_RANGEaround it can return primers that never actually flank your region of interest.dgfromcalc_hairpin/calc_heterodimeris in cal/mol, not kcal/mol — comparing against a "-9" threshold intended for kcal/mol silently disables the check; always use the cal/mol scale (-9000) shown above.- qPCR amplicons that are too long or GC-rich hurt efficiency — keep amplicons 70-150 bp and avoid designing across a strong secondary-structure region; for RT-qPCR, set
SEQUENCE_TARGETto force one primer across an exon-exon junction so genomic DNA isn't co-amplified. - BLAST specificity checks need
blastn-short, not defaultblastn— the default word size (11) misses valid 18-25 bp primer matches, giving false confidence that a primer is unique.
See Also
bio-applied-genetic-engineering-in-silico— from-scratch Tm models (Wallace, SantaLucia NN) and restriction-site/cloning primer logic without primer3bio-core-blast-searching— full BLAST+ workflow (local blastdb setup, qblast, E-value/identity parsing) for deeper specificity screeningbio-core-biopython-essentials—Bio.Seq/Bio.SeqUtilsfundamentals used for Tm and reverse-complement operations