41 min read

> *"You do not write production code in assembly. You read it constantly, because it is the only

Prerequisites

  • 1
  • 2
  • 3
  • 4
  • 5

Learning Objectives

  • Explain what role OpenQASM plays in the quantum software stack and why an intermediate representation exists at all.
  • Read and write OpenQASM 3: registers, gate applications, measurement, parameters, and classical control.
  • Export a Qiskit circuit to OpenQASM 2 and 3 with the current API, and import QASM back into a circuit.
  • Read a transpiled circuit's QASM and identify the basis gates, the physical qubit assignment, and the hardware-native gate definitions.
  • State precisely what a QASM round trip preserves and what it silently discards, and name the case where the loss changes the physics.
  • Choose between OpenQASM 2 and 3 for a given task, and use QASM as an interchange format between frameworks.

Chapter 6: OpenQASM

"You do not write production code in assembly. You read it constantly, because it is the only place the truth is written down."

Overview

Every framework in this book compiles to the same thing. Qiskit, Cirq, PennyLane, and Braket all have their own circuit objects and their own APIs, and underneath all of them is a textual representation that hardware actually consumes: OpenQASM.

Learning to read it buys you three things.

You can see what the transpiler did. Chapter 2 showed that your h and cx become rz, sx, and ecr on real hardware; §6.5 shows you the exact text, including which physical qubits the compiler chose and how it defines the hardware-native gates in terms of ones you know. This is the most direct window into the gap between the circuit you wrote and the circuit that ran.

You can move circuits between frameworks. QASM is the interchange format, and §6.8 uses it to carry a circuit out of Qiskit and into something else — which is what makes Part III's five-framework comparison possible at all.

You can archive a circuit in a form that will still parse in five years. A pickled Qiskit object is a hostage to Qiskit's version history, as Chapter 1 §1.2 made clear. A QASM file is a specification-conformant text document.

There is also a fourth thing, and it is the one this chapter treats most carefully: QASM does not preserve everything. A round trip through QASM 3 silently discards a circuit's global phase — which Chapter 3 §3.7 taught you to ignore, except when the circuit is later used as a controlled operation, at which point it is not ignorable at all. §6.6 demonstrates the loss and states the rule.

In this chapter, you will learn to:

  • Say what an intermediate representation is for, and why the quantum stack has one.
  • Read and write OpenQASM 3: registers, gates, measurement, parameters, classical control.
  • Export and import with the current Qiskit API — and know why the old qc.qasm() is gone.
  • Read transpiled QASM and identify basis gates, physical qubits, and gate definitions.
  • State what a round trip preserves and what it loses, and when the loss matters.
  • Choose between QASM 2 and QASM 3, and use QASM as an interchange format.

Learning Paths

How to read this chapter by track. - 🔰 Beginner — §6.1, §6.2, §6.3, and §6.5. You can skip §6.6 and §6.7 until something breaks. - 🔬 Researcher — §6.5 and §6.6 matter most. Archiving circuits as QASM alongside a paper is good practice, and knowing what the archive loses is part of doing it honestly. - 🤖 Quantum ML — §6.4's parameter handling. QASM 3 supports free parameters and QASM 2 does not, which decides the format for any variational work. - 🏗️ Quantum Engineer — the whole chapter, and §6.5 twice. Reading transpiler output is a daily skill from Chapter 10 onward. - 🔐 Security — skim. §6.8's interoperability discussion is the relevant part.


6.1 Why an Assembly Language

Chapter 1 §1.3 put OpenQASM in the middle of the stack, between the circuit you write and the transpiler that lowers it. It is worth being precise about why that layer exists, because "we needed a text format" is not the whole answer.

A common target for many producers. Qiskit, Cirq, and half a dozen research tools all need to emit something a control system can execute. Without a shared target, every framework needs a custom backend for every device, which is an $N \times M$ problem. With one, it is $N + M$. This is the same argument that produced LLVM IR in classical compilation, and it is just as decisive here.

A stable boundary. Frameworks churn — Qiskit deleted execute() and the entire Pulse module within two major versions. A specification maintained by a standards process moves slower on purpose, which makes it the right thing to write to disk.

A place where the truth is written down. A QuantumCircuit object is a Python data structure whose semantics live in Qiskit's source. A QASM file has a grammar and a specification. When you need to answer "what exactly does this circuit do," the text is a better artifact than the object.

Human readability at the right level. This is underrated. QASM is low-level enough to show you what really executes and high-level enough to read. Pulse schedules (Chapter 31) are neither.

⚛️ The Physics Underneath — Nothing. And that is the point.

This is the one section in Part I with no physics in it. OpenQASM is a file format.

That is worth noticing, because it locates something true about the field: a large fraction of quantum programming is ordinary software engineering — serialization, compilation, interchange, versioning — applied to an unusual instruction set. Chapter 40 makes the career argument that this is why classical software engineers do well here. The quantum part is a smaller fraction of the work than the marketing suggests.

The $N \times M$ argument, worked

The claim deserves arithmetic, because the saving is bigger than "everyone agrees on a file format" makes it sound.

This book covers five frameworks — Qiskit, Cirq, Q#, PennyLane, and Braket. Suppose each wants to target $M$ devices. Without a shared representation, every framework writes and maintains a backend for every device: $5M$ pieces of code, each of which tracks two moving targets at once. With a shared representation, each framework writes one emitter and each device writes one consumer: $5 + M$.

   devices M    without an IR (5M)    with one (5 + M)    ratio
   ---------    ------------------    ----------------    -----
       2                10                    7            1.4x
       5                25                   10            2.5x
      10                50                   15            3.3x
      20               100                   25            4.0x

The ratio is $5M/(5+M)$, and as $M$ grows it approaches $5$ — the number of producers. The saving converges on the size of the smaller population, which is the reason an intermediate representation is worth proposing even when one side of the boundary is tiny, and the reason it becomes indispensable when both sides grow.

Where the argument is weaker than it sounds

An intermediate representation only saves work on the information it can carry. Anything a producer knows that the format cannot express has to be re-derived on the far side, and that cost does not appear anywhere in the $N + M$ count.

Chapter 18 §18.3 measured two clean cases. A Cirq circuit built with three deliberately separated Moments exports to the same six lines of QASM as the same gates packed into one moment, and comes back as one moment either way:

  built with EARLIEST : 1 moment  ->  qasm (6 lines)  ->  1 moment
  built with NEW      : 3 moments ->  qasm (6 lines)  ->  1 moment

  round-trip unitary preserved : True   (both)

And a circuit transpiled onto FakeSherbrooke with an explicit initial_layout=[40, 41, 42] comes back from a QASM 2 round trip with layout is None.

In both cases the circuit crossed the boundary intact and the decision did not. §6.6 returns to this at length; the point here is narrower and it belongs with the argument for the format rather than against it: the $N + M$ saving is real, and part of what pays for it is discarded information. A common vocabulary is common precisely because it omits what only one producer can say.

6.2 OpenQASM 3, Read

Here is a complete OpenQASM 3 program. It is the Bell state.

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];

Line by line:

Line Meaning
OPENQASM 3.0; Version declaration. Mandatory, first non-comment line
include "stdgates.inc"; Pulls in the standard gate library — h, cx, rz, x, and the rest
bit[2] c; A classical register of 2 bits named c
qubit[2] q; A quantum register of 2 qubits named q
h q[0]; Apply H to qubit 0
cx q[0], q[1]; CNOT, control first, target second
c[0] = measure q[0]; Measure into a classical bit — note it is an assignment

The measurement syntax is the clearest improvement over QASM 2, which wrote measure q[0] -> c[0];. Treating measurement as an assignment that returns a value is both more readable and what makes classical control possible in §6.4.

Whole-register operations work too:

c = measure q;          // measure all of q into all of c

Comments, whitespace, and case

// for line comments, /* ... */ for blocks. Whitespace is free-form. Statements end with semicolons. The language is case-sensitive and gate names are lowercase.

The type system, and what each type is for

QASM 2 has two kinds of thing: a quantum register and a classical register. QASM 3 has a type system, and that single change is most of the distance between the two languages.

Type What it holds What it is for
qubit a quantum wire the thing being computed on
bit one classical bit measurement results, and the conditions read off them
bool true or false predicates that are not measurement results
int[n] / uint[n] an $n$-bit integer loop counters, syndrome values, switch selectors
float[n] an $n$-bit float gate angles — this is what a Qiskit Parameter becomes
angle[n] a fixed-point angle in $[0, 2\pi)$ rotations, with wraparound in the type
complex[float[n]] a complex number classical arithmetic inside a hybrid program
duration a length of time delay, and anything schedule-aware
stretch a duration the compiler solves for "pad this idle to whatever makes the branches align"
array[T, n] a fixed-size array lookup tables, per-shot classical state

Three of those are worth dwelling on, because they are where the format stops being a circuit notation.

bit is not bool, and the separation is load-bearing. A bit is a storage location a measurement writes into; a bool is a value a predicate produces. Keeping them distinct is what lets bit[2] c; be indexed, sliced, compared as a whole register, and handed to a switch, while if (c[0]) still reads like ordinary code. QASM 2 had only the register, and paid for it: its conditionals could compare an entire register against an integer and nothing else, which is exactly why §6.4's single-bit if_test comes back as a QASM2ExportError.

int is what makes multi-way branching expressible. A switch selects on an integer, so a two-bit syndrome has to become an int before it can choose among four corrections. §6.4 shows the cast happening in Qiskit's own output.

duration and stretch are the types no classical language has. A duration is a physical time with a unit attached — 100dt, 160ns — and a stretch is a duration whose value is left to the compiler subject to constraints you write down. That second one exists because the useful statement in a timing-sensitive experiment is rarely "wait 320 ns"; it is "wait however long it takes for these two branches to finish together." Chapter 31's scheduling work is where that distinction earns its keep, and it is also where §6.7's warning bites hardest: the specification defines these types and the tool at the other end may not implement them.

📊 What the Numbers Sayfloat[64] is a width, not a precision guarantee.

input float[64] theta; says the parameter occupies 64 bits, which is what a Python float is. It says nothing about whether the hardware resolves 64 bits of angle. Chapter 31 §31.1 describes what a gate physically is — a shaped microwave pulse whose phase sets the rotation axis — and that phase is produced by control electronics with their own finite resolution and their own clock.

The general habit, and it applies to every field in the table above: a type in a serialization format tells you what the file can carry, not what the device can do. Those are different questions, and the file is silent on the second one.

6.3 Exporting from Qiskit

The API changed, and this is one of the changes most likely to trip you.

from qiskit import QuantumCircuit, qasm2, qasm3

qc = QuantumCircuit(2, 2)
qc.h(0)
qc.cx(0, 1)
qc.measure([0, 1], [0, 1])

print(qasm3.dumps(qc))          # OpenQASM 3 as a string
print(qasm2.dumps(qc))          # OpenQASM 2 as a string
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];
OPENQASM 2.0;
include "qelib1.inc";
qreg q[2];
creg c[2];
h q[0];
cx q[0],q[1];
measure q[0] -> c[0];
measure q[1] -> c[1];

🗝️ Version Noteqc.qasm() is gone.

Every pre-2024 tutorial contains this:

python print(qc.qasm()) # ✗ removed in Qiskit 1.0

The replacement is module-level and explicit about which version you want:

python from qiskit import qasm2, qasm3 qasm3.dumps(qc) # ✓ qasm2.dumps(qc) # ✓

The change was an improvement, not churn for its own sake: qc.qasm() silently produced QASM 2 and gave you no way to ask for QASM 3, at a moment when QASM 3 was becoming the format that mattered. But it does mean that a large amount of otherwise-correct code on the internet no longer runs, and this is one of the two or three most common failures a new reader hits.

⚠️ Common Pitfalldump and load are not symmetric.

json and pickle taught you that dump and load both take file objects. Here they do not:

python qasm3.dump(circuit, stream) # a file OBJECT qasm3.load(filename) # a PATH -- not an object qasm2.dump(circuit, path_or_stream) qasm2.load(filename) # a PATH

Passing an open file to qasm3.load() raises TypeError: expected str, bytes or os.PathLike object, not TextIOWrapper — a message that reads like your file handle is broken rather than like you called the wrong overload.

The habit that sidesteps it entirely: use dumps/loads with strings and do your own file I/O. You then control the encoding (which matters on Windows), you can prepend a comment line (§6.6 will give you a reason to), and the asymmetry never comes up.

```python with open("bell.qasm", "w", encoding="utf-8") as f: f.write(qasm3.dumps(qc))

with open("bell.qasm", encoding="utf-8") as f: qc = qasm3.loads(f.read()) ```

6.4 Importing, and Writing by Hand

Import is symmetric:

text = qasm3.dumps(qc)
restored = qasm3.loads(text)
print(dict(restored.count_ops()))          # {'measure': 2, 'h': 1, 'cx': 1}

And you can write QASM by hand and load it, which is genuinely useful when you want a circuit specified exactly, with no compiler between you and it:

hand_written = """
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 = measure q;
"""

qc = qasm3.loads(hand_written)
print(qc.draw())
     ┌───┐          ┌─┐
q_0: ┤ H ├──■───────┤M├──────
     └───┘┌─┴─┐     └╥┘┌─┐
q_1: ─────┤ X ├──■───╫─┤M├───
          └───┘┌─┴─┐ ║ └╥┘┌─┐
q_2: ──────────┤ X ├─╫──╫─┤M├
               └───┘ ║  ║ └╥┘
c: 3/════════════════╩══╩══╩═
                     0  1  2

A three-qubit GHZ state, specified in seven lines of text.

Parameters

QASM 3 has first-class support for free parameters, declared as input:

from qiskit.circuit import Parameter

theta = Parameter("theta")
qc = QuantumCircuit(1)
qc.ry(theta, 0)
print(qasm3.dumps(qc))
OPENQASM 3.0;
include "stdgates.inc";
input float[64] theta;
qubit[1] q;
ry(theta) q[0];

This matters enormously for Part VI and for the book's project: a variational ansatz is a parameterized circuit, and being able to serialize one without binding its parameters is the difference between archiving a model and archiving a single evaluation of it.

QASM 2 cannot do this at all:

qasm2.dumps(qc)
# QASM2ExportError: 'Cannot represent circuits with unbound parameters in OpenQASM 2.'

Classical control

QASM 3 supports the dynamic-circuit constructs that Chapter 9 is about:

qc = QuantumCircuit(2, 2)
qc.h(0)
qc.measure(0, 0)
with qc.if_test((qc.clbits[0], 1)):
    qc.x(1)
qc.measure(1, 1)

print(qasm3.dumps(qc))
OPENQASM 3.0;
include "stdgates.inc";
bit[2] c;
qubit[2] q;
h q[0];
c[0] = measure q[0];
if (c[0]) {
  x q[1];
}
c[1] = measure q[1];

That if block — measure, branch on the result, act — is the whole of dynamic quantum computing, and it reads exactly like the classical code it is. QASM 2 refuses:

QASM2ExportError: 'OpenQASM 2 only supports register-equality conditions'

The four control-flow forms, and what each one buys

if is the one you meet first, and it is one of four. Qiskit 2.5.1 emits all of them. Each maps onto something Chapter 9 needs.

if/else — branch once on a measured bit. Add an else arm and the export follows:

c[0] = measure q[0];
if (c[0]) {
  x q[0];
} else {
  z q[0];
}

for — bounded repetition, with the bound written in the file.

for int _ in [0:2] {
  rx(0.1) q[0];
}

The loop variable is declared int, and the range is a closed interval. This is not a loop over shots — it is unrolled quantum work inside a single execution.

while — repeat until a classical condition clears.

h q[0];
c[0] = measure q[0];
while (c[0]) {
  h q[0];
  c[0] = measure q[0];
}

That is Chapter 9 §9.6's repeat-until-success, written directly. It is also the construct that most clearly separates a program format from a circuit format: the number of gates that will execute is not determined until the machine runs, so there is no circuit diagram of this.

switch — an $n$-way branch on an integer. This is Chapter 25 §25.7's syndrome decoding, where a two-bit syndrome selects one of several corrections:

bit[2] c;
int switch_dummy;
qubit[2] q;
c[0] = measure q[0];
c[1] = measure q[1];
switch_dummy = c;
switch (switch_dummy) {
  case 0 {
    x q[0];
  }
  case 1 {
    z q[0];
  }
  default {
    y q[0];
  }
}

Watch the int switch_dummy; line. A switch selects on an integer and c is a bit[2], so the exporter declares a temporary of the right type and assigns the register into it. That is §6.2's type system doing visible work — the cast that QASM 2 had no vocabulary to express, and the reason QASM 2 cannot represent syndrome decoding at all.

⚙️ Under the Transpiler — the one instruction with no price on it.

Chapter 9 asked a 127-qubit ECR device what it charges for each instruction it supports:

```text instruction duration properties


rz 0.0 ns (virtual) 127 qubit entries sx 56.9 ns 127 qubit entries ecr 341.3 - 881.8 ns 144 link entries measure 1,216.0 ns 127 qubit entries reset 1,272.9 - 1,400.9 ns 127 qubit entries if_else -- {None: None} ```

Every instruction there carries a duration except the one QASM 3 exists to express. Ask for if_else's and the lookup refuses, while the measurement beside it answers immediately:

text durations().get("measure", [2]) -> 5472 dt = 1,216.0 ns durations().get("if_else", [2]) -> TranspilerError: 'Duration of if_else on qubits [2] is not found.'

That is not an oversight. The cost of a conditional block depends on how fast the control stack can read a result, evaluate a predicate, and emit a pulse — a property of the stack, not of the instruction. Chapter 9 §9.7 prices it from the runtime side.

The consequence for reading QASM: a file containing an if block cannot be costed by counting lines, and neither depth() nor the four numbers §6.5 teaches you to extract will see the latency. A dynamic circuit is the one case where the text under-reports the cost rather than over-reporting it.

Timing, and the unit that is not portable

QASM 3 has delay, barrier, and reset, and Qiskit emits all three:

x q[0];
delay[100dt] q[0];
barrier q[0];
x q[0];

You can also write real units, and the exporter preserves what you asked for:

x q[0];
delay[160ns] q[0];
x q[0];

Those two forms are not equally portable, and the difference is a trap. 160ns means 160 nanoseconds anywhere. 100dt means one hundred device cycles, and dt is a property of the machine that is not in the file. This book measured it on two different devices: Chapter 39 read dt = 4e-09 s, and Chapter 31 §31.1 read target.dt = 2.2222e-10 s = 0.2222 ns. So the same four characters mean

   delay[100dt]  on Ch. 39's device   ->  100 x 4.0e-09 s   =  400.00 ns
   delay[100dt]  on Ch. 31's device   ->  100 x 2.2222e-10  =   22.22 ns

an 18-fold difference for byte-identical text. If you are writing QASM that will be read somewhere else, write the unit you mean. dt is the right unit for a file that will be submitted back to the device it came from, and the wrong unit for an archive.

6.5 Reading Transpiled QASM

This is the payoff, and it is the reason to learn the format.

Take the Bell state, transpile it for real hardware, and dump the result:

from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
from qiskit_ibm_runtime.fake_provider import FakeSherbrooke

backend = FakeSherbrooke()
pm = generate_preset_pass_manager(optimization_level=1, backend=backend, seed_transpiler=42)
isa = pm.run(qc)
print(qasm3.dumps(isa))
OPENQASM 3.0;
include "stdgates.inc";
gate ecr _gate_q_0, _gate_q_1 {
  s _gate_q_0;
  sx _gate_q_1;
  cx _gate_q_0, _gate_q_1;
  x _gate_q_0;
}
bit[2] c;
rz(-pi) $0;
sx $0;
rz(-pi/2) $1;
sx $1;
rz(-pi) $1;
ecr $1, $0;
rz(-pi/2) $0;
sx $0;
rz(pi/2) $0;
rz(pi/2) $1;
sx $1;
rz(pi/2) $1;
c[0] = measure $0;
c[1] = measure $1;

Four things in that text are worth extracting.

$0` and `$1 instead of q[0] and q[1]. The $ prefix means a physical qubit — a specific location on the chip, not a logical wire. The register is gone because the mapping has been made. This is the transpiler's layout decision, written down, and it is exactly the information Chapter 4's Case Study 1 said you must record with every result.

No h and no cx at the top level. As promised since Chapter 2: those gates do not exist on this device. In their place, rz and sx and one ecr.

A gate ecr definition at the top. This is the nicest detail in the file. ecr is not in stdgates.inc, so the exporter defines it — in terms of gates that are standard:

gate ecr a, b { s a; sx b; cx a, b; x a; }

You are looking at a hardware-native gate expressed in a vocabulary you already know. That is a genuinely useful thing to be handed, and it is available nowhere else in the toolchain.

📐 Math Aside — that definition is not ECRGate. It is ECRGate times $e^{i\pi/4}$.

Transcribe gate ecr a, b { s a; sx b; cx a, b; x a; } straight back into Qiskit and compare it to the library's ECRGate:

text exactly equal : False equal up to phase : True phase ratio : 0.70710678+0.70710678j = e^{i pi/4}

Every nonzero entry of the exported definition is $e^{i\pi/4}$ times the corresponding entry of ECRGate. The definition is right as an operation and wrong as a matrix.

The determinant pins the phase down with almost no linear algebra. A global phase $\alpha$ on a $4 \times 4$ unitary multiplies its determinant by $\alpha^4$, so

$$\alpha^4 \;=\; \frac{\det(\text{exported definition})}{\det(\text{ECRGate})}.$$

Measured, those determinants are $-1$ and $+1$: ECRGate sits in $SU(4)$ and the four-gate decomposition does not. That gives $\alpha^4 = -1$, whose four solutions are $e^{i\pi/4}$, $e^{i3\pi/4}$, $e^{i5\pi/4}$, and $e^{i7\pi/4}$ — and the measurement above picks the first.

You can see where the $-1$ comes from by multiplying the factors, using $\det(A \otimes B) = \det(A)^2\det(B)^2$ for two-by-twos:

$$\det(S \otimes I) = i^2 = -1, \quad \det(I \otimes \sqrt{X}) = i^2 = -1, \quad > \det(\text{CX}) = -1, \quad \det(X \otimes I) = (-1)^2 = +1,$$

and the product is $-1$. The single-gate culprit is sx itself: $\sqrt{X} = e^{i\pi/4}R_x(\pi/2)$ — verified — which is the same $e^{i\pi/4}$ that appears in the ratio.

Why this belongs in a chapter about serialization. §6.6 is about to tell you that a QASM round trip discards global phase. Here is one manufacturing a global phase, inside the definition of the hardware-native gate, in the file you were about to treat as an exact record. Re-import the transpiled Bell state and the counts come back perfectly — {'rz': 7, 'sx': 4, 'measure': 2, 'ecr': 1} — but the ecr arrives as a generic Gate whose matrix does not equal ECRGate's. Nothing you can measure about that circuit changes. Control it, and everything does.

Seven rz gates, and they are free. Chapter 3 §3.8 established that rz is virtual on superconducting hardware — zero duration, zero error. Count the lines: seven rz, four sx, one ecr, two measure. So the true physical cost of this circuit is four sx pulses and one ecr — five real operations out of twelve instructions. Everything else is bookkeeping in the classical controller.

That ratio is worth internalizing. Instruction count is not cost. A QASM listing that looks twelve operations long executes as five, and a listing that looks short can be expensive if it is all two-qubit gates. Chapter 28 optimizes against the real cost, not the apparent one.

From five operations to a number of nanoseconds

"Five real operations out of twelve instructions" is the right correction to make, and it is still not a duration. The five are not interchangeable. Chapter 9 read the durations off a 127-qubit ECR device — the same family as FakeSherbrooke — and they span more than an order of magnitude:

   rz             0.0 ns          virtual
   sx            56.9 ns
   ecr    341.3 - 881.8 ns        depends on which link
   measure    1,216.0 ns

Price the transpiled Bell state with those numbers.

   four sx pulses            227.6 ns      (4 x 56.9)
   one ecr           341.3 -   881.8 ns
   one readout             1,216.0 ns
   ------------------------------------
   worst case              2,325.4 ns  =  2.3 us
   best case               1,784.9 ns  =  1.8 us

The readout is the largest single term. It is longer than every gate in the circuit put together by a factor of between 1.1 and 2.1, depending on which link the transpiler picked. The seven rz that dominate the listing contribute zero; the four sx you were just told to count contribute between 9.8% and 12.8% of the total; and the operation nobody thinks of as a gate owns the budget. (The two measure instructions sit on different qubits, so they can overlap — one readout's worth of time, not two.)

Now put that against the coherence budget. Chapter 39 measured $T_1$ on one chip spanning 15.2 to 483.0 μs. On the best qubit this circuit consumes 0.5% of the available coherence; on the worst it consumes 15%. Same circuit, same text, same QASM file — and the difference is entirely which physical qubit the $0 refers to.

Three cost models, three different answers, and the QASM text supports only the first two:

   instructions          12      what the listing looks like
   real operations        5      count non-rz              (Ch. 3 section 3.8)
   nanoseconds       ~1,800 - 2,325      needs the backend's Target, which is not in the file

That third row is the one the file cannot give you, and §6.6 is the general version of that sentence.

⚙️ Under the Transpiler — The QASM is the audit trail.

When a circuit works in simulation and fails on hardware, the transpiled QASM answers most of the questions you will have, in one place:

Question Where in the QASM
Which physical qubits ran this? the $n identifiers
Did the router insert SWAPs? count the two-qubit gates against what you wrote
What is the real gate cost? count non-rz operations
What does the native gate actually do? the gate definition at the top
Did an optimization pass change my circuit? diff the QASM at two optimization levels

That last one is a technique worth adopting: dump QASM at optimization levels 0 and 3, diff them, and you have a precise record of every decision the compiler made. Chapter 10 uses it constantly.

🧪 Run It — Diff two optimization levels.

```python import difflib

texts = {} for level in (0, 3): pm = generate_preset_pass_manager(optimization_level=level, backend=backend, seed_transpiler=42) texts[level] = qasm3.dumps(pm.run(qc)).splitlines()

print("\n".join(difflib.unified_diff(texts[0], texts[3], "level 0", "level 3", lineterm=""))) ```

Chapter 2's Exercise 2.20 measured level 0 at 25 operations and level 3 at 9. Now you can see which nine, and which sixteen disappeared.

6.6 What a Round Trip Preserves — and What It Loses

QASM is a serialization format, and every serialization format loses something. Knowing what is the difference between using it confidently and being surprised.

What survives

qc = QuantumCircuit(2)
qc.h(0)
qc.cx(0, 1)
back = qasm3.loads(qasm3.dumps(qc))
print(Operator(qc).equiv(Operator(back)))          # True

Custom gates survive, with their definitions:

sub = QuantumCircuit(2, name="myblock")
sub.h(0)
sub.cx(0, 1)

big = QuantumCircuit(3)
big.append(sub.to_gate(), [0, 1])
big.x(2)
print(qasm3.dumps(big))
OPENQASM 3.0;
include "stdgates.inc";
gate myblock _gate_q_0, _gate_q_1 {
  h _gate_q_0;
  cx _gate_q_0, _gate_q_1;
}
qubit[3] q;
myblock q[0], q[1];
x q[2];
back = qasm3.loads(qasm3.dumps(big))
print(dict(back.count_ops()))                       # {'myblock': 1, 'x': 1}
print(Operator(big).equiv(Operator(back)))          # True

The block came back as a named gate, not flattened into its constituents. Structure is preserved.

What does not survive

import numpy as np

g = QuantumCircuit(1)
g.h(0)
g.global_phase = np.pi / 4
print("before:", g.global_phase)

back = qasm3.loads(qasm3.dumps(g))
print("after: ", back.global_phase)
print("equiv:", Operator(g).equiv(Operator(back)))
print("exactly equal:", np.allclose(Operator(g).data, Operator(back).data))
before: 0.7853981633974483
after:  0.0
equiv: True
exactly equal: False

The global phase is gone. Silently, with no warning, and the circuit still passes equiv because equiv compares up to global phase by design.

For most purposes this is harmless — that is exactly what Chapter 3 §3.7 said. But recall the exception:

⚠️ Common Pitfall — The QASM round trip that changes your algorithm.

Chapter 3 §3.7 ended with a rule: never delete a global phase from a subcircuit you might later control. A round trip through QASM deletes it for you.

```python

A subcircuit with a meaningful global phase -- say, part of phase estimation

block = QuantumCircuit(1) block.h(0) block.global_phase = np.pi / 4

archived = qasm3.loads(qasm3.dumps(block)) # phase silently lost

Later, someone controls it:

qc = QuantumCircuit(2) qc.h(0) qc.append(archived.to_gate().control(1), [0, 1]) # <-- now WRONG ```

Controlled-$U$ and controlled-$e^{i\phi}U$ are different operations. The phase that was unobservable becomes a relative phase between branches the moment it is controlled — which is phase kickback, the engine of Deutsch–Jozsa, Bernstein–Vazirani, and quantum phase estimation (Chapters 19, 20, 22).

Here is the difference, measured. A subcircuit with global_phase = π is used as a controlled operation, once before archiving and once after:

text controlled-original -> {'0': 291, '1': 1757} controlled-archived -> {'0': 1758, '1': 290}

The answer inverted. Same gate counts, same equiv check, opposite result.

This is a real bug and it is nearly invisible, because every test that compares with equiv passes and the failure only appears in the one downstream use that matters.

Defenses: 1. Record circuit.global_phase alongside the QASM when you archive a subcircuit. 2. In a round-trip test, compare with np.allclose(Operator(a).data, Operator(b).data), not equiv, when the phase matters. 3. Serialize with QPY (qiskit.qpy) instead when you need exact fidelity to a Qiskit circuit — it is Qiskit-specific and version-sensitive, which is the trade.

★ Why the answer inverted, derived

Those counts are a measurement. The reason they are those counts is a five-line calculation, and doing it is worth more than accepting the result, because the calculation tells you how large the effect is in general rather than in this one case.

The experiment is the standard interference sandwich: put the control into superposition, apply controlled-$U$, un-superpose, measure the control. Take $U = e^{i\phi}H$ — a Hadamard carrying the global phase that the archive is about to drop — and the target starting in $|0\rangle$.

After the first Hadamard on the control:

$$\tfrac{1}{\sqrt2}\big(|0\rangle_c + |1\rangle_c\big)\,|0\rangle_t.$$

The controlled gate acts only on the $|1\rangle_c$ branch, so it is precisely there that the global phase attaches:

$$\tfrac{1}{\sqrt2}\Big(|0\rangle_c|0\rangle_t \;+\; e^{i\phi}\,|1\rangle_c\,|+\rangle_t\Big).$$

This is the whole mechanism in one line. A factor multiplying the entire state is unobservable; a factor multiplying one branch of a superposition is a relative phase, and relative phases interfere. Chapter 26 §26.3 gives the general matrix form — $\text{ctrl}(e^{i\phi}U) = (P(\phi)\otimes I)\, \text{ctrl}(U)$, so the stray phase becomes a phase gate sitting on the control line.

Apply the second Hadamard ($|0\rangle \to |+\rangle$, $|1\rangle \to |-\rangle$) and collect the terms with $|0\rangle_c$:

$$|0\rangle_c \otimes \tfrac12\Big(|0\rangle_t + e^{i\phi}|+\rangle_t\Big),$$

whose components are $a_0 = \tfrac12\big(1 + e^{i\phi}/\sqrt2\big)$ and $a_1 = e^{i\phi}/(2\sqrt2)$. Then

$$P(\text{control}=0) \;=\; |a_0|^2 + |a_1|^2 \;=\; \frac{2 + \sqrt{2}\cos\phi}{4}.$$

Verified against Statevector at six values of $\phi$, agreeing to eight decimals. Two of those values are the ones the case study ran:

   phi        P(0) exact     as a fraction     book's measured counts
   ---------------------------------------------------------------------
   pi         0.14644661     (2 - sqrt2)/4     291/2048  = 0.14209
   0          0.85355339     (2 + sqrt2)/4     1758/2048 = 0.85840

The two probabilities are exact reflections of each other, which is why the answer did not merely shift — it inverted. And the measured counts sit within one standard error of the derived values: shot noise at 2,048 shots is $\sqrt{p(1-p)/N} = 0.0078$, and the two discrepancies are 0.0044 and 0.0048. The measurement and the derivation agree, which is the check worth doing.

🔬 Honest Assessment — this test is maximally sensitive at $\phi = \pi$ and nearly blind at small $\phi$.

The formula says the loss shifts $P(0)$ by

$$\Delta P \;=\; \frac{\sqrt2}{4}\big(1 - \cos\phi\big) \;\approx\; \frac{\sqrt2}{8}\,\phi^2 > \quad\text{for small }\phi.$$

At $\phi = \pi$ that is 0.7071 — the largest swing a probability can have short of certainty, and the reason the case study is so vivid. At $\phi = 0.01$ it is 1.77 × 10⁻⁵, which at 2,048 shots is a fortieth of the shot noise.

The effect is quadratic in the lost phase, so a small lost phase is invisible to this experiment. Do not read that as reassurance. The error in the operator is still linear in $\phi$: Chapter 26 §26.3 points out that a discrepancy of $\phi = 0.01$ becomes a genuine one-percent rotation error on the control line, and prices detecting it by sampling at 360,000 shots. A test that happens to be insensitive to the size of a bug is not a test that the bug is small — it is §6.6's structural blindness in a quantitative form, and it is the reason the defence is to record the phase rather than to measure for it.

And a second loss: parameter names

Export the project's two-qubit ansatz and look carefully at the identifiers:

from qiskit.circuit import ParameterVector

thetas = ParameterVector("theta", 4)
ansatz = QuantumCircuit(2)
ansatz.ry(thetas[0], 0)
ansatz.ry(thetas[1], 1)
ansatz.cx(0, 1)
ansatz.ry(thetas[2], 0)
ansatz.ry(thetas[3], 1)

print(qasm3.dumps(ansatz))
OPENQASM 3.0;
include "stdgates.inc";
input float[64] _theta_0_;
input float[64] _theta_1_;
input float[64] _theta_2_;
input float[64] _theta_3_;
qubit[2] q;
ry(_theta_0_) q[0];
...

theta[0] became _theta_0_. The square brackets are not legal in an OpenQASM identifier, so the exporter substitutes underscores.

The circuit is still correct — the structure and the unitary are fine. What breaks is anything that binds parameters by name:

back = qasm3.loads(qasm3.dumps(ansatz))
back.assign_parameters({"theta[0]": 0.5})     # KeyError -- that name no longer exists

And a ParameterVector no longer round-trips as a vector; it comes back as four unrelated parameters that merely look related.

This is milder than the global-phase problem — it fails loudly with a KeyError rather than silently changing physics — but it is the same category, and both are recorded in comments by the project checkpoint below.

⚠️ Common Pitfall — Do not try to reproduce the exporter's mangling.

The obvious fix is to write a mangle() that reproduces Qiskit's rule and use it to map names back. Resist it. The exact rule is an implementation detail — a first attempt at this book's checkpoint guessed theta[0]theta_0_ and missed the leading underscore, which is precisely the kind of thing that changes between releases.

Normalize both sides instead. Strip every character that is not alphanumeric from both the original and the loaded name, and match on that: theta[0] and _theta_0_ both reduce to theta0. Any underscore-substitution scheme still matches, and the code survives an exporter change.

A third loss: gate names that collide with the standard library

Custom gates were listed above under what survives, and the unitary does survive. The name does not, if it happens to be taken.

Name a custom gate after something in stdgates.inc:

c = QuantumCircuit(1, name="h")          # a custom gate called "h"
c.x(0)
outer = QuantumCircuit(1)
outer.append(c.to_gate(), [0])
print(qasm3.dumps(outer))
OPENQASM 3.0;
include "stdgates.inc";
gate h_0 _gate_q_0 {
  x _gate_q_0;
}
qubit[1] q;
h_0 q[0];

The gate is now called h_0. The exporter is doing the only correct thing available to it — h is taken by the include, and emitting a second definition would produce a file that either fails to parse or silently redefines the Hadamard for every other gate in the document. But the rename is not reported, and it does not come back:

   original op names : ['h']
   after round trip  : ['h_0']
   unitary preserved : True

Same category as theta[0]_theta_0_, and the same underlying reason: an identifier is not part of the physics, so the format does not guarantee it, so anything keyed on the identifier breaks. The defence is also the same — normalize, do not reproduce the rule.

What differs is the failure mode, and this one is worse than the parameter case. Parameter mangling raises a KeyError the moment you bind by name. A renamed gate raises nothing at all, until some downstream code does count_ops()["h"] and gets a KeyError that reads as though a gate went missing rather than as though a gate got renamed.

And a fourth: the layout, which §6.5 spent a page reading

§6.5 read the physical qubit assignment straight out of the text — $0`, `$1 — and called it "the transpiler's layout decision, written down." It is written down. It does not come back.

Round-trip the transpiled Bell state from §6.5:

   isa.num_qubits                    127
   reimported num_qubits               2
   count_ops equal                  True

A 127-qubit circuit went in and a 2-qubit circuit came out, with identical gate counts. The importer sees $0` and `$1, concludes that two wires are needed, and builds a two-wire circuit. Every gate survived. The statement "these gates run on physical qubits 0 and 1 of a 127-qubit device" did not, because it was never a gate.

Push the indices up and the same thing happens at a different width. Chapter 18 §18.3 transpiled a three-qubit GHZ with initial_layout=[40, 41, 42] and round-tripped it both ways:

  after a QASM 2 round trip
    layout is None    : True
    num_qubits        : 127
    count_ops equal   : True

  after a QASM 3 round trip
    layout is None                      : False
    initial_index_layout()              : [0, 1, 2, 3, 4, ...]
    num_qubits                          : 43
    count_ops equal                     : True

QASM 2 has no $ syntax at all, so it emits a wide logical register and the layout comes back None — an honest, visible loss. QASM 3 does better, and that is the problem. It comes back with a layout object that is the identity, sized to 43 because the largest index touched was $42. Code downstream that reads .layout gets an answer. The answer is wrong, and nothing warns it.

A QASM file is a logical circuit. That is the correct design for an interchange format — the whole point of a portable circuit is that it is not yet committed to one device's qubit 40 — but it means the single most valuable decision in the pipeline does not travel with the file. Chapter 29 measured what that decision is worth: a hardware-aware level-1 transpilation scored 0.9116 against a naive level-3 one at 0.7720, a gap of 0.1397 produced by nothing but qubit choice. Chapter 39's 14-qubit layout sweep found fidelity ranging 0.5755 to 0.7911 across 24 seeds — a 2.03× spread in error on one circuit and one chip.

🐛 Debug This — the round-trip test that passed while the circuit lost 125 qubits.

count_ops was equal in every measurement above. It is the check people write, because it is the check that is easy to write.

python assert dict(qc.count_ops()) == dict(back.count_ops()) # passes

It passed while the width went from 127 to 2. It passed while the layout went from a real assignment to the identity. It passed while the global phase went from $\pi/4$ to $0$, and while theta[0] became _theta_0_, and while a gate called h became h_0.

A gate-count comparison can only detect losses that are gates. Nothing this chapter has documented is a gate.

The obvious escalation — compare the operators instead — is unavailable at the width where it would matter most:

text Operator(isa) -> ValueError: Maximum allowed dimension exceeded

A $2^{127}$-dimensional matrix is not a thing you can build. The exact test breaks down at exactly the width where the layout loss is most expensive. That is the reason §6.5's four-number habit exists at all: physical qubits, two-qubit count, real pulses, and depth are checkable when the unitary is not.

Chapter 26 §26.3 catalogues four equality tests and the four different questions they answer. This is the case where none of them applies and you fall back to metadata.

🔬 Honest Assessment — two pieces of advice that look contradictory, and are not.

Chapter 18 §18.3 concludes serialize before you transpile. Chapter 39 §39.8 concludes transpile once, serialize the transpiled circuit, and submit that. Both are right, because they answer different questions.

```text goal what to serialize why


portability, archiving the LOGICAL circuit basis gates and layout are device-specific; re-derive them reproducing a run the TRANSPILED circuit a fixed seed is not a fixed layout ```

The second one carries a caveat this section just measured: serializing the transpiled circuit as QASM does not actually pin the layout, because the import gives you back the identity. To make it work you need either a format that preserves the binding — QPY — or the physical qubit list recorded separately and fed back as an explicit initial_layout.

That list is not optional metadata. Chapter 39 §39.8 sorts execution metadata into three tiers, and puts physical_qubits in Tier 1 — provider-side, unrecoverable, alongside the job ID, the execution timestamp, and the calibration snapshot. Its reproducibility check returns False while any of those four is None, and it is deliberately not satisfied by "the code is committed."

That is the same argument this section has been making from the serialization side. The QASM is the code. The layout was never in the code.

§39.8 also names why re-deriving it does not work: seed_transpiler=42 fixes one input to a scoring function that reads the device's current error rates, and the device recalibrates between runs. The seed is deterministic; the function it seeds is not stationary.

The general shape of the trade

QASM 3 QPY (Qiskit's binary format)
Cross-framework yes no
Human-readable yes no
Stable across versions yes (a specification) no (Qiskit-versioned)
Preserves global phase no yes
Preserves every Qiskit detail no yes

Use QASM to communicate and archive. Use QPY to checkpoint within one Qiskit version. They are not competitors.

The four losses, in one place

Lost Fails how Detected by count_ops? By equiv? Defence
global phase silently; changes physics under control no no record in a comment; exact comparison
parameter names loudly, KeyError at bind time no n/a normalize both sides
gate names silently; KeyError much later no n/a normalize both sides
layout silently; QASM 3 returns the identity no untestable at width record physical_qubits out of band

Read down the "detected by" columns. Not one of the four is visible to either check anybody writes, and they are not visible for the same structural reason: each is a property of the circuit that is not a gate, and both checks are gate-level. That is not a criticism of the checks — it is the definition of what a serialization boundary is for, and the reason the enumeration above is the useful artefact rather than any single test.

6.7 QASM 2 Versus QASM 3

Both are current in the sense that both are in wide use. The differences that decide your choice:

Feature QASM 2 QASM 3
Registers qreg q[2]; creg c[2]; qubit[2] q; bit[2] c;
Measurement measure q[0] -> c[0]; c[0] = measure q[0];
Standard library qelib1.inc stdgates.inc
Free parameters not supported input float[64] theta;
Classical control register equality only full if/else, loops, expressions
Physical qubits no syntax $0`, `$1
Classical types none int, float, bool, bit, arrays
Timing none duration, delay, barrier, boxes
Tooling maturity very broad broad and growing

The short version: QASM 2 is a circuit format. QASM 3 is a program format — it has real classical types, real control flow, and a notion of time. That difference is what dynamic circuits need.

Choose QASM 2 when maximum compatibility with older tools matters and your circuit is static and fully bound. Choose QASM 3 otherwise, and always for anything with parameters, classical control, or timing.

★ The gate set is the real portability boundary

The table above compares language features. In practice the thing that breaks a QASM file in the field is almost never a language feature. It is a gate name.

QASM 2 does support custom gate definitions, and a plain one round-trips cleanly:

sub = QuantumCircuit(2, name="myblock")
sub.h(0); sub.cx(0, 1)
big = QuantumCircuit(3); big.append(sub.to_gate(), [0, 1]); big.x(2)
print(qasm2.dumps(big))
OPENQASM 2.0;
include "qelib1.inc";
gate myblock q0,q1 { h q0; cx q0,q1; }
qreg q[3];
myblock q[0],q[1];
x q[2];
   reload count_ops: {'myblock': 1, 'x': 1}

Now build the same shape out of gates Qiskit considers ordinary:

sub = QuantumCircuit(2, name="native_block")
sub.sx(0)
sub.ecr(0, 1)
big = QuantumCircuit(2)
big.append(sub.to_gate(), [0, 1])
print(qasm2.dumps(big))
OPENQASM 2.0;
include "qelib1.inc";
gate ecr q0,q1 { s q0; sx q1; cx q0,q1; x q0; }
gate native_block q0,q1 { sx q0; ecr q0,q1; }
qreg q[2];
native_block q[0],q[1];

The export succeeds. The exporter walks the definition tree and emits every gate it needs, including ecr in terms of simpler ones — exactly the helpful behaviour §6.5 praised.

Then Qiskit cannot read the file it just wrote:

   qasm2.loads(text)
   QASM2ParseError: "<input>:3,23: 'sx' is not defined in this scope"

Line 3, column 23 is the sx q1 inside the gate ecr definition. The file defines ecr in terms of sx and then never defines sx, because the exporter treated sx as standard and the importer does not. The result is a syntactically valid, semantically incomplete document: a definition that references an undefined name.

How wide is the gap? Take fourteen gates a Qiskit user would call ordinary, export each one to QASM 2 on its own, and try to read it back:

   round-trips clean : h, x, rz, cx, cz, ccx, id
   fails on import   : sx, sxdg, ecr, swap, rzz, p, u

Seven of fourteen. swap fails. p and u fail — the gates the modern qelib1.inc was revised to include. Qiskit's QASM 2 importer implements the original 2017 library and nothing since, so the boundary of "standard" is not where either side assumes it is.

Chapter 18 §18.3 hit the identical error string on a transpiled circuit and gives the fix:

qasm2.loads(text, custom_instructions=qasm2.LEGACY_CUSTOM_INSTRUCTIONS)

Verified: it recovers all seven, count_ops intact. But look at what that fix isyou have to tell the importer, out of band, which non-standard gates to expect. The file does not carry that information, and no other framework's importer has the flag.

This is why custom gate definitions are the least portable feature in the format. Everything else degrades predictably and early: QASM 2 refuses a free parameter with a clear QASM2ExportError at export time, and refuses a single-bit condition with a clear message at export time. A gate definition fails later, in someone else's parser, months after the file was written, with an error naming a gate you never typed.

⚠️ Common Pitfall — a QASM 2 file that Qiskit wrote and Qiskit cannot read.

The rule that falls out is Chapter 18's: a format is only as portable as the gate set you wrote in it.

Qiskit's basis gates for a modern superconducting device are rz, sx, x, ecr, measure. Two of those five are outside what the QASM 2 importer knows, which means every transpiled IBM circuit exported to QASM 2 has this problem. It is not an edge case; it is the default output of the default toolchain.

Three ways out, in the order you should prefer them:

  1. Serialize before transpiling. The logical circuit is h and cx, both of which round-trip clean. This is the same conclusion §6.6 reached from the layout side, by a completely different route — which is the strongest kind of agreement.
  2. Use QASM 3. stdgates.inc is wider, and the QASM 3 importer resolves inline gate definitions without a flag: §6.5's transpiled Bell state re-imports with {'rz': 7, 'sx': 4, 'measure': 2, 'ecr': 1} intact. With the caveat §6.5's Math Aside already stated — the reconstructed ecr is ECRGate times $e^{i\pi/4}$.
  3. Pass LEGACY_CUSTOM_INSTRUCTIONS. It works, and it works only inside Qiskit.

🔬 Honest Assessment — Format churn is a real cost, and it is not over.

QASM 2 was published in 2017; QASM 3 in 2021, with revisions since. Tooling support for QASM 3 is good and uneven — some parsers implement the circuit subset and stop short of the classical language, which means a file that is specification-valid may still be rejected by a tool that claims QASM 3 support.

Practical consequences:

  • Test your round trip against the specific tool you care about, not against the specification.
  • Keep the source of truth in your framework, not in QASM. Regenerate the QASM; do not edit it and re-import as your primary workflow.
  • When archiving, save both: the QASM for portability and readability, and the framework's native serialization for fidelity. Storage is free; a circuit you cannot reconstruct is not.

This is unglamorous and it is the kind of thing that separates a reproducible project from one that quietly rots. Chapter 39 §39.6 makes the same argument about whole experiments.

6.8 QASM as the Interchange Format

The practical payoff: moving a circuit between frameworks.

# Author in Qiskit
from qiskit import QuantumCircuit, qasm2

qc = QuantumCircuit(3)
qc.h(0)
qc.cx(0, 1)
qc.cx(1, 2)
text = qasm2.dumps(qc)

# ... hand `text` to another framework's importer ...

Every major framework can consume QASM in some form — Cirq, Braket, and the Azure toolchain all have importers, with varying coverage. Chapter 18 does this properly across all five, and finds exactly the caveats §6.7 warns about.

Two rules make the practice work.

Use QASM 2 for interchange unless you need QASM 3 features. QASM 2 support is more uniform, and a circuit that survives everywhere is worth more than one that exercises the newest syntax.

Verify the round trip, do not assume it. The test is three lines and it is the difference between portability and hope:

from qiskit.quantum_info import Operator

def check_round_trip(qc, mod=qasm3, exact=False):
    """Assert a circuit survives serialization. Set exact=True when phase matters."""
    back = mod.loads(mod.dumps(qc))
    if exact:
        assert np.allclose(Operator(qc).data, Operator(back).data), "phase or unitary changed"
    else:
        assert Operator(qc).equiv(Operator(back)), "unitary changed"
    assert dict(qc.count_ops()) == dict(back.count_ops()), "gate counts changed"
    return back

That function belongs in every project that serializes circuits, and Chapter 27 puts it in a test suite.

🔀 In Another Framework — who actually reads and writes QASM.

Chapter 18 §18.3 checked all five frameworks in both directions:

Framework Exports Imports Note
Qiskit QASM 2 + QASM 3 QASM 2 + QASM 3 native, both directions
Cirq QASM 2 QASM 2 import lives in cirq.contrib and needs pip install ply
Braket QASM 3 QASM 3 OpenQASM 3 is Braket's native IR
PennyLane qml.to_openqasm qml.from_qasm plus native plugins
Q# / QDK qdk.openqasm.compile qdk.openqasm.circuit full OpenQASM module

All five, in both directions. That is the strongest single argument that OpenQASM is a real standard rather than one vendor's format, and it is why this book can compare five frameworks at all.

Two asymmetries are worth carrying. Cirq's import is a second-class citizen — export works out of the box, import lives in cirq.contrib and fails with ModuleNotFoundError until you install an extra dependency. And Braket's native IR is QASM 3, with no translation step, which makes it unusually good as a translation hub.

Where the interchange argument fails

The format is a real standard and it still does not make circuits interchangeable. Chapter 18 measured three failures, and they are different in kind — worth separating, because only one of them has a fix.

Qubit indexing is not in the file, and it changes the answer. Export an asymmetric two-qubit circuit from Qiskit to QASM 2, import it into Cirq, and compare state vectors:

  cirq (via qasm) : [0, 0, 0.7071, 0.7071]

The two agree only after bit reversal. This is not a bug in either framework. QASM names qubits explicitly — x q[0]; means qubit 0 in any framework, unambiguously — but where qubit 0 sits in a state vector is a per-framework convention that QASM has no opinion about. The program transfers; the indexing does not. This one has a fix: convert results at exactly one place in the pipeline, and test it.

QASM 3's own physical-qubit syntax is a dialect in practice. Hand a QASM 3 file containing $40 to Cirq's importer:

  cirq.contrib.qasm_import on QASM 3 text
    QasmException: Illegal character '$' at line 9

Cirq's importer is a QASM 2 importer, and QASM 2 has no $ to be illegal about. A specification-valid file was rejected by a tool that reads the format — §6.7's warning, in its sharpest form.

And the dispatch itself can fail before any parsing happens. PennyLane 0.45.1's qml.from_qasm is a dispatcher, not a parser: it looks up a registered converter and raises if none is installed.

  qml.from_qasm(text)
  RuntimeError: Failed to load the qasm plugin. ...
  __cause__ -> KeyError: 'qasm'

Put the three together and you get the honest version of §6.1's argument. OpenQASM turns $N \times M$ into $N + M$ for the circuit, and leaves you an $N \times M$ problem for everything the circuit is not. The gate sequence is genuinely portable. The indexing convention, the layout, the schedule, the global phase, and the identifiers are each a per-pair negotiation, and this chapter has now measured a loss in all five.

That is not an argument against using it. It is the argument for the discipline §6.7 already stated: test your round trip against the specific tool you care about, not against the specification — and keep the source of truth in your framework.

🧱 Project Checkpointvqelab/qasm.py.

The project gets an archival layer: dump any ansatz to OpenQASM 3, with its parameters intact, and read it back.

```python

vqelab/qasm.py

from qiskit import QuantumCircuit, qasm3

def archive(circuit: QuantumCircuit, path: str) -> None: """Write a circuit to OpenQASM 3, recording what QASM cannot carry.""" with open(path, "w", encoding="utf-8") as f: if circuit.global_phase: # QASM 3 silently discards this. Record it so the loss is recoverable. f.write(f"// vqelab: global_phase = {circuit.global_phase!r}\n") f.write(qasm3.dumps(circuit)) ```

The comment line is the whole point. QASM will not carry the global phase, so we write it into a comment where a human — or restore() — can find it. That is three lines of defensive engineering against a bug that is otherwise invisible until it changes a controlled operation.

This matters for the project specifically: the ansatz from Chapter 4 will be reused as a controlled operation nowhere, but the QPE circuit of Chapter 22 will be, and the habit needs to be in place before it is needed. The checkpoint file also adds restore() and a round-trip test.

6.9 Summary

OpenQASM is the intermediate representation of the quantum stack. It exists for the same reasons LLVM IR does: a common target turns an $N \times M$ problem into $N + M$, it is a stable boundary against framework churn, and it is where the truth about a circuit is written down in a form with a specification.

QASM 3 syntax: OPENQASM 3.0;, include "stdgates.inc";, qubit[n] q;, bit[n] c;, gate applications, and measurement as an assignmentc[0] = measure q[0];.

The API changed. qc.qasm() was removed in Qiskit 1.0. Use qasm3.dumps/loads and qasm2.dumps/loads — and note that dump takes a stream while load takes a filename, unlike json and pickle. Sidestep it by using dumps/loads with strings and doing your own file I/O.

Reading transpiled QASM is the payoff. It shows you the physical qubits ($0`, `$1), the basis gates that replaced your h and cx, the definition of hardware-native gates like ecr in terms of standard ones, and — by counting non-rz operations — the real cost. Diffing the QASM at two optimization levels is a precise record of every compiler decision.

A round trip preserves the unitary and custom gate structure. It loses two things.

It silently discards the global phase — harmless except when the circuit is later used as a controlled operation, where an unobservable global phase becomes an observable relative phase (phase kickback). Measured, that inverted the answer: {'1': 1757} became {'0': 1758}.

And it mangles parameter names: theta[0] becomes _theta_0_, because brackets are illegal in an OpenQASM identifier. This one fails loudly with a KeyError rather than silently, and it means a ParameterVector does not survive as a vector.

Record both in comments alongside the QASM — parsers ignore comments, so the file stays portable. Use exact comparison rather than equiv in round-trip tests when phase matters. And normalize names rather than reproducing the exporter's mangling rule, which is an implementation detail.

QASM 2 is a circuit format; QASM 3 is a program format with real classical types, control flow, timing, and — decisively for variational work — free parameters, which QASM 2 cannot represent at all.

Use QASM to communicate and archive; use QPY to checkpoint within one Qiskit version. Save both. And verify round trips rather than assuming them — tool support for QASM 3 is good and uneven, and a specification-valid file can still be rejected by a tool that claims to support it.


Part I ends here. You can build circuits, run them on real hardware, read the results honestly, reason about how many shots you need, and see what the compiler did to your work. That is a complete foundation.

Next: Chapter 7 opens Part II and goes deep on Qiskit itself — its architecture, the primitives, and the framing question that governs every hardware decision you will make: which question am I asking, "what outcomes?" or "what expectation value?"