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)

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

  1. Forgetting to transpile: Running an abstract circuit on hardware without transpilation causes errors or silent incorrect mapping.

  2. 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.

  3. Over-measuring: Measuring a qubit collapses its state. Don’t measure mid-circuit unless you specifically need classical feedback (dynamic circuits).

  4. 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.

  5. Statevector vs. sampling: StatevectorSimulator gives exact amplitudes but scales exponentially. Use it for small circuits (< 25 qubits). Use AerSimulator with 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.

pythonquantum-computingqiskitibm

See Also