Classical Control Flow in OpenQASM 3
Use OpenQASM 3's classical control flow features: if/else on measurement outcomes, for loops, while loops, and real-time classical computation for dynamic quantum circuits.
Circuit diagrams
What Changed from OpenQASM 2
OpenQASM 2 had exactly one classical control feature: a simple if statement that conditioned a single gate on the integer value of a classical register. That was enough for measurement-based teleportation corrections but little else.
OpenQASM 3 adds a full classical programming model alongside the quantum operations:
if/elseblocks with arbitrary conditionsforloops over integer ranges or arrayswhileloops with classical conditions- A typed classical variable system (
bit,int,uint,bool,float) - Classical bit and integer arithmetic
switch/casestatementsdelay,stretch, andboxtiming directives- Real-time classical functions (
def)
This makes dynamic circuits (circuits where later gates depend on earlier measurement results) expressible directly in the language. You no longer need to hand-unroll loops, flatten conditionals into single gates, or rely on external scripts to generate repetitive gate sequences.
OpenQASM 2 if vs OpenQASM 3 if
The if statement in OpenQASM 2 was highly constrained. The full syntax was:
// OpenQASM 2 -- limited conditional
if (creg == 3) x q[0];
This single line captures everything QASM 2 could do with classical branching. Specifically, it had these restrictions:
- Single gate only. You could condition exactly one gate. To condition two gates on the same measurement, you had to write two separate
iflines. - Full register comparison only. The condition always compared the entire classical register (interpreted as an integer) against a literal value. You could not test individual bits within a register.
- Equality only. The only comparison operator was
==. No less-than, greater-than, bitwise operations, or logical operators. - No else clause. There was no way to express “if this, do A, otherwise do B.”
Here is the same logic written in OpenQASM 2 and then in OpenQASM 3 for comparison:
// OpenQASM 2 style: condition two gates on creg == 1
// Each gate needs its own if statement
if (c == 1) x q[0];
if (c == 1) z q[1];
// OpenQASM 3 style: condition a block of gates with else
if (c[0] == 1) {
x q[0];
z q[1];
} else {
h q[0];
}
OpenQASM 3 removes every one of the old restrictions. You can condition blocks of gates, test individual bits, use any comparison operator, combine conditions with logical operators, and include else branches. The rest of this tutorial explores each of these capabilities.
The Classical Type System
OpenQASM 3 introduces a typed variable system for classical data. Understanding these types is essential for writing correct classical control flow, because the type of a variable determines what operations you can perform with it and how the hardware interprets it at runtime.
bit and bit[n]
The bit type represents a single classical bit. A bit[n] is an array of n bits. Measurement results are stored in bit variables.
OPENQASM 3.0;
include "stdgates.inc";
qubit[4] q;
bit result; // a single bit
bit[4] results; // an array of 4 bits
result = measure q[0]; // measure one qubit into a single bit
results = measure q; // measure all 4 qubits into the bit array
// Access individual bits with indexing
if (results[2] == 1) {
x q[3];
}
bool
The bool type holds a boolean value (true or false). It is the result type of comparison expressions. You can also declare bool variables directly.
OPENQASM 3.0;
include "stdgates.inc";
qubit[2] q;
bit[2] c;
bool parity_odd;
h q[0];
cx q[0], q[1];
c = measure q;
// Bool from a comparison
parity_odd = (c[0] != c[1]);
if (parity_odd) {
x q[0];
reset q[0];
}
int, uint, int[n], uint[n]
The int type is a signed 32-bit integer by default. The uint type is unsigned. You can specify a custom bit-width with int[n] or uint[n].
OPENQASM 3.0;
include "stdgates.inc";
int count = 0; // 32-bit signed integer, initialized to 0
uint[8] syndrome_val; // 8-bit unsigned integer
int[16] offset = -42; // 16-bit signed integer
// Integer arithmetic
count = count + 1;
syndrome_val = 2 * syndrome_val + 1;
Integers are useful for loop counters, syndrome decoding (where you convert a bit array to an integer for use in a switch statement), and tracking iteration counts in adaptive protocols.
float[32] and float[64]
Floating-point types exist in QASM 3, but most classical controllers on current hardware have limited or no support for runtime float arithmetic. Use floats primarily for gate angle parameters that the compiler resolves at compile time.
OPENQASM 3.0;
include "stdgates.inc";
qubit q;
float[64] angle = pi / 4.0; // compile-time angle calculation
rz(angle) q;
If you need to compute angles dynamically at runtime, check your target backend’s documentation. Many controllers only support integer arithmetic in the real-time path.
const: Compile-Time Constants
The const keyword declares a value that the compiler resolves before the circuit is sent to hardware. Constants cost zero execution time because they disappear during compilation, replaced by their literal values everywhere they appear.
OPENQASM 3.0;
include "stdgates.inc";
const int N = 4; // resolved at compile time
const float[64] theta = pi / 8; // also resolved at compile time
qubit[N] q; // N is substituted with 4 by the compiler
bit[N] c;
for int i in [0:N-1] {
rz(theta) q[i]; // theta is substituted with pi/8
}
c = measure q;
Constants are the right choice whenever a value is known before execution. They make your code readable without adding overhead.
If/Else with Compound Conditions
OpenQASM 3 supports the logical operators && (and), || (or), and ! (not), as well as all the standard comparison operators (==, !=, <, >, <=, >=). You can combine these to write complex branching logic.
OPENQASM 3.0;
include "stdgates.inc";
qubit[3] q;
bit[2] syndrome;
// ... syndrome extraction circuit ...
// Compound conditions with logical AND
if (syndrome[0] == 1 && syndrome[1] == 0) {
x q[0]; // error on qubit 0
} else if (syndrome[0] == 1 && syndrome[1] == 1) {
x q[1]; // error on qubit 1
} else if (syndrome[0] == 0 && syndrome[1] == 1) {
x q[2]; // error on qubit 2
}
// syndrome 00 means no error, so no else clause needed
You can also nest conditions and use the ! operator:
bit[2] flags;
bool any_error = (flags[0] == 1 || flags[1] == 1);
bool no_error = !any_error;
if (no_error) {
// proceed with computation
h q[0];
}
These compound conditions execute on the classical controller at nanosecond timescales. The cost is negligible compared to gate execution times, so feel free to write readable conditions without worrying about performance.
Switch/Case for Syndrome Decoding
When you have many possible bit patterns to check (as in error correction syndrome tables), switch/case is more readable and more efficient than a chain of if/else if blocks.
OPENQASM 3.0;
include "stdgates.inc";
qubit[5] q; // 3 data + 2 ancilla
bit[2] syndrome;
int syndrome_int;
// ... syndrome extraction circuit ...
syndrome[0] = measure q[3];
syndrome[1] = measure q[4];
// Convert bit array to integer for the switch
syndrome_int = 2 * syndrome[0] + syndrome[1];
switch (syndrome_int) {
case 0: { break; } // no error
case 1: { x q[2]; break; } // error on qubit 2
case 2: { x q[0]; break; } // error on qubit 0
case 3: { x q[1]; break; } // error on qubit 1
}
The switch/case construct compiles more efficiently than an if/else chain because the classical controller can use a lookup table instead of evaluating conditions sequentially. For a 2-bit syndrome this difference is small, but for larger codes (like the Steane code with a 3-bit syndrome giving 8 cases) it becomes meaningful.
Each case block must end with break; to prevent fall-through to the next case.
For Loops in Depth
OpenQASM 3 for loops iterate over integer ranges. The basic syntax is for int i in [start:end], which iterates from start to end inclusive.
Basic Range
OPENQASM 3.0;
include "stdgates.inc";
qubit[4] q;
bit[4] c;
// Apply H to each qubit: i takes values 0, 1, 2, 3
for int i in [0:3] {
h q[i];
}
c = measure q;
Note that the range [0:3] is inclusive on both ends, so i takes values 0, 1, 2, and 3.
Range with Step
You can specify a step size with the syntax [start:step:end]:
// Apply X to even-indexed qubits: i takes values 0, 2, 4, 6
for int i in [0:2:7] {
x q[i];
}
Here i iterates as 0, 2, 4, 6. The value 7 is not reached because the next step (8) would exceed it.
GHZ State Preparation with a For Loop
A GHZ state on n qubits is (|00...0> + |11...1>) / sqrt(2). The preparation circuit applies a Hadamard to the first qubit, then a chain of CNOTs. Without loops, you would need to write n-1 separate CNOT lines. With a loop:
OPENQASM 3.0;
include "stdgates.inc";
const int N = 8;
qubit[N] q;
bit[N] c;
// Prepare GHZ state on N qubits
h q[0];
for int i in [1:N-1] {
cx q[0], q[i];
}
c = measure q;
This is functionally identical to writing out 7 CNOT gates by hand, but it scales cleanly to any value of N. The compiler unrolls the loop before sending the circuit to hardware.
QFT with Nested Loops
The Quantum Fourier Transform demonstrates nested loops with computed gate parameters:
OPENQASM 3.0;
include "stdgates.inc";
const int N = 4;
qubit[N] q;
bit[N] c;
// QFT rotation layers
for int j in [0:N-1] {
h q[j];
for int k in [j+1:N-1] {
// Controlled phase rotation: angle = pi / 2^(k-j)
ctrl @ phase(pi / (2 ** (k - j))) q[k], q[j];
}
}
// Swap to correct bit order
for int i in [0:N/2 - 1] {
swap q[i], q[N - 1 - i];
}
c = measure q;
While Loops for Adaptive Protocols
While loops enable repeat-until-success (RUS) circuits, where a quantum operation is retried until a classical success condition is met.
OPENQASM 3.0;
include "stdgates.inc";
// Repeat-until-success: prepare a specific non-Clifford state
qubit[2] q;
bit ancilla_result;
int attempts = 0;
// Loop until success (ancilla measured as 0)
while (ancilla_result != 0 || attempts == 0) {
// Reset ancilla each attempt
reset q[1];
// Probabilistic preparation circuit
h q[1];
t q[0];
cx q[1], q[0];
tdg q[0];
cx q[1], q[0];
ancilla_result = measure q[1];
attempts += 1;
if (ancilla_result == 1) {
// Correction on failed attempt
s q[0];
}
}
// q[0] now holds the desired state
The while loop runs on the classical controller, and each iteration re-executes the quantum gates. This means each failed attempt costs coherence time. In practice, RUS protocols are designed so that the success probability per iteration is high (typically 50% or more), keeping the expected number of iterations small.
Timing and Delay Directives
OpenQASM 3 introduces timing control that QASM 2 completely lacked. These directives let you specify when operations happen relative to each other and to the hardware clock.
delay
The delay directive inserts a wait period on one or more qubits:
OPENQASM 3.0;
include "stdgates.inc";
qubit[3] q;
bit[2] alice_bits;
bit bob_bit;
// Prepare Bell pair
h q[1];
cx q[1], q[2];
// Alice's Bell measurement
cx q[0], q[1];
h q[0];
alice_bits[0] = measure q[0];
alice_bits[1] = measure q[1];
// Bob's qubit waits for the classical feedforward latency
// On current hardware this is roughly 300-500 nanoseconds
delay[500ns] q[2];
// Then apply corrections
if (alice_bits[1] == 1) {
x q[2];
}
if (alice_bits[0] == 1) {
z q[2];
}
bob_bit = measure q[2];
You can specify delay in several units:
delay[100ns] q;— wait 100 nanosecondsdelay[100us] q;— wait 100 microsecondsdelay[100dt] q;— wait 100 hardware time samples (dt is backend-specific, typically around 0.22 ns on IBM hardware)
Delays are critical for modeling real feedforward latency. Without an explicit delay, the compiler may schedule Bob’s correction gates before the classical result is available, leading to incorrect behavior or a compilation error.
stretch
The stretch directive is a flexible delay that the compiler can expand or contract to align timing across different parts of the circuit:
qubit[2] q;
stretch a;
h q[0];
delay[a] q[1]; // q[1] waits however long q[0]'s H gate takes
cx q[0], q[1]; // now both qubits are synchronized
The compiler determines the actual duration of a based on the gate times of surrounding operations. This is useful when you want to synchronize qubits without hardcoding backend-specific timing values.
Box Keyword for Timing Alignment
The box keyword groups operations with a shared timing constraint. All operations inside a box must complete before any operation after the box can start.
OPENQASM 3.0;
include "stdgates.inc";
qubit[4] q;
bit[4] c;
// All Hadamard gates in this box execute in the same time window
box {
h q[0];
h q[1];
h q[2];
h q[3];
}
// This CNOT layer starts only after all Hadamards have finished
box {
cx q[0], q[1];
cx q[2], q[3];
}
c = measure q;
Boxes are the QASM 3 way of expressing “moments” or “layers” in a circuit. Without boxes, the compiler is free to reorder operations as long as data dependencies are respected. Boxes give you explicit control over synchronization when you need it, for example when calibrating a circuit or when simultaneous gate execution matters for crosstalk management.
Classical Functions (def)
OpenQASM 3 allows you to define classical subroutines with the def keyword. These functions run on the classical controller, not on qubits. They accept classical arguments, perform classical computation, and return a single classical value.
OPENQASM 3.0;
include "stdgates.inc";
// Define a parity function over a 4-bit syndrome
def parity(bit[4] syndrome) -> bit {
return syndrome[0] ^ syndrome[1] ^ syndrome[2] ^ syndrome[3];
}
qubit[5] q;
bit[4] s;
bit p;
// ... syndrome measurement circuit ...
s[0] = measure q[1];
s[1] = measure q[2];
s[2] = measure q[3];
s[3] = measure q[4];
p = parity(s);
if (p == 1) {
// Odd number of syndrome bits are set: error detected
x q[0];
}
Functions cannot apply quantum gates or perform measurements. They are purely classical. This restriction exists because the classical controller and the QPU are separate processing units. A function call stays entirely on the controller side.
You can use functions to encapsulate syndrome decoding logic, parity checks, or any other classical computation that you want to reuse in multiple places within the same circuit file.
The 3-Qubit Repetition Code (Full Example)
The 3-qubit repetition code is the simplest quantum error correcting code. It encodes a single logical qubit into three physical qubits and can correct a single bit-flip error. Here is a complete, correct implementation in OpenQASM 3.
OPENQASM 3.0;
include "stdgates.inc";
// 3 data qubits + 2 ancilla qubits for syndrome extraction
qubit[5] q;
bit[2] syndrome;
int syndrome_int;
bit result;
// Step 1: Encode the logical qubit
// q[0] starts in the state we want to protect (here, |0>)
// The encoding copies q[0]'s state to q[1] and q[2]
cx q[0], q[1];
cx q[0], q[2];
// Logical |0> is now encoded as |000>, logical |1> as |111>
// Step 2: Error occurs (simulated)
// In a real circuit, this would be hardware noise, not an explicit gate.
// We flip q[1] to simulate a single bit-flip error.
x q[1];
// State is now |010> (if the logical qubit was |0>)
// Step 3: Syndrome extraction
// Ancilla q[3] measures the parity of q[0] and q[1]
// Ancilla q[4] measures the parity of q[1] and q[2]
cx q[0], q[3];
cx q[1], q[3];
cx q[1], q[4];
cx q[2], q[4];
syndrome[0] = measure q[3];
syndrome[1] = measure q[4];
// Step 4: Decode the syndrome and apply correction
// Syndrome table:
// syndrome[0]=0, syndrome[1]=0 -> no error (int 0)
// syndrome[0]=1, syndrome[1]=0 -> error on q[0] (int 2)
// syndrome[0]=1, syndrome[1]=1 -> error on q[1] (int 3)
// syndrome[0]=0, syndrome[1]=1 -> error on q[2] (int 1)
syndrome_int = 2 * syndrome[0] + syndrome[1];
switch (syndrome_int) {
case 0: { break; } // no error detected
case 1: { x q[2]; break; } // correct qubit 2
case 2: { x q[0]; break; } // correct qubit 0
case 3: { x q[1]; break; } // correct qubit 1
}
// Step 5: Verify the logical qubit
// After correction, all three data qubits should agree
result = measure q[0];
// result should be 0, matching the original logical state
A few important notes about this code:
- The syndrome measurement uses ancilla qubits rather than measuring data qubits directly. This preserves the quantum state of the data qubits.
- The syndrome bits encode parity checks between pairs of data qubits. If q[0] and q[1] disagree, syndrome[0] is 1. If q[1] and q[2] disagree, syndrome[1] is 1. From these two parity checks, you can uniquely identify which qubit (if any) was flipped.
- This code corrects single bit-flip errors only. It cannot detect or correct phase-flip errors, and it cannot handle errors on the ancilla qubits themselves. Fault-tolerant error correction addresses these limitations by using more sophisticated syndrome extraction circuits and larger codes (like the Steane [[7,1,3]] code or the surface code).
Quantum Teleportation in OpenQASM 3
Here is the full quantum teleportation protocol, combining mid-circuit measurement, classical feedforward, and timing awareness:
OPENQASM 3.0;
include "stdgates.inc";
// Three qubits: message (0), Alice's half of Bell pair (1), Bob's (2)
qubit[3] q;
bit[2] alice_bits;
bit bob_bit;
// Prepare the message qubit in state |+>
h q[0];
// Create Bell pair between Alice and Bob
h q[1];
cx q[1], q[2];
// Alice's Bell measurement
cx q[0], q[1];
h q[0];
alice_bits[0] = measure q[0];
alice_bits[1] = measure q[1];
// Bob's real-time corrections (classical feedforward)
if (alice_bits[1] == 1) {
x q[2];
}
if (alice_bits[0] == 1) {
z q[2];
}
bob_bit = measure q[2];
// bob_bit should match the original message state with P=1
Real-Time vs Compile-Time Computation
QASM 3 programs involve three distinct layers of computation, and understanding when each runs is critical for writing correct circuits.
Compile-Time Constants
Values declared with const are resolved by the compiler before the circuit reaches the hardware. They do not consume any execution time.
const int N = 4;
const float[64] angle = pi / 8.0;
// These are replaced by literal values during compilation
// The hardware never sees "N" or "angle" -- only 4 and 0.3927...
Real-Time Classical Computation
Expressions involving non-const classical variables are evaluated by the control hardware during circuit execution. This includes measurement results, loop counters, and syndrome values.
bit m;
int count = 0;
m = measure q[0]; // m is determined at runtime
count = count + 1; // evaluated by the classical controller in real time
if (m == 1) { x q[1]; } // branching decision made at runtime
Real-time classical operations take nanoseconds on modern controllers. This is fast relative to gate times (tens to hundreds of nanoseconds) but not free.
Quantum Operations
Gate applications and measurements are executed by the QPU. These are the most time-consuming operations in the circuit.
h q[0]; // ~30-50 ns on superconducting hardware
cx q[0], q[1]; // ~200-400 ns on superconducting hardware
bit m = measure q[0]; // ~500-1000 ns on superconducting hardware
The distinction matters because you cannot use runtime-dependent values in contexts that require compile-time constants. For example, you cannot use a measurement result to determine the number of qubits in a register, because qubit allocation happens at compile time. Similarly, const values cannot depend on measurement outcomes.
// This is INVALID:
bit m = measure q[0];
const int n = m; // ERROR: const requires a compile-time value
// This is also INVALID:
bit m = measure q[0];
qubit[m] new_reg; // ERROR: register size must be compile-time
Running OpenQASM 3 from Qiskit
Qiskit provides tools to convert between its QuantumCircuit objects and OpenQASM 3 strings. This is useful for writing circuits in QASM 3 and executing them through Qiskit’s runtime infrastructure.
Loading and Exporting QASM 3
from qiskit import QuantumCircuit
from qiskit.qasm3 import loads, dumps
# Load an OpenQASM 3 string into a Qiskit circuit
qasm3_code = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[2] q;
bit[2] c;
h q[0];
cx q[0], q[1];
bit mid;
mid = measure q[0];
if (mid == 1) { x q[1]; }
c[0] = measure q[0];
c[1] = measure q[1];
"""
qc = loads(qasm3_code)
print(qc.draw())
# Export a Qiskit QuantumCircuit back to QASM 3
print(dumps(qc))
Building Dynamic Circuits in Qiskit and Exporting
You can also build dynamic circuits using Qiskit’s Python API and then export them to QASM 3:
from qiskit import QuantumCircuit
from qiskit.qasm3 import dumps
qc = QuantumCircuit(2, 2)
qc.h(0)
qc.cx(0, 1)
# Mid-circuit measurement
qc.measure(0, 0)
# Classical conditional gate
with qc.if_test((qc.clbits[0], 1)):
qc.x(1)
qc.measure(1, 1)
# Export to OpenQASM 3
qasm3_str = dumps(qc)
print(qasm3_str)
Running on IBM Hardware with Dynamic Circuit Support
Dynamic circuits require backend support for real-time classical computation and feedforward. On IBM Quantum, you run them through Qiskit Runtime:
from qiskit_ibm_runtime import QiskitRuntimeService, SamplerV2 as Sampler
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
service = QiskitRuntimeService()
# Choose a backend that supports dynamic circuits
# Most IBM Eagle and Heron processors support them
backend = service.least_busy(
simulator=False,
operational=True
)
# Transpile the circuit for the target backend
pm = generate_preset_pass_manager(optimization_level=1, backend=backend)
transpiled = pm.run(qc)
# Run with the Sampler primitive
sampler = Sampler(mode=backend)
job = sampler.run([transpiled], shots=4096)
result = job.result()
print(result[0].data)
If you try to run a dynamic circuit on a backend that does not support classical feedforward, you will get an error during transpilation or submission. The error message typically says something like "Backend does not support dynamic circuits" or "c_if is not supported". Check your target backend’s configuration to confirm dynamic circuit support before submitting jobs.
Common Mistakes
Assuming for loops run in parallel
QASM 3 for loops are sequential. Each iteration runs after the previous one completes. If you write:
for int i in [0:3] {
h q[i];
}
This produces four sequential H gates in the compiled circuit. The compiler may parallelize them if the qubits are independent, but the loop itself does not imply parallelism. If you need to guarantee simultaneous execution, use a box.
Using float arithmetic on the classical controller
Most current quantum controllers have limited support for floating-point operations at runtime. If you need angle values, compute them at compile time using const:
// Good: angle resolved at compile time
const float[64] theta = pi / 4.0;
rz(theta) q[0];
// Risky: angle computed at runtime (may fail on some backends)
int n;
n = measure q[1];
rz(pi / (2 ** n)) q[0]; // requires runtime float division
The second example requires the classical controller to perform floating-point division during execution. Some backends support this; many do not.
While loops without guaranteed termination
Hardware has finite coherence time and execution time limits. A while loop that depends on a low-probability measurement outcome may run for many iterations, during which qubits decohere and the circuit becomes meaningless.
// Dangerous: success probability may be very low
while (result != 0) {
// ... complex probabilistic circuit ...
result = measure q[0];
}
Always design your RUS protocol so that the expected number of iterations is small (single digits). If you need many attempts, consider restructuring the algorithm or accepting probabilistic outcomes.
Confusing QASM 2 and QASM 3 if syntax
Some compilers accept QASM 2 style if statements without warning. This can lead to subtle bugs:
// QASM 2 style (may be silently accepted by some parsers)
if (c == 1) x q[0];
// QASM 3 style (preferred, unambiguous)
if (c[0] == 1) {
x q[0];
}
Always use braces and explicit bit indexing in QASM 3 to avoid ambiguity.
Using string literals for bit comparison
This is invalid QASM 3:
// INVALID: string comparison does not exist in QASM 3
if (syndrome == "01") {
x q[0];
}
QASM 3 does not support string literals for bit pattern matching. Instead, compare individual bits or convert to an integer:
// Correct: compare individual bits
if (syndrome[0] == 0 && syndrome[1] == 1) {
x q[0];
}
// Also correct: convert to integer and compare
int s = 2 * syndrome[0] + syndrome[1];
if (s == 1) {
x q[0];
}
Key Takeaways
OpenQASM 3’s classical control flow transforms the language from a simple gate list format into a real programming language for quantum-classical computation. The typed variable system gives you fine-grained control over classical data. If/else blocks, switch/case, for loops, and while loops let you express dynamic protocols directly. Timing directives and boxes give you control over synchronization that QASM 2 never offered. Classical functions let you encapsulate reusable logic on the controller side.
All of this runs on the classical control hardware at nanosecond timescales, making mid-circuit measurement and feedforward practical within a single coherence window. As quantum error correction and adaptive algorithms become standard practice, these features move from “nice to have” to essential.
Was this tutorial helpful?