Qiskit Advanced Free 3/61 in series 60 minutes

Qiskit Pulse: Low-Level Quantum Hardware Control

Go below the circuit level with Qiskit Pulse: build custom microwave pulse schedules, define your own single-qubit gates via pulse calibration, measure qubit coherence times T1 and T2, and run experiments on IBM Quantum backends.

What you'll learn

  • Qiskit Pulse
  • pulse control
  • microwave pulses
  • calibration
  • IBM Quantum

Prerequisites

  • Strong Python skills
  • Solid quantum computing foundations
  • Linear algebra and complex numbers

Quantum gates are ultimately implemented by carefully shaped microwave pulses delivered to superconducting qubits. Qiskit Pulse gives you direct access to this layer, letting you design custom pulse waveforms, precisely time multi-pulse sequences, and override the default gate calibrations with your own. This level of control is essential for pulse-level error mitigation, gate calibration experiments, and hardware characterization.

The Pulse Abstraction Layer

Qiskit Pulse sits between circuit-level programming and raw hardware control. It provides:

  • Channels: logical representations of hardware signal lines (DriveChannel, MeasureChannel, ControlChannel, AcquireChannel)
  • Instructions: Play, Delay, Acquire, SetFrequency, ShiftPhase, SetPhase
  • Waveforms (Pulse shapes): Gaussian, GaussianSquare, Drag, Constant, and custom arrays
  • Schedules: ordered collections of instructions with precise timing

Channels

Every qubit in IBM’s architecture has associated hardware channels:

# Requires: qiskit_ibm_runtime
from qiskit.pulse import DriveChannel, MeasureChannel, ControlChannel, AcquireChannel

qubit_index = 0

# Drive channel: sends control pulses to the qubit
d0 = DriveChannel(qubit_index)

# Measure channel: sends readout tone to the resonator
m0 = MeasureChannel(qubit_index)

# Acquire channel: digitizes the readout signal
a0 = AcquireChannel(qubit_index)

# Control channel: used for two-qubit gates (cross-resonance)
# Index maps to specific qubit pairs -- backend-dependent
u0 = ControlChannel(0)

For cross-resonance two-qubit gates, the control channel carries a pulse at the target qubit’s frequency while the control qubit’s drive channel also receives pulses. The exact channel indices for each qubit pair are found in the backend’s configuration.

Pulse Shapes

Qiskit Pulse provides standard waveforms as library objects:

# Requires: qiskit_ibm_runtime
from qiskit.pulse.library import Gaussian, GaussianSquare, Drag, Constant

# Gaussian pulse: smooth bell curve, minimizes spectral leakage
gauss = Gaussian(duration=160, amp=0.2, sigma=40)

# GaussianSquare: flat top with Gaussian ramps, used for two-qubit and readout
gs = GaussianSquare(duration=800, amp=0.1, sigma=64, width=672)

# DRAG pulse: adds a derivative term to suppress leakage to |2>
# beta controls the DRAG correction strength
drag = Drag(duration=160, amp=0.2, sigma=40, beta=-0.5)

# Constant (rectangular) pulse: simple square wave
const = Constant(duration=200, amp=0.1)

The DRAG (Derivative Removal by Adiabatic Gate) pulse is critical for high-fidelity single-qubit gates on transmon qubits. Without DRAG correction, the Gaussian pulse leaks population to the second excited state 2|2\rangle, which is outside the qubit subspace.

Building a Schedule

A Schedule collects instructions and specifies their timing:

# Requires: qiskit_ibm_runtime
import qiskit.pulse as pulse
from qiskit.pulse.library import Drag, GaussianSquare, Constant
from qiskit.pulse import DriveChannel, MeasureChannel, AcquireChannel, Schedule
from qiskit.pulse import Play, Delay, Acquire, ShiftPhase

with pulse.build(name="custom_x_gate") as x_sched:
    # Play the DRAG pulse on the drive channel
    pulse.play(Drag(duration=160, amp=0.2, sigma=40, beta=-0.5), DriveChannel(0))

with pulse.build(name="measure_q0") as meas_sched:
    # Readout: play tone on measure channel, acquire on acquire channel
    pulse.play(
        GaussianSquare(duration=1792, amp=0.05, sigma=64, width=1664),
        MeasureChannel(0)
    )
    pulse.acquire(1792, AcquireChannel(0), pulse.MemorySlot(0))

# Combine: run x gate then measure
with pulse.build(name="x_and_measure") as full_sched:
    pulse.call(x_sched)
    pulse.call(meas_sched)

print(full_sched)

Defining a Custom Gate via Pulse Calibration

The most powerful Pulse feature for circuit-level users is pulse calibration: replacing the default gate implementation with your own pulse schedule. This is done by attaching a Schedule to a gate as a calibration:

# Requires: qiskit_ibm_runtime
from qiskit import QuantumCircuit
from qiskit.circuit import Gate
import qiskit.pulse as pulse
from qiskit.pulse.library import Drag
from qiskit.pulse import DriveChannel

# Define a custom gate object
custom_rz = Gate("my_rz", 1, [])  # name, num_qubits, params

# Build the circuit
qc = QuantumCircuit(1, 1)
qc.append(custom_rz, [0])
qc.measure(0, 0)

# Attach a pulse calibration to the gate
with pulse.build() as rz_sched:
    # Virtual Z rotation: just shift the phase, no physical pulse needed
    pulse.shift_phase(3.14159 / 2, DriveChannel(0))

qc.add_calibration("my_rz", [0], rz_sched)

# When transpiled and submitted, the backend will use rz_sched
# instead of its default RZ implementation

Virtual Z rotations (phase shifts) are instantaneous in the rotating frame. IBM hardware implements all RZ gates this way by default, and understanding this is essential for pulse-level optimization.

For a custom single-qubit gate that has no virtual implementation, you provide a physical pulse:

# Requires: qiskit_ibm_runtime
from qiskit.circuit import Parameter
import numpy as np

# Parameterized custom gate
amp_param = Parameter("amp")
rx_gate = Gate("custom_rx", 1, [amp_param])

def rx_schedule(amp_value):
    with pulse.build() as sched:
        pulse.play(
            Drag(duration=160, amp=amp_value, sigma=40, beta=-0.5),
            DriveChannel(0)
        )
    return sched

qc = QuantumCircuit(1, 1)
qc.append(rx_gate.assign_parameters({amp_param: 0.15}), [0])
qc.measure(0, 0)
qc.add_calibration("custom_rx", [0], rx_schedule(0.15))

Measuring T1 with a Custom Pulse Sequence

T1 (energy relaxation time) measures how long an excited qubit takes to relax back to 0|0\rangle. The experiment: apply an X pulse to prepare 1|1\rangle, wait a variable delay, then measure. The decay of the excited state probability gives T1.

# Requires: qiskit_ibm_runtime
from qiskit import QuantumCircuit
from qiskit_aer import AerSimulator
from qiskit_aer.noise import NoiseModel, thermal_relaxation_error
import numpy as np
import qiskit.pulse as pulse
from qiskit.pulse.library import Drag
from qiskit.pulse import DriveChannel

# Simulate on a fake backend with T1 = 100 microseconds
noise_model = NoiseModel()
t1_us = 100  # microseconds
t2_us = 80
error = thermal_relaxation_error(t1=t1_us * 1e3, t2=t2_us * 1e3, time=1)  # time in ns
noise_model.add_quantum_error(error, ["id"], [0])

delays_ns = np.linspace(0, 200_000, 20)  # 0 to 200 microseconds in ns
results = []

for delay_ns in delays_ns:
    qc = QuantumCircuit(1, 1)
    qc.x(0)                # Prepare |1>
    qc.delay(int(delay_ns), 0, unit="ns")   # Wait
    qc.measure(0, 0)

    backend = AerSimulator(noise_model=noise_model)
    job = backend.run(qc, shots=1024)
    counts = job.result().get_counts()
    p1 = counts.get("1", 0) / 1024
    results.append(p1)

# Fit exponential decay to extract T1
from scipy.optimize import curve_fit

def exp_decay(t, t1, a, b):
    return a * np.exp(-t / t1) + b

popt, _ = curve_fit(exp_decay, delays_ns / 1000, results,
                    p0=[100_000, 1.0, 0.0], maxfev=10000)
measured_t1_us = popt[0] / 1000
print(f"Measured T1: {measured_t1_us:.1f} us (true: {t1_us} us)")

Measuring T2 with a Ramsey Sequence

T2 (dephasing time) uses a Ramsey sequence: X/2 pulse, wait, X/2 pulse, measure. The oscillation frequency reveals any detuning from the drive frequency, and the envelope gives T2:

from qiskit.circuit import QuantumCircuit
import numpy as np

detuning_MHz = 0.5  # Intentional detuning for visible oscillations
delays_ns = np.linspace(0, 50_000, 40)

for delay_ns in delays_ns:
    qc = QuantumCircuit(1, 1)
    qc.h(0)                           # X/2 pulse (Hadamard approximation)
    qc.delay(int(delay_ns), 0, unit="ns")
    # Add virtual Z rotation for detuning
    qc.rz(2 * np.pi * detuning_MHz * delay_ns * 1e-3, 0)
    qc.h(0)
    qc.measure(0, 0)
    # ... run and collect P(|0>) vs delay

The oscillation frequency of the P(|0>) signal equals the detuning frequency. The Gaussian envelope of the oscillations decays with time constant T2.

Running on IBM Quantum

To run pulse schedules on real IBM hardware, you submit them through the backend’s job interface. The backend must support Pulse (check backend.configuration().open_pulse):

from qiskit_ibm_runtime import QiskitRuntimeService

service = QiskitRuntimeService(channel="ibm_quantum")
# Find a backend with open pulse support
backends = service.backends(open_pulse=True, operational=True, simulator=False)
backend = backends[0]

print(f"Using: {backend.name}")
print(f"Supported instructions: {backend.configuration().basis_gates}")
print(f"dt (device timestep): {backend.configuration().dt * 1e9:.2f} ns")

# Pulse schedules are submitted as jobs directly
job = backend.run(full_sched, shots=1024)
result = job.result()

The device timestep dt is typically around 0.2-0.5 ns. All pulse durations must be multiples of dt.

Qiskit Pulse provides the visibility and control needed for hardware characterization, gate calibration, and implementing novel pulse sequences that go beyond what pre-calibrated gates allow. Mastering it bridges the gap between algorithm design and the physical reality of superconducting quantum processors.

Was this tutorial helpful?