OpenQASM Intermediate Free 7/8 in series 25 min

Subroutines and Reusable Gates in OpenQASM 3

Define reusable gate subroutines and parameterized gates in OpenQASM 3. Learn the gate declaration syntax, scoping rules, and how to build modular circuit libraries.

What you'll learn

  • OpenQASM 3
  • subroutines
  • gate definitions
  • quantum circuits
  • modular circuits

Prerequisites

  • Python proficiency
  • Beginner quantum computing concepts (superposition, entanglement)
  • Linear algebra basics

OpenQASM 3 provides two distinct mechanisms for packaging reusable quantum logic: the gate keyword and the def keyword. Understanding when to use each is the fundamental design decision in OpenQASM 3 program structure.

The gate keyword defines a reusable unitary block. Think of it as a pure quantum function that takes qubits and angle parameters, applies rotations, and has no side effects. Because gates are guaranteed unitary, the compiler can reason about them structurally: it can compute the adjoint, generate a controlled version, and optimize gate sequences through algebraic simplification.

The def keyword defines a subroutine. A subroutine is equivalent to a classical function that can also interact with quantum registers, perform measurements, branch on classical values, and optionally return classical data. Subroutines are strictly more powerful than gates, but that power comes at a cost: the compiler cannot treat a subroutine as a simple unitary object.

The rule is straightforward. If the operation is unitary and you want the compiler to reason about it as a gate (computing its adjoint, generating controlled versions, performing gate-level optimization), use gate. If the operation needs classical control flow, measurement, or must return a classical value, use def.

This tutorial covers both mechanisms in detail, including their syntax, scoping rules, and the practical patterns you need to build modular OpenQASM 3 programs.

Gate Definitions

The gate keyword defines a unitary operation on qubits. Gates accept two kinds of arguments: classical angle parameters in parentheses, and qubit arguments after the parameter list.

OPENQASM 3.0;

// A parameterized single-qubit rotation around the Z axis
gate rz_custom(theta) q {
    gphase(-theta / 2);
    U(0, 0, theta) q;
}

// A two-qubit controlled rotation
gate crz(theta) ctrl, target {
    ctrl @ U(0, 0, theta / 2) target;
    cx ctrl, target;
    U(0, 0, -theta / 2) target;
    cx ctrl, target;
}

Understanding the rz_custom Gate

The gphase(-theta / 2) call in rz_custom applies a global phase to the entire quantum state. A global phase is a scalar factor e^(i*phi) multiplied across every amplitude in the state vector. Because global phase affects all amplitudes equally, it is physically unobservable in isolation: no measurement outcome changes. However, global phase becomes observable when a gate is used as part of a controlled operation. If you wrap rz_custom in a ctrl @ modifier, the global phase turns into a relative phase between the control-qubit states, which produces measurable interference effects. Including gphase ensures that rz_custom matches the exact matrix definition of the standard RZ gate, so controlled versions behave correctly.

Understanding the crz Decomposition

The crz gate implements a controlled-RZ rotation using a standard phase-kickback decomposition. Here is what each step accomplishes:

  1. ctrl @ U(0, 0, theta/2) target applies a Z-rotation of theta/2 to the target, controlled on the control qubit. When the control is |1>, the target picks up a phase of theta/2.
  2. cx ctrl, target flips the target conditioned on the control. This entangles the two qubits and sets up the phase cancellation in the next step.
  3. U(0, 0, -theta/2) target applies an unconditional Z-rotation of -theta/2 to the target.
  4. The second cx ctrl, target undoes the bit flip from step 2.

When the control qubit is |0>, the controlled-U in step 1 does nothing, and the unconditional rotations in steps 2 through 4 cancel each other out. When the control qubit is |1>, the two rotations add constructively to produce a net rotation of theta. This is the standard decomposition you find in textbooks for any controlled single-qubit phase rotation.

Gate Restrictions

Gate bodies can only contain quantum operations: unitary gates, gphase, and calls to other gate definitions. You cannot place measurement, reset, if statements, classical variable declarations, or while loops inside a gate body. If you try, the compiler raises an error. This restriction is what makes gates useful to the compiler: because the body is guaranteed to describe a unitary matrix, the compiler can safely compute its inverse, generate controlled versions, and perform algebraic optimization passes.

Calling Gate Definitions

Once you define a gate, you call it the same way you call any built-in gate. The definition only appears once; you can reuse it on any compatible set of qubits throughout the program.

OPENQASM 3.0;
include "stdgates.inc";

// Single-qubit gate with one parameter
gate ry_half q {
    ry(pi / 2) q;
}

// Two-qubit gate with two parameters
gate rzz(theta) a, b {
    cx a, b;
    rz(theta) b;
    cx a, b;
}

// Three-qubit gate, no parameters
gate toffoli_custom a, b, c {
    h c;
    cx b, c;
    tdg c;
    cx a, c;
    t c;
    cx b, c;
    tdg c;
    cx a, c;
    t b;
    t c;
    h c;
    cx a, b;
    t a;
    tdg b;
    cx a, b;
}

qubit[4] q;

// Call the single-qubit gate on different qubits
ry_half q[0];
ry_half q[2];
ry_half q[3];

// Call the two-qubit gate
rzz(pi / 4) q[0], q[1];
rzz(pi / 8) q[2], q[3];

// Call the three-qubit gate
toffoli_custom q[0], q[1], q[2];

Each gate call applies the full unitary defined in the gate body. The qubit arguments in the call map positionally to the qubit parameters in the definition: the first argument maps to the first parameter, the second to the second, and so on. The number of qubits in the call must match the number in the definition exactly.

You can also apply gate modifiers to any user-defined gate:

// Controlled version of ry_half
ctrl @ ry_half q[0], q[1];

// Inverse of rzz
inv @ rzz(pi / 4) q[0], q[1];

// Controlled-controlled (doubly controlled) version
ctrl @ ctrl @ ry_half q[0], q[1], q[2];

These modifiers work because the compiler knows the gate body is unitary. It can compute the adjoint (for inv @) or lift the operation into a larger Hilbert space (for ctrl @) automatically.

Subroutines

For operations that mix classical and quantum work, use def. Subroutines can accept both classical types and qubit registers, and they can return classical values.

OPENQASM 3.0;
include "stdgates.inc";

// A subroutine that applies a QFT on 3 qubits
def qft3(qubit[3] q) {
    h q[0];
    cp(pi / 2) q[0], q[1];
    cp(pi / 4) q[0], q[2];
    h q[1];
    cp(pi / 2) q[1], q[2];
    h q[2];
    // Bit reversal swap
    swap q[0], q[2];
}

// Declare registers
qubit[3] a;
qubit[3] b;

// Apply QFT to each register
qft3(a);
qft3(b);

How the QFT Circuit Works

The Quantum Fourier Transform maps computational basis states to their frequency-domain representation. The circuit follows a systematic structure:

  1. Hadamard on the first qubit. This creates an equal superposition on qubit 0, establishing the highest-frequency component of the transform.
  2. Controlled phase gates from qubit 0 to each subsequent qubit. The cp(pi/2) gate between qubits 0 and 1 applies a phase of pi/2 conditional on both qubits being |1>. The cp(pi/4) between qubits 0 and 2 applies a smaller phase. These controlled rotations encode the relative phases that define the Fourier basis states.
  3. Repeat for the next qubit. Qubit 1 gets a Hadamard, followed by controlled phase gates to all qubits after it (here, just qubit 2 with cp(pi/2)).
  4. Hadamard on the last qubit. Qubit 2 gets only a Hadamard since there are no subsequent qubits.
  5. Bit reversal swap. The QFT naturally produces output in bit-reversed order. The swap q[0], q[2] corrects this so the output register has the standard ordering.

For an n-qubit QFT, the pattern generalizes: each qubit k gets a Hadamard followed by controlled phase gates cp(pi / 2^(j-k)) to every qubit j > k, and finally the output is bit-reversed.

Subroutines are called by value for classical arguments and by reference for qubit arguments. Modifying a qubit inside a subroutine is visible to the caller; there is no copy of quantum state.

Subroutines with Classical Arguments and Return Values

Subroutines can accept classical arguments, perform computation on them, and return classical results. This is useful for measurement-based protocols where you need to post-process measurement outcomes.

OPENQASM 3.0;
include "stdgates.inc";

// Measure a qubit and return the result as an integer
def measure_and_report(qubit q) -> bit {
    bit result;
    result = measure q;
    return result;
}

// Compute a parity check across two qubits
def parity_check(qubit a, qubit b) -> bit {
    bit ba;
    bit bb;
    ba = measure a;
    bb = measure b;
    // XOR gives parity
    return ba ^ bb;
}

qubit[2] data;
h data[0];
cx data[0], data[1];

bit parity = parity_check(data[0], data[1]);

Subroutines Calling Other Subroutines

Subroutines can call other subroutines and gate definitions freely. This lets you build layered abstractions where higher-level routines compose lower-level ones.

OPENQASM 3.0;
include "stdgates.inc";

// Low-level: prepare a Bell pair
def bell_pair(qubit a, qubit b) {
    h a;
    cx a, b;
}

// Low-level: Bell measurement
def bell_measure(qubit a, qubit b) -> bit[2] {
    cx a, b;
    h a;
    bit[2] result;
    result[0] = measure a;
    result[1] = measure b;
    return result;
}

// High-level: teleportation protocol, composes the two above
def teleport(qubit source, qubit alice, qubit bob) {
    // Create entangled pair shared between Alice and Bob
    bell_pair(alice, bob);

    // Bell measurement on source and Alice's half
    bit[2] corrections = bell_measure(source, alice);

    // Apply corrections to Bob's qubit based on measurement
    if (corrections[1] == 1) {
        x bob;
    }
    if (corrections[0] == 1) {
        z bob;
    }
}

qubit source;
qubit alice;
qubit bob;

// Prepare an arbitrary state to teleport
rx(pi / 3) source;

teleport(source, alice, bob);

This layered design keeps each subroutine small and testable. The teleport subroutine does not need to know the internal structure of bell_pair or bell_measure; it only relies on their interface.

Scoping Rules

OpenQASM 3 uses lexical scoping. Variables declared inside a subroutine are local to that subroutine. Gate parameters are in scope only within the gate body. Classical variables declared at the top level are global and visible inside subroutines.

OPENQASM 3.0;
include "stdgates.inc";

float[64] global_angle = pi / 4;   // global, visible everywhere

def apply_global_rotation(qubit q) {
    rz(global_angle) q;             // global_angle is accessible here
    float[64] local_phase = pi / 8; // local_phase is NOT visible outside
    rz(local_phase) q;
}

qubit target;
apply_global_rotation(target);

Local Variables Stay Local

Variables declared inside a subroutine or block do not exist outside that scope. Attempting to reference them produces a compiler error.

OPENQASM 3.0;
include "stdgates.inc";

def prepare(qubit q) {
    float[64] internal_angle = pi / 6;
    rz(internal_angle) q;
}

qubit q;
prepare(q);

// ERROR: internal_angle is not defined in this scope
// rz(internal_angle) q;   // This line would fail to compile

The same rule applies to variables declared inside if, while, or for blocks. Their scope is limited to the block in which they appear.

Gate Parameters vs. Subroutine Arguments

Gate parameters and subroutine arguments look similar in syntax but differ in an important way. Gate parameters are compile-time angle expressions. The compiler resolves them when it processes the gate definition and can use their values for algebraic optimization. Subroutine arguments are runtime values. They can depend on measurement outcomes or other dynamic computation, and the compiler cannot resolve them ahead of time.

OPENQASM 3.0;
include "stdgates.inc";

// theta is a compile-time parameter: the compiler knows its value
// when it encounters each call site
gate my_rz(theta) q {
    rz(theta) q;
}

// angle is a runtime argument: its value may depend on
// measurement outcomes or other dynamic computation
def my_adaptive_rz(float[64] angle, qubit q) {
    rz(angle) q;
}

qubit[2] q;
bit b;

// Compile-time: compiler knows this is rz(pi/4)
my_rz(pi / 4) q[0];

// Runtime: angle depends on measurement result
h q[0];
b = measure q[0];
float[64] computed_angle = pi / 4;
if (b == 1) {
    computed_angle = pi / 2;
}
my_adaptive_rz(computed_angle, q[1]);

Why Gates Cannot Capture External Variables

A key restriction: gate definitions cannot reference classical variables declared outside the gate. Gates are pure unitary objects. Their behavior must be fully described by their parameter list. If a gate could capture an external classical variable, its unitary matrix would depend on state outside the parameter list. The compiler could no longer compute the adjoint or controlled version from the parameters alone, because it would need to track the external variable too. This would break the gate model that makes inv @ and ctrl @ modifiers possible.

If you need a rotation angle that comes from a global variable or a measurement result, use a subroutine instead. Subroutines do not make the same guarantees to the compiler, so they can freely reference external state.

OPENQASM 3.0;
include "stdgates.inc";

float[64] external_angle = pi / 3;

// ERROR: gates cannot reference external_angle
// gate bad_gate q {
//     rz(external_angle) q;   // Compiler rejects this
// }

// CORRECT: use a subroutine instead
def good_rotation(qubit q) {
    rz(external_angle) q;       // Subroutines can access globals
}

Building a Modular Circuit Library

A practical pattern is to define a library of subroutines in a separate file and include it. OpenQASM 3 supports include for both the standard gate library and user-defined files.

// File: my_lib.inc
gate rxx(theta) a, b {
    h a;
    h b;
    cx a, b;
    rz(theta) b;
    cx a, b;
    h b;
    h a;
}

gate ryy(theta) a, b {
    rx(pi / 2) a;
    rx(pi / 2) b;
    cx a, b;
    rz(theta) b;
    cx a, b;
    rx(-pi / 2) a;
    rx(-pi / 2) b;
}

gate rzz(theta) a, b {
    cx a, b;
    rz(theta) b;
    cx a, b;
}

gate xy(theta) a, b {
    // XY interaction: (XX + YY) rotation, common in transmon cross-resonance gates
    rxx(theta / 2) a, b;
    ryy(theta / 2) a, b;
}

def bell_pair(qubit a, qubit b) {
    h a;
    cx a, b;
}

def ghz_state(qubit[4] q) {
    h q[0];
    cx q[0], q[1];
    cx q[1], q[2];
    cx q[2], q[3];
}

Why RXX, RYY, and RZZ Appear So Often

The rxx, ryy, and rzz gates are the standard Ising-type interaction gates. They implement rotations of the form exp(-i * theta/2 * PP) where PP is XX, YY, or ZZ respectively. These gates appear throughout quantum computing for several reasons:

  • Variational algorithms. QAOA and VQE ansatze commonly use ZZ and XX interactions because they naturally encode cost-function terms (for QAOA) and generate entanglement with tunable strength (for VQE hardware-efficient ansatze).
  • Native hardware interactions. On trapped-ion platforms, the Molmer-Sorensen gate is natively an XX interaction. On superconducting platforms, cross-resonance gates produce ZX-type interactions that decompose into these Ising forms. Expressing circuits in terms of native interaction types reduces transpilation overhead.
  • Hamiltonian simulation. Simulating a spin-chain Hamiltonian (Heisenberg, Ising, XY models) requires exactly these interaction terms. Trotterized time evolution breaks the Hamiltonian into a product of these gates.

Including rxx, ryy, rzz, and their combinations in your circuit library gives you the building blocks for most variational and simulation workloads.

// Main program
OPENQASM 3.0;
include "stdgates.inc";
include "my_lib.inc";

qubit[2] q;
bit[2] c;

bell_pair(q[0], q[1]);
rxx(pi / 4) q[0], q[1];

c = measure q;

Parameterized Gate Sequences with Classical Control

Subroutines unlock patterns that gates cannot express: you can branch on classical values and apply different gate sequences based on runtime measurement outcomes.

OPENQASM 3.0;
include "stdgates.inc";

// Adaptive rotation: angle is determined at runtime
def adaptive_rz(float[64] angle, qubit q) {
    rz(angle) q;
}

qubit[2] q;
bit feedback;

h q[0];
feedback = measure q[0];

// Apply a correction to q[1] based on the measurement result
if (feedback == 1) {
    adaptive_rz(pi, q[1]);
} else {
    adaptive_rz(pi / 2, q[1]);
}

This kind of dynamic circuit (where gate parameters depend on prior measurement results) is the core of measurement-based quantum computation and active error correction, and it requires subroutines rather than static gate definitions.

Mid-Circuit Measurement and Correction

The most important application of classical control flow in quantum circuits is the measure-and-correct pattern. You measure a qubit in the middle of the circuit, inspect the result, and apply a correction gate to fix the state. This pattern appears in quantum teleportation, lattice surgery, and active quantum error correction.

Here is a complete example that implements a single round of bit-flip error detection and correction on a three-qubit repetition code:

OPENQASM 3.0;
include "stdgates.inc";

// Encode a single logical qubit into three physical qubits
def encode_repetition(qubit data, qubit a1, qubit a2) {
    cx data, a1;
    cx data, a2;
}

// Measure the two syndrome bits without disturbing the code space
def measure_syndromes(qubit q0, qubit q1, qubit q2,
                      qubit s0, qubit s1) -> bit[2] {
    // Syndrome s0 checks parity of q0, q1
    cx q0, s0;
    cx q1, s0;
    // Syndrome s1 checks parity of q1, q2
    cx q1, s1;
    cx q2, s1;

    bit[2] syndrome;
    syndrome[0] = measure s0;
    syndrome[1] = measure s1;
    return syndrome;
}

// Apply correction based on syndrome
def correct(bit[2] syndrome, qubit q0, qubit q1, qubit q2) {
    if (syndrome == 2'b10) {
        // Error on q0
        x q0;
    } else if (syndrome == 2'b11) {
        // Error on q1
        x q1;
    } else if (syndrome == 2'b01) {
        // Error on q2
        x q2;
    }
    // syndrome == 00 means no error detected
}

qubit data;
qubit[2] ancilla_data;
qubit[2] syndrome_qubits;

// Prepare a state to protect
h data;

// Encode
encode_repetition(data, ancilla_data[0], ancilla_data[1]);

// (Noise would occur here in a real experiment)

// Measure syndromes and correct
bit[2] syn = measure_syndromes(data, ancilla_data[0], ancilla_data[1],
                                syndrome_qubits[0], syndrome_qubits[1]);

// Reset syndrome qubits for potential reuse
reset syndrome_qubits[0];
reset syndrome_qubits[1];

// Apply correction
correct(syn, data, ancilla_data[0], ancilla_data[1]);

This subroutine-based structure separates the encoding, syndrome extraction, and correction into independent units. Each subroutine uses mid-circuit measurement and classical branching, which is why none of these operations can be expressed as a gate. In a real error correction experiment, you would repeat the syndrome measurement and correction cycle many times per logical operation.

When to Use Gates vs. Subroutines

Use this quick reference when deciding between gate and def:

RequirementUse gateUse def
Pure unitary, no measurementYesPossible, but gate preferred
Need inv @ (adjoint)YesNo, not supported
Need ctrl @ (controlled version)YesNo, not supported
Classical if/else branchingNo, not allowedYes
Mid-circuit measurementNo, not allowedYes
Return a classical valueNo, gates return nothingYes
Angle depends on measurement resultNo, parameters are compile-timeYes, arguments are runtime
Angle is a fixed expression (pi/4, etc.)YesPossible, but gate preferred
Compiler gate optimization (cancellation, commutation)Yes, compiler understands gate structureNo, compiler treats subroutine as opaque
Call other gates inside the bodyYesYes
Call subroutines inside the bodyNo, not allowedYes

The guiding principle: give the compiler as much information as possible. If your operation is purely unitary, declaring it as a gate lets the compiler optimize it, invert it, and generate controlled versions. Only reach for def when you need capabilities that the gate model cannot express.

Common Pitfalls

Using classical variables inside a gate definition. Gate definitions cannot capture runtime variables from the enclosing scope. The body of a gate is a self-contained unitary description parameterized only by its declared angle parameters. If you reference an external classical variable, the compiler rejects the program. The fix is to either pass the value as a gate parameter or convert the gate to a subroutine with def.

Calling a subroutine before it is declared. OpenQASM 3 requires forward declarations or definition before use. If your subroutine foo calls bar, then bar must be defined (or forward-declared) before foo. Place library includes at the top of the file to avoid ordering issues. A good convention is to define low-level building blocks first and higher-level routines afterward.

Qubit count mismatches. If a subroutine expects qubit[3] and you pass a qubit[2], the compiler rejects it. The size must match exactly. Unlike classical arrays in some languages, there is no implicit padding or truncation. If you need a subroutine that works on different register sizes, you must define separate versions or use a fixed size and document the expectation.

Return types and measurement. Only subroutines can return classical values. Gates never return anything. If you write a gate and then realize you need to measure inside it and return a bit, you must refactor it into a subroutine.

Applying gate modifiers to subroutines. The ctrl @, inv @, and pow @ modifiers only work on gate definitions. Writing ctrl @ my_subroutine where my_subroutine is defined with def produces a compiler error. This is the most common mistake when refactoring a gate into a subroutine. If you previously had ctrl @ my_gate q[0], q[1] and you change my_gate from a gate to a def (perhaps because you added measurement), every call site that uses a modifier breaks. The fix is to keep a gate version for the unitary core and wrap it in a subroutine only at the level where you need classical control.

Forgetting that qubit arguments are passed by reference. Any gate or measurement applied to a qubit inside a subroutine affects the original qubit in the caller. There is no isolation. If your subroutine measures a qubit, that qubit collapses in the caller’s register. This is physically correct (quantum states cannot be copied), but it surprises programmers who expect function-call semantics to provide isolation.

Subroutines and reusable gates turn OpenQASM 3 from an assembly language into a structured circuit description language. Once you internalize the gate-vs-subroutine distinction, modular circuit design in OpenQASM 3 becomes straightforward.

Was this tutorial helpful?