Qiskit Intermediate Free 52/61 in series 30 min

Qiskit Sampler and Estimator: The Primitives API

How to use Qiskit's Sampler and Estimator primitives for efficient quantum circuit execution on real hardware and simulators.

What you'll learn

  • Qiskit Runtime
  • primitives
  • Sampler
  • Estimator
  • quantum circuits

Prerequisites

  • Python proficiency
  • Beginner quantum computing concepts (superposition, entanglement)
  • Linear algebra basics

Qiskit’s primitives API introduced a cleaner, hardware-agnostic interface for running quantum circuits. Instead of directly calling backend.run(), you interact with two high-level abstractions: Sampler and Estimator. These are the building blocks for most modern Qiskit workflows, and understanding them deeply will save you significant time as you move from simulators to real quantum hardware.

Why Primitives Exist

Before primitives, every Qiskit program followed the same tedious pattern. You wanted to compute the expectation value of some Hamiltonian, so you had to:

  1. Decompose the Hamiltonian into individual Pauli terms (e.g., 0.5 * ZZ + 0.3 * XI + 0.2 * IZ).
  2. Construct a separate measurement circuit for each Pauli term, appending the correct basis rotations and measure() calls.
  3. Submit each circuit as an individual job to the backend with backend.run().
  4. Collect counts from every job, compute each Pauli expectation value from the measurement statistics, and sum them with the original coefficients.

This was error-prone and verbose. Worse, it tied your code to a specific backend. Switching from a local simulator to IBM hardware required rewriting the job submission and result handling logic.

Primitives solve both problems. The Estimator handles Hamiltonian decomposition, measurement circuit construction, job submission, and result aggregation internally. The Sampler handles circuit execution and returns probability distributions directly. And crucially, the same code that runs on a local StatevectorEstimator runs identically on IBM hardware through EstimatorV2, with no changes to your application logic.

This portability is the key design principle: write your algorithm once, run it anywhere.

What Are Primitives?

Primitives abstract away backend-specific details and give you a consistent interface across simulators and real IBM quantum hardware. There are two:

  • Sampler returns quasi-probability distributions (measurement outcomes) from a circuit. Use it when you need full bitstring statistics.
  • Estimator returns expectation values of observables given a circuit and a set of Pauli operators. Use it when you need scalar values like energy.

Both primitives are available locally via qiskit_aer or remotely via qiskit_ibm_runtime.

Setting Up

Install the required packages:

pip install qiskit qiskit-aer qiskit-ibm-runtime

Using the Sampler

The Sampler runs a circuit and returns a probability distribution over bitstrings.

from qiskit import QuantumCircuit
from qiskit_aer.primitives import Sampler

# Build a simple Bell state circuit with measurements
qc = QuantumCircuit(2)
qc.h(0)       # Put qubit 0 into superposition
qc.cx(0, 1)   # Entangle qubit 0 and qubit 1
qc.measure_all()

sampler = Sampler()
job = sampler.run([qc])
result = job.result()

print(result.quasi_dists[0])
# Output: {0: 0.5, 3: 0.5}  (binary 00 and 11)

The result is a QuasiDistribution mapping integer bitstring labels to probabilities. Integer 0 corresponds to 00 and 3 to 11 (in Qiskit’s little-endian bit ordering, where qubit 0 is the least significant bit).

Quasi-Distributions Explained

You might wonder why the result type is called QuasiDistribution instead of just “distribution.” The reason is that when error mitigation is active (which it is by default at resilience level 1 and above), the mitigation process can produce slightly negative values.

For example, you might see output like:

{0: 0.51, 3: 0.50, 1: -0.01}

This happens because readout error mitigation techniques (such as M3 or TREX) work by inverting a calibrated noise matrix. The inversion is a linear algebra operation, and it does not enforce that all output values remain non-negative. The result is a quasi-probability distribution: it sums to 1.0, but individual entries can be slightly negative.

These negative values are not physical probabilities. They are artifacts of the mitigation math. For most applications, you want to convert the quasi-distribution to a proper probability distribution before interpreting it:

from qiskit import QuantumCircuit
from qiskit_aer.primitives import Sampler

qc = QuantumCircuit(2)
qc.h(0)
qc.cx(0, 1)
qc.measure_all()

sampler = Sampler()
job = sampler.run([qc])
result = job.result()

quasi_dist = result.quasi_dists[0]
print("Quasi-distribution:", quasi_dist)

# Convert to nearest valid probability distribution
prob_dist = quasi_dist.nearest_probability_distribution()
print("Probability distribution:", prob_dist)

The nearest_probability_distribution() method finds the closest valid probability distribution (in L2 norm) by redistributing the negative mass. This is the standard approach when you need strictly non-negative probabilities for downstream processing.

Using the Estimator

The Estimator computes expectation values of Pauli observables without requiring explicit measurements in the circuit.

from qiskit import QuantumCircuit
from qiskit.quantum_info import SparsePauliOp
from qiskit.primitives import StatevectorEstimator
from qiskit.circuit import Parameter

theta = Parameter("theta")

# Parameterized circuit (no measurements needed for Estimator)
qc = QuantumCircuit(2)
qc.ry(theta, 0)
qc.cx(0, 1)

# Observable: ZZ
observable = SparsePauliOp("ZZ")

estimator = StatevectorEstimator()
job = estimator.run([(qc, observable, [0.5])])
result = job.result()

print(result[0].data.evs)
# Expectation value of ZZ at theta=0.5

Notice that the circuit has no measure() or measure_all() calls. The Estimator handles measurement internally by computing <psi|O|psi> for each observable O. In fact, adding measurements to a circuit you pass to StatevectorEstimator is a common mistake: it ignores them, but they add confusion.

StatevectorEstimator vs AerEstimator vs EstimatorV2

Qiskit provides several Estimator implementations, and choosing the right one depends on your workflow stage.

StatevectorEstimator (from qiskit.primitives) computes expectation values using exact statevector simulation. There are no shots, no sampling noise, and no hardware noise. This is ideal for algorithm development and debugging because you get mathematically exact results. Use it when you want to verify that your circuit logic is correct before introducing noise.

from qiskit.primitives import StatevectorEstimator

# Exact simulation, no noise, no shot noise
estimator = StatevectorEstimator()

AerEstimator (from qiskit_aer.primitives) uses shot-based simulation with optional noise models. This lets you study how your algorithm behaves under realistic sampling statistics and device noise without consuming real QPU time. Use it when you want to test noise resilience or estimate how many shots you need.

from qiskit_aer.primitives import Estimator as AerEstimator
from qiskit_aer.noise import NoiseModel, depolarizing_error

# Build a simple noise model
noise_model = NoiseModel()
noise_model.add_all_qubit_quantum_error(depolarizing_error(0.01, 1), ["rx", "ry", "rz"])
noise_model.add_all_qubit_quantum_error(depolarizing_error(0.02, 2), ["cx"])

estimator = AerEstimator(
    backend_options={"noise_model": noise_model},
    run_options={"shots": 4096},
)

EstimatorV2 (from qiskit_ibm_runtime) runs on real IBM quantum hardware. This is what you use for production workloads. It includes built-in error mitigation and supports Sessions for efficient multi-job workflows.

from qiskit_ibm_runtime import QiskitRuntimeService, EstimatorV2, Session

service = QiskitRuntimeService(channel="ibm_quantum")
backend = service.least_busy(operational=True, simulator=False)

with Session(backend=backend) as session:
    estimator = EstimatorV2(mode=session)

A typical development workflow progresses through all three: StatevectorEstimator for correctness checking, AerEstimator for noise studies, and EstimatorV2 for real hardware execution.

The PUB Format in Depth

On IBM hardware, the V2 primitives accept circuits in PUB (Primitive Unified Bloc) format. Understanding PUBs is essential for efficient hardware usage.

SamplerV2 PUB Format

A SamplerV2 PUB is a tuple of up to three elements:

(circuit, parameter_values, shots)
  • circuit: A transpiled (ISA-compliant) quantum circuit with measurements.
  • parameter_values: A numpy array or list of parameter bindings. Pass an empty list [] if the circuit has no parameters.
  • shots: (Optional) Number of shots for this specific PUB. If omitted, the Sampler uses its default shot count from options.

EstimatorV2 PUB Format

An EstimatorV2 PUB is a tuple of up to four elements:

(circuit, observables, parameter_values, precision)
  • circuit: A transpiled (ISA-compliant) quantum circuit without measurements.
  • observables: A SparsePauliOp or list of SparsePauliOp instances to evaluate.
  • parameter_values: (Optional) Parameter bindings.
  • precision: (Optional) Target precision for the expectation value estimate. The runtime calculates how many shots are needed to achieve this precision.

Batched Parameter Sweeps with PUBs

One of the most powerful features of the PUB format is batched parameter sweeps. Instead of submitting one job per parameter value, you pass a 2D array of parameters in a single PUB. The primitive evaluates all parameter sets in one job, which dramatically reduces queue overhead.

import numpy as np
from qiskit import QuantumCircuit
from qiskit.circuit import Parameter
from qiskit.quantum_info import SparsePauliOp
from qiskit.primitives import StatevectorEstimator

theta = Parameter("theta")
qc = QuantumCircuit(2)
qc.ry(theta, 0)
qc.cx(0, 1)

observable = SparsePauliOp("ZZ")

# Sweep theta from 0 to pi in 20 steps
# Shape must be (num_parameter_sets, num_parameters)
param_values = np.linspace(0, np.pi, 20).reshape(-1, 1)

estimator = StatevectorEstimator()
# Single PUB with 20 parameter sets
job = estimator.run([(qc, observable, param_values)])
result = job.result()

# result[0].data.evs is an array of 20 expectation values
evs = result[0].data.evs
for i, (angle, ev) in enumerate(zip(param_values.flatten(), evs)):
    print(f"theta={angle:.3f}  <ZZ>={ev:.4f}")

This is the key efficiency feature of PUBs. When running on hardware, a batched parameter sweep runs as a single queued job rather than 20 separate jobs, saving both queue wait time and classical overhead.

Running on IBM Hardware via Runtime (V2 API)

On IBM Quantum hardware, use SamplerV2 and EstimatorV2 from qiskit_ibm_runtime. These accept circuits in PUB format, which replaces the older list-of-tuples interface.

from qiskit import QuantumCircuit
from qiskit_ibm_runtime import QiskitRuntimeService, SamplerV2, Session
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager

service = QiskitRuntimeService(channel="ibm_quantum")
backend = service.least_busy(operational=True, simulator=False)

# Build a Bell state circuit
qc = QuantumCircuit(2)
qc.h(0)
qc.cx(0, 1)
qc.measure_all()

# Transpile to ISA (Instruction Set Architecture) before submitting
pm = generate_preset_pass_manager(backend=backend, optimization_level=1)
isa_qc = pm.run(qc)

with Session(backend=backend) as session:
    sampler = SamplerV2(mode=session)
    # PUB for Sampler: (circuit, param_values, shots)
    pub = (isa_qc, [], 1024)
    job = sampler.run([pub])
    result = job.result()
    counts = result[0].data.meas.get_counts()
    print(counts)
    # {'00': ~512, '11': ~512}

For the Estimator, PUBs are (circuit, observables, param_values):

from qiskit_ibm_runtime import EstimatorV2, Session
from qiskit.quantum_info import SparsePauliOp

observable = SparsePauliOp("ZZ")

with Session(backend=backend) as session:
    estimator = EstimatorV2(mode=session)
    pub = (isa_qc, [observable])
    job = estimator.run([pub])
    result = job.result()
    ev = result[0].data.evs
    print(f"<ZZ> = {ev:.4f}")

Batching Multiple Circuits (V2)

Pass multiple PUBs to reduce job overhead:

from qiskit_ibm_runtime import EstimatorV2, Session
from qiskit.quantum_info import SparsePauliOp

pubs = [
    (isa_qc1, [SparsePauliOp("ZZ")]),
    (isa_qc2, [SparsePauliOp("XI")]),
    (isa_qc3, [SparsePauliOp("IZ")]),
]

with Session(backend=backend) as session:
    estimator = EstimatorV2(mode=session)
    job = estimator.run(pubs)
    results = job.result()
    for i, res in enumerate(results):
        print(f"Circuit {i}: <O> = {res.data.evs:.4f}")

Sessions vs Batch Mode

When running multiple jobs on IBM hardware, how you manage QPU access matters for both performance and cost.

Session Mode

With a Session, all jobs share a reserved slice of QPU time. Once your first job starts executing, the backend holds your reservation open so that subsequent jobs in the same session skip the queue. This is critical for iterative algorithms like VQE, where each optimizer step depends on the previous result.

from qiskit_ibm_runtime import QiskitRuntimeService, EstimatorV2, Session

service = QiskitRuntimeService(channel="ibm_quantum")
backend = service.least_busy(operational=True, simulator=False)

with Session(backend=backend) as session:
    estimator = EstimatorV2(mode=session)
    # Job 1 queues normally
    job1 = estimator.run([pub1])
    result1 = job1.result()
    
    # Job 2 skips the queue because the session holds the reservation
    job2 = estimator.run([pub2])
    result2 = job2.result()

Without a Session, each job queues independently. If the queue has 50 jobs ahead of you, your second job waits behind all of them, even though it is logically part of the same workflow.

Batch Mode

Starting with qiskit_ibm_runtime 0.20+, Batch mode provides an alternative. Batch mode groups multiple independent jobs together, allowing the runtime to schedule them efficiently without holding a persistent QPU reservation. This works well when your jobs do not depend on each other.

from qiskit_ibm_runtime import QiskitRuntimeService, SamplerV2, Batch

service = QiskitRuntimeService(channel="ibm_quantum")
backend = service.least_busy(operational=True, simulator=False)

with Batch(backend=backend) as batch:
    sampler = SamplerV2(mode=batch)
    # These jobs are independent and can execute in any order
    job_a = sampler.run([pub_a])
    job_b = sampler.run([pub_b])
    job_c = sampler.run([pub_c])

# Collect results after the batch completes
result_a = job_a.result()
result_b = job_b.result()
result_c = job_c.result()

When to use which:

  • Use Session for iterative algorithms where job N depends on the result of job N-1 (VQE, QAOA with warm starts, adaptive circuits).
  • Use Batch for embarrassingly parallel workloads where all jobs are independent (parameter sweeps across different circuits, benchmarking suites).

Full VQE Example with EstimatorV2

Variational Quantum Eigensolver (VQE) is the canonical use case for the Estimator primitive. Here is a complete energy minimization loop for the hydrogen molecule (H2) at bond length 0.735 angstroms.

import numpy as np
from qiskit import QuantumCircuit
from qiskit.circuit import Parameter
from qiskit.quantum_info import SparsePauliOp
from qiskit.primitives import StatevectorEstimator
from scipy.optimize import minimize

# H2 Hamiltonian (simplified, in the STO-3G basis after qubit mapping)
hamiltonian = SparsePauliOp.from_list([
    ("II", -1.0523732),
    ("IZ",  0.3979374),
    ("ZI", -0.3979374),
    ("ZZ", -0.0112801),
    ("XX",  0.1809312),
])

# Parameterized ansatz: a hardware-efficient 2-qubit circuit
theta0 = Parameter("t0")
theta1 = Parameter("t1")
theta2 = Parameter("t2")

ansatz = QuantumCircuit(2)
ansatz.ry(theta0, 0)
ansatz.ry(theta1, 1)
ansatz.cx(0, 1)
ansatz.ry(theta2, 1)

estimator = StatevectorEstimator()

def cost_function(params):
    """Evaluate <psi(params)|H|psi(params)> using the Estimator."""
    param_values = np.array(params).reshape(1, -1)
    pub = (ansatz, hamiltonian, param_values)
    job = estimator.run([pub])
    result = job.result()
    energy = result[0].data.evs[0]
    return energy

# Initial random parameters
x0 = np.random.uniform(-np.pi, np.pi, size=3)

# Classical optimization loop
opt_result = minimize(cost_function, x0, method="COBYLA", options={"maxiter": 200})

print(f"Optimal energy: {opt_result.fun:.6f} Ha")
print(f"Optimal parameters: {opt_result.x}")
print(f"Exact ground state energy: -1.137284 Ha")

The VQE loop works as follows:

  1. The classical optimizer proposes a set of parameter values.
  2. The Estimator evaluates the expectation value of the Hamiltonian at those parameters.
  3. The optimizer reads the energy and proposes new parameters.
  4. Steps 2 and 3 repeat until the energy converges.

The Estimator handles all the complexity of decomposing the Hamiltonian into Pauli terms, constructing measurement circuits, and aggregating results. Your code only needs to pass the SparsePauliOp and read back a scalar.

To run the same VQE on real hardware, replace StatevectorEstimator() with EstimatorV2(mode=session) and transpile the ansatz first. Everything else stays the same.

QAOA with SamplerV2

Quantum Approximate Optimization Algorithm (QAOA) uses the Sampler rather than the Estimator. While VQE computes energy as a scalar expectation value, QAOA samples bitstrings from the final state and picks the best one as the solution to a combinatorial optimization problem.

import numpy as np
from qiskit import QuantumCircuit
from qiskit.circuit import Parameter
from qiskit.primitives import StatevectorSampler

# Simple MaxCut on a triangle graph (3 nodes, 3 edges)
# Edges: (0,1), (1,2), (0,2)
# Cost function: number of edges cut by the bitstring partition

gamma = Parameter("gamma")
beta = Parameter("beta")

# QAOA circuit for 1 layer (p=1)
qaoa = QuantumCircuit(3)

# Initial superposition
qaoa.h(range(3))

# Cost unitary: exp(-i * gamma * C)
# For each edge (i,j), apply RZZ(gamma)
qaoa.rzz(gamma, 0, 1)
qaoa.rzz(gamma, 1, 2)
qaoa.rzz(gamma, 0, 2)

# Mixer unitary: exp(-i * beta * B)
qaoa.rx(2 * beta, range(3))

qaoa.measure_all()

sampler = StatevectorSampler()

def qaoa_cost(params):
    """Sample bitstrings and compute average cut value."""
    gamma_val, beta_val = params
    pub = (qaoa, [gamma_val, beta_val], 4096)
    job = sampler.run([pub])
    result = job.result()
    counts = result[0].data.meas.get_counts()
    
    # Evaluate MaxCut cost for each sampled bitstring
    total_cost = 0.0
    total_shots = sum(counts.values())
    
    for bitstring, count in counts.items():
        bits = [int(b) for b in bitstring]
        # Count edges where endpoints differ
        cut = 0
        edges = [(0, 1), (1, 2), (0, 2)]
        for i, j in edges:
            if bits[i] != bits[j]:
                cut += 1
        total_cost += cut * count
    
    return -total_cost / total_shots  # Negative because we minimize

from scipy.optimize import minimize

x0 = [np.pi / 4, np.pi / 8]
opt_result = minimize(qaoa_cost, x0, method="COBYLA", options={"maxiter": 100})

# Run one more time with optimal parameters to get the best bitstring
pub = (qaoa, list(opt_result.x), 4096)
job = sampler.run([pub])
result = job.result()
counts = result[0].data.meas.get_counts()

best_bitstring = max(counts, key=counts.get)
print(f"Best bitstring: {best_bitstring}")
print(f"Sample counts: {counts}")

The key difference from VQE: QAOA cares about the individual bitstrings it samples, not just a scalar average. That is why it uses the Sampler. The best bitstring encodes the approximate solution to the optimization problem.

Choosing Between Sampler and Estimator

Use SamplerV2 when you need full bitstring distributions, such as in QAOA or variational classifiers where you interpret measurement counts directly.

Use EstimatorV2 when you need expectation values of observables, such as in VQE where you evaluate energy as <psi|H|psi>. EstimatorV2 avoids manually constructing measurement circuits for each Pauli term and handles observable decomposition internally.

A simple rule of thumb: if your algorithm’s answer is a bitstring, use Sampler. If your algorithm’s answer is a number (expectation value), use Estimator.

Error Mitigation: Resilience Levels

EstimatorV2 accepts EstimatorOptions for enabling error mitigation. The resilience_level setting controls how aggressively the runtime mitigates hardware noise, trading QPU time for accuracy.

LevelTechniquesQPU OverheadWhen to Use
0No mitigation1x (baseline)Quick tests, debugging, when you need raw hardware results
1Readout error mitigation + dynamical decoupling~1.5xDefault for most workflows; good accuracy with moderate cost
2Level 1 + zero-noise extrapolation (ZNE)3-5xWhen you need higher accuracy and can afford the extra shots
3Probabilistic error cancellation (PEC)20-100xResearch-grade accuracy requirements; very expensive

Level 0 returns raw hardware results. No corrections are applied. This is the fastest option but also the noisiest.

Level 1 applies two techniques. Readout error mitigation corrects for measurement errors (qubits that flip during readout). Dynamical decoupling inserts identity-equivalent pulse sequences during idle periods to suppress decoherence. This is the recommended default for production workloads.

Level 2 adds zero-noise extrapolation (ZNE). The runtime runs your circuit at multiple amplified noise levels (for example, by inserting extra CNOT pairs that logically cancel but physically add noise). It then extrapolates the results back to the zero-noise limit. This requires 3 to 5 times more QPU time because the circuit runs at multiple noise scale factors.

Level 3 uses probabilistic error cancellation (PEC). This technique represents the ideal (noiseless) circuit as a quasi-probability distribution over noisy circuits. It samples from this distribution and combines the results. PEC can achieve near-exact results but requires 20 to 100 times more shots, making it impractical for large circuits. It is primarily a research tool.

from qiskit_ibm_runtime import EstimatorV2, Session
from qiskit_ibm_runtime.options import EstimatorOptions

options = EstimatorOptions()
options.resilience_level = 1  # Readout mitigation + dynamical decoupling

# For higher accuracy (at higher cost):
# options.resilience_level = 2  # Adds ZNE (3-5x QPU time)

with Session(backend=backend) as session:
    estimator = EstimatorV2(mode=session, options=options)
    job = estimator.run([pub])
    result = job.result()

Migrating from V1 to V2 Primitives

The V1 primitives (Sampler and Estimator without the V2 suffix in qiskit_ibm_runtime) are deprecated as of qiskit_ibm_runtime 0.21+. If you have existing V1 code, here are the key differences.

Input format changed. V1 used positional lists of circuits, observables, and parameters:

# V1 (deprecated)
job = estimator.run([circuit], [observable], [param_values])

V2 uses PUBs, which group related data into tuples:

# V2 (current)
pub = (circuit, observable, param_values)
job = estimator.run([pub])

Result structure changed. V1 returned a flat result object:

# V1 result access
energy = result.values[0]

V2 returns a list of PUB results, each with a data namespace:

# V2 result access
energy = result[0].data.evs

Session usage changed. V1 primitives accepted session as a constructor argument. V2 uses the mode parameter, which also accepts Batch objects:

# V1 (deprecated)
estimator = Estimator(session=session)

# V2 (current)
estimator = EstimatorV2(mode=session)

Common Mistakes

Forgetting to transpile to ISA

Real IBM backends only accept circuits expressed in their native gate set (the Instruction Set Architecture). If you submit an untranspiled circuit, the job will fail with a transpilation error or produce incorrect results.

from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager

# Always transpile before submitting to hardware
pm = generate_preset_pass_manager(backend=backend, optimization_level=1)
isa_circuit = pm.run(circuit)

This step is not needed for local simulators like StatevectorEstimator or AerEstimator, which accept arbitrary gates.

Adding measurements to Estimator circuits

The Estimator computes expectation values analytically (for statevector) or by internally constructing the correct measurement circuits (for hardware). If you add measure_all() to a circuit before passing it to StatevectorEstimator, the estimator ignores those measurements. This does not cause an error, but it adds confusion and suggests a misunderstanding of how the primitive works.

# Wrong: unnecessary measurements
qc = QuantumCircuit(2)
qc.h(0)
qc.cx(0, 1)
qc.measure_all()  # Don't do this for Estimator

# Correct: no measurements for Estimator
qc = QuantumCircuit(2)
qc.h(0)
qc.cx(0, 1)

The Sampler, by contrast, requires measurements in the circuit because its job is to return measurement statistics.

Confusing Sampler and Estimator outputs

The Sampler returns probability distributions over bitstrings (dictionaries mapping integers or bitstrings to probabilities). The Estimator returns scalar expectation values (floating-point numbers). Mixing up which primitive to use for which task leads to confusing results.

If you find yourself manually computing sum(bitstring_value * probability) from Sampler output, you probably want the Estimator instead.

Not using Sessions for iterative algorithms

If you run a VQE loop with 100 optimizer iterations and submit each job independently (no Session), each iteration queues separately. On a busy system, this can turn a 10-minute computation into a multi-hour wait. Always wrap iterative workflows in a Session:

# Bad: each job queues independently
for i in range(100):
    estimator = EstimatorV2(mode=backend)  # No session
    job = estimator.run([pub])
    result = job.result()

# Good: jobs share a QPU reservation
with Session(backend=backend) as session:
    estimator = EstimatorV2(mode=session)
    for i in range(100):
        job = estimator.run([pub])
        result = job.result()

Summary

The Sampler and Estimator primitives are the foundation of modern Qiskit programming. They provide a portable, high-level interface that handles the complexity of circuit execution, observable decomposition, and error mitigation. Understanding the PUB format, choosing the right primitive for your algorithm, and using Sessions effectively will make your quantum computing workflows both more efficient and more reliable.

The V2 primitives are the current standard for all IBM Quantum programs. Start with StatevectorEstimator for development, graduate to AerEstimator for noise studies, and deploy with EstimatorV2 on real hardware when you are ready.

Was this tutorial helpful?