Migrate from Sampler to Executor
This guide describes how to move quantum sampling workloads from the IBM Quantum® Sampler primitive to the Executor primitive.
The Executor primitive is part of the directed execution model. All components in the directed execution model are currently in beta and might not be stable. You are invited to test them and provide feedback by opening an issue in the Samplomatic or Qiskit Runtime GitHub repositories.
Should you migrate?
Not everyone should migrate from Sampler to Executor. There are many differences between the primitives, but the following guidance can help you decide whether to migrate:
Migrate to Executor if you are a quantum information scientist who runs utility-scale experiments and needs fine-grained, reproducible control over techniques like Pauli twirling, noise-model learning and injection, and basis changes — or who needs one of the additional capabilities provided by Executor.
Continue using Sampler if you want a simple, high-level interface and are want the primitive to manage error suppression and mitigation for you.
Key differences between Executor and Sampler
Sampler and Executor both sample the output registers of quantum circuits, but they target different users:
- Sampler is a high-level abstraction. It has the following characteristics:
- It has built-in error suppression (dynamical decoupling and twirling)
- It makes implicit decisions for you.
- It is designed so algorithm developers can focus on innovation rather than data conversion.
- Executor is part of the directed execution model. It differs from Sampler in many ways and has the following characteristics:
-
It has no built-in error suppression or mitigation. Instead, you capture your design intent on the client side (by using circuit annotations and a samplex), and the costly generation of circuit variants is shifted to the server side.
-
It makes no implicit decisions. It follows your directives exactly, giving full control and transparency.
-
Executor and Samplomatic together expose additional capabilities that Sampler does not offer, including (but not limited to) the following:
- More twirling groups: Samplomatic lets you choose which twirling group to apply
per box, rather than being limited to the single strategy that Sampler applies for you. It also supports twirling groups other than Pauli, such as the
"local_c1"twirling group. - Kerneled and classified measurements together: Setting
QuantumProgram.meas_level = "both"(added inqiskit-ibm-runtime0.48.0) requests that both classified and kerneled measurements be present in the results, instead of picking a single measurement type per job. - Twirling for circuits with fractional gates: Executor can apply twirling to circuits that contain fractional gates.
- Fine-grained, composable error mitigation: For example, choosing which circuit layers to mitigate and adjusting the noise rates injected into the circuit.
NoteFuture new capabilities are expected to be released to Executor first and might not be ported to Sampler. If you rely on access to the latest features, Executor is the more future-proof choice.
- More twirling groups: Samplomatic lets you choose which twirling group to apply
per box, rather than being limited to the single strategy that Sampler applies for you. It also supports twirling groups other than Pauli, such as the
-
Conceptual mapping
Concept | Sampler | Executor |
|---|---|---|
| Import | from qiskit_ibm_runtime import SamplerV2 | from qiskit_ibm_runtime import Executor |
| Input | List of PUBs (tuples) | A QuantumProgram of QuantumProgramItem objects |
| Circuit + params | (circuit, params, shots) tuple | program.append_circuit_item(circuit, circuit_arguments=...) |
| Twirling | TwirlingOptions | Explicit through annotated boxes and a samplex (append_samplex_item) |
| Run call | sampler.run([pub, ...]) | executor.run(program) |
| Result type | PrimitiveResult of SamplerPubResult | QuantumProgramResult (iterable) |
| Accessing data | result[0].data.<register> (BitArray) | result[0]["<register>"] (np.ndarray) |
| Noise management | Built-in options | You compose it (annotations, samplex, NoiseLearnerV3) |
Overview of migration steps
- Install Samplomatic.
- Change the imports.
- Replace PUB tuples with a
QuantumProgram. - Change how shots are expressed.
- Move parameter values from the PUB into
circuit_argumentsorsamplex_arguments. - Replace
twirlingand resilience options with annotated boxes (generate_boxing_pass_manager) and a samplex (build). - Update other options as necessary.
- Use
NoiseLearnerV3to replace built-in noise learning. - Update result parsing.
- Undo twirling explicitly.
Step 1. Install the required packages
Executor and the directed execution model require the Samplomatic package:
pip install qiskit qiskit-ibm-runtime samplomatic
# For visualization support:
# pip install samplomatic[vis]qiskit-ibm-runtime 0.48.0 is recommended for Executor, as it adds
the meas_level = "both" option and the "local_c1" twirling group, and requires
qiskit >= 2.3.0 and samplomatic >= 0.18.0.
Unlike Sampler (SamplerV2), the base Qiskit package does not yet provide
a base class for the Executor primitive.
Step 2. Change the imports
Sampler:
from qiskit_ibm_runtime import SamplerV2 as SamplerExecutor:
from qiskit_ibm_runtime import QiskitRuntimeService, Executor
from qiskit_ibm_runtime.quantum_program import QuantumProgramStep 3. Replace PUB tuples with a QuantumProgram
Instead of passing a list of tuples, when using Executor, you build a QuantumProgram and append items to it.
A QuantumProgram accepts two kinds of items, circuit items and samplex items. Which one you use depends on whether
you want the item's circuit to be randomized at runtime.
append_circuit_item: Appends aCircuitItem, which is a circuit and (optionally) its parameter values. It is executed as-is, without any randomization. Use this when you just want to sample a circuit, exactly as Sampler would with a PUB that has no twirling, for example, a plain sampling job, or when you already manually included any variants you want.append_samplex_item: Appends aSamplexItem, which is a template circuit plus a samplex that generates randomized parameter sets on the server side. Use this when you want the circuit's content randomized. The primary case is with twirling (gate or measurement) or noise injection. This capability replaces Sampler's built-in twirling.
A single QuantumProgram can accept both item types; each appended item is executed as an
independent task and produces its own entry in the results. In general, use append_circuit_item when your circuit does not need randomized. Otherwise, use append_samplex_item.
The next sections show each in turn: parameterized circuits that use
append_circuit_item, and migrating twirling by using append_samplex_item.
Step 4. Change how shots are requested
Move shots from the PUB to QuantumProgram(shots=...). In Executor, shots applies to the whole job. Submit multiple jobs if you need different shot counts.
Before: Sampler
# Run — shots are passed to run()
sampler = Sampler(mode=backend)
job = sampler.run([(isa_circuit,)], shots=25)
result = job.result()After: Executor
# Build a QuantumProgram — shots are on the program
program = QuantumProgram(shots=25)
program.append_circuit_item(isa_circuit)Step 5. Migrate parameterized circuits
With Sampler, parameter values are the second element of the PUB tuple. With Executor,
pass them as circuit_arguments to append_circuit_item.
Before: Sampler
params = np.random.rand(10, circuit.num_parameters) # 10 parameter sets
job = sampler.run([(isa_circuit, params)])
result = job.result()After: Executor
program = QuantumProgram(shots=1024)
program.append_circuit_item(
isa_circuit,
circuit_arguments=np.random.rand(10, circuit.num_parameters), # 10 sets
)
job = executor.run(program)
result = job.result()
# CircuitItem result shape: (parameter_sets, shots, register_bits) -> (10, 1024, 2)
result_0 = result[0]["meas"]Step 6. Migrate built-in twirling to explicit annotations
This is the most significant change. Sampler applies twirling for you by using options. With Executor, you declare that intent explicitly by using annotated boxes and a samplex (from Samplomatic).
Before: Sampler (twirling by using options)
sampler = Sampler(mode=backend)
sampler.options.twirling.enable_gates = True
sampler.options.twirling.enable_measure = True
job = sampler.run([(isa_circuit, params)])After: Executor (twirling by using boxes and a samplex)
from samplomatic import build
from samplomatic.transpiler import generate_boxing_pass_manager
# 1. Group gates and measurements into annotated boxes with twirling annotations
boxes_pm = generate_boxing_pass_manager(
enable_gates=True, # gate twirling
enable_measures=True, # measurement twirling
)
boxed_circuit = boxes_pm.run(isa_circuit)
# 2. Build the (template circuit, samplex) pair.
# The template circuit's single-qubit gates are replaced by parameterized gates;
# the samplex encodes how to generate the randomized parameters at runtime.
template_circuit, samplex = build(boxed_circuit)
# 3. Append as a samplex item, specifying the number of randomizations
program = QuantumProgram(shots=1024)
program.append_samplex_item(
template_circuit,
samplex=samplex,
samplex_arguments={
"parameter_values": np.random.rand(10, 2), # original circuit params
},
shape=(28, 10), # 28 randomizations x 10 parameter sets
)
# 4. Run
job = executor.run(program)
result = job.result()Because the template circuit and samplex are built on the client side, you can inspect and sample them locally to verify the output before sending anything to hardware.
Verification: Sample the template circuit locally
You can draw randomizations from the samplex and bind them to the template
circuit to confirm the samplex is producing the parameter values you expect.
The parameter values returned by samplex.sample are directly compatible with the
template circuit's parameters.
# Check which inputs the samplex requires (for the twirling example above,
# this is just the original circuit's parameter values).
print(samplex.inputs())
# Bind the required inputs, then draw a few randomizations locally.
inputs = samplex.inputs().bind(
parameter_values=np.random.rand(2), # one set of the original circuit's params
)
outputs = samplex.sample(inputs, num_randomizations=3)
# Assign one randomization's parameter values to the template circuit and inspect it.
bound_template = template_circuit.assign_parameters(outputs["parameter_values"][0])
bound_template.draw("mpl", idle_wires=False)To go further, you can verify each randomization is logically equivalent to the
original circuit, for example, by converting both to Operator objects and comparing (after
accounting for the outputs["measurement_flips.<register>"] corrections that undo
measurement twirling), or by comparing expectation values from a local
StatevectorSampler or StatevectorEstimator run. See the Samplomatic
Samplex inputs and outputs
guide for a complete walkthrough.
Step 7. Update options as necessary
There are fewer options available to Executor than Sampler, because error-mitigation choices now live in your annotations and samplex instead of options.
There is also a structural difference in where settings live.
-
With Sampler, everything, including choices that affect result post-processing, is configured on the primitive's options or in the PUB.
-
With Executor, choices that affect how the job results are shaped and post-processed are set on the
QuantumProgram, not onExecutorOptions.
Examples:
Sampler | Executor |
|---|---|
shots | QuantumProgram(shots=...) |
meas_level | QuantumProgram.meas_level |
ExecutorOptions holds only lower-level execution and environment
settings that don't change the structure of the returned data. It has just three top-level groups:
environment(EnvironmentOptions):execution(ExecutionOptions): Contains fewer options than with Sampler. For example, there is nomeas_typeExecutor option.experimental
Notable options that exist in Sampler but not Executor include the following:
twirlingdynamical_decoupling- resilience or error-mitigation option groups
Instead, the above option values are expressed through the directed execution model.
Example:
from qiskit_ibm_runtime import Executor, ExecutorOptions
options = ExecutorOptions(
environment={"log_level": "INFO"},
execution={"init_qubits": True},
)
# or mutate after construction:
options = ExecutorOptions()
options.environment.log_level = "INFO"
options.execution.init_qubits = True
executor = Executor(mode=backend, options=options)Step 8. Use NoiseLearnerV3 to replace built-in noise learning
Because Executor does not have built-in noise noise learning, you must request noise learning manually by using the NoiseLearnerV3 class, as described in the Noise learning helper guide.
Step 9. Change how you access results
In Executor, results are NumPy arrays, not BitArray objects. You index by register name string (result[0]["meas"]) and get an np.ndarray back. There is no need to remember the .data.<register> attribute path.
To update from Sampler to Executor, change result[i].data.<reg> (BitArray) to result[i]["<reg>"] (np.ndarray), then rewrite get_counts-based post-processing as NumPy operations.
Task | Sampler | Executor |
|---|---|---|
| Get register data | result[0].data.meas | result[0]["meas"] |
| Data type | BitArray | np.ndarray |
| Counts dictionary | result[0].data.meas.get_counts() | Post-process the array manually |
| Multiple registers | result[0].data.<name> per register | result[0]["<name>"] per register |
| CircuitItem array shape | - | (parameter_sets, shots, register_bits) |
| SamplexItem array shape | - | (randomizations, parameter_sets, shots, register_bits) |
| Undo measurement twirling | Automatic | result[i]["measurement_flips.<name>"] + XOR |
Sampler's BitArray offers helpers (get_counts, slice_bits, slice_shots, expectation_values, post-selection masks). Executor returns raw NumPy arrays so you can perform this post-processing with standard NumPy operations.
Step 10. Handle twirled results (bit-flip corrections)
When you apply measurement twirling through a SamplexItem, Executor returns the raw
(twirled) measurements plus the bit-flip corrections needed to undo the twirling.
You apply them manually; nothing is corrected implicitly.
When using Executor, undo twirling explicitly by using the measurement_flips.<reg> corrections and an XOR, as shown in the following example:
# SamplexItem result shape: (randomizations, parameter_sets, shots, register_bits)
result_1 = result[1]["meas"] # example: (28, 10, 1024, 2)
# Bit-flip corrections to undo measurement twirling
flips_1 = result[1]["measurement_flips.meas"] # example: (28, 10, 1, 2)
# Undo the twirling through classical XOR (broadcasts over the shots axis)
unflipped_result_1 = result_1 ^ flips_1There is no equivalent step in Sampler because it undoes twirling for you.
Full example: Migrate a basic sampling job
Before: Sampler
import numpy as np
from qiskit.circuit import QuantumCircuit
from qiskit.transpiler import generate_preset_pass_manager
from qiskit_ibm_runtime import QiskitRuntimeService, SamplerV2 as Sampler
# 1. Account + backend
service = QiskitRuntimeService()
backend = service.least_busy(operational=True, simulator=False)
# 2. Circuit
circuit = QuantumCircuit(2)
circuit.h(0)
circuit.h(1)
circuit.cz(0, 1)
circuit.h(1)
circuit.measure_all()
# 3. Transpile to ISA
pm = generate_preset_pass_manager(optimization_level=1, backend=backend)
isa_circuit = pm.run(circuit)
# 4. Run — shots are passed to run()
sampler = Sampler(mode=backend)
job = sampler.run([(isa_circuit,)], shots=25)
result = job.result()
# 5. Access results: a BitArray keyed by register name
counts = result[0].data.meas.get_counts()After: Executor
import numpy as np
from qiskit.circuit import QuantumCircuit
from qiskit.transpiler import generate_preset_pass_manager
from qiskit_ibm_runtime import QiskitRuntimeService, Executor
from qiskit_ibm_runtime.quantum_program import QuantumProgram
# 1. Account + backend (unchanged)
service = QiskitRuntimeService()
backend = service.least_busy(operational=True, simulator=False)
# 2. Circuit (unchanged)
circuit = QuantumCircuit(2)
circuit.h(0)
circuit.h(1)
circuit.cz(0, 1)
circuit.h(1)
circuit.measure_all()
# 3. Transpile to ISA (unchanged)
pm = generate_preset_pass_manager(optimization_level=1, backend=backend)
isa_circuit = pm.run(circuit)
# 4. Build a QuantumProgram — shots are on the program
program = QuantumProgram(shots=25)
program.append_circuit_item(isa_circuit)
# 5. Run
executor = Executor(mode=backend)
job = executor.run(program)
result = job.result()
# 6. Access results: a plain np.ndarray keyed by register name
# shape = (shots, register_bits)
meas = result[0]["meas"]Current limitations and caveats
Because Executor and the directed execution model are in beta, a few things are worth knowing before you commit to a migration:
- No simulator support yet. Unlike Sampler — which has an
AerSamplerimplementation inqiskit-aerfor local simulation — there is currently no simulator backend for Executor. Simulator support is expected to arrive soon. In the meantime, you can still inspect and sample the template circuit locally to validate your workflow before submitting to hardware. - This guide covers Sampler only, not Estimator. Migrating from Estimator to Executor is considerably more involved than migrating from Sampler, because Estimator computes expectation values rather than returning raw samples — reproducing that with Executor requires additional post-processing. Utility functions to help with the Estimator → Executor migration are still under development, so this guide intentionally focuses on the Sampler workflow.