Qiskit Backend Integration

The mqt.core.plugins.qiskit module provides a Qiskit BackendV2-compatible interface to QDMI devices via the MQT Core QDMI bindings. This integration lets you execute Qiskit circuits on QDMI devices with a standard Qiskit workflow.

Installation

Install MQT Core with Qiskit support:

uv pip install "mqt-core[qiskit]"
python -m pip install "mqt-core[qiskit]"

Quickstart

 1from mqt.core.plugins.qiskit import QDMIBackend
 2from qiskit import QuantumCircuit
 3
 4# Open the registered DDSIM device by its stable ID
 5backend = QDMIBackend.from_device_id("mqt.ddsim.default")
 6
 7# Create a simple circuit
 8qc = QuantumCircuit(2)
 9qc.h(0)
10qc.cx(0, 1)
11qc.measure_all()
12
13# Execute the circuit
14job = backend.run(qc, shots=1024)
15result = job.result()
16counts = result.get_counts()
17
18assert sum(counts.values()) == 1024
19assert set(counts) <= {"00", "11"}
20print(f"Results: {counts}")
Results: {'00': 520, '11': 504}

Provider and Device Discovery

Using the Provider

The QDMIProvider discovers client-visible QDMI devices. Use it when an application must enumerate backends.

1from mqt.core.plugins.qiskit import QDMIProvider
2
3# Create a provider
4provider = QDMIProvider()
5
6# List all available backends
7backends = provider.backends()
8for backend in backends:
9    print(f"{backend.name}: {backend.target.num_qubits} qubits")
MQT Core DDSIM QDMI Device: 65535 qubits

Getting a Specific Backend

1# Open a backend directly by stable device ID
2from mqt.core.plugins.qiskit import QDMIBackend
3
4backend = QDMIBackend.from_device_id("mqt.ddsim.default")
5print(f"Backend: {backend.name}")
6print(f"Qubits: {backend.target.num_qubits}")
Backend: MQT Core DDSIM QDMI Device
Qubits: 65535

Optional session keywords configure this fresh device session through the MQT Core QDMI driver. Their names and value types are described by mqt.core.typing.QDMISessionParameters. The selected Driver defines their meaning and precedence:

backend = QDMIBackend.from_device_id(
    "provider.device",
    token="access-token",
    custom1="provider-specific-value",
)

Filtering Backends

# Filter backends by name substring
filtered_qdmi = provider.backends(name="QDMI")  # Matches all backends with "QDMI" in name
filtered_ddsim = provider.backends(name="DDSIM")  # Matches "MQT Core DDSIM QDMI Device"

# Filter by full name also works
exact = provider.backends(name="MQT Core DDSIM QDMI Device")

Authentication

QDMIBackend.from_device_id and QDMIProvider.get_backend_by_device_id accept the device-session parameters in mqt.core.typing.QDMISessionParameters, including base_url, token, and auth_file. The device validates these values and can also use environment variables or a platform credential provider. QDMIProvider.backends() uses a fresh session without explicit authentication parameters.

Device Capabilities and Target

The backend automatically introspects the QDMI device and constructs a Qiskit Target object describing device capabilities.

1# Access device properties via the Target
2print(f"Number of qubits: {backend.target.num_qubits}")
3print(f"Supported operations: {backend.target.operation_names}")
4
5# Check coupling map (if device has limited connectivity)
6coupling_map = backend.target.build_coupling_map()
7if coupling_map:
8    print(f"Coupling map: {coupling_map}")
Number of qubits: 65535
Supported operations: dict_keys(['global_phase', 'id', 'x', 'cx', 'ccx', 'mcx', 'y', 'cy', 'z', 'cz', 'ccz', 'h', 'ch', 's', 'cs', 'sdg', 'csdg', 't', 'tdg', 'sx', 'csx', 'sxdg', 'r', 'rx', 'crx', 'ry', 'cry', 'rz', 'crz', 'p', 'cp', 'mcphase', 'u1', 'cu1', 'u2', 'u', 'cu', 'swap', 'cswap', 'iswap', 'dcx', 'ecr', 'rxx', 'ryy', 'rzz', 'rzx', 'xx_minus_yy', 'xx_plus_yy', 'rccx', 'measure', 'reset'])

The backend maps QDMI device operations to corresponding Qiskit gates, including:

  • Single-qubit Pauli gates: x, y, z, id/i

  • Hadamard: h

  • Phase gates: s, sdg, t, tdg, sx, sxdg, p, phase, gphase

  • Rotation gates (parametric): rx, ry, rz, r/prx

  • Universal gates (parametric): u, u1, u2, u3

  • Two-qubit gates: cx/cnot, cy, cz, ch, cs, csdg, csx, swap, iswap, dcx, ecr

  • Two-qubit parametric gates: cp, cu1, cu3, crx, cry, crz, rxx, ryy, rzz, rzx, xx_plus_yy, xx_minus_yy

  • Three-qubit gates: ccx, ccz, cswap, rccx

  • Multi-controlled gates: mcx, mcx_gray, mcphase/mcp

  • Non-unitary operations: reset, measure

Circuit Execution

Circuits must meet the following requirements before execution:

  1. All parameters must be bound: Circuits with unbound parameters raise CircuitValidationError

  2. Only supported operations: Operations not supported by the device raise UnsupportedOperationError

  3. Valid shots value: Must be a non-negative integer

Optional retries

Automatic retries are disabled (max_retries=0). To retry confirmed failed jobs up to three times, opt in for a run:

job = backend.run(qc, shots=1024, max_retries=3)

Use backend.set_options(max_retries=3) to apply this setting to later runs. Retries create additional executions and can incur charges. Successful jobs are reused; errors that do not confirm job failure require explicit recovery.

Parameter Binding

The backend supports automatic parameter binding through the parameter_values argument. You can pass parameter values either as dictionaries or as sequences of values:

 1from qiskit.circuit import Parameter
 2
 3# Option 1: Bind parameters manually
 4theta = Parameter("theta")
 5parameterized = QuantumCircuit(1)
 6parameterized.ry(theta, 0)
 7parameterized.measure_all()
 8
 9qc_bound = parameterized.assign_parameters({theta: 1.5708})
10job = backend.run(qc_bound, shots=100)
11
12# Option 2: Use parameter_values argument (recommended)
13job = backend.run(parameterized, parameter_values=[{theta: 1.5708}], shots=100)
14
15# For multiple circuits with different parameters
16circuits = [parameterized, parameterized, parameterized]
17param_values = [{theta: 0.5}, {theta: 1.0}, {theta: 1.5}]
18job = backend.run(circuits, parameter_values=param_values, shots=100)
19bound_results = job.result()
20assert len(bound_results.results) == 3
21print([bound_results.get_counts(i) for i in range(3)])
[{'0': 94, '1': 6}, {'0': 84, '1': 16}, {'0': 57, '1': 43}]

Job Handling

Job Status

The QDMIJob returned by backend.run() tracks the submitted circuits:

from qiskit.providers import JobStatus

job = backend.run(qc, shots=1024)

# Check job status
status = job.status()
print(f"Job status: {status}")

Retrieving Results

Results are lazily fetched when you call result():

# Run the circuit
job = backend.run(qc, shots=1024)

# Get results (waits for completion if needed)
result = job.result()

# Access measurement counts
counts = result.get_counts()

# Access result metadata
exp_result = result.results[0]
print(f"Circuit name: {exp_result.header['name']}")
print(f"Shots: {exp_result.shots}")
print(f"Success: {exp_result.success}")

Recovering a failed batch

A submission or result error preserves accepted jobs and successful results. Keep the handle from the exception to inspect and recover the batch:

1from mqt.core.plugins.qiskit import JobExecutionError, JobSubmissionError
2
3try:
4    job = backend.run([qc, qc_bound], shots=1000)
5    result = job.result()
6except (JobSubmissionError, JobExecutionError) as error:
7    job = error.job
8    print(error)

After an interruption, use backend.last_job. It is None if the latest run() stopped before preparing a batch. Read accepted work without starting new executions using collect():

1entries = job.collect()
2available = [i for i, entry in enumerate(entries) if entry.result is not None]
3print("Circuits with results:", available)
Circuits with results: [0, 1]

The entries retain each circuit’s attempts, results, and errors. After resolving a connection or result-read problem, call job.result() again; it reuses successful results. If needed, use job.submit() for untouched inputs or job.resubmit([i]) to replace a failed or cancelled attempt, then retrieve the result again. See QDMIJob for uncertain submissions and explicit cancellation. Recovery requires the same Python process.

Multi-Circuit Execution

The backend supports both single-circuit and multi-circuit execution. You can submit multiple circuits in a single call. Circuits with the same program format and shot count use one native QDMI job when the device supports that program list. Otherwise, the backend submits independent jobs before waiting for results. Each entry retains its result index and attempt history; recovery replaces only confirmed failed circuits and keeps successful siblings.

For example:

 1# Create multiple circuits
 2qc1 = QuantumCircuit(2)
 3qc1.h(0)
 4qc1.cx(0, 1)
 5qc1.measure_all()
 6
 7qc2 = QuantumCircuit(2)
 8qc2.x(0)
 9qc2.cx(0, 1)
10qc2.measure_all()
11
12qc3 = QuantumCircuit(2)
13qc3.h([0, 1])
14qc3.measure_all()
15
16# Submit all circuits at once
17circuits = [qc1, qc2, qc3]
18job = backend.run(circuits, shots=1000)
19
20# Get aggregated results
21result = job.result()
22
23# Process results for each circuit
24for idx in range(len(circuits)):
25    counts = result.get_counts(idx)
26    print(f"Circuit {idx} results: {counts}")
Circuit 0 results: {'00': 499, '11': 501}
Circuit 1 results: {'11': 1000}
Circuit 2 results: {'00': 226, '01': 267, '10': 264, '11': 243}

Qiskit Primitives

Use Qiskit’s BackendSamplerV2 and BackendEstimatorV2. The backend factories construct these native objects with typed keyword options. Qiskit supplies the defaults and validates the options:

 1from qiskit.quantum_info import SparsePauliOp
 2
 3measured_circuit = QuantumCircuit(2)
 4measured_circuit.h(0)
 5measured_circuit.cx(0, 1)
 6measured_circuit.measure_all()
 7circuit = measured_circuit.remove_final_measurements(inplace=False)
 8sampler = backend.sampler(default_shots=1024)
 9estimator = backend.estimator(default_precision=0.1, abelian_grouping=True)
10
11samples = sampler.run([measured_circuit]).result()[0]
12counts = samples.data.meas.get_counts()
13estimate = estimator.run([(circuit, SparsePauliOp("ZZ"))]).result()[0]
14assert sum(counts.values()) == 1024
15assert set(counts) <= {"00", "11"}
16assert float(estimate.data.evs) == 1.0
17print("Bell counts:", counts)
18print("ZZ expectation:", float(estimate.data.evs))
Bell counts: {'00': 501, '11': 523}
ZZ expectation: 1.0

Sampler defaults to 1024 shots. Estimator requires positive precision and defaults to 1/64 (4096 shots); it groups qubit-wise commuting measurements. Both use Qiskit’s broadcasting, metadata, and asynchronous primitive jobs. Calling result() waits for completion. PUBs with equal shot counts share a backend batch; different shot counts or precisions follow Qiskit’s scheduling. Primitive-job cancellation follows Qiskit’s future semantics: it does not abort an already-running backend call.

Backend requirements

Feature

Required QDMI result support

Counts-only execution and native Estimator

HIST_KEYS and HIST_VALUES

memory=True and native Sampler

SHOTS

Native Sampler requests memory automatically. DDSIM supports both primitives; counts-only devices must add SHOTS support to run Sampler. The backend never reconstructs shots from counts; when memory is requested, it derives counts from those same genuine shots.

1sampler = backend.sampler(default_shots=100)
2samples = sampler.run([qc]).result()[0]
3print(samples.data.meas.get_counts())
{'11': 49, '00': 51}

A device must advertise its supported operations and accept OpenQASM 2, OpenQASM 3, or a registered program format. Transpile circuits to the backend target before submission. Estimator also needs the basis rotations and measurements that Qiskit generates for the observables. Use the provider’s specialized backend when its program dialect requires one, such as amazon.braket.qdmi.qiskit.AmazonBraketBackend for Braket.

Results must contain one binary digit per classical bit, with clbits[0] on the right, including unmeasured bits initialized to zero. Classical registers must partition circuit.clbits in register order; loose, aliased, and reordered bits are rejected. Serializers and providers must preserve this mapping. Shot order is unchanged across registers, so joint samples and postselection remain valid.

The backend accepts nonnegative integer shots and max_retries options and a boolean memory option. QDMI has no standard seed parameter, so the generic backend rejects non-None seed_simulator. DDSIM’s custom seed parameter is available through direct QDMI job submission; other providers can define different custom parameters. Other execution options are unsupported. The backend validates the whole batch before submission, submits jobs in circuit order, and collects results in that order. Missing memory, invalid bitstrings or shot totals, and failed or canceled jobs raise instead of yielding partial or zero-filled samples. Recovery lets you continue from accepted work after a failure.

Error Handling

The module provides specific exceptions for different error conditions:

from mqt.core.plugins.qiskit import (
    CircuitValidationError,
    UnsupportedOperationError,
    UnsupportedDeviceError,
    JobSubmissionError,
    TranslationError,
    UnsupportedFormatError,
)

try:
    job = backend.run(qc, shots=1024)
    result = job.result()
except CircuitValidationError as e:
    # Invalid circuit (unbound parameters, invalid shots, etc.)
    print(f"Circuit validation failed: {e}")
except UnsupportedOperationError as e:
    # Circuit contains operations not supported by device
    print(f"Unsupported operation: {e}")
except UnsupportedDeviceError as e:
    # Device cannot be represented in Qiskit's Target model
    print(f"Unsupported device: {e}")
except JobSubmissionError as e:
    # Failed to submit job to device
    print(f"Job submission failed: {e}")
except TranslationError as e:
    # Failed to convert circuit to supported program format
    print(f"Translation error: {e}")
except UnsupportedFormatError as e:
    # No supported program format available
    print(f"Unsupported format: {e}")

Implementation Details

Circuit Serialization

When you run a circuit, the backend:

  1. Validates the circuit (checks for unbound parameters, supported operations, valid options)

  2. Serializes the circuit into one of the program formats supported by the target device, through the program serializer registered for that format

  3. Submits the program to the QDMI device via device.submit_job()

  4. Returns a QDMIJob

The built-in OpenQASM serializers validate circuit width and ordered operation placements against native QDMI metadata after preprocessing. They reject an invalid circuit before any job in its batch is submitted. This check uses the native device sites because a backend extension may hide sites from its public Target or use preprocessing to address them. It does not route circuits.

Control-flow instructions require explicit support in the backend’s Target; their bodies are checked recursively using the enclosing circuit’s qubits. Advertising OpenQASM 3 alone does not enable control flow. Custom serializers retain responsibility for validating the native programs they produce.

Program Serializers

A program serializer turns one circuit into one program in one program format. MQT Core provides the serializers for OpenQASM 2 and OpenQASM 3. Every other format belongs to the package that owns the device, which registers its serializer through the same registry.

A format fixes the kind of payload it carries, so there are two signatures. A text format takes a serializer that returns str:

def serialize(circuit: QuantumCircuit, backend: QDMIBackend) -> str: ...

A binary format takes one that returns bytes:

def serialize(circuit: QuantumCircuit, backend: QDMIBackend) -> bytes: ...

is_binary_program_format() states which kind a format carries. The backend checks the returned type against the format and raises TranslationError on a mismatch. A serializer reads the device through device and the supported operations through target.

A package advertises its serializers through the mqt.core.qiskit.program_serializers entry point group. The entry point name is the ProgramFormat member name:

[project.entry-points."mqt.core.qiskit.program_serializers"]
IQM_JSON = "iqm.qdmi.serializers:qiskit_to_iqm_json"

register_program_serializer() does the same at run time:

from mqt.core.plugins.qiskit import register_program_serializer
from mqt.core.qdmi import ProgramFormat

register_program_serializer(ProgramFormat.IQM_JSON, qiskit_to_iqm_json)

Pass replace=True to take over a format that already has a serializer, including OpenQASM 2 and OpenQASM 3.

A device usually accepts several formats. The backend walks them in the order of PROGRAM_FORMAT_PREFERENCE and uses the first one that has a serializer, so the order of the list decides and not the order the device reports:

IQM_JSON, CUSTOM1 ... CUSTOM5,
QIR_ADAPTIVE_MODULE, QIR_ADAPTIVE_STRING,
QPY, QASM3,
QIR_BASE_MODULE, QIR_BASE_STRING,
QASM2

Device-native formats take precedence. Among standard formats, those with classical control precede restricted profiles; binary encoding wins ties within a QIR profile.

Device-specific execution options

Backend subclasses can add named execution options in _default_options and implement _job_parameters(options) to validate and encode them as QDMI custom1 through custom5 submission parameters. The hook receives the subclass’s defaults merged with per-run overrides, excluding the base options shots, memory, and max_retries. It runs before any circuit is submitted. Custom values can be strings, scalars, or exact bytes, as accepted by Device.submit_job. Unknown option names are rejected. The base hook rejects declared options it cannot encode, so a subclass that adds options must override it and handle each one. The generic backend defines no vendor-specific option names or values.

Use backend.set_options(...) to configure defaults for direct runs, native samplers, and native estimators. A direct backend.run(..., **options) call can override those defaults without changing them. Native samplers also accept backend.sampler(run_options={...}). Native estimators use the backend defaults because Qiskit’s estimator does not expose a run_options field.

Device Introspection

The backend builds its Target by:

  1. Querying the QDMI device for available operations

  2. Mapping each operation to the corresponding Qiskit gate

  3. Preserving each operation’s ordered site tuples, including gates on three or more qubits

  4. Including operation properties (duration, fidelity) if available

Instruction durations use seconds: the backend multiplies raw QDMI durations by the device’s duration scale factor and converts the advertised time unit. An absent scale factor defaults to one. A reported duration with a missing or unsupported unit, or an invalid scale factor, raises UnsupportedOperationError. Operations without duration metadata remain uncalibrated.

API Reference

For complete API documentation, see: