OpenQASM Beginner Free 4/8 in series 12 min read

OpenQASM 3 and Qiskit Interoperability

How to convert between Qiskit QuantumCircuit objects and OpenQASM 3 strings for sharing, archiving, and cross-platform workflows.

What you'll learn

  • OpenQASM
  • QASM 3
  • Qiskit
  • interoperability
  • circuit export
  • circuit import

Prerequisites

  • Basic Python (variables, functions, loops)
  • No quantum physics background needed

Why Convert Between Qiskit and OpenQASM 3

Qiskit is a Python SDK for building and running quantum circuits. OpenQASM 3 is a text-based language for describing quantum programs. They represent the same thing (quantum circuits) in different formats, and you will frequently need to move between them.

Common reasons to convert:

  • Sharing circuits with collaborators: Your collaborator uses PennyLane, not Qiskit. Exporting your circuit to QASM lets them import it into their framework without installing Qiskit or rewriting anything. The .qasm file acts as a neutral exchange format that both sides can read.
  • Cross-platform execution: You developed a circuit locally in Qiskit, but you want to run it on Quantinuum hardware through their portal. Quantinuum accepts OpenQASM 3 directly, so you export to QASM and submit without going through Qiskit’s runtime at all.
  • Archival and reproducibility: Two years from now, the Qiskit API may have changed, and a pickled QuantumCircuit object may not load cleanly. A .qasm file is a stable, human-readable record of a circuit that does not depend on any particular SDK version. You can always reconstruct the circuit from the text.
  • Importing published circuits: A research paper on arXiv includes a QASM listing for a variational ansatz. Instead of manually rebuilding the circuit gate by gate from the paper’s figure, you copy the QASM text and load it directly into Qiskit with a single function call.
  • Continuous integration and testing: Your team commits .qasm files alongside test scripts in version control. A CI pipeline loads each file, runs it on a simulator, and checks that the output distribution matches expected results. The circuit definitions stay SDK-independent, so switching from Qiskit to Cirq on the runner side requires changing only the loader.

Exporting a Qiskit Circuit to OpenQASM 3

Use qiskit.qasm3.dumps() to convert a QuantumCircuit to an OpenQASM 3 string:

from qiskit import QuantumCircuit
from qiskit import qasm3

# Build a simple Bell state circuit
qc = QuantumCircuit(2, 2)
qc.h(0)
qc.cx(0, 1)
qc.measure([0, 1], [0, 1])

# Export to OpenQASM 3
qasm_string = qasm3.dumps(qc)
print(qasm_string)

This produces output like:

OPENQASM 3.0;
include "stdgates.inc";
bit[2] c;
qubit[2] q;
h q[0];
cx q[0], q[1];
c[0] = measure q[0];
c[1] = measure q[1];

To write directly to a file, use qasm3.dump() with a file handle:

with open("bell_state.qasm", "w") as f:
    qasm3.dump(qc, f)

Understanding the QASM 3 Output

If you have not seen QASM syntax before, the exported output can look unfamiliar. Here is what each line means in the Bell state example above:

OPENQASM 3.0; is the version declaration. It must be the first non-comment line in every QASM 3 file. Parsers use this to determine which grammar rules to apply, so omitting it or writing OPENQASM 2.0; will cause QASM 3 loaders to reject the file.

include "stdgates.inc"; imports the standard gate library. This file defines common gates like h, cx, x, z, s, t, and others. Without this include, the parser does not know what h or cx means, and the file will fail to load. You do not need to provide the stdgates.inc file yourself; QASM 3 parsers bundle it.

bit[2] c; declares a classical register named c with 2 bits. Note that Qiskit’s QASM 3 exporter uses the bit keyword, not the older creg keyword from QASM 2. The bit type is the QASM 3 way to declare classical storage.

qubit[2] q; declares a quantum register named q with 2 qubits. Similarly, Qiskit outputs qubit instead of the QASM 2 keyword qreg. If your Qiskit circuit has named registers (e.g., QuantumRegister(3, name='data')), those names appear in the QASM output.

h q[0]; applies the Hadamard gate to qubit 0. This is the standard QASM 3 gate syntax: the gate name, followed by the target qubit(s), followed by a semicolon. Multi-qubit gates list their arguments separated by commas.

cx q[0], q[1]; applies a CNOT gate with q[0] as control and q[1] as target. The argument ordering matches the standard convention: control first, target second.

c[0] = measure q[0]; measures qubit 0 and stores the result in classical bit 0. This assignment syntax is new in QASM 3. In QASM 2, the same operation was written with an arrow: measure q[0] -> c[0];. The QASM 3 syntax reads more naturally as “the classical bit receives the measurement result.”

Importing OpenQASM 3 into Qiskit

Use qasm3.loads() to parse an OpenQASM 3 string back into a QuantumCircuit:

from qiskit import qasm3

qasm_source = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[2] q;
bit[2] c;
h q[0];
cx q[0], q[1];
c[0] = measure q[0];
c[1] = measure q[1];
"""

qc = qasm3.loads(qasm_source)
print(qc.draw())

To load from a file:

qc = qasm3.load("bell_state.qasm")

Loading a Circuit from a Research Paper

Suppose you find a QASM 3 circuit listing in a paper or a GitHub repository. Here is the full workflow to get it running in Qiskit.

First, save the QASM string to a file. You can do this manually, or inline it in your script:

from qiskit import qasm3
from qiskit.primitives import StatevectorSampler

# QASM 3 circuit from a paper (a simple GHZ state preparation)
qasm_from_paper = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[3] q;
bit[3] c;
h q[0];
cx q[0], q[1];
cx q[1], q[2];
c[0] = measure q[0];
c[1] = measure q[1];
c[2] = measure q[2];
"""

# Load the QASM into a Qiskit QuantumCircuit
qc = qasm3.loads(qasm_from_paper)

# Inspect the circuit to confirm it matches the paper
print(qc.draw())

Output:

     ┌───┐          ┌─┐
q_0: ┤ H ├──■───────┤M├──────
     └───┘┌─┴─┐     └╥┘┌─┐
q_1: ─────┤ X ├──■───╫─┤M├───
          └───┘┌─┴─┐ ║ └╥┘┌─┐
q_2: ──────────┤ X ├─╫──╫─┤M├
               └───┘ ║  ║ └╥┘
c: 3/═════════════════╩══╩══╩═
                      0  1  2

Now run it on a simulator:

# Run on the StatevectorSampler
sampler = StatevectorSampler()
job = sampler.run([qc], shots=1024)
result = job.result()

# Get the counts
counts = result[0].data.c.get_counts()
print(counts)
# Expected: roughly equal counts for '000' and '111'

If the paper provides the QASM as a file, download it and load directly:

qc = qasm3.load("path/to/paper_circuit.qasm")

This approach saves time and avoids transcription errors compared to rebuilding circuits by hand from a figure.

Round-Trip Verification

A useful test when working with QASM interop is to verify that a circuit survives a round trip (export then re-import) without changing its behavior:

from qiskit import QuantumCircuit, qasm3
from qiskit.quantum_info import Operator

# Original circuit
original = QuantumCircuit(2)
original.h(0)
original.cx(0, 1)
original.s(1)

# Round trip: Qiskit -> QASM 3 -> Qiskit
qasm_str = qasm3.dumps(original)
reconstructed = qasm3.loads(qasm_str)

# Compare the unitary matrices
op_original = Operator(original)
op_reconstructed = Operator(reconstructed)

print(f"Unitaries equal: {op_original.equiv(op_reconstructed)}")

If the unitaries match, the round trip preserved the circuit’s quantum operation exactly.

Exporting with Parameters

Parameterized circuits are common in variational algorithms like VQE and QAOA. When you export a parameterized circuit to QASM 3, the parameter names appear as input variables in the output.

from qiskit import QuantumCircuit, qasm3
from qiskit.circuit import Parameter

theta = Parameter('theta')
phi = Parameter('phi')

qc = QuantumCircuit(2)
qc.ry(theta, 0)
qc.ry(phi, 1)
qc.cx(0, 1)

# Export with unbound parameters
qasm_str = qasm3.dumps(qc)
print(qasm_str)

The output includes input declarations for each parameter:

OPENQASM 3.0;
include "stdgates.inc";
input float[64] theta;
input float[64] phi;
qubit[2] q;
ry(theta) q[0];
ry(phi) q[1];
cx q[0], q[1];

The input keyword in QASM 3 marks these as values that must be supplied at runtime. This is useful for sharing ansatz templates: your collaborator receives the circuit structure and can bind their own parameter values later.

If you bind the parameters before exporting, the QASM output contains concrete numeric values instead:

# Bind parameters to specific values
bound_qc = qc.assign_parameters({theta: 1.57, phi: 0.78})

qasm_str_bound = qasm3.dumps(bound_qc)
print(qasm_str_bound)

The bound output replaces the input declarations with literal numbers:

OPENQASM 3.0;
include "stdgates.inc";
qubit[2] q;
ry(1.57) q[0];
ry(0.78) q[1];
cx q[0], q[1];

Choose unbound export when sharing a circuit template. Choose bound export when sharing a specific instance of a circuit that is ready to run.

Decomposing Before Export

Some Qiskit instructions cannot be directly expressed in QASM 3. These include:

  • Initialize: Sets qubits to a specific statevector. This is a high-level Qiskit operation that the QASM 3 exporter does not know how to serialize.
  • 3-qubit and higher gates: Gates like CCXGate (Toffoli) are supported, but custom multi-qubit unitaries defined with UnitaryGate have no direct QASM 3 representation.
  • Custom Python gates: If you define a gate as a Python class, the exporter cannot convert arbitrary Python logic into QASM text.

Attempting to export these gates without decomposition raises an error. The cleanest solution is to use transpile with optimization_level=0 to decompose the circuit into standard gates before exporting:

from qiskit import QuantumCircuit, qasm3, transpile
from qiskit.circuit.library import UnitaryGate
import numpy as np

# A circuit with a custom unitary gate
qc = QuantumCircuit(2)
qc.h(0)
# Apply a custom 2-qubit unitary
unitary_matrix = np.array([
    [1, 0, 0, 0],
    [0, 0, 1, 0],
    [0, 1, 0, 0],
    [0, 0, 0, 1]
]) / 1  # SWAP matrix
qc.append(UnitaryGate(unitary_matrix), [0, 1])

# This would fail: qasm3.dumps(qc)

# Decompose to standard gates first
decomposed = transpile(qc, basis_gates=['u', 'cx'], optimization_level=0)
print(decomposed.draw())

# Now export successfully
qasm_str = qasm3.dumps(decomposed)
print(qasm_str)

Using optimization_level=0 tells the transpiler to decompose without rearranging or optimizing the circuit. This preserves the structure as closely as possible while converting everything to standard gates. If you also want the transpiler to simplify redundant gates, use optimization_level=1 or higher, but be aware that this may change the circuit structure significantly.

The basis_gates argument controls which gates appear in the output. Common choices:

  • ['u', 'cx']: Universal gate set, works everywhere
  • ['h', 'cx', 's', 't']: Clifford+T, common in error correction literature
  • ['rx', 'ry', 'rz', 'cx']: Rotation-based, common in variational algorithms

Pick the basis set that matches your target platform or your audience’s conventions.

Differences from OpenQASM 2

Qiskit previously used OpenQASM 2 via the .qasm() method on QuantumCircuit. This older interface is deprecated in favor of the qasm3 module. Key differences:

# Old way (OpenQASM 2, deprecated)
# qasm2_string = qc.qasm()

# New way (OpenQASM 3)
qasm3_string = qasm3.dumps(qc)

Practical differences between QASM 2 and QASM 3 export:

FeatureQASM 2 (.qasm())QASM 3 (qasm3.dumps())
HeaderOPENQASM 2.0;OPENQASM 3.0;
Classical typescreg c[2];bit[2] c;
Quantum registersqreg q[2];qubit[2] q;
Measurement syntaxmeasure q[0] -> c[0];c[0] = measure q[0];
Classical controlLimited ifFull if/else, while, for
Custom gatesSupportedSupported, plus modifiers

What Gets Preserved and What Does Not

When exporting to QASM 3, the gate operations and measurements are preserved faithfully. However, some Qiskit-specific metadata does not survive the conversion:

Preserved:

  • Gate operations and their parameters
  • Qubit and classical bit registers
  • Measurement operations
  • Barrier instructions
  • Classical conditionals (if statements)

Not preserved:

  • Circuit name and metadata
  • Qiskit-specific instructions that have no QASM 3 equivalent (e.g., Initialize, UnitaryGate without decomposition)
  • Layout and transpilation information
  • Custom Python-defined gates (must be decomposed first)

If your circuit contains non-standard instructions, decompose them before exporting:

from qiskit.transpiler import PassManager
from qiskit.transpiler.passes import Unroll3qOrMore, Decompose

# Decompose high-level gates before QASM export
pm = PassManager([Decompose(), Unroll3qOrMore()])
decomposed = pm.run(qc)
qasm_str = qasm3.dumps(decomposed)

Working with Other Platforms

QASM 3 is increasingly supported across quantum computing platforms, making it a practical interchange format. Here is how to move circuits between Qiskit and other major tools.

Quantinuum via pytket

Quantinuum’s hardware accepts QASM 3, and the pytket library provides a convenient loader:

from pytket.qasm import circuit_from_qasm_str

# Start with a QASM 3 string exported from Qiskit
qasm_str = qasm3.dumps(qc)

# Load into pytket
tket_circuit = circuit_from_qasm_str(qasm_str)

# From here, submit to Quantinuum backends via pytket-quantinuum

The pytket parser handles most standard QASM 3 constructs. If you use advanced QASM 3 features like classical loops or subroutines, verify that pytket supports them, as coverage varies by version.

Amazon Braket

Amazon Braket has partial support for loading QASM 3 circuits:

from braket.circuits import Circuit

# Load a QASM 3 string into Braket
braket_circuit = Circuit.from_ir(qasm_str)

Braket’s QASM 3 support covers standard gates and measurements but does not yet handle all QASM 3 features (such as classical control flow or parameterized inputs). For circuits that use only basic gates, this path works well. For more complex circuits, test the import carefully.

Cirq (Google)

Cirq does not have a built-in QASM 3 importer as of early 2026. It does support QASM 2 import:

import cirq

# Cirq supports QASM 2, not QASM 3
# Export from Qiskit as QASM 2 if targeting Cirq
from qiskit import qasm2
qasm2_str = qasm2.dumps(qc)
cirq_circuit = cirq.contrib.qasm_import.circuit_from_qasm(qasm2_str)

If you need to share circuits with Cirq users, exporting as QASM 2 (using qiskit.qasm2.dumps()) is currently the most reliable path.

PennyLane (Xanadu)

PennyLane can load QASM 2 strings via its from_qasm function:

import pennylane as qml

qasm2_str = qasm2.dumps(qc)
dev = qml.device("default.qubit", wires=2)
loaded_circuit = qml.from_qasm(qasm2_str)

PennyLane’s QASM 3 support is under active development. Check the latest PennyLane release notes for current status.

Summary of Platform QASM Support

PlatformQASM 2QASM 3
QiskitFull (via qiskit.qasm2)Full (via qiskit.qasm3)
pytket / QuantinuumFullSupported (most features)
Amazon BraketFullPartial
CirqFullNot yet supported
PennyLaneFullIn development

When sharing circuits across platforms, QASM 2 remains the safest lowest-common-denominator format. Use QASM 3 when your target platform explicitly supports it and you need QASM 3 features like parameterized inputs or classical control flow.

Common Mistakes

Using qc.qasm() instead of qasm3.dumps(qc)

The .qasm() method on QuantumCircuit produces QASM 2 output and is deprecated. New code should always use the qasm3 module:

# Wrong: produces QASM 2, deprecated
# old_qasm = qc.qasm()

# Correct: produces QASM 3
from qiskit import qasm3
new_qasm = qasm3.dumps(qc)

If you specifically need QASM 2 output (for example, to share with a platform that only supports QASM 2), use the explicit qasm2 module instead of the deprecated method:

from qiskit import qasm2
qasm2_str = qasm2.dumps(qc)

Loading QASM 2 with the QASM 3 parser

The qasm3.loads() function expects QASM 3 syntax. If you pass it a QASM 2 string, it fails because the two formats have different grammars. Always match the parser to the format:

from qiskit import qasm2, qasm3

# QASM 2 string (note: uses creg/qreg and arrow syntax)
qasm2_source = """
OPENQASM 2.0;
include "qelib1.inc";
qreg q[2];
creg c[2];
h q[0];
measure q[0] -> c[0];
"""

# Wrong: this will raise a parse error
# qc = qasm3.loads(qasm2_source)

# Correct: use the QASM 2 parser
qc = qasm2.loads(qasm2_source)

Check the header line (OPENQASM 2.0; vs OPENQASM 3.0;) to determine which parser to use. If you are writing a utility that handles both formats, read the first line of the string and dispatch accordingly.

Exporting circuits with unsupported gates

If your circuit contains Initialize, UnitaryGate, or other Qiskit-specific instructions, the QASM 3 exporter raises an error because these instructions have no standard QASM 3 representation. Decompose first:

from qiskit import transpile

# Circuit with an Initialize instruction
qc = QuantumCircuit(2)
qc.initialize([0, 1, 0, 0], [0, 1])  # Initialize to |01>

# This raises an error:
# qasm3.dumps(qc)

# Fix: decompose to standard gates
decomposed = transpile(qc, basis_gates=['u', 'cx'], optimization_level=0)
qasm_str = qasm3.dumps(decomposed)

Assuming all stdgates.inc gates work everywhere

The QASM 3 standard gate library (stdgates.inc) defines dozens of gates. However, each hardware platform supports only a small native gate set. A circuit that uses h, cx, t, and s will export to valid QASM 3, but the target hardware may only support rz, sx, and cx. The QASM file is syntactically correct, yet the hardware backend rejects it.

The solution is to transpile to your target platform’s native gates before exporting:

from qiskit import transpile

# Transpile to a specific backend's native gate set
transpiled = transpile(qc, basis_gates=['rz', 'sx', 'cx'], optimization_level=1)
qasm_str = qasm3.dumps(transpiled)

This produces QASM 3 output that uses only the gates your target hardware actually supports.

A Practical Workflow

Here is a common pattern for teams that use multiple quantum SDKs:

  1. Build and test circuits in Qiskit (or any SDK with QASM 3 export)
  2. Export to .qasm files and commit them to version control
  3. On the execution side, load the .qasm file into whichever SDK targets your hardware
  4. Results flow back through your analysis pipeline regardless of which SDK ran the circuit

This keeps your circuit definitions portable and SDK-independent, while letting each team member use the tools they prefer.

Was this tutorial helpful?