Qiskit crash course
Enough Qiskit to solve CTF challenges, in about three hours if you type the examples rather than read them. Everything runs locally on AerSimulator: no IBM account, no transpilation for real hardware, no noise models, no pulse, no Runtime primitives. Qiskit 2.5.0, qiskit-aer 0.17.2.
- Sanity check and the 2.x API
- QuantumCircuit basics
- Running things: counts vs statevector
- Bit ordering (read this twice)
- Gates, controls, custom unitaries
- Superposition and entanglement in counts
- Phase kickback and the Hadamard test
- Parameterised circuits
- OpenQASM interop
- Inspecting an unknown circuit
- 10 idioms you will actually retype
1. Sanity check and the 2.x API
Run this first. If it prints two version numbers and a roughly 50/50 split, the environment is fine and you can stop worrying about setup.
import qiskit, qiskit_aer
print(qiskit.__version__, qiskit_aer.__version__)
from qiskit import QuantumCircuit
from qiskit_aer import AerSimulator
qc = QuantumCircuit(2, 2)
qc.h(0)
qc.cx(0, 1)
qc.measure([0, 1], [0, 1])
print(AerSimulator().run(qc, shots=1000).result().get_counts())
2.5.0 0.17.2
{'00': 504, '11': 496}
Qiskit 2.x removed a lot of the API that appears in tutorials, blog posts and model-generated code written against 0.x/1.x. The table is what actually changed for CTF-relevant code.
| Dead / removed | Use instead |
|---|---|
from qiskit import execute | backend.run(qc), or backend.run(transpile(qc, backend)) |
from qiskit import Aer | from qiskit_aer import AerSimulator |
Aer.get_backend("qasm_simulator") | AerSimulator() |
Aer.get_backend("statevector_simulator") | Statevector(qc) from qiskit.quantum_info |
qc.h(0).c_if(creg, 1) | with qc.if_test((clbit, 1)): qc.h(0) |
qc.bind_parameters({...}) | qc.assign_parameters({...}) |
from qiskit.circuit.library import QFT | QFTGate(n) |
qiskit.circuit.QuantumCircuit.qasm() | qiskit.qasm2.dumps(qc) / qiskit.qasm3.dumps(qc) |
BasicAer, IBMQ, qiskit.providers.aer | gone; not needed offline |
execute or Aer, it will
ImportError — rewrite it with the right column above before debugging
anything else.2. QuantumCircuit basics
QuantumCircuit(n) gives n qubits and no classical bits.
QuantumCircuit(n, m) gives n qubits and m classical
bits. Qubits start in |0>. You only need classical bits if you call
.measure(); the statevector path (§3) needs none.
from qiskit import QuantumCircuit
qc = QuantumCircuit(3, 3)
qc.h(0) # Hadamard on qubit 0
qc.cx(0, 1) # CNOT: control 0, target 1
qc.ccx(0, 1, 2) # Toffoli: controls 0,1, target 2
qc.barrier() # visual/optimisation fence, no physical effect
qc.measure(range(3), range(3)) # qubit i -> clbit i
print(qc.draw(output="text"))
┌───┐ ░ ┌─┐
q_0: ┤ H ├──■────■───░─┤M├──────
└───┘┌─┴─┐ │ ░ └╥┘┌─┐
q_1: ─────┤ X ├──■───░──╫─┤M├───
└───┘┌─┴─┐ ░ ║ └╥┘┌─┐
q_2: ──────────┤ X ├─░──╫──╫─┤M├
└───┘ ░ ║ ║ └╥┘
c: 3/═══════════════════╩══╩══╩═
0 1 2
| Call | Meaning |
|---|---|
qc.measure(q, c) | measure qubit q into clbit c; both can be lists |
qc.measure_all() | adds a barrier + a fresh meas register measuring every qubit |
qc.measure_active() | same but only qubits that have gates on them |
qc.reset(q) | force qubit back to |0> mid-circuit |
qc.compose(other, qubits=[...]) | returns a new circuit with other appended (does not mutate) |
qc.append(gate, [qubits]) | append a Gate object |
qc.to_gate() / qc.to_instruction() | turn a circuit into a reusable block |
qc.inverse() | dagger of the whole circuit (no measurements allowed) |
qc.draw() | text diagram; reverse_bits=True draws q0 at the bottom |
compose and to_gate are how you build "apply the oracle, then
its inverse" structures without copy-pasting gates:
sub = QuantumCircuit(2, name="blk")
sub.h(0); sub.cx(0, 1)
g = sub.to_gate()
big = QuantumCircuit(4)
big.append(g, [1, 2])
big.append(g.inverse(), [1, 2])
print(big.draw())
q_0: ───────────────────
┌──────┐┌─────────┐
q_1: ┤0 ├┤0 ├
│ blk ││ blk_dg │
q_2: ┤1 ├┤1 ├
└──────┘└─────────┘
q_3: ───────────────────
(The combined operator is the identity, which is a cheap way to verify you wired the qubit indices right.)
3. Running things: counts vs statevector
Two completely different execution paths. Pick deliberately.
| AerSimulator + shots | quantum_info (Statevector / Operator) | |
|---|---|---|
| What you get | sampled measurement counts | exact amplitudes / exact matrix |
| Needs measurements? | yes | no — and they must be absent |
| Statistical noise | yes (shot noise) | none |
| Use when | the challenge is about sampling, or you want to mimic what a real run looks like | you want the answer, which is most of the time |
Statevector /
Operator first. It's faster to write, has no shot noise to squint through,
and gives phases that counts throw away.Shot-based path
from qiskit import QuantumCircuit, transpile
from qiskit_aer import AerSimulator
sim = AerSimulator()
qc = QuantumCircuit(2, 2)
qc.h(0); qc.cx(0, 1)
qc.measure([0, 1], [0, 1])
result = sim.run(transpile(qc, sim), shots=1000).result()
print(result.get_counts()) # {'00': 504, '11': 496}
transpile(qc, sim). Aer executes basic gates
directly, but anything it doesn't have a kernel for — most notably a
controlled UnitaryGate — fails at run time with
AerError: 'unknown instruction: c-unitary'. transpile decomposes
it into gates Aer knows. It costs nothing when it isn't needed.Per-shot outcomes instead of aggregate counts:
m = QuantumCircuit(1, 1); m.h(0); m.measure(0, 0)
print(sim.run(transpile(m, sim), shots=8, memory=True).result().get_memory())
# ['1', '0', '0', '0', '0', '0', '1', '1']
Exact path
import numpy as np
from qiskit import QuantumCircuit
from qiskit.quantum_info import Statevector, Operator
qc = QuantumCircuit(2) # NOTE: no classical bits, no measure
qc.h(0); qc.cx(0, 1)
sv = Statevector(qc)
print(np.round(sv.data, 3)) # [0.707+0.j 0.+0.j 0.+0.j 0.707+0.j]
print(sv.probabilities_dict()) # {'00': 0.5, '11': 0.5}
print(sv.to_dict()) # {'00': (0.707+0j), '11': (0.707+0j)}
print(np.round(Operator(qc).data, 3))
[[ 0.707+0.j 0.707+0.j 0. +0.j 0. +0.j]
[ 0. +0.j 0. +0.j 0.707+0.j -0.707+0.j]
[ 0. +0.j 0. +0.j 0.707+0.j 0.707+0.j]
[ 0.707+0.j -0.707+0.j 0. +0.j 0. +0.j]]
Other Statevector methods worth knowing:
Statevector.from_label("01") # basis state, string is little-endian (§4)
Statevector([1, 0, 0, 1] / np.sqrt(2)) # from raw amplitudes
Statevector(qc).sample_counts(1000) # sample without touching a simulator
Statevector(qc).evolve(other_circuit)
Statevector(qc).expectation_value(SparsePauliOp("ZZ"))
partial_trace(Statevector(qc), [1, 2]) # reduced density matrix on the rest
from qiskit.quantum_info import SparsePauliOp
b = QuantumCircuit(2); b.h(0); b.cx(0, 1)
print(Statevector(b).expectation_value(SparsePauliOp("ZZ")).real) # 0.9999999999999998
print(Statevector(b).expectation_value(SparsePauliOp("XX")).real) # 0.9999999999999998
print(Statevector(b).expectation_value(SparsePauliOp("ZI")).real) # 0.0
If you want a statevector out of Aer specifically (rare, but it appears in challenge scaffolding), the mechanism is a save instruction:
qc = QuantumCircuit(2); qc.h(0); qc.cx(0, 1)
qc.save_statevector()
sim = AerSimulator(method="statevector")
res = sim.run(transpile(qc, sim)).result()
print(np.round(res.get_statevector().data, 3)) # [0.707+0.j 0.+0.j 0.+0.j 0.707+0.j]
4. Bit ordering (read this twice)
Concretely — set qubit 1 and qubit 3 of a 4-qubit register:
from qiskit import QuantumCircuit, transpile
from qiskit_aer import AerSimulator
qc = QuantumCircuit(4, 4)
qc.x(1)
qc.x(3)
qc.measure(range(4), range(4))
counts = AerSimulator().run(transpile(qc, AerSimulator()), shots=64).result().get_counts()
print(counts)
k = list(counts)[0]
print("key as printed :", k)
print("key[::-1] :", k[::-1], " <- index i == qubit i")
bits = k[::-1]
print("qubit1 =", bits[1], "qubit3 =", bits[3])
{'1010': 64}
key as printed : 1010
key[::-1] : 0101 <- index i == qubit i
qubit1 = 1 qubit3 = 1
Read '1010' left to right and you would say qubits 0 and 2 are set. Wrong.
The string is q3 q2 q1 q0. The fix is one slice:
bits = key[::-1] # now bits[i] is qubit i
Statevector labels use the same convention, so the two paths agree:
qc2 = QuantumCircuit(4); qc2.x(1); qc2.x(3)
print(list(Statevector(qc2).probabilities_dict())[0]) # 1010
| Situation | Do you reverse? |
|---|---|
| Indexing a counts key by qubit number | yes — key[::-1][i] |
| Converting a key to an integer where qubit 0 is the LSB | no — int(key, 2) is already correct |
| Converting a key to an integer where qubit 0 is the MSB (common in challenge text) | yes — int(key[::-1], 2) |
| Comparing to a hand-written |q0 q1 q2> ket from a challenge PDF | yes |
| Statevector amplitude index → basis state | format(i, "0{}b".format(n)), already little-endian |
| Drawing, when you want q0 on top like a textbook | qc.draw(reverse_bits=True) |
The endianness also shows up in gate matrices, which trips people comparing against a challenge's given matrix. Qiskit's CNOT with control 0 does not look like the textbook CNOT:
c = QuantumCircuit(2); c.cx(0, 1)
print(np.real(Operator(c).data).astype(int))
c2 = QuantumCircuit(2); c2.cx(1, 0)
print(np.real(Operator(c2).data).astype(int))
cx(0,1): cx(1,0):
[[1 0 0 0] [[1 0 0 0]
[0 0 0 1] [0 1 0 0]
[0 0 1 0] [0 0 0 1]
[0 1 0 0]] [0 0 1 0]]
cx(1, 0) is the textbook matrix. If a challenge hands you the textbook
CNOT and you build cx(0,1), you have silently swapped control and target.
5. Gates, controls, custom unitaries
Single qubit
| Method | Matrix / effect |
|---|---|
qc.x(q) | bit flip [[0,1],[1,0]] |
qc.y(q) | [[0,-i],[i,0]] |
qc.z(q) | phase flip diag(1,-1) |
qc.h(q) | |0>→|+>, |1>→|-> |
qc.s(q) / qc.sdg(q) | diag(1, i) / diag(1, -i) — Z1/2 |
qc.t(q) / qc.tdg(q) | diag(1, eiπ/4) / conj — Z1/4 |
qc.rx(θ,q), ry, rz | rotation by θ about that axis; note rz carries a global phase relative to p |
qc.p(λ,q) | diag(1, eiλ) — phase gate, no global phase |
qc.u(θ,φ,λ,q) | general single-qubit unitary |
qc.id(q) | identity (placeholder) |
Multi qubit
qc = QuantumCircuit(2)
qc.cz(0, 1) # controlled Z (symmetric in its two args)
qc.cp(np.pi/2, 0, 1) # controlled phase
qc.swap(0, 1)
qc.cy(0, 1); qc.ch(0, 1); qc.crx(0.3, 0, 1)
print(qc.count_ops())
# OrderedDict({'cz': 1, 'cp': 1, 'swap': 1, 'cy': 1, 'ch': 1, 'crx': 1})
qc.ccx(a, b, t) # Toffoli
qc.mcx([0,1,2,3], 4) # multi-controlled X, any number of controls
qc.cswap(c, a, b) # Fredkin
mcx with 4 controls decomposes to a lot of gates
({'h': 26, 'cx': 18, 'cp': 9, 't': 8, 'tdg': 8}) — fine on a simulator,
worth knowing if a challenge scores you on gate count.
Building controlled versions of anything
.control(n) exists on every Gate object and returns a new gate
with n extra control qubits, prepended to the argument list.
from qiskit.circuit.library import SGate
qc = QuantumCircuit(3)
qc.append(SGate().control(2), [0, 1, 2]) # controls 0,1 -> S on 2
print(qc.draw())
q_0: ──■──
│
q_1: ──■──
┌─┴─┐
q_2: ┤ S ├
└───┘
To control a whole sub-circuit: sub.to_gate().control(1).
Custom unitaries
Two forms. qc.unitary(matrix, qubits) is the shortcut;
UnitaryGate gives you an object you can .control() or
.inverse().
import numpy as np
from qiskit.circuit.library import UnitaryGate
U = np.array([[0, 1], [1, 0]], dtype=complex)
qc = QuantumCircuit(1)
qc.unitary(U, [0], label="myU")
print(np.round(Operator(qc).data, 3)) # [[0.+0.j 1.+0.j]
# [1.+0.j 0.+0.j]]
qc2 = QuantumCircuit(2)
qc2.append(UnitaryGate(U, label="U").control(1), [0, 1])
The matrix must be unitary to within tolerance or the constructor raises. For a 2-qubit unitary, pass a 4×4 matrix and two qubit indices — and remember §4: the matrix is interpreted with the first listed qubit as the least significant.
6. Superposition and entanglement in counts
What each state looks like when you sample it. Learn to read these at a glance; most "identify the state" challenges are answered by staring at counts for five seconds.
sim = AerSimulator()
# product state: H on both qubits
p = QuantumCircuit(2, 2); p.h(0); p.h(1); p.measure([0,1],[0,1])
# [('00', 486), ('01', 501), ('10', 507), ('11', 506)]
# Bell pair |Φ+>
b = QuantumCircuit(2, 2); b.h(0); b.cx(0, 1); b.measure([0,1],[0,1])
# [('00', 504), ('11', 496)]
# GHZ on 4 qubits
n = 4
g = QuantumCircuit(n, n); g.h(0)
for i in range(n-1): g.cx(i, i+1)
g.measure(range(n), range(n))
# [('0000', 981), ('1111', 1019)]
| Counts pattern | Reading |
|---|---|
| all 2n keys, roughly equal | uniform superposition, no correlation |
only 00…0 and 11…1 | GHZ-type; every qubit perfectly correlated |
only 01 and 10 | anti-correlated — |Ψ+> or |Ψ-> |
| one key, 100% | computational basis state, no superposition |
| two keys 50/50 that aren't complements | partial entanglement or a product with one qubit fixed |
Statevector, or rotate the basis first: append h to every qubit
before measuring. Under that change, the Bell pair still gives
[('00', 1007), ('11', 993)] — the correlation survives in the X basis, which
is exactly what a CHSH-style challenge is probing.7. Phase kickback and the Hadamard test
This is the crux of "here is a black-box unitary, tell us which one it is" challenges, which show up in nearly every quantum CTF. Get this section into muscle memory.
Why a global phase becomes measurable
A unitary U and eiφU are physically identical when applied on their own. Every measurement statistic you can take is the same. Two circuits differing by a global phase are indistinguishable, full stop.
Put the same U under a control and that stops being true. If the target is in an eigenstate |ψ> with U|ψ> = λ|ψ>, then controlled-U acting on (|0>+|1>)/√2 ⊗ |ψ> gives
(|0> + λ|1>)/√2 ⊗ |ψ>
The phase λ has moved off the target and onto the control, where it is a relative phase between |0> and |1> — and relative phases are measurable. That's the kickback. A Hadamard on the control converts it to a population difference:
H (|0> + λ|1>)/√2 = ((1+λ)|0> + (1-λ)|1>)/2
For λ = +1 you always measure 0. For λ = −1 you always measure 1. Deterministic, one shot suffices.
Worked example: distinguish Z from −Z, and I from Z
import numpy as np
from qiskit import QuantumCircuit, transpile
from qiskit.circuit.library import UnitaryGate
from qiskit_aer import AerSimulator
sim = AerSimulator()
Z = np.array([[1, 0], [0, -1]], dtype=complex)
I = np.eye(2, dtype=complex)
def hadamard_test(U, prep):
qc = QuantumCircuit(2, 1)
prep(qc) # target = eigenstate of U
qc.h(0) # control -> |+>
qc.append(UnitaryGate(U, label="U").control(1), [0, 1])
qc.h(0) # interfere
qc.measure(0, 0)
return sim.run(transpile(qc, sim), shots=2048).result().get_counts()
prep0 = lambda qc: None # target stays |0>
prep1 = lambda qc: qc.x(1) # target |1>
print("U = +Z, target |0>:", hadamard_test( Z, prep0))
print("U = -Z, target |0>:", hadamard_test(-Z, prep0))
print("U = I, target |1>:", hadamard_test( I, prep1))
print("U = Z, target |1>:", hadamard_test( Z, prep1))
U = +Z, target |0>: {'0': 2048}
U = -Z, target |0>: {'1': 2048}
U = I, target |1>: {'0': 2048}
U = Z, target |1>: {'1': 2048}
Zero ambiguity, no shot noise to interpret. Peek at the state just before the final Hadamard to see the kickback directly:
for name, U in [("+Z", Z), ("-Z", -Z)]:
qc = QuantumCircuit(2); qc.h(0)
qc.append(UnitaryGate(U).control(1), [0, 1])
print(name, np.round(Statevector(qc).data, 3))
+Z [ 0.707-0.j 0.707-0.j -0. -0.j -0. +0.j]
-Z [ 0.707-0.j -0.707-0.j 0. -0.j 0. -0.j]
Same target, opposite sign on the |1>-of-control amplitude.
Operator comparison (§10) settles
it faster.General Hadamard test: estimating <ψ|U|ψ>
When |ψ> is not an eigenstate, the same circuit estimates the overlap. P(0) −
P(1) gives the real part; insert sdg on the control before the controlled-U
to get the imaginary part.
def hadamard_test_value(U, prep, imag=False, shots=200000):
qc = QuantumCircuit(2, 1)
prep(qc)
qc.h(0)
if imag: qc.sdg(0)
qc.append(UnitaryGate(U).control(1), [0, 1])
qc.h(0); qc.measure(0, 0)
c = sim.run(transpile(qc, sim), shots=shots).result().get_counts()
return 2 * c.get('0', 0) / shots - 1
T = np.array([[1, 0], [0, np.exp(1j*np.pi/4)]], dtype=complex)
prep = lambda qc: qc.h(1) # target |+>
print("Re:", round(hadamard_test_value(T, prep), 3))
print("Im:", round(hadamard_test_value(T, prep, imag=True), 3))
Re: 0.854 exact: 0.854
Im: 0.355 exact: 0.354
<+|T|+> = (1 + eiπ/4)/2 = 0.854 + 0.354i. Matches.
8. Parameterised circuits
Build once, bind many times. Useful for sweeping an angle to find the one a challenge wants.
import numpy as np
from qiskit.circuit import Parameter, ParameterVector
th = Parameter("θ")
qc = QuantumCircuit(1, 1); qc.ry(th, 0); qc.measure(0, 0)
print(qc.parameters) # ParameterView([Parameter(θ)])
for v in [0, np.pi/2, np.pi]:
bound = qc.assign_parameters({th: v})
print(round(v, 3), sim.run(transpile(bound, sim), shots=1000).result().get_counts())
0 {'0': 1000}
1.571 {'1': 505, '0': 495}
3.142 {'1': 1000}
pv = ParameterVector("x", 3)
qc2 = QuantumCircuit(3)
for i in range(3): qc2.rx(pv[i], i)
qc2.assign_parameters([0.1, 0.2, 0.3]) # positional list also works
assign_parameters returns a new circuit; pass inplace=True to
mutate. You cannot run a circuit with unbound parameters.
9. OpenQASM interop
Challenges hand you .qasm files and sometimes want one back. Both
directions are one call.
from qiskit import qasm2, qasm3
qc = QuantumCircuit(2, 2); qc.h(0); qc.cx(0, 1); qc.measure([0,1],[0,1])
print(qasm2.dumps(qc)) # string
qasm2.dump(qc, "out.qasm") # file
qc_back = qasm2.loads(src) # string -> circuit
qc_back = qasm2.load("in.qasm") # file -> circuit
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];
QASM 3 dumps with the same API and a different syntax:
print(qasm3.dumps(qc))
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];
qasm3.dumps works out of the box, but
qasm3.loads needs a separate package, otherwise it raises
MissingOptionalLibraryError: The 'qiskit_qasm3_import' library is required.
It is already in env/requirements.txt and installed in this venv; if you ever
rebuild the environment, confirm it landed rather than discovering the gap mid-challenge.
QASM 2 parsing is built in and needs nothing.QASM 2 keeps the same little-endian convention: q[0] is the rightmost bit
of the counts key. QASM syntax reminders live on the cheat
sheet.
10. Inspecting an unknown circuit
The standard opening move when a challenge hands you a circuit or a QASM file and asks what it does. Run all of it before thinking hard about anything.
import numpy as np
from qiskit import qasm2
from qiskit.quantum_info import Operator, Statevector
qc = qasm2.load("chal.qasm")
print(qc.draw())
print("qubits", qc.num_qubits, "depth", qc.depth(), "ops", dict(qc.count_ops()))
print("probabilities:", {k: round(v, 4) for k, v in Statevector(qc).probabilities_dict().items()})
U = Operator(qc).data
print("shape", U.shape, "unitary?", np.allclose(U.conj().T @ U, np.eye(len(U))))
┌───┐
q_0: ┤ H ├─■───■───────
├───┤ │ │
q_1: ┤ H ├─┼───■───────
├───┤ │ ┌─┴─┐┌───┐
q_2: ┤ H ├─■─┤ X ├┤ H ├
└───┘ └───┘└───┘
qubits 3 depth 4 ops {'h': 4, 'cz': 1, 'ccx': 1}
probabilities: {'000': 0.25, '001': 0.0, '010': 0.25, '011': 0.0,
'100': 0.0, '101': 0.25, '110': 0.0, '111': 0.25}
shape (8, 8) unitary? True
| Call | Tells you |
|---|---|
qc.draw() | the structure; first thing, always |
qc.count_ops() | gate histogram — spot a QFT (many cp) vs a Grover diffuser (many h+mcx) |
qc.depth(), qc.size(), qc.num_qubits | scale; whether brute-forcing 2n inputs is viable |
qc.decompose() | expand one level of composite gates; call repeatedly |
qc.data | the instruction list, iterable in Python |
Operator(qc).data | the full 2n×2n matrix (fine to ~12 qubits) |
Operator(a).equiv(Operator(b)) | equal up to global phase |
np.allclose(A.data, B.data) | equal exactly, phase included |
Statevector(qc).probabilities_dict() | output distribution with zero shot noise |
Statevector(qc).to_dict() | nonzero amplitudes with phases |
partial_trace(sv, [i, j]) | reduced state — mixed result means entanglement |
Identifying a mystery gate by brute-force comparison against the standard library:
from qiskit.circuit.library import XGate, YGate, ZGate, HGate, SGate, TGate
mystery = QuantumCircuit(1); mystery.z(0); mystery.x(0); mystery.z(0)
M = Operator(mystery)
for name, g in [("X",XGate()),("Y",YGate()),("Z",ZGate()),
("H",HGate()),("S",SGate()),("T",TGate())]:
if M.equiv(Operator(g)):
print("mystery ==", name, "(up to global phase)")
print(np.round(M.data, 3))
print("exactly X?", np.allclose(M.data, Operator(XGate()).data))
mystery == X (up to global phase)
[[ 0.+0.j -1.+0.j]
[-1.+0.j 0.+0.j]]
exactly X? False
ZXZ = −X. .equiv says yes, np.allclose says no. Which
one the challenge wants depends on whether the phase is observable — and per §7 it becomes
observable the moment the gate is controlled. Check both.
Entanglement test via reduced density matrix — a pure reduced state means the qubit is unentangled, a maximally mixed one means it is maximally entangled with the rest:
from qiskit.quantum_info import partial_trace
qc = QuantumCircuit(3); qc.h(0); qc.cx(0,1); qc.cx(1,2)
print(np.round(partial_trace(Statevector(qc), [1, 2]).data, 3))
# [[0.5+0.j 0. +0.j]
# [0. +0.j 0.5+0.j]] -> maximally mixed -> q0 is entangled
11. 10 idioms you will actually retype
# 0. Imports, every time
import numpy as np
from qiskit import QuantumCircuit, transpile, qasm2, qasm3
from qiskit.quantum_info import Statevector, Operator, partial_trace, SparsePauliOp
from qiskit.circuit.library import UnitaryGate
from qiskit_aer import AerSimulator
sim = AerSimulator()
# 1. Run and get counts
def run(qc, shots=4096):
return sim.run(transpile(qc, sim), shots=shots).result().get_counts()
# 2. Exact distribution, no shots, no classical bits, no measure gates
Statevector(qc).probabilities_dict()
# 3. Nonzero amplitudes WITH phases
{k: np.round(v, 3) for k, v in Statevector(qc).to_dict().items()}
# 4. Fix the endianness (bits[i] is now qubit i)
bits = key[::-1]
# 5. Most likely outcome
top = max(counts, key=counts.get)
# 6. Bitstring -> integer. qubit0 = LSB: int(key, 2). qubit0 = MSB: int(key[::-1], 2)
# 7. Bitstring -> ASCII (flags arrive this way)
"".join(chr(int(bits[i:i+8], 2)) for i in range(0, len(bits), 8))
# 8. Are two circuits the same gate?
Operator(a).equiv(Operator(b)) # up to global phase
np.allclose(Operator(a).data, Operator(b).data) # exactly
# 9. Hadamard test: distinguish U0 from U1 via phase kickback
qc = QuantumCircuit(2, 1)
# prepare target (qubit 1) in a shared eigenstate with different eigenvalues
qc.h(0)
qc.append(UnitaryGate(U).control(1), [0, 1])
qc.h(0); qc.measure(0, 0)
run(qc) # {'0': N} -> eigenvalue +1 ; {'1': N} -> -1
# 10. Load a challenge and look at it
qc = qasm2.load("chal.qasm")
print(qc.draw()); print(qc.count_ops(), qc.depth(), qc.num_qubits)
print(np.round(Operator(qc).data, 3))
Failure modes, ranked by how much time they cost
| Symptom | Cause |
|---|---|
| Answer is right but reversed / flag is gibberish | little-endian; §4 |
AerError: unknown instruction: c-unitary | missing transpile(qc, sim) |
QiskitError: Cannot apply instruction with classical bits: measure | Statevector(qc) on a circuit with measurements — remove them or use qc.remove_final_measurements(inplace=False) |
ImportError: cannot import name 'execute' | 1.x code; §1 table |
Counts are all '0'*n | forgot .measure(), or measured before the gates |
| Two circuits "should" match but don't | global phase — try .equiv before allclose |
MissingOptionalLibraryError on QASM 3 load | pip install qiskit-qasm3-import |
| Control and target look swapped | textbook CNOT matrix is Qiskit's cx(1, 0); §4 |