Qiskit Quantum Circuits in Python — Deep Dive
Circuit Construction Internals
Qiskit’s QuantumCircuit is a directed acyclic graph (DAG) under the hood. Each gate is a node, and qubit/classical bit wires form edges. While you interact with it as a sequential list of instructions, the DAG representation enables powerful optimizations during transpilation.
Creating Circuits
from qiskit import QuantumCircuit
# 3 qubits, 3 classical bits
qc = QuantumCircuit(3, 3)
# Superposition on qubit 0
qc.h(0)
# Entangle qubit 0 with qubit 1, then qubit 1 with qubit 2
qc.cx(0, 1)
qc.cx(1, 2)
# Measure all qubits
qc.measure([0, 1, 2], [0, 1, 2])
This creates a GHZ state — a three-qubit entangled state where measurement produces either 000 or 111 with equal probability (in an ideal, noiseless environment).
Parameterized Circuits
For variational algorithms, circuits accept parameters that are bound at runtime:
from qiskit.circuit import Parameter
theta = Parameter('θ')
phi = Parameter('φ')
qc = QuantumCircuit(2)
qc.ry(theta, 0)
qc.rz(phi, 1)
qc.cx(0, 1)
# Bind specific values later
bound_circuit = qc.assign_parameters({theta: 1.57, phi: 0.78})
Parameterized circuits are the backbone of quantum machine learning and optimization, where classical optimizers iteratively adjust parameters.
Circuit Composition
Complex circuits are built by composing smaller ones:
sub_circuit = QuantumCircuit(2, name='bell_pair')
sub_circuit.h(0)
sub_circuit.cx(0, 1)
main = QuantumCircuit(4)
main.compose(sub_circuit, qubits=[0, 1], inplace=True)
main.compose(sub_circuit, qubits=[2, 3], inplace=True)
You can also convert sub-circuits to custom gates with to_gate() or to_instruction() for reuse.
The Transpilation Pipeline
Transpilation converts an abstract circuit into one executable on specific hardware. It’s a multi-stage process:
Stage 1: Unrolling
Decomposes custom gates and high-level operations into the backend’s basis gates (typically {CX, ID, RZ, SX, X} for IBM hardware).
Stage 2: Routing
Maps virtual qubits to physical qubits, inserting SWAP gates where direct connections don’t exist. IBM processors have limited connectivity — not every qubit can interact with every other qubit.
Stage 3: Optimization
Reduces gate count through cancellation (adjacent inverse gates), commutation (reordering non-conflicting gates), and consolidation (merging single-qubit gate sequences).
Stage 4: Scheduling
Assigns timing to operations, accounting for gate durations and parallelism.
from qiskit import transpile
from qiskit_ibm_runtime import QiskitRuntimeService
service = QiskitRuntimeService()
backend = service.backend("ibm_brisbane")
transpiled = transpile(
qc,
backend=backend,
optimization_level=3, # 0-3, higher = more aggressive
seed_transpiler=42 # reproducibility
)
print(f"Original depth: {qc.depth()}")
print(f"Transpiled depth: {transpiled.depth()}")
print(f"Gate count: {transpiled.count_ops()}")
Optimization level 3 includes heavy optimization passes like gate synthesis and routing heuristics. It’s slower but produces shorter circuits, which matters on noisy hardware where every additional gate introduces errors.
Custom Transpiler Passes
For advanced use cases, you can write custom passes:
from qiskit.transpiler import PassManager
from qiskit.transpiler.passes import Unroller, Optimize1qGates, CXCancellation
pm = PassManager([
Unroller(['cx', 'u3']),
Optimize1qGates(),
CXCancellation(),
])
optimized = pm.run(qc)
Execution on Real Hardware
Using Qiskit Runtime
IBM’s recommended execution model uses the Qiskit Runtime service, which runs circuits closer to the hardware and supports error mitigation:
from qiskit_ibm_runtime import QiskitRuntimeService, SamplerV2
service = QiskitRuntimeService()
backend = service.least_busy(simulator=False, min_num_qubits=3)
sampler = SamplerV2(backend)
# Transpile first
transpiled = transpile(qc, backend)
# Execute
job = sampler.run([transpiled], shots=4000)
result = job.result()
# Access counts
counts = result[0].data.meas.get_counts()
print(counts) # e.g., {'000': 1932, '111': 1847, '001': 52, ...}
Error Mitigation
Real hardware produces noisy results. Qiskit Runtime supports built-in error mitigation:
from qiskit_ibm_runtime import EstimatorV2, EstimatorOptions
options = EstimatorOptions()
options.resilience_level = 2 # 0=none, 1=light, 2=heavy
estimator = EstimatorV2(backend, options=options)
Resilience level 2 applies techniques like zero-noise extrapolation (ZNE), which runs circuits at artificially increased noise levels and extrapolates back to the zero-noise limit.
Noise-Aware Simulation
For development without hardware access, Qiskit Aer provides noise models:
from qiskit_aer import AerSimulator
from qiskit_aer.noise import NoiseModel, depolarizing_error
# Build a simple noise model
noise_model = NoiseModel()
error_1q = depolarizing_error(0.001, 1) # 0.1% error per single-qubit gate
error_2q = depolarizing_error(0.01, 2) # 1% error per two-qubit gate
noise_model.add_all_qubit_quantum_error(error_1q, ['u3', 'x', 'sx'])
noise_model.add_all_qubit_quantum_error(error_2q, ['cx'])
# Or pull a noise model from real hardware
# noise_model = NoiseModel.from_backend(backend)
sim = AerSimulator(noise_model=noise_model)
transpiled = transpile(qc, sim)
result = sim.run(transpiled, shots=4000).result()
Realistic noise rates for IBM hardware circa 2025: single-qubit gate errors ~0.01-0.1%, two-qubit (CX) gate errors ~0.5-2%, measurement errors ~0.5-3%.
Advanced Circuit Patterns
Quantum Fourier Transform
A building block for Shor’s algorithm and phase estimation:
import numpy as np
from qiskit import QuantumCircuit
def qft(n):
qc = QuantumCircuit(n)
for i in range(n):
qc.h(i)
for j in range(i + 1, n):
qc.cp(np.pi / 2**(j - i), j, i)
# Swap qubit ordering
for i in range(n // 2):
qc.swap(i, n - i - 1)
return qc
qft_circuit = qft(4)
Grover’s Search
Searches an unsorted database of N items in O(√N) steps:
def grover_oracle(marked_state: str):
"""Oracle that marks a specific state."""
n = len(marked_state)
qc = QuantumCircuit(n)
# Flip qubits where marked_state has '0'
for i, bit in enumerate(reversed(marked_state)):
if bit == '0':
qc.x(i)
# Multi-controlled Z gate
qc.h(n - 1)
qc.mcx(list(range(n - 1)), n - 1)
qc.h(n - 1)
# Unflip
for i, bit in enumerate(reversed(marked_state)):
if bit == '0':
qc.x(i)
return qc
Variational Circuit Template
Used in VQE, QAOA, and quantum ML:
from qiskit.circuit import ParameterVector
def variational_layer(n_qubits, params):
qc = QuantumCircuit(n_qubits)
for i in range(n_qubits):
qc.ry(params[i], i)
for i in range(n_qubits - 1):
qc.cx(i, i + 1)
return qc
params = ParameterVector('θ', 8)
ansatz = QuantumCircuit(4)
for layer in range(2):
offset = layer * 4
layer_params = [params[offset + i] for i in range(4)]
ansatz.compose(variational_layer(4, layer_params), inplace=True)
Performance Considerations
Circuit Depth vs. Width
- Depth (longest path of gates) determines vulnerability to decoherence. Shallower circuits produce more accurate results.
- Width (number of qubits) determines which hardware can run your circuit.
- Target: keep depth under ~100 for current hardware, though this increases yearly.
Transpilation Caching
Transpilation is expensive. Cache results for repeated executions:
import json
from qiskit.qpy import dump, load
# Save transpiled circuit
with open('transpiled.qpy', 'wb') as f:
dump(transpiled, f)
# Load later
with open('transpiled.qpy', 'rb') as f:
loaded = load(f)[0]
Batching Jobs
Send multiple circuits in a single job to reduce queue overhead:
circuits = [transpile(c, backend) for c in circuit_list]
job = sampler.run(circuits, shots=4000)
Common Pitfalls
-
Forgetting to transpile: Running an abstract circuit on hardware without transpilation causes errors or silent incorrect mapping.
-
Ignoring qubit connectivity: Writing circuits that assume all-to-all qubit connectivity works on simulators but explodes in gate count on real hardware due to inserted SWAPs.
-
Over-measuring: Measuring a qubit collapses its state. Don’t measure mid-circuit unless you specifically need classical feedback (dynamic circuits).
-
Not accounting for T1/T2 times: Qubits lose their quantum state over time (T1 ~ 100-300 μs on IBM hardware). Long circuits may decohere before completion.
-
Statevector vs. sampling:
StatevectorSimulatorgives exact amplitudes but scales exponentially. Use it for small circuits (< 25 qubits). UseAerSimulatorwith shots for larger circuits.
Real-World Applications
- JP Morgan uses Qiskit for option pricing with quantum Monte Carlo
- Daimler explored battery material simulation for electric vehicles
- ExxonMobil investigated routing optimization for maritime shipping
- IBM Quantum Network has 200+ organizations running research programs
These are still largely research-stage. Practical quantum advantage for commercial problems is expected in the late 2020s as hardware quality improves.
One thing to remember: Qiskit’s real power is bridging the gap between abstract quantum algorithms and physical hardware constraints — the transpiler, noise models, and error mitigation aren’t just nice-to-haves, they’re essential for any result you can trust.
See Also
- Python Cirq Quantum Programming Google's Cirq lets you program quantum computers in Python — like writing a recipe for the world's weirdest kitchen
- Python Pennylane Quantum Ml How PennyLane mixes quantum computing and AI together — like teaching a magical calculator to learn from its mistakes
- Python Quantum Annealing Python How quantum annealing finds the best solution by shaking problems until the answer falls out — and how D-Wave lets you try it in Python
- Python Quantum Cryptography Simulation How quantum physics creates unbreakable secret codes — and how you can simulate the whole thing in Python
- Python Quantum Error Correction Why quantum computers make so many mistakes and how Python helps fix them — like spell-check for the universe's tiniest computers