> *"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.
In This Chapter
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 Say —
float[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 Note —
qc.qasm()is gone.Every pre-2024 tutorial contains this:
python print(qc.qasm()) # ✗ removed in Qiskit 1.0The 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 Pitfall —
dumpandloadare not symmetric.
jsonandpickletaught you thatdumpandloadboth 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 PATHPassing an open file to
qasm3.load()raisesTypeError: 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/loadswith 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
ifblock cannot be costed by counting lines, and neitherdepth()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 isECRGatetimes $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'sECRGate:
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$:
ECRGatesits 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
sxitself: $\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 theecrarrives as a genericGatewhose matrix does not equalECRGate'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 $nidentifiersDid the router insert SWAPs? count the two-qubit gates against what you wrote What is the real gate cost? count non- rzoperationsWhat does the native gate actually do? the gatedefinition at the topDid 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
equivcheck, opposite result.This is a real bug and it is nearly invisible, because every test that compares with
equivpasses and the failure only appears in the one downstream use that matters.Defenses: 1. Record
circuit.global_phasealongside the QASM when you archive a subcircuit. 2. In a round-trip test, compare withnp.allclose(Operator(a).data, Operator(b).data), notequiv, 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 guessedtheta[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 totheta0. 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_opswas 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()) # passesIt 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 calledhbecameh_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 exceededA $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_qubitsin Tier 1 — provider-side, unrecoverable, alongside the job ID, the execution timestamp, and the calibration snapshot. Its reproducibility check returnsFalsewhile any of those four isNone, 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=42fixes 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 is — you 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:
- Serialize before transpiling. The logical circuit is
handcx, 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.- Use QASM 3.
stdgates.incis wider, and the QASM 3 importer resolves inlinegatedefinitions 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 reconstructedecrisECRGatetimes $e^{i\pi/4}$.- 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.contriband needspip install plyBraket QASM 3 QASM 3 OpenQASM 3 is Braket's native IR PennyLane qml.to_openqasmqml.from_qasmplus native plugins Q# / QDK qdk.openqasm.compileqdk.openqasm.circuitfull 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.contriband fails withModuleNotFoundErroruntil 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 Checkpoint —
vqelab/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 assignment — c[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?"