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': 500, '11': 524}

Provider and Device Discovery

Using the Provider

The QDMIProvider discovers registered 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 apply explicit overrides to this fresh device session. Their names and value types are described by mqt.core.typing.QDMISessionParameters; persistent configuration remains the default:

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

QDMIProvider does not define a generic credential interface. It opens each registered device with its persistent definition. Configure credentials through the selected QDMI device implementation. For example, a provider can use a credential file, an environment variable, or a platform credential-provider chain. See QDMI device configuration for persistent session settings.

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, mcz, mcp, mcrx, mcry, mcrz

  • 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

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': 95, '1': 5}, {'0': 73, '1': 27}, {'0': 50, '1': 50}]

Job Handling

Job Status

The QDMIJob wraps a QDMI job and provides status tracking:

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}")

Multi-Circuit Execution

The backend supports both single-circuit and multi-circuit execution. You can submit multiple circuits in a single call:

 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': 482, '11': 518}
Circuit 1 results: {'11': 1000}
Circuit 2 results: {'00': 258, '01': 261, '10': 227, '11': 254}

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: {'11': 537, '00': 487}
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': 51, '00': 49}

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 boolean memory options. 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. Remote IDs are queried only when needed. Submission or collection failure triggers best-effort cancellation of submitted jobs; cancellation errors do not replace the original error. Missing memory, invalid bitstrings or shot totals, and failed or canceled jobs raise instead of yielding partial or zero-filled samples. Successful repeated reads reuse the result.

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

A device-native format comes first, because a package that registers a serializer for its own device’s format wants that format used. The standardized formats follow in order of what a circuit may contain: the QIR adaptive profile allows classical control, QPY carries a Qiskit circuit without loss, and OpenQASM 3 expresses control flow, while the QIR base profile forbids classical feedback and OpenQASM 2 has no control flow at all. Encoding only breaks a tie within one profile, because it decides how the program travels rather than what it may say. CALIBRATION and BATCH_JOB are absent because a serialized circuit is not what they carry.

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: