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.

On this page
  1. Sanity check and the 2.x API
  2. QuantumCircuit basics
  3. Running things: counts vs statevector
  4. Bit ordering (read this twice)
  5. Gates, controls, custom unitaries
  6. Superposition and entanglement in counts
  7. Phase kickback and the Hadamard test
  8. Parameterised circuits
  9. OpenQASM interop
  10. Inspecting an unknown circuit
  11. 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 / removedUse instead
from qiskit import executebackend.run(qc), or backend.run(transpile(qc, backend))
from qiskit import Aerfrom 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 QFTQFTGate(n)
qiskit.circuit.QuantumCircuit.qasm()qiskit.qasm2.dumps(qc) / qiskit.qasm3.dumps(qc)
BasicAer, IBMQ, qiskit.providers.aergone; not needed offline
Assume any Qiskit snippet you find on the internet or generate from memory is 1.x. If it imports 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
CallMeaning
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 + shotsquantum_info (Statevector / Operator)
What you getsampled measurement countsexact amplitudes / exact matrix
Needs measurements?yesno — and they must be absent
Statistical noiseyes (shot noise)none
Use whenthe challenge is about sampling, or you want to mimic what a real run looks likeyou want the answer, which is most of the time
CTF answers are almost always exact — a flag bitstring, "which unitary is it", "what state does this prepare". Reach for 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}
Always wrap in 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)

Qiskit is little-endian. Qubit 0 is the RIGHTMOST character of every counts key and every statevector label. This is the opposite of how nearly every textbook, every other quantum SDK, and every CTF challenge statement writes tensor products. It is the single largest source of wasted hours in a quantum CTF: your circuit is right, your answer is reversed, and nothing errors.

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
SituationDo you reverse?
Indexing a counts key by qubit numberyeskey[::-1][i]
Converting a key to an integer where qubit 0 is the LSBnoint(key, 2) is already correct
Converting a key to an integer where qubit 0 is the MSB (common in challenge text)yesint(key[::-1], 2)
Comparing to a hand-written |q0 q1 q2> ket from a challenge PDFyes
Statevector amplitude index → basis stateformat(i, "0{}b".format(n)), already little-endian
Drawing, when you want q0 on top like a textbookqc.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

MethodMatrix / 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, rzrotation by θ about that axis; note rz carries a global phase relative to p
qc.p(λ,q)diag(1, e) — 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 patternReading
all 2n keys, roughly equaluniform superposition, no correlation
only 00…0 and 11…1GHZ-type; every qubit perfectly correlated
only 01 and 10anti-correlated — |Ψ+> or |Ψ->
one key, 100%computational basis state, no superposition
two keys 50/50 that aren't complementspartial entanglement or a product with one qubit fixed
Counts cannot distinguish |Φ+> from |Φ->, or |+> from |->. The sign lives in a relative phase that Z-basis measurement erases. To see it, either use 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 eU 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.

Recipe for any "which unitary is it" challenge. 1. Find a state that is an eigenstate of both candidates but with different eigenvalues. |0> and |1> work for anything diagonal; |+>/|−> for anything X-like. 2. Prepare it on the target. 3. Control qubit in |+>, apply controlled-U, Hadamard, measure the control. 4. Different candidates → different deterministic outcome. If both candidates have the same eigenvalue on every basis state you try, they differ by more than a phase and a plain 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
CallTells 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_qubitsscale; whether brute-forcing 2n inputs is viable
qc.decompose()expand one level of composite gates; call repeatedly
qc.datathe instruction list, iterable in Python
Operator(qc).datathe 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

SymptomCause
Answer is right but reversed / flag is gibberishlittle-endian; §4
AerError: unknown instruction: c-unitarymissing transpile(qc, sim)
QiskitError: Cannot apply instruction with classical bits: measureStatevector(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'*nforgot .measure(), or measured before the gates
Two circuits "should" match but don'tglobal phase — try .equiv before allclose
MissingOptionalLibraryError on QASM 3 loadpip install qiskit-qasm3-import
Control and target look swappedtextbook CNOT matrix is Qiskit's cx(1, 0); §4