Using the MQT Compiler Collection from Python

The mqt.core.mlir module provides Python access to the MQT Compiler Collection. It accepts source strings, .qasm, .mlir, and .jeff files, Qiskit QuantumCircuit objects, and typed compiler programs. The requested output format determines where compilation stops and which program type is returned.

The compiler collection is the circuit and program interface in MQT Core v4.

Install MQT Core and import the compiler interface:

1from mqt.core.mlir import OutputFormat, QCProgram, QIRProfile, compile_program

To compile for a configured QDMI device, see target compilation.

Compile an OpenQASM program

The following OpenQASM program prepares a Bell state and records the outcome of measuring both qubits.

 1bell_qasm = """OPENQASM 3.0;
 2include "stdgates.inc";
 3
 4qubit[2] q;
 5bit[2] result;
 6
 7h q[0];
 8cx q[0], q[1];
 9result = measure q;
10"""
11
12compiled = compile_program(bell_qasm)
13print(compiled.ir)
module {
  func.func @main() -> !cbit.reg<2> attributes {mqt.entry_point} {
    %c1 = arith.constant 1 : index
    %c0 = arith.constant 0 : index
    %alloc = memref.alloc() {mqt.register_name = "q"} : memref<2x!qc.qubit>
    %0 = cbit.alloc(#cbit.init<undefined>) {mqt.register_name = "result"} : !cbit.reg<2>
    %1 = memref.load %alloc[%c0] : memref<2x!qc.qubit>
    qc.h %1 : !qc.qubit
    %2 = memref.load %alloc[%c1] : memref<2x!qc.qubit>
    qc.ctrl(%1) targets (%arg0 = %2) {
      qc.x %arg0 : !qc.qubit
      qc.yield
    } : {!qc.qubit}, {!qc.qubit}
    %3 = qc.measure %1 : !qc.qubit -> i1
    cbit.store %3, %0[%c0] : !cbit.reg<2>
    %4 = qc.measure %2 : !qc.qubit -> i1
    cbit.store %4, %0[%c1] : !cbit.reg<2>
    memref.dealloc %alloc : memref<2x!qc.qubit>
    return %0 : !cbit.reg<2>
  }
}

By default, compile_program() runs the standard optimization pipeline and returns a QCProgram. Its ir property exposes the textual MLIR representation for inspection and debugging. Programs do not need to be written in MLIR to use the compiler.

Inspect a QC program

Use the inspection methods of a QCProgram to count gates without parsing the textual IR:

1print("Gates:", compiled.num_gates())
2print("Single-qubit gates:", compiled.num_single_qubit_gates())
3print("Two-qubit gates:", compiled.num_two_qubit_gates())
Gates: 2
Single-qubit gates: 1
Two-qubit gates: 1

These counts describe the entry-point IR. A gate in each structured control-flow region counts once, regardless of the runtime path or loop iteration count. Barriers do not count, and operations inside gate modifiers do not count again.

Select an output format

Select an output format to stop the pipeline at a particular representation:

Purpose

Output format

Result type

Inspect frontend translation

OutputFormat.QC_IMPORT

QCProgram

Inspect QCO immediately after conversion

OutputFormat.QCO

QCOProgram

Inspect QCO after optimization

OutputFormat.QCO_OPTIMIZED

QCOProgram

Obtain the optimized circuit

OutputFormat.QC (default)

QCProgram

Emit an optimized OpenQASM program

OutputFormat.OPENQASM3

OpenQASMProgram

Serialize a compiler program

OutputFormat.JEFF

JeffProgram

Generate QIR

OutputFormat.QIR_BASE or OutputFormat.QIR_ADAPTIVE

QIRProgram

For example, select optimized QCO to inspect the representation after the default QCO pass pipeline:

1optimized = compile_program(bell_qasm, output=OutputFormat.QCO_OPTIMIZED)
2print(optimized.ir)
module {
  func.func @main() -> !cbit.reg<2> attributes {mqt.entry_point} {
    %c1 = arith.constant 1 : index
    %c0 = arith.constant 0 : index
    %c2 = arith.constant 2 : index
    %0 = qtensor.alloc(%c2) {mqt.register_name = "q"} : tensor<2x!qco.qubit>
    %1 = cbit.alloc(#cbit.init<undefined>) {mqt.register_name = "result"} : !cbit.reg<2>
    %out_tensor, %result = qtensor.extract %0[%c0] : tensor<2x!qco.qubit>
    %2 = qco.h %result : !qco.qubit -> !qco.qubit
    %out_tensor_0, %result_1 = qtensor.extract %out_tensor[%c1] : tensor<2x!qco.qubit>
    %controls_out, %targets_out = qco.ctrl(%2) targets (%arg0 = %result_1) {
      %5 = qco.x %arg0 : !qco.qubit -> !qco.qubit
      qco.yield %5 : !qco.qubit
    } : ({!qco.qubit}, {!qco.qubit}) -> ({!qco.qubit}, {!qco.qubit})
    %qubit_out, %result_2 = qco.measure %controls_out : !qco.qubit
    cbit.store %result_2, %1[%c0] : !cbit.reg<2>
    %3 = qtensor.insert %qubit_out into %out_tensor_0[%c0] : tensor<2x!qco.qubit>
    %qubit_out_3, %result_4 = qco.measure %targets_out : !qco.qubit
    %4 = qtensor.insert %qubit_out_3 into %3[%c1] : tensor<2x!qco.qubit>
    cbit.store %result_4, %1[%c1] : !cbit.reg<2>
    qtensor.dealloc %4 : tensor<2x!qco.qubit>
    return %1 : !cbit.reg<2>
  }
}

Emit OpenQASM

Request OPENQASM3 to emit the program after the normal QCO optimization and conversion back to QC:

1openqasm = compile_program(bell_qasm, output=OutputFormat.OPENQASM3)
2print(openqasm.source)
OPENQASM 3.1;
include "stdgates.inc";

output bit[2] result;
qubit[2] q;

h q[0];
ctrl @ x q[0], q[1];
result[0] = measure q[0];
result[1] = measure q[1];

The returned OpenQASMProgram owns its source and can write it directly:

1from pathlib import Path
2from tempfile import TemporaryDirectory
3
4with TemporaryDirectory() as directory:
5    path = Path(directory) / "bell.qasm"
6    openqasm.write(path)
7    reparsed = QCProgram.from_qasm_file(path)
8
9assert reparsed.is_valid

Use to_openqasm3() to clean up and export the current QC program without QCO optimization. The resulting OpenQASMProgram can be passed directly to compile_program():

1recompiled = compile_program(openqasm, output=OutputFormat.QC_IMPORT)
2assert isinstance(recompiled, QCProgram)

The exporter targets practical structured programs with static qubit and bit indices. Dynamic indexing, dynamic ranges, surviving runtime assertions, checked-index machinery, and live poison values fail with an MLIR diagnostic. See OpenQASM input and output for the complete support table.

Use Qiskit circuits directly

Install the optional Qiskit integration with mqt-core[qiskit]. The extra also supports SDK uses with older Qiskit releases; direct compiler translation requires a registered version, currently Qiskit 2.5.x. These circuits can be translated between QuantumCircuit and QCProgram:

 1from qiskit import QuantumCircuit
 2
 3qiskit_bell = QuantumCircuit(2, 2)
 4qiskit_bell.h(0)
 5qiskit_bell.cx(0, 1)
 6qiskit_bell.measure(range(2), range(2))
 7
 8direct = QCProgram.from_qiskit(qiskit_bell)
 9restored = direct.to_qiskit()
10compiled_qiskit = compile_program(qiskit_bell)
11
12assert direct.is_valid  # Export does not consume the QC program.
13assert restored.count_ops() == qiskit_bell.count_ops()
14assert compiled_qiskit.is_valid

This compiler route is the Qiskit circuit interface in MQT Core v4.

See Qiskit compatibility for supported circuit features and translation limitations.

Run passes explicitly

QCProgram, QCOProgram, JeffProgram, and QIRProgram own their MLIR modules. Conversions between these MLIR-backed program objects consume their source by default, avoiding an implicit copy of a potentially large module. Pass copy=True when the source must remain available. OpenQASMProgram instead owns immutable source text and remains reusable when passed to compile_program.

The following example keeps the imported QC program, applies transformations to QCO, and converts the result back to QC:

 1qc = QCProgram.from_qasm_str(bell_qasm)
 2qco = qc.to_qco(copy=True)
 3qco.cleanup()
 4qco.merge_single_qubit_rotation_gates()
 5qco.lift_hadamards()
 6final_qc = qco.to_qc()
 7
 8assert qc.is_valid
 9assert not qco.is_valid
10print(final_qc.ir)
module {
  func.func @main() -> !cbit.reg<2> attributes {mqt.entry_point} {
    %c1 = arith.constant 1 : index
    %c0 = arith.constant 0 : index
    %c2 = arith.constant 2 : index
    %alloc = memref.alloc() {mqt.register_name = "q"} : memref<2x!qc.qubit>
    %0 = cbit.alloc(#cbit.init<undefined>) {mqt.register_name = "result"} : !cbit.reg<2>
    %1 = memref.load %alloc[%c0] : memref<2x!qc.qubit>
    qc.h %1 : !qc.qubit
    %2 = memref.load %alloc[%c1] : memref<2x!qc.qubit>
    qc.ctrl(%1) targets (%arg0 = %2) {
      qc.x %arg0 : !qc.qubit
      qc.yield
    } : {!qc.qubit}, {!qc.qubit}
    %3 = qc.measure %1 : !qc.qubit -> i1
    cbit.store %3, %0[%c0] : !cbit.reg<2>
    %4 = qc.measure %2 : !qc.qubit -> i1
    cbit.store %4, %0[%c1] : !cbit.reg<2>
    memref.dealloc %alloc : memref<2x!qc.qubit>
    return %0 : !cbit.reg<2>
  }
}

Architecture-independent QCO transformations can also be composed with MLIR’s textual pass-pipeline syntax. The same pass names and options are accepted by mqt-cc:

1custom = compile_program(
2    bell_qasm,
3    output=OutputFormat.QCO_OPTIMIZED,
4    qco_pipeline="hadamard-lifting,merge-single-qubit-rotation-gates",
5)
6assert custom.is_valid
7print(custom.ir)
module {
  func.func @main() -> !cbit.reg<2> attributes {mqt.entry_point} {
    %c1 = arith.constant 1 : index
    %c0 = arith.constant 0 : index
    %c2 = arith.constant 2 : index
    %0 = qtensor.alloc(%c2) {mqt.register_name = "q"} : tensor<2x!qco.qubit>
    %1 = cbit.alloc(#cbit.init<undefined>) {mqt.register_name = "result"} : !cbit.reg<2>
    %out_tensor, %result = qtensor.extract %0[%c0] : tensor<2x!qco.qubit>
    %2 = qco.h %result : !qco.qubit -> !qco.qubit
    %out_tensor_0, %result_1 = qtensor.extract %out_tensor[%c1] : tensor<2x!qco.qubit>
    %controls_out, %targets_out = qco.ctrl(%2) targets (%arg0 = %result_1) {
      %5 = qco.x %arg0 : !qco.qubit -> !qco.qubit
      qco.yield %5 : !qco.qubit
    } : ({!qco.qubit}, {!qco.qubit}) -> ({!qco.qubit}, {!qco.qubit})
    %qubit_out, %result_2 = qco.measure %controls_out : !qco.qubit
    cbit.store %result_2, %1[%c0] : !cbit.reg<2>
    %3 = qtensor.insert %qubit_out into %out_tensor_0[%c0] : tensor<2x!qco.qubit>
    %qubit_out_3, %result_4 = qco.measure %targets_out : !qco.qubit
    %4 = qtensor.insert %qubit_out_3 into %3[%c1] : tensor<2x!qco.qubit>
    cbit.store %result_4, %1[%c1] : !cbit.reg<2>
    qtensor.dealloc %4 : tensor<2x!qco.qubit>
    return %1 : !cbit.reg<2>
  }
}

Pauli twirling is available as an opt-in textual pass. It supports CX, CZ, ECR, and iSWAP gates, keeps every inserted Pauli operation (including identities) explicit, and preserves the exact global phase. The seed defaults to 42:

1twirled = compile_program(bell_qasm, output=OutputFormat.QCO)
2twirled.run_pass_pipeline("pauli-twirl-2q-gates{seed=42}")
3print(twirled.ir)
module {
  func.func @main() -> !cbit.reg<2> attributes {mqt.entry_point} {
    %c1 = arith.constant 1 : index
    %c0 = arith.constant 0 : index
    %c2 = arith.constant 2 : index
    %0 = qtensor.alloc(%c2) {mqt.register_name = "q"} : tensor<2x!qco.qubit>
    %1 = cbit.alloc(#cbit.init<undefined>) {mqt.register_name = "result"} : !cbit.reg<2>
    %out_tensor, %result = qtensor.extract %0[%c0] : tensor<2x!qco.qubit>
    %2 = qco.h %result : !qco.qubit -> !qco.qubit
    %3 = qtensor.insert %2 into %out_tensor[%c0] : tensor<2x!qco.qubit>
    %out_tensor_0, %result_1 = qtensor.extract %3[%c0] : tensor<2x!qco.qubit>
    %out_tensor_2, %result_3 = qtensor.extract %out_tensor_0[%c1] : tensor<2x!qco.qubit>
    %4 = qco.x %result_1 : !qco.qubit -> !qco.qubit
    %5 = qco.y %result_3 : !qco.qubit -> !qco.qubit
    %controls_out, %targets_out = qco.ctrl(%4) targets (%arg0 = %5) {
      %12 = qco.x %arg0 : !qco.qubit -> !qco.qubit
      qco.yield %12 : !qco.qubit
    } : ({!qco.qubit}, {!qco.qubit}) -> ({!qco.qubit}, {!qco.qubit})
    %6 = qco.y %controls_out : !qco.qubit -> !qco.qubit
    %7 = qco.z %targets_out : !qco.qubit -> !qco.qubit
    %8 = qtensor.insert %7 into %out_tensor_2[%c1] : tensor<2x!qco.qubit>
    %9 = qtensor.insert %6 into %8[%c0] : tensor<2x!qco.qubit>
    %out_tensor_4, %result_5 = qtensor.extract %9[%c0] : tensor<2x!qco.qubit>
    %qubit_out, %result_6 = qco.measure %result_5 : !qco.qubit
    %10 = qtensor.insert %qubit_out into %out_tensor_4[%c0] : tensor<2x!qco.qubit>
    cbit.store %result_6, %1[%c0] : !cbit.reg<2>
    %out_tensor_7, %result_8 = qtensor.extract %10[%c1] : tensor<2x!qco.qubit>
    %qubit_out_9, %result_10 = qco.measure %result_8 : !qco.qubit
    %11 = qtensor.insert %qubit_out_9 into %out_tensor_7[%c1] : tensor<2x!qco.qubit>
    cbit.store %result_10, %1[%c1] : !cbit.reg<2>
    qtensor.dealloc %11 : tensor<2x!qco.qubit>
    return %1 : !cbit.reg<2>
  }
}

Each invocation produces one deterministic realization; omitting the seed reproduces the realization selected by seed=42. To construct an ensemble for noise tailoring, transform copies with different seeds, execute them, and aggregate their measurement results. Different seeds are not guaranteed to produce distinct realizations.

This is a raw-QCO transformation. It does not place twirling relative to target mapping or synthesis and does not guarantee that the result uses a target’s native gate set. Target-aware twirling is not currently available through the target compilation pipeline.

The raw qubit-reuse pass and its composite preparation pipeline are both available through the compiler collection. Two independent measured qubits can share one physical qubit, with a reset between uses. This example uses scalar QCO values directly; register operations and intervening classical stores can prevent the raw pass from proving that reuse is safe:

 1from mqt.core.mlir import QCOProgram
 2
 3independent_qubits = """module {
 4  func.func @main() -> (i1, i1) attributes {mqt.entry_point} {
 5    %q0 = qco.alloc : !qco.qubit
 6    %q1 = qco.alloc : !qco.qubit
 7    %h0 = qco.h %q0 : !qco.qubit -> !qco.qubit
 8    %h1 = qco.h %q1 : !qco.qubit -> !qco.qubit
 9    %m0, %c0 = qco.measure %h0 : !qco.qubit
10    %m1, %c1 = qco.measure %h1 : !qco.qubit
11    qco.sink %m0 : !qco.qubit
12    qco.sink %m1 : !qco.qubit
13    return %c0, %c1 : i1, i1
14  }
15}
16"""
17raw_reuse = QCOProgram.from_mlir_str(independent_qubits)
18before = raw_reuse.ir.count("qco.alloc")
19raw_reuse.reuse_qubits()
20
21composite_reuse = QCOProgram.from_mlir_str(independent_qubits)
22composite_reuse.run_qubit_reuse_pipeline()
23assert raw_reuse.is_valid and composite_reuse.is_valid
24after = raw_reuse.ir.count("qco.alloc")
25assert before == 2 and after == 1
26print(f"Qubit allocations: {before}{after}")
27print(raw_reuse.ir)
Qubit allocations: 2 → 1
module {
  func.func @main() -> (i1, i1) attributes {mqt.entry_point} {
    %0 = qco.alloc : !qco.qubit
    %1 = qco.h %0 : !qco.qubit -> !qco.qubit
    %qubit_out, %result = qco.measure %1 : !qco.qubit
    %2 = qco.reset %qubit_out : !qco.qubit -> !qco.qubit
    %3 = qco.h %2 : !qco.qubit -> !qco.qubit
    %qubit_out_0, %result_1 = qco.measure %3 : !qco.qubit
    qco.sink %qubit_out_0 : !qco.qubit
    return %result, %result_1 : i1, i1
  }
}

The same flows can be composed with the default optimization pipeline in the compiler driver:

mqt-cc input.qasm --emit=qco-optimized \
  --pass-pipeline='builtin.module(reuse-qubits,mqt-qco-default)'
mqt-cc input.qasm --emit=qco-optimized \
  --pass-pipeline='builtin.module(mqt-qubit-reuse,mqt-qco-default)'

The mqt-qubit-reuse pipeline lifts measurements and replaces classical controls before applying the raw reuse-qubits pass. It also runs remove-dead-gates, which can remove gates whose results are unused, including operations on unmeasured qubits. Use these passes only when those results may be discarded.

The qco_pipeline argument replaces the default QCO optimization pipeline. It is applied when compilation proceeds beyond the raw OutputFormat.QCO checkpoint.

Serialize programs and generate QIR

jeff is a serializable representation that can be stored and compiled again in a later process.

Integer expressions support widths through 64 bits. Integer absolute value and power require jeff’s native widths: 1, 8, 16, 32, or 64. Import preserves straight-line array snapshots, but rejects live old array values across mutating control flow and shared array updates inside switch or while regions. Scalar branch results and loop state are preserved, including different loop input and result tuples. Quantum allocations and deallocations inside conditional regions remain unsupported.

 1from pathlib import Path
 2from tempfile import TemporaryDirectory
 3
 4with TemporaryDirectory() as directory:
 5    path = Path(directory) / "bell.jeff"
 6    jeff = compile_program(bell_qasm, output=OutputFormat.JEFF)
 7    jeff.write(path)
 8    restored = compile_program(path, output=OutputFormat.QC)
 9
10assert restored.is_valid

To generate QIR, select a target profile. QIRProgram provides the QIR MLIR through ir and the translated LLVM IR through llvm_ir.

1qir = compile_program(bell_qasm, output=OutputFormat.QIR_BASE)
2assert qir.profile is QIRProfile.BASE
3print(qir.llvm_ir)
; ModuleID = 'LLVMDialectModule'
source_filename = "LLVMDialectModule"

@qir.result_label_result_1 = internal constant [9 x i8] c"result_1\00"
@qir.result_label_result_0 = internal constant [9 x i8] c"result_0\00"
@qir.result_label_result = internal constant [7 x i8] c"result\00"

define i64 @main() #0 {
  call void @__quantum__rt__initialize(ptr null)
  br label %1

1:                                                ; preds = %0
  call void @__quantum__qis__h__body(ptr null)
  call void @__quantum__qis__cx__body(ptr null, ptr inttoptr (i64 1 to ptr))
  br label %2

2:                                                ; preds = %1
  call void @__quantum__qis__mz__body(ptr null, ptr null)
  call void @__quantum__qis__mz__body(ptr inttoptr (i64 1 to ptr), ptr inttoptr (i64 1 to ptr))
  br label %3

3:                                                ; preds = %2
  call void @__quantum__rt__array_record_output(i64 2, ptr @qir.result_label_result)
  call void @__quantum__rt__result_record_output(ptr null, ptr @qir.result_label_result_0)
  call void @__quantum__rt__result_record_output(ptr inttoptr (i64 1 to ptr), ptr @qir.result_label_result_1)
  ret i64 0
}

declare void @__quantum__rt__initialize(ptr)

declare void @__quantum__qis__h__body(ptr)

declare void @__quantum__qis__cx__body(ptr, ptr)

declare void @__quantum__qis__mz__body(ptr, ptr) #1

declare void @__quantum__rt__result_record_output(ptr, ptr)

declare void @__quantum__rt__array_record_output(i64, ptr)

attributes #0 = { "entry_point" "output_labeling_schema"="labeled" "qir_profiles"="base_profile" "required_num_qubits"="2" "required_num_results"="2" }
attributes #1 = { "irreversible" }

!llvm.module.flags = !{!0, !1, !2, !3, !4}

!0 = !{i32 1, !"qir_major_version", i32 2}
!1 = !{i32 7, !"qir_minor_version", i32 1}
!2 = !{i32 1, !"dynamic_qubit_management", i1 false}
!3 = !{i32 1, !"dynamic_result_management", i1 false}
!4 = !{i32 2, !"Debug Info Version", i32 3}

Use to_bitcode() to obtain LLVM bitcode as bytes, or write_bitcode() to write a .bc file directly. The QIR guide shows how to execute the generated bytes directly with QIR-Runner’s qirrunner Python package.

The mqt-cc compiler driver selects the QIR serialization from the output filename. Use .ll for textual LLVM IR and .bc for LLVM bitcode:

mqt-cc input.qasm --emit=qir-base -o output.ll
mqt-cc input.qasm --emit=qir-adaptive -o output.bc

Writing QIR to standard output also produces textual LLVM IR. All other output filenames, including filenames without an extension, retain the bitcode output used by earlier versions.

The QC, QCO, and QTensor references describe the underlying operations. See Conversions for the lowering steps between dialects.