Skip to content
Kartikey Purohit
← Course overview

Day 1 — Circuit Basics

Lesson

C1000-179 exam cram — QuantumCircuit construction, gates, measurement, composition, properties. All snippets verified against Qiskit 2.5.1 with the repo venv: /home/kartikey_purohit/development/ibm-qiskit/.venv/bin/python


1. Constructing a QuantumCircuit

Three equivalent styles:

from qiskit import QuantumCircuit, QuantumRegister, ClassicalRegister
 
# Style 1: bare integers (auto-creates registers named 'q' and 'c')
qc = QuantumCircuit(2, 2)
print(qc.num_qubits, qc.num_clbits)   # 2 2
print(qc.qregs)                       # [QuantumRegister(2, 'q')]
print(qc.cregs)                       # [ClassicalRegister(2, 'c')]
 
# Style 2: explicit registers (you pick the names)
qr = QuantumRegister(3, 'data')
cr = ClassicalRegister(3, 'out')
qc = QuantumCircuit(qr, cr)
 
# Style 3: qubits only, no clbits
qc = QuantumCircuit(4)

Multiple quantum registers simply concatenate:

qc = QuantumCircuit(QuantumRegister(2, 'a'), QuantumRegister(3, 'b'))
print(qc.num_qubits)   # 5

AncillaRegister

AncillaRegister qubits are ordinary qubits, just tagged as ancillas:

from qiskit.circuit import AncillaRegister
qr  = QuantumRegister(2, 'q')
anc = AncillaRegister(1, 'anc')
cr  = ClassicalRegister(2, 'c')
qc  = QuantumCircuit(qr, anc, cr)
print(qc.num_qubits, qc.num_ancillas, qc.num_clbits)  # 3 1 2
print(qc.width())                                     # 5
Common exam traps
  • num_qubits includes ancilla qubits (3 above, not 2).
  • width() = qubits + clbits (3 + 2 = 5), not just qubits.
  • QuantumCircuit(3, 2)width() is 5.

2. Single-qubit gates

All are methods on the circuit: x, y, z, h, s, sdg, t, tdg, p, rx, ry, rz, u, sx, sxdg, id.

import numpy as np
qc = QuantumCircuit(1)
qc.h(0)
qc.p(np.pi/4, 0)          # phase gate: diag(1, e^{i*lam})
qc.rx(np.pi/2, 0)         # rotation gates take (angle, qubit)
qc.u(np.pi/2, 0, np.pi, 0)  # u(theta, phi, lam, qubit)

Key identities (verified with Operator(...).equiv):

from qiskit.quantum_info import Operator
# t;t == s          -> True
# sx;sx == x        -> True
# s;sdg == identity -> True
# u(pi/2, 0, pi) == h            -> True
# u(0, 0, lam) == p(lam)  (exact) -> True
# p(pi) == z (exact)              -> True
# rz(pi) equiv z (up to GLOBAL PHASE only; not exactly equal)
qz  = QuantumCircuit(1); qz.z(0)
qrz = QuantumCircuit(1); qrz.rz(np.pi, 0)
print(Operator(qrz).equiv(Operator(qz)))   # True
print(Operator(qrz) == Operator(qz))       # False  <- global phase differs!

Gate broadcasting over lists works:

qc = QuantumCircuit(3)
qc.x([0, 1, 2])            # also qc.h(range(3))
print(qc.count_ops())      # OrderedDict({'x': 3})
Common exam traps
  • The identity gate is qc.id(0). qc.i(0) does not exist in 2.x (hasattr(qc, 'i') is False).
  • p vs rz: same up to global phase; p(pi) equals z exactly, rz(pi) does not (it's -i·Z).
  • Rotation/phase gates take the angle first, then the qubit: qc.rx(theta, qubit).
  • sdg/tdg are the adjoints (dagger) of s/t — S and T are not self-inverse (unlike x/y/z/h/cx).

3. Two-qubit gates

cx, cz, cy, ch, swap, iswap, cp, crx, cry, crz, rxx, ryy, rzz, ecr — all circuit methods.

qc = QuantumCircuit(2)
qc.cx(0, 1)         # control=0, target=1
qc.cp(np.pi/2, 0, 1)  # controlled-phase: (angle, control, target)
qc.rzz(0.3, 0, 1)     # two-qubit rotations: (angle, q1, q2)
qc.ecr(0, 1)          # echoed cross-resonance: an IBM-hardware-native entangler

Symmetric vs asymmetric (does swapping the two qubit arguments change the operator?):

Symmetric (order irrelevant) Asymmetric (order matters)
cz, cp, swap, iswap, rxx, ryy, rzz cx, cy, ch, ecr, crx, cry, crz

(cz(0,1) == cz(1,0) verified True; cx(0,1) == cx(1,0) verified False. Note crx/cry/crz are asymmetric even though cz/cp are symmetric.)

Useful equivalence: swap = 3 alternating CNOTs:

sw = QuantumCircuit(2); sw.swap(0, 1)
c3 = QuantumCircuit(2); c3.cx(0, 1); c3.cx(1, 0); c3.cx(0, 1)
print(Operator(sw).equiv(Operator(c3)))   # True

iswap swaps and adds a phase of i to the swapped |01⟩/|10⟩ amplitudes:

from qiskit.quantum_info import Statevector
qc = QuantumCircuit(2)
qc.x(0)
qc.iswap(0, 1)
print(Statevector(qc))
# Statevector([0.+0.j, 0.+0.j, 0.+1.j, 0.+0.j],
#             dims=(2, 2))          <- i|10>, i.e. amplitude i on qubit-1=1
Common exam traps
  • qc.cnot() and qc.toffoli() were REMOVED in Qiskit 2.x. Only cx and ccx exist. (hasattr(qc, 'cnot')False.)
  • Argument order for controlled gates is (control, target); for controlled rotations it's (angle, control, target).
  • cz is symmetric, so drawings show it as two dots — there is no "target" side.

4. Multi-qubit gates

qc = QuantumCircuit(4)
qc.ccx(0, 1, 2)          # Toffoli: controls 0,1 -> target 2
qc.cswap(0, 1, 2)        # Fredkin: control 0 swaps 1<->2
qc.mcx([0, 1, 2], 3)     # multi-controlled X: control LIST, then target
print(qc.count_ops())    # OrderedDict({'ccx': 1, 'cswap': 1, 'mcx': 1})

Build controls from any gate object with .control(n):

from qiskit.circuit.library import XGate
g = XGate().control(2)
print(g.name, g.num_qubits)   # ccx 3

5. Measurement, barrier, reset

qc = QuantumCircuit(2, 2)
qc.h(0)
qc.measure(0, 0)              # qubit 0 -> clbit 0
qc.measure([0, 1], [0, 1])    # broadcast form
qc.barrier()                  # visual/optimization fence
qc.reset(0)                   # forces qubit back to |0>

measure_all() — memorize this behavior

qc = QuantumCircuit(2)
qc.h(0); qc.cx(0, 1)
qc.measure_all()
print(qc.cregs)        # [ClassicalRegister(2, 'meas')]
print(qc.count_ops())  # OrderedDict({'measure': 2, 'h': 1, 'cx': 1, 'barrier': 1})

measure_all():

  1. adds a new ClassicalRegister named 'meas' (even if you already have clbits!),
  2. inserts a barrier first,
  3. measures every qubit.
qc = QuantumCircuit(2, 2)        # already has creg 'c'
qc.h(0)
qc.measure_all()
print(qc.num_clbits, [r.name for r in qc.cregs])   # 4 ['c', 'meas']
# use qc.measure_all(add_bits=False) to reuse the existing clbits instead

measure_active() measures only qubits that have at least one gate on them, into a new register also named 'meas':

qc = QuantumCircuit(3)
qc.h(0); qc.cx(0, 1)          # qubit 2 is idle
qc.measure_active()
print(qc.cregs)               # [ClassicalRegister(2, 'meas')]
Common exam traps
  • measure_all() on a circuit that already has a classical register doubles your clbits ('c' + 'meas') unless you pass add_bits=False.
  • measure_all() silently inserts a barrier — it shows up in count_ops().
  • The sampler result attribute is named after the register: result[0].data.meas after measure_all(), but result[0].data.c for a default ClassicalRegister.

6. Bit ordering — little-endian, always

Qiskit is little-endian: qubit 0 is the least significant bit. In drawings q0 is the top wire; in bitstrings q0 is the rightmost character.

from qiskit.primitives import StatevectorSampler
qc = QuantumCircuit(3, 3)
qc.x(0)
qc.x(2)
qc.measure(range(3), range(3))
sampler = StatevectorSampler(seed=1)
print(sampler.run([qc], shots=10).result()[0].data.c.get_counts())
# {'101': 10}     <- read as q2 q1 q0 = 1 0 1  (the number 5)
Common exam traps
  • x(0) on a 2-qubit circuit gives counts key '01', not '10'.
  • Statevector index k corresponds to bitstring of k with q0 as the low bit: Bell state has amplitudes at indices 0 and 3 → '00' and '11'.

7. Bell and GHZ

bell = QuantumCircuit(2)
bell.h(0)
bell.cx(0, 1)
print(Statevector(bell))
# Statevector([0.70710678+0.j, 0.        +0.j, 0.        +0.j,
#              0.70710678+0.j],
#             dims=(2, 2))

Sampled (deterministic with a seed):

bellm = bell.copy()
bellm.measure_all()
sampler = StatevectorSampler(seed=42)
print(sampler.run([bellm], shots=1000).result()[0].data.meas.get_counts())
# {'11': 497, '00': 503}

GHZ — two layouts, same state, different depth:

g4 = QuantumCircuit(4)                       # chain: h, cx(0,1), cx(1,2), cx(2,3)
g4.h(0); g4.cx(0, 1); g4.cx(1, 2); g4.cx(2, 3)
print(g4.depth())                            # 4
 
g4b = QuantumCircuit(4)                      # tree/fan-out
g4b.h(0); g4b.cx(0, 1); g4b.cx(0, 2); g4b.cx(1, 3)
print(g4b.depth())                           # 3
print(Statevector(g4).equiv(Statevector(g4b)))   # True

8. Composition: compose / append / tensor

compose — merge another circuit onto this one (returns a NEW circuit)

a = QuantumCircuit(2); a.h(0)
b = QuantumCircuit(2); b.cx(0, 1)
c = a.compose(b)
print(c.count_ops())   # OrderedDict({'h': 1, 'cx': 1})
print(a.count_ops())   # OrderedDict({'h': 1})   <- a unchanged (use inplace=True to mutate)
  • qubits=[...] remaps: big.compose(small, qubits=[2, 1]) puts small's q0→big's q2, q1→big's q1.
  • front=True prepends:
a = QuantumCircuit(1); a.x(0)
b = QuantumCircuit(1); b.h(0)
c = a.compose(b, front=True)
print([ci.operation.name for ci in c.data])   # ['h', 'x']

append — add one Instruction/Gate onto specific qubits (mutates in place)

bell = QuantumCircuit(2, name='bell')
bell.h(0); bell.cx(0, 1)
gate = bell.to_gate()
 
qc = QuantumCircuit(3)
qc.append(gate, [0, 2])
print(qc.count_ops())              # OrderedDict({'bell': 1})
print(qc.depth(), qc.size())       # 1 1   <- opaque gate counts as ONE op
print(qc.decompose().count_ops())  # OrderedDict({'h': 1, 'cx': 1})

append with the wrong number of qubits raises CircuitError.

tensor — stack circuits side by side

x1 = QuantumCircuit(1); x1.x(0)
h1 = QuantumCircuit(1); h1.h(0)
t = x1.tensor(h1)          # ARGUMENT (h1) goes on the LOWER wires
print(t)
#      ┌───┐
# q_0: ┤ H ├
#      ├───┤
# q_1: ┤ X ├
#      └───┘
print(Statevector(t).probabilities_dict())   # {'10': 0.5, '11': 0.5}  (q1 fixed at 1)
Common exam traps
  • compose() returns a new circuit and leaves the original untouched (unless inplace=True); append() mutates.
  • a.tensor(b): b lands on qubit 0 upward, a on the higher wires (matches a ⊗ b in little-endian matrix order).
  • An appended composite gate counts as one op for size()/depth()/count_ops() until you decompose().

9. inverse / power / copy

qc = QuantumCircuit(1)
qc.s(0); qc.t(0)
inv = qc.inverse()
print([ci.operation.name for ci in inv.data])   # ['tdg', 'sdg']  <- reversed AND daggered

inverse() on a circuit containing a measurement raises CircuitError: 'inverse() not implemented for measure.'

qc = QuantumCircuit(1)
qc.t(0)
p = qc.power(2)                          # wraps the repeated circuit as a sub-gate
print(p.decompose().count_ops())         # OrderedDict({'t': 2})

copy() is a real deep copy; plain assignment is just an alias:

qc = QuantumCircuit(1)
alias = qc
cp = qc.copy()
qc.h(0)
print(alias.size(), cp.size())   # 1 0
# qc.copy_empty_like() keeps registers but drops all instructions (size 0)

10. to_gate / to_instruction / control

bell = QuantumCircuit(2, name='bell')
bell.h(0); bell.cx(0, 1)
 
g = bell.to_gate()          # Gate: must be UNITARY (no measure/reset)
i = bell.to_instruction()   # Instruction: may contain measure/reset
 
cbell = g.control(1)
print(cbell.name, cbell.num_qubits)   # cbell 3   <- control qubit is FIRST when appended

to_gate() on a circuit with clbits fails: QiskitError: 'Circuit with classical bits cannot be converted to gate.' Use to_instruction() for anything non-unitary.


11. Circuit properties

qc = QuantumCircuit(2, 2)
qc.h(0); qc.cx(0, 1); qc.measure([0, 1], [0, 1])
print(qc.depth())       # 3   (h -> cx -> measures in parallel)
print(qc.size())        # 4   (h + cx + 2 measures)
print(qc.width())       # 4   (2 qubits + 2 clbits)
print(qc.count_ops())   # OrderedDict({'measure': 2, 'h': 1, 'cx': 1})

Depth = longest path through the circuit (critical path), NOT the gate count.

qc = QuantumCircuit(3)
qc.h(0); qc.cx(0, 1); qc.h(2)
print(qc.depth(), qc.size())   # 2 3   <- h(2) runs in parallel with layer 1

Barriers: excluded from size(), included in count_ops(), and they constrain layering (gates can't cross them) even though a barrier itself is not a layer:

qc = QuantumCircuit(2)
qc.h(0); qc.barrier(); qc.x(1)
print(qc.depth(), qc.size(), len(qc.data))  # 2 2 3
print(qc.count_ops())                       # OrderedDict({'h': 1, 'barrier': 1, 'x': 1})
# without the barrier: depth would be 1 (h and x in parallel)
Common exam traps
  • depth() is the longest path, so parallel gates don't add depth.
  • size() skips barriers; count_ops() and len(qc.data) include them.
  • width() counts clbits too. num_qubits doesn't.
  • Measurements count toward both size() and depth().

CODING LABS — predict, THEN run

Run each with: /home/kartikey_purohit/development/ibm-qiskit/.venv/bin/python labN.py Write your predictions on paper first. Full solutions are in solution-bank.md.

Lab 1 — Depth Detective

Predict depth / size / width / count_ops for A, B, and C before running.

"""Lab 1: Depth Detective. Predict BEFORE running:
depth, size, width, count_ops of each circuit."""
from qiskit import QuantumCircuit
 
# --- Circuit A ---
qa = QuantumCircuit(3)
qa.h(0)
qa.h(1)
qa.cx(0, 1)
qa.x(2)
qa.cx(1, 2)
print("A depth:", qa.depth())
print("A size: ", qa.size())
print("A width:", qa.width())
print("A ops:  ", dict(qa.count_ops()))
 
# --- Circuit B: same gates, plus a barrier ---
qb = QuantumCircuit(3)
qb.h(0)
qb.h(1)
qb.barrier()
qb.cx(0, 1)
qb.x(2)
qb.cx(1, 2)
print("B depth:", qb.depth())
print("B size: ", qb.size())
print("B ops:  ", dict(qb.count_ops()))
 
# --- Circuit C: B with measure_all ---
qc = qb.copy()
qc.measure_all()
print("C width:", qc.width())
print("C clbit registers:", [r.name for r in qc.cregs])
print("C depth:", qc.depth())

Think hard about: does the barrier change B's depth vs A? What does measure_all do to width?

Lab 2 — The Little-Endian Vault

The vault opens only if you predict every bitstring correctly. Predict all three counts dicts.

"""Lab 2: The Little-Endian Vault. Predict every printed bitstring BEFORE running."""
from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorSampler
 
sampler = StatevectorSampler(seed=2026)
 
# Step 1: write the number 6 (binary 110) into a 3-qubit register.
vault = QuantumCircuit(3, 3)
vault.x(1)
vault.x(2)
vault.measure(range(3), range(3))
counts = sampler.run([vault], shots=100).result()[0].data.c.get_counts()
print("Step 1 counts:", counts)
 
# Step 2: same circuit but swap qubits 0 and 2 before measuring.
vault2 = QuantumCircuit(3, 3)
vault2.x(1)
vault2.x(2)
vault2.swap(0, 2)
vault2.measure(range(3), range(3))
counts2 = sampler.run([vault2], shots=100).result()[0].data.c.get_counts()
print("Step 2 counts:", counts2)
 
# Step 3: measure qubits in REVERSED clbit order (q0->c2, q1->c1, q2->c0).
vault3 = QuantumCircuit(3, 3)
vault3.x(1)
vault3.x(2)
vault3.measure([0, 1, 2], [2, 1, 0])
counts3 = sampler.run([vault3], shots=100).result()[0].data.c.get_counts()
print("Step 3 counts:", counts3)

Lab 3 — The Bell Gate Factory

You package a Bell pair as a reusable gate, wire it into a GHZ, then control it. Predict every print.

"""Lab 3: The Bell Gate Factory. Predict outputs BEFORE running."""
from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorSampler
 
# Step 1: package a Bell pair as a reusable Gate.
bell = QuantumCircuit(2, name="bell")
bell.h(0)
bell.cx(0, 1)
bell_gate = bell.to_gate()
print("Step 1:", bell_gate.name, "| qubits:", bell_gate.num_qubits)
 
# Step 2: append it to a 3-qubit circuit on qubits [1, 2], then extend to GHZ.
qc = QuantumCircuit(3)
qc.append(bell_gate, [1, 2])
qc.cx(1, 0)
print("Step 2 ops:", dict(qc.count_ops()))
print("Step 2 depth:", qc.depth())
print("Step 2 decomposed ops:", dict(qc.decompose().count_ops()))
 
# Step 3: measure and sample. What two bitstrings appear?
qc.measure_all()
sampler = StatevectorSampler(seed=99)
counts = sampler.run([qc], shots=2000).result()[0].data.meas.get_counts()
print("Step 3 counts:", counts)
 
# Step 4: a CONTROLLED bell gate, control stays |0>. What comes out?
cbell = bell_gate.control(1)
print("Step 4 gate:", cbell.name, "| qubits:", cbell.num_qubits)
qc4 = QuantumCircuit(3)
qc4.append(cbell, [0, 1, 2])   # qubit 0 is the control
qc4.measure_all()
counts4 = sampler.run([qc4], shots=1000).result()[0].data.meas.get_counts()
print("Step 4 counts:", counts4)

Hints: in Step 2 the Bell gate is one opaque op; in Step 4 remember what a controlled anything does when the control is |0⟩.