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.
Circuit diagrams
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:
- Decompose the Hamiltonian into individual Pauli terms (e.g.,
0.5 * ZZ + 0.3 * XI + 0.2 * IZ). - Construct a separate measurement circuit for each Pauli term, appending the correct basis rotations and
measure()calls. - Submit each circuit as an individual job to the backend with
backend.run(). - 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: ASparsePauliOpor list ofSparsePauliOpinstances 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:
- The classical optimizer proposes a set of parameter values.
- The Estimator evaluates the expectation value of the Hamiltonian at those parameters.
- The optimizer reads the energy and proposes new parameters.
- 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.
| Level | Techniques | QPU Overhead | When to Use |
|---|---|---|---|
| 0 | No mitigation | 1x (baseline) | Quick tests, debugging, when you need raw hardware results |
| 1 | Readout error mitigation + dynamical decoupling | ~1.5x | Default for most workflows; good accuracy with moderate cost |
| 2 | Level 1 + zero-noise extrapolation (ZNE) | 3-5x | When you need higher accuracy and can afford the extra shots |
| 3 | Probabilistic error cancellation (PEC) | 20-100x | Research-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?