# Bio Applied Primer Design

> Design PCR/qPCR primers with primer3-py design_primers/calc_hairpin, Bio.SeqUtils Tm, and blastn specificity checks. Use when designing PCR, qPCR, cloning, or genotyping primers, or checking Tm/dimers/specificity.

- Skill: `pavel-kravchenko/bio-applied-primer-design` (Agent Skill)
- Install (CLI): `npx skillmds@latest add pavel-kravchenko/bio-applied-primer-design`
- Raw SKILL.md: https://api.skillmd.com/api/skills/pavel-kravchenko/bio-applied-primer-design/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: pavel-kravchenko (https://skillmd.com/u/pavel-kravchenko)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/pavel-kravchenko/bio-applied-primer-design

---


# 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 older `designPrimers`/camelCase API is deprecated — use the snake_case bindings.
- `biopython` >= 1.83 (`Bio.SeqUtils.MeltingTemp` for an independent nearest-neighbor Tm cross-check)
- `blastn` (BLAST+ >= 2.15) for specificity screening against a local or NCBI database
- Python >= 3.9

## Prerequisites

```bash
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.

```python
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.

```python
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.

```python
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_TARGET` is not the amplicon** — it marks a region that must fall *inside* the designed product, not the primer binding sites; forgetting to set `PRIMER_PRODUCT_SIZE_RANGE` around it can return primers that never actually flank your region of interest.
- **`dg` from `calc_hairpin`/`calc_heterodimer` is 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_TARGET` to force one primer across an exon-exon junction so genomic DNA isn't co-amplified.
- **BLAST specificity checks need `blastn-short`**, not default `blastn` — 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 primer3
- `bio-core-blast-searching` — full BLAST+ workflow (local blastdb setup, qblast, E-value/identity parsing) for deeper specificity screening
- `bio-core-biopython-essentials` — `Bio.Seq`/`Bio.SeqUtils` fundamentals used for Tm and reverse-complement operations

