Skip to main contentIBM Quantum Documentation Preview
This is a preview build of IBM Quantum® documentation. Refer to quantum.cloud.ibm.com/docs for the official documentation.

Migrate from Sampler to Executor

This guide describes how to move quantum sampling workloads from the IBM Quantum® Sampler primitive to the Executor primitive.

Beta release

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 in qiskit-ibm-runtime 0.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.
      Note

      Future 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.


Conceptual mapping

Concept
Sampler
Executor
Importfrom qiskit_ibm_runtime import SamplerV2from qiskit_ibm_runtime import Executor
InputList of PUBs (tuples)A QuantumProgram of QuantumProgramItem objects
Circuit + params(circuit, params, shots) tupleprogram.append_circuit_item(circuit, circuit_arguments=...)
TwirlingTwirlingOptionsExplicit through annotated boxes and a samplex (append_samplex_item)
Run callsampler.run([pub, ...])executor.run(program)
Result typePrimitiveResult of SamplerPubResultQuantumProgramResult (iterable)
Accessing dataresult[0].data.<register> (BitArray)result[0]["<register>"] (np.ndarray)
Noise managementBuilt-in optionsYou compose it (annotations, samplex, NoiseLearnerV3)

Overview of migration steps

  1. Install Samplomatic.
  2. Change the imports.
  3. Replace PUB tuples with a QuantumProgram.
  4. Change how shots are expressed.
  5. Move parameter values from the PUB into circuit_arguments or samplex_arguments.
  6. Replace twirling and resilience options with annotated boxes (generate_boxing_pass_manager) and a samplex (build).
  7. Update other options as necessary.
  8. Use NoiseLearnerV3 to replace built-in noise learning.
  9. Update result parsing.
  10. 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]
Version note

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.

Note

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 Sampler

Executor:

from qiskit_ibm_runtime import QiskitRuntimeService, Executor
from qiskit_ibm_runtime.quantum_program import QuantumProgram

Step 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 a CircuitItem, 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 a SamplexItem, 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 on ExecutorOptions.

Examples:

Sampler
Executor
shotsQuantumProgram(shots=...)
meas_levelQuantumProgram.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:

Notable options that exist in Sampler but not Executor include the following:

  • twirling dynamical_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 dataresult[0].data.measresult[0]["meas"]
Data typeBitArraynp.ndarray
Counts dictionaryresult[0].data.meas.get_counts()Post-process the array manually
Multiple registersresult[0].data.<name> per registerresult[0]["<name>"] per register
CircuitItem array shape-(parameter_sets, shots, register_bits)
SamplexItem array shape-(randomizations, parameter_sets, shots, register_bits)
Undo measurement twirlingAutomaticresult[i]["measurement_flips.<name>"] + XOR
Note

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_1

There 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 AerSampler implementation in qiskit-aer for 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.

Next steps