Qiskit Beginner Free 17/61 in series 25 min read

Build Your First Quantum Circuit in Qiskit (Complete Beginner Guide)

Step-by-step guide to installing Qiskit, building your first quantum circuit, creating a Bell state, simulating it locally, and running it on a real IBM Quantum device.

What you'll learn

  • Qiskit
  • quantum circuit
  • beginner
  • Bell state
  • measurement
  • IBM Quantum

Prerequisites

  • Basic Python (variables, functions, loops)
  • No quantum physics background needed

This tutorial walks you through building your first quantum circuit using Qiskit, IBM’s open-source quantum computing framework. By the end, you will have simulated a Bell state locally and submitted it to a real quantum computer. Every line of code is explained.

Prerequisites

You need Python 3.9 or later. No prior quantum computing experience is required, but familiarity with Python basics (variables, functions, print statements) will help.

Installing Qiskit

Qiskit 2.x is the current stable version. Install it with pip:

pip install qiskit

For local simulation (which you need for this tutorial), also install:

pip install qiskit-aer

Verify the installation:

import qiskit
print(qiskit.__version__)  # Should print 2.x.x

What Is a Qubit, Physically?

Before writing any code, it helps to understand what a qubit actually is. You already know classical bits. Inside your computer, a bit is a tiny transistor etched into a silicon chip. The transistor sits at one of two voltage levels: low voltage represents 0, high voltage represents 1. Billions of these transistors switching between two states is all a classical computer does.

A qubit is a two-level quantum system. That phrase is precise and important. Any physical system that has exactly two distinguishable quantum states can serve as a qubit. The key word is “quantum”: the system obeys the rules of quantum mechanics, which means it can exist in a superposition of both states simultaneously.

IBM’s Transmon Qubits

IBM builds its quantum computers using transmon qubits. A transmon is a superconducting circuit made from aluminum (or niobium) that sits inside a dilution refrigerator cooled to approximately 15 millikelvin, colder than outer space. At this temperature, the metal becomes superconducting (zero electrical resistance), and quantum effects dominate.

The critical component is a Josephson junction: two superconducting electrodes separated by a thin insulating barrier. This junction acts as a nonlinear inductor. A normal inductor has evenly spaced energy levels, like a ladder with equal rung spacing. The Josephson junction’s nonlinearity makes the energy levels unequally spaced. That unequal spacing lets engineers isolate just the two lowest energy levels and treat them as |0⟩ and |1⟩. Without the nonlinearity, you could not address just two levels, and you would not have a usable qubit.

Google’s Sycamore processor uses similar superconducting transmon technology. IonQ takes a different approach: they trap individual ytterbium ions in electromagnetic fields and use two internal energy levels of each ion as the qubit states. Both approaches are valid ways to build a two-level quantum system.

Qubit State vs. Measurement Outcome

There is a crucial distinction to keep in mind throughout this tutorial. The qubit state is a quantum object described by two complex numbers called amplitudes:

|ψ⟩ = α|0⟩ + β|1⟩

Here, α and β are complex numbers satisfying |α|² + |β|² = 1. The qubit state is rich: it lives on the surface of a sphere (the Bloch sphere) and can point in any direction.

When you measure the qubit, you get a classical result: either 0 or 1. The probability of getting 0 is |α|² and the probability of getting 1 is |β|². After measurement, the qubit state collapses to whichever outcome you observed. The quantum information is destroyed.

This means a qubit stores far more information than a classical bit while it remains unmeasured, but you can only extract one bit of classical information when you measure it. This tension between the richness of quantum states and the limitations of measurement is at the heart of quantum computing.

What Is a Quantum Circuit?

A quantum circuit is a sequence of quantum operations (gates) applied to qubits, followed by measurements that extract classical results. Think of it like a recipe: you start with ingredients (qubits initialized to |0⟩), apply transformations (gates), and read out the result (measurement).

In Qiskit, you build a circuit by creating a QuantumCircuit object and calling methods on it.

Your First Quantum Circuit: A Single Qubit

Let’s start with the simplest possible circuit: one qubit, one gate, one measurement.

from qiskit import QuantumCircuit

# Create a circuit with 1 qubit and 1 classical bit
qc = QuantumCircuit(1, 1)

The first argument (1) is the number of qubits. The second argument (1) is the number of classical bits that will store measurement results.

# Apply a Hadamard gate to qubit 0
qc.h(0)

The .h(0) call applies the Hadamard gate to qubit 0. The Hadamard gate puts a qubit into equal superposition: if the qubit starts in |0⟩, after H it has a 50% chance of measuring 0 and a 50% chance of measuring 1.

# Measure qubit 0 and store the result in classical bit 0
qc.measure(0, 0)

The .measure(qubit_index, classical_bit_index) method collapses the quantum state and writes a 0 or 1 to the classical bit.

# Draw the circuit to visualize it
print(qc.draw())

This prints a text diagram of your circuit:

     ┌───┐┌─┐
  q: ┤ H ├┤M├
     └───┘└╥┘
c: 1/══════╩═
           0

The Hadamard Gate in Detail

The Hadamard gate is the most important single-qubit gate you will use. Let’s understand exactly what it does.

The Matrix

Every quantum gate is a unitary matrix. The Hadamard gate has this 2x2 matrix:

H = (1/√2) * [[1,  1],
               [1, -1]]

You can verify this with numpy:

import numpy as np

# Define the Hadamard matrix
H = (1 / np.sqrt(2)) * np.array([[1, 1],
                                   [1, -1]])

# Define |0⟩ as a column vector
ket_0 = np.array([1, 0])

# Apply H to |0⟩
result = H @ ket_0
print(result)
# Output: [0.70710678, 0.70710678]
# This is [1/√2, 1/√2], which represents (|0⟩ + |1⟩) / √2

The output vector [1/√2, 1/√2] means the qubit has amplitude 1/√2 on |0⟩ and amplitude 1/√2 on |1⟩. The probability of measuring 0 is (1/√2)² = 1/2, and the probability of measuring 1 is also (1/√2)² = 1/2. That is why you see roughly 50/50 outcomes.

What H Does to |1⟩

The Hadamard gate also has a well-defined action on |1⟩:

ket_1 = np.array([0, 1])
result = H @ ket_1
print(result)
# Output: [0.70710678, -0.70710678]
# This is (|0⟩ - |1⟩) / √2

Notice the minus sign. When you apply H to |1⟩, you get (|0⟩ - |1⟩)/√2. The probabilities are still 50/50 (because |-1/√2|² = 1/2), but the relative phase between the two terms is different. This phase difference is invisible in a single measurement but becomes critical when you combine gates, as you will see later.

H Applied Twice Is the Identity

A fundamental property of the Hadamard gate: applying it twice returns the qubit to its original state.

# Verify: H * H = Identity
HH = H @ H
print(np.round(HH, 10))
# Output: [[1. 0.]
#          [0. 1.]]
# This is the 2x2 identity matrix

You can demonstrate this in Qiskit. Apply H twice to |0⟩ and measure:

from qiskit import QuantumCircuit, transpile
from qiskit_aer import AerSimulator

# Apply H twice
qc_hh = QuantumCircuit(1, 1)
qc_hh.h(0)   # |0⟩ -> (|0⟩ + |1⟩) / √2
qc_hh.h(0)   # (|0⟩ + |1⟩) / √2 -> |0⟩
qc_hh.measure(0, 0)

simulator = AerSimulator()
compiled = transpile(qc_hh, simulator)
job = simulator.run(compiled, shots=1000)
counts = job.result().get_counts()
print(counts)
# Output: {'0': 1000}
# Always measures 0, because the qubit is back in |0⟩

Every single shot returns 0. The two Hadamard gates cancel each other out perfectly.

More Single-Qubit Gates

The Hadamard gate is just one of several single-qubit gates you will use frequently. Let’s walk through the most important ones.

The X Gate (Quantum NOT)

The X gate flips |0⟩ to |1⟩ and |1⟩ to |0⟩. It is the quantum version of a classical NOT gate.

Matrix:

X = [[0, 1],
     [1, 0]]
from qiskit import QuantumCircuit, transpile
from qiskit_aer import AerSimulator

# X gate: flip |0⟩ to |1⟩
qc_x = QuantumCircuit(1, 1)
qc_x.x(0)        # Apply X gate to qubit 0
qc_x.measure(0, 0)

simulator = AerSimulator()
compiled = transpile(qc_x, simulator)
job = simulator.run(compiled, shots=1000)
counts = job.result().get_counts()
print(counts)
# Output: {'1': 1000}
# Always measures 1, because X flipped |0⟩ to |1⟩

The Z Gate (Phase Flip)

The Z gate leaves |0⟩ unchanged and multiplies |1⟩ by -1. Its matrix is:

Z = [[1,  0],
     [0, -1]]

At first glance, the Z gate seems to do nothing useful. If you apply Z to |0⟩ and measure, you always get 0. If you apply Z to |1⟩ and measure, you always get 1. The minus sign on |1⟩ is a global phase in those cases, and global phase has no observable effect.

But the Z gate matters when the qubit is in superposition. Here is the key demonstration:

from qiskit import QuantumCircuit, transpile
from qiskit_aer import AerSimulator

simulator = AerSimulator()

# Without Z: H then H returns to |0⟩
qc_no_z = QuantumCircuit(1, 1)
qc_no_z.h(0)          # |0⟩ -> |+⟩ = (|0⟩ + |1⟩) / √2
qc_no_z.h(0)          # |+⟩ -> |0⟩
qc_no_z.measure(0, 0)

compiled = transpile(qc_no_z, simulator)
job = simulator.run(compiled, shots=1000)
print("Without Z:", job.result().get_counts())
# Output: {'0': 1000}

# With Z: H, Z, then H gives |1⟩
qc_with_z = QuantumCircuit(1, 1)
qc_with_z.h(0)        # |0⟩ -> (|0⟩ + |1⟩) / √2
qc_with_z.z(0)        # (|0⟩ + |1⟩) / √2 -> (|0⟩ - |1⟩) / √2
qc_with_z.h(0)        # (|0⟩ - |1⟩) / √2 -> |1⟩
qc_with_z.measure(0, 0)

compiled = transpile(qc_with_z, simulator)
job = simulator.run(compiled, shots=1000)
print("With Z:", job.result().get_counts())
# Output: {'1': 1000}

Without Z, you measure 0 every time. With Z inserted between the two H gates, you measure 1 every time. The Z gate flipped the relative phase, and the second H gate converted that phase difference into a measurable amplitude difference. This is quantum interference in action, and it is the mechanism behind nearly every quantum algorithm.

The S and T Gates

The S gate is the “square root of Z.” It applies a phase of π/2 (90 degrees) to |1⟩ instead of π (180 degrees) like Z does. Its matrix is:

S = [[1, 0],
     [0, i]]

The T gate applies a phase of π/4 (45 degrees):

T = [[1, 0    ],
     [0, e^(iπ/4)]]

These gates are important in quantum error correction and in decomposing arbitrary rotations. The key idea: Z, S, and T all leave measurement probabilities unchanged on their own but alter the interference pattern when combined with other gates.

from qiskit import QuantumCircuit, transpile
from qiskit_aer import AerSimulator

simulator = AerSimulator()

# S gate demonstration: H, S, H, measure
qc_s = QuantumCircuit(1, 1)
qc_s.h(0)
qc_s.s(0)    # Apply phase of π/2 to |1⟩ component
qc_s.h(0)
qc_s.measure(0, 0)

compiled = transpile(qc_s, simulator)
job = simulator.run(compiled, shots=1000)
print("H-S-H:", job.result().get_counts())
# Output: roughly {'0': 500, '1': 500}
# S applies only a partial phase, so interference is partial

Notice that H-Z-H gives 100% outcome 1, but H-S-H gives 50/50. The S gate’s smaller phase rotation produces less interference than the Z gate’s full phase flip. This illustrates a general principle: phase gates are invisible on single measurements but control the interference that quantum algorithms rely on.

The CNOT Gate in Detail

The CNOT (Controlled-NOT) gate is the most important two-qubit gate. It acts on two qubits: a control and a target. If the control qubit is |1⟩, the target qubit gets flipped. If the control is |0⟩, nothing happens to the target.

Truth Table

InputOutput
|00⟩|00⟩
|01⟩|01⟩
|10⟩|11⟩
|11⟩|10⟩

The first qubit is the control, the second is the target. When the control is 1 (rows 3 and 4), the target flips.

The 4x4 Matrix

The CNOT gate, written as a matrix in the computational basis {|00⟩, |01⟩, |10⟩, |11⟩}, is:

CNOT = [[1, 0, 0, 0],
        [0, 1, 0, 0],
        [0, 0, 0, 1],
        [0, 0, 1, 0]]

The bottom-right 2x2 block is swapped compared to the identity, which is the X gate acting on the target qubit, but only when the control qubit is |1⟩.

Circuit Notation

In circuit diagrams, the CNOT appears as a solid dot (●) on the control qubit connected by a vertical line to a circled plus (⊕) on the target qubit. In Qiskit’s text output, the control shows as and the target shows as X.

Three Key Properties

Property 1: CNOT is its own inverse. Applying CNOT twice returns to the original state.

from qiskit import QuantumCircuit, transpile
from qiskit_aer import AerSimulator

# Start with |10⟩, apply CNOT twice
qc_cnot2 = QuantumCircuit(2, 2)
qc_cnot2.x(0)         # Set qubit 0 to |1⟩, so we have |10⟩
qc_cnot2.cx(0, 1)     # CNOT: |10⟩ -> |11⟩
qc_cnot2.cx(0, 1)     # CNOT again: |11⟩ -> |10⟩ (back to original)
qc_cnot2.measure([0, 1], [0, 1])

simulator = AerSimulator()
compiled = transpile(qc_cnot2, simulator)
job = simulator.run(compiled, shots=1000)
print(job.result().get_counts())
# Output: {'01': 1000}
# Qiskit uses little-endian bit ordering: '01' means qubit 0 = 1, qubit 1 = 0
# This is |10⟩ in standard notation, confirming we returned to the start

Property 2: CNOT on a control in superposition creates entanglement.

qc_ent = QuantumCircuit(2, 2)
qc_ent.h(0)           # Put control in superposition: (|0⟩ + |1⟩) / √2
qc_ent.cx(0, 1)       # CNOT creates entanglement
qc_ent.measure([0, 1], [0, 1])

compiled = transpile(qc_ent, simulator)
job = simulator.run(compiled, shots=1000)
print(job.result().get_counts())
# Output: {'00': ~500, '11': ~500}
# Entangled! Only correlated outcomes appear.

Property 3: CNOT on |00⟩ does nothing. The control is |0⟩, so the target stays untouched.

qc_00 = QuantumCircuit(2, 2)
qc_00.cx(0, 1)        # Control is |0⟩, so nothing happens
qc_00.measure([0, 1], [0, 1])

compiled = transpile(qc_00, simulator)
job = simulator.run(compiled, shots=1000)
print(job.result().get_counts())
# Output: {'00': 1000}
# Both qubits remain in |0⟩

Building a Bell State

Now for something more interesting: a Bell state. This is the simplest example of quantum entanglement: two qubits that are correlated regardless of distance.

from qiskit import QuantumCircuit

# Create a circuit with 2 qubits and 2 classical bits
bell = QuantumCircuit(2, 2)

# Step 1: Apply Hadamard to qubit 0
# This puts qubit 0 into superposition: (|0⟩ + |1⟩) / √2
bell.h(0)

# Step 2: Apply CNOT with qubit 0 as control and qubit 1 as target
# This entangles the two qubits
bell.cx(0, 1)

# Step 3: Measure both qubits
bell.measure([0, 1], [0, 1])

print(bell.draw())

Output:

     ┌───┐     ┌─┐   
q_0: ┤ H ├──■──┤M├───
     └───┘┌─┴─┐└╥┘┌─┐
q_1: ─────┤ X ├─╫─┤M├
          └───┘ ║ └╥┘
c: 2/═══════════╩══╩═
                0  1

The symbol is the control qubit. The X with the line connected to is the CNOT target.

Understanding the Bell State Step by Step

Let’s trace through the quantum state at every stage of this circuit. This is the most important section of the tutorial, so read it carefully.

Step 1: The initial state.

Both qubits start in |0⟩. The two-qubit state is the tensor product |0⟩ ⊗ |0⟩ = |00⟩. As a four-component vector in the computational basis {|00⟩, |01⟩, |10⟩, |11⟩}:

import numpy as np

# Initial state: |00⟩
state = np.array([1, 0, 0, 0], dtype=complex)
print("Initial state |00⟩:", state)
# Output: [1.+0.j, 0.+0.j, 0.+0.j, 0.+0.j]

All the probability is concentrated in the |00⟩ component.

Step 2: After the Hadamard gate on qubit 0.

The Hadamard acts only on qubit 0. To express this as a 4x4 matrix acting on the full two-qubit space, we take the tensor product H ⊗ I, where I is the 2x2 identity (acting on qubit 1, which is unchanged).

# Hadamard on qubit 0, identity on qubit 1
H = (1 / np.sqrt(2)) * np.array([[1, 1], [1, -1]])
I = np.array([[1, 0], [0, 1]])

# Tensor product: H acts on qubit 0, I acts on qubit 1
H_full = np.kron(H, I)

state_after_h = H_full @ state
print("After H on qubit 0:", np.round(state_after_h, 4))
# Output: [0.7071+0.j, 0.+0.j, 0.7071+0.j, 0.+0.j]

The state is now [1/√2, 0, 1/√2, 0]. In Dirac notation, this is:

(1/√2)|00⟩ + (1/√2)|10⟩ = |+⟩ ⊗ |0⟩ = |+0⟩

Qubit 0 is in superposition (equal parts |0⟩ and |1⟩). Qubit 1 is still |0⟩. There is no entanglement yet; the state is a simple product of the two individual qubit states.

Step 3: After the CNOT gate.

Now the CNOT acts on both qubits together. When the control (qubit 0) is |0⟩, the target (qubit 1) stays the same. When the control is |1⟩, the target flips.

# CNOT matrix (qubit 0 = control, qubit 1 = target)
CNOT = np.array([[1, 0, 0, 0],
                  [0, 1, 0, 0],
                  [0, 0, 0, 1],
                  [0, 0, 1, 0]])

state_after_cnot = CNOT @ state_after_h
print("After CNOT:", np.round(state_after_cnot, 4))
# Output: [0.7071+0.j, 0.+0.j, 0.+0.j, 0.7071+0.j]

The state is now [1/√2, 0, 0, 1/√2]. In Dirac notation:

(1/√2)|00⟩ + (1/√2)|11⟩

Look at what happened. The |00⟩ component stayed as |00⟩ (control was 0, target unchanged). The |10⟩ component became |11⟩ (control was 1, target flipped). The result has zero amplitude on |01⟩ and |10⟩.

Why this is remarkable: Each qubit individually looks completely random. If you measure just qubit 0, you get 0 or 1 with equal probability. Same for qubit 1. But the two qubits are perfectly correlated: they always agree. You will never see one qubit as 0 and the other as 1. This correlation is entanglement, and it has no classical explanation. No classical system of two independent random coins can guarantee they always land the same way.

Running the Bell State Simulation

from qiskit_aer import AerSimulator
from qiskit import transpile

simulator = AerSimulator()
compiled_bell = transpile(bell, simulator)

job = simulator.run(compiled_bell, shots=1000)
result = job.result()
counts = result.get_counts()

print(counts)
# Output: {'00': ~500, '11': ~500}
# Notice: '01' and '10' never appear

The absence of “01” and “10” is the signature of entanglement; the qubits are correlated.

Visualizing Results as a Histogram

Qiskit includes visualization tools that make results easier to read:

from qiskit.visualization import plot_histogram
import matplotlib.pyplot as plt

plot_histogram(counts)
plt.savefig("bell_state_results.png")
plt.show()

This generates a bar chart showing the probability of each outcome.

Inspecting the Statevector

Before adding measurements, you can inspect the exact quantum state:

from qiskit import QuantumCircuit
from qiskit_aer import AerSimulator
from qiskit import transpile

# Circuit without measurement
bell_no_measure = QuantumCircuit(2)
bell_no_measure.h(0)
bell_no_measure.cx(0, 1)

simulator = AerSimulator(method='statevector')

# save_statevector() must be called before transpile
bell_no_measure.save_statevector()
compiled = transpile(bell_no_measure, simulator)

job = simulator.run(compiled)
statevector = job.result().get_statevector()

print(statevector)
# Statevector([0.70710678+0.j, 0.        +0.j, 0.        +0.j,
#              0.70710678+0.j],
#             dims=(2, 2))
# Indices: 00, 01, 10, 11
# Non-zero at index 0 (|00⟩) and index 3 (|11⟩) -- exactly the Bell state

Understanding Shots and Statistics

Each shot is one execution of the circuit from start to finish. The quantum state is prepared, gates are applied, and then a measurement collapses the state to one definite outcome. That single outcome is one data point.

With 1 shot, you learn almost nothing. You get either “00” or “11”, but you cannot tell whether the probabilities are 50/50 or 99/1. With 1000 shots, you build a statistical picture that approximates the true probability distribution.

The Statistics of Measurement

For the Bell state, the probability of “00” is exactly 0.5 and the probability of “11” is exactly 0.5. When you run 1000 shots, the number of “00” outcomes follows a binomial distribution with n=1000 and p=0.5.

The expected value is n * p = 500. The standard deviation is √(n * p * (1-p)) = √(1000 * 0.5 * 0.5) ≈ 15.8. This means most of the time you will see between about 484 and 516 counts of “00” (within one standard deviation of the mean). Seeing 450 or 550 would be unusual but possible.

Demonstrating Shot-to-Shot Variation

from qiskit import QuantumCircuit, transpile
from qiskit_aer import AerSimulator

bell = QuantumCircuit(2, 2)
bell.h(0)
bell.cx(0, 1)
bell.measure([0, 1], [0, 1])

simulator = AerSimulator()
compiled = transpile(bell, simulator)

# Run the Bell state 10 times, each with 1000 shots
for i in range(10):
    job = simulator.run(compiled, shots=1000)
    counts = job.result().get_counts()
    count_00 = counts.get('00', 0)
    count_11 = counts.get('11', 0)
    print(f"Run {i+1}: 00={count_00}, 11={count_11}")

# Example output:
# Run 1: 00=512, 11=488
# Run 2: 00=493, 11=507
# Run 3: 00=521, 11=479
# Run 4: 00=498, 11=502
# Run 5: 00=487, 11=513
# ...
# The counts fluctuate around 500 each time

The fluctuations are not a bug or a sign of broken hardware. They are a fundamental feature of quantum mechanics. Even a perfect quantum computer with zero noise produces statistical variation in measurement outcomes.

The Three-Qubit GHZ State

The Bell state entangles two qubits. A natural extension is the GHZ (Greenberger-Horne-Zeilinger) state, which entangles three qubits:

(|000⟩ + |111⟩) / √2

The construction follows the same pattern as the Bell state. Apply H to the first qubit, then chain CNOT gates to spread the entanglement.

from qiskit import QuantumCircuit, transpile
from qiskit_aer import AerSimulator

# Build the GHZ circuit
ghz = QuantumCircuit(3, 3)
ghz.h(0)           # Put qubit 0 in superposition
ghz.cx(0, 1)       # Entangle qubit 0 and qubit 1
ghz.cx(1, 2)       # Entangle qubit 1 and qubit 2
ghz.measure([0, 1, 2], [0, 1, 2])

print(ghz.draw())

Output:

     ┌───┐          ┌─┐
q_0: ┤ H ├──■───────┤M├──
     └───┘┌─┴─┐     └╥┘
q_1: ─────┤ X ├──■───╫───
          └───┘┌─┴─┐ ║ ┌─┐
q_2: ──────────┤ X ├─╫─┤M├
               └───┘ ║ └╥┘
c: 3/═════════════════╩══╩═
                      0  2
# Run the simulation
simulator = AerSimulator()
compiled = transpile(ghz, simulator)
job = simulator.run(compiled, shots=1000)
counts = job.result().get_counts()
print(counts)
# Output: {'000': ~500, '111': ~500}
# Only '000' and '111' appear -- all three qubits are perfectly correlated

All three qubits are in a superposition of “all zero” and “all one.” No other combinations (like ‘001’, ‘010’, ‘011’, etc.) appear. This three-qubit entanglement is a building block for quantum teleportation, quantum error correction codes, and experimental tests of quantum mechanics (specifically, tests of local hidden variable theories through GHZ paradox arguments).

You can extend this pattern to any number of qubits. An N-qubit GHZ state uses one H gate and N-1 CNOT gates arranged in a chain.

The Transpile Step Explained

You may have noticed that every code example calls transpile() before running the circuit. This step is essential, and understanding why helps you reason about what happens on real hardware.

Why Transpile Exists

Real quantum hardware only supports a small set of native gates. For IBM’s quantum processors, the native gate set is:

  • RZ: Rotation around the Z-axis (any angle)
  • SX: Square root of X (a specific rotation)
  • X: The NOT gate
  • ECR or CX: An entangling two-qubit gate (varies by processor)

Notice that the Hadamard gate is not in this list. When you write qc.h(0) in your code, the hardware cannot execute an H gate directly. The transpiler decomposes H into a sequence of native gates that produce the same unitary transformation.

Seeing the Transpiled Circuit

from qiskit import QuantumCircuit, transpile
from qiskit_aer import AerSimulator

# Build the Bell state circuit
bell = QuantumCircuit(2, 2)
bell.h(0)
bell.cx(0, 1)
bell.measure([0, 1], [0, 1])

# Transpile for the simulator
simulator = AerSimulator()
transpiled = transpile(bell, simulator)

print("Original circuit:")
print(bell.draw())
print("\nTranspiled circuit:")
print(transpiled.draw())
print(f"\nOriginal gate count: {bell.count_ops()}")
print(f"Transpiled gate count: {transpiled.count_ops()}")

When you transpile for real hardware, the simple 2-gate circuit (H + CNOT) may expand to 5 or more native gates. Each additional gate introduces a small amount of error, which is why shorter circuits generally produce better results on real hardware.

Optimization Levels

The transpile() function accepts an optimization_level parameter:

  • Level 0: No optimization, just decompose into native gates
  • Level 1 (default): Light optimization, cancels adjacent inverse gates
  • Level 2: Medium optimization, includes gate commutation
  • Level 3: Heavy optimization, tries many different decompositions to find the shortest circuit
# Compare optimization levels
for level in range(4):
    t = transpile(bell, simulator, optimization_level=level)
    print(f"Level {level}: depth={t.depth()}, ops={t.count_ops()}")

For small circuits the differences are minor. For larger circuits, optimization level 3 can significantly reduce gate count, but it takes longer to compile.

Circuit Visualization Options

Qiskit provides several ways to draw circuits. Knowing your options helps during debugging and when preparing figures for presentations.

Text Output (Default)

from qiskit import QuantumCircuit

qc = QuantumCircuit(2, 2)
qc.h(0)
qc.cx(0, 1)
qc.measure([0, 1], [0, 1])

# ASCII art, works everywhere including remote terminals
print(qc.draw('text'))

Matplotlib Output

# Requires matplotlib installed
qc.draw('mpl')
# Returns a matplotlib Figure object
# In Jupyter notebooks, this renders inline automatically
# In scripts, save it:
qc.draw('mpl').savefig('bell_circuit.png', dpi=150, bbox_inches='tight')

LaTeX Output

# Requires pylatexenc installed: pip install pylatexenc
print(qc.draw('latex_source'))
# Outputs raw LaTeX code you can paste into a document

Circuit Statistics

print(f"Circuit depth: {qc.depth()}")
# Depth is the longest path from input to output (number of gate layers)

print(f"Gate counts: {qc.count_ops()}")
# Dictionary showing how many of each gate type

print(f"Number of qubits: {qc.num_qubits}")
print(f"Number of classical bits: {qc.num_clbits}")

Circuit depth is an important metric. Deeper circuits take longer to execute, and on real hardware, qubits lose their quantum state (decohere) over time. Keeping depth low improves result quality.

Running a Local Simulation

Quantum hardware is expensive and access is limited. For development and testing, you simulate locally using Qiskit’s AerSimulator:

from qiskit_aer import AerSimulator
from qiskit import transpile

# Simpler approach using AerSimulator directly
simulator = AerSimulator()
compiled_circuit = transpile(qc, simulator)

job = simulator.run(compiled_circuit, shots=1000)
result = job.result()

counts = result.get_counts()
print(counts)  # Output: {'0': ~500, '1': ~500}

The shots=1000 argument means the circuit runs 1000 times. Because the Hadamard gate creates equal superposition, you get roughly equal counts of 0 and 1. The exact numbers vary each run because quantum measurement is probabilistic.

Sending to IBM Quantum (Free Tier)

IBM Quantum provides free access to real quantum hardware. Here is how to submit a job:

Step 1: Create an IBM Quantum Account

Go to quantum.ibm.com and create a free account. After logging in, copy your API token from the account settings page.

Step 2: Save Your Credentials

from qiskit_ibm_runtime import QiskitRuntimeService

# Run this once to save your credentials locally
QiskitRuntimeService.save_account(
    channel="ibm_quantum",
    token="YOUR_API_TOKEN_HERE",
    overwrite=True
)

Step 3: List Available Backends

service = QiskitRuntimeService()
backends = service.backends(operational=True, min_num_qubits=2)
for b in backends:
    print(b.name, b.num_qubits, b.status().pending_jobs)

Step 4: Submit the Bell State to Real Hardware

from qiskit_ibm_runtime import QiskitRuntimeService, SamplerV2 as Sampler
from qiskit import transpile

service = QiskitRuntimeService()

# Pick the least busy backend
backend = service.least_busy(operational=True, min_num_qubits=2)
print(f"Using backend: {backend.name}")

# Transpile for the specific hardware
transpiled = transpile(bell, backend)

# Run using Sampler primitive
sampler = Sampler(backend)
job = sampler.run([transpiled], shots=1000)

print(f"Job ID: {job.job_id()}")
print("Waiting for results...")
result = job.result()
print(result[0].data.c.get_counts())

On real hardware, you will see some “01” and “10” counts due to noise. This is normal; quantum hardware is imperfect. You might see 5-15% noise on current devices.

IBM Hardware Queue and Job Management

When you submit a circuit to IBM Quantum, the job goes through several stages before you see results.

The Job Lifecycle

Every job follows this sequence:

  1. QUEUED: Your job is waiting in line behind other users’ jobs
  2. RUNNING: The quantum processor is executing your circuit
  3. COMPLETED: Results are ready for download

You can check the status at any time:

print(job.status())
# Output: JobStatus.QUEUED, JobStatus.RUNNING, or JobStatus.DONE

Retrieving Results Later

For hardware jobs, the queue wait can range from seconds to hours depending on demand. You do not need to keep your Python session open. Save the job ID and retrieve results later:

# Save the job ID (copy this string somewhere safe)
job_id = job.job_id()
print(f"Save this job ID: {job_id}")

# Later, in a new Python session:
from qiskit_ibm_runtime import QiskitRuntimeService

service = QiskitRuntimeService()
retrieved_job = service.job(job_id)

# Check if it's done
print(retrieved_job.status())

# Get results (blocks until complete if still running)
result = retrieved_job.result()
print(result[0].data.c.get_counts())

Free Tier Limits

IBM provides 10 minutes per month of quantum hardware time on the free tier. A single circuit with 1000 shots takes roughly 10 seconds of hardware time (the actual duration varies by circuit depth and processor). This means you can run dozens of small experiments each month at no cost.

Checking Queue Status

Before submitting, you can check how busy a backend is:

service = QiskitRuntimeService()
backend = service.least_busy(operational=True, min_num_qubits=2)
status = backend.status()
print(f"Backend: {backend.name}")
print(f"Pending jobs: {status.pending_jobs}")

Choosing the least busy backend minimizes your wait time.

Reading Hardware Results and Understanding Noise

When you run the Bell state on a real quantum computer, the results look different from simulation. Instead of a perfect split between “00” and “11” with nothing else, you might see:

{'00': 478, '11': 492, '01': 23, '10': 7}

The “01” and “10” outcomes should be impossible for a perfect Bell state. Their presence is not a bug in your code. It comes from three sources of hardware noise.

Gate Errors

Every quantum gate has a small probability of applying the wrong operation. On current IBM hardware, single-qubit gates have error rates around 0.01-0.1%, and CNOT gates have error rates around 0.5-1%. A CNOT error can partially flip the target qubit or introduce unwanted phases, producing outcomes that a perfect circuit would never generate.

Readout Errors

The measurement process itself is imperfect. When a qubit is in state |0⟩, there is a small probability (typically 1-3%) that the detector reports 1 instead. This is called a readout error or measurement error. It affects the final counts without corrupting the quantum state during computation.

Decoherence

Qubits gradually lose their quantum properties over time. Two timescales matter:

  • T1 (relaxation time): How long before |1⟩ decays to |0⟩. Typically 100-300 microseconds on IBM hardware.
  • T2 (dephasing time): How long before superposition states lose their phase coherence. Typically 50-200 microseconds.

If your circuit takes too long to execute (too many gates, too much depth), the qubits decohere before measurement, degrading your results. This is why circuit depth matters so much on real hardware.

Readout Error Mitigation

Qiskit provides tools to partially correct readout errors. The basic idea: run calibration circuits (prepare |0⟩ and |1⟩, measure, see how often the detector gets it wrong), then use that calibration data to correct your experiment’s results.

# Conceptual example of readout error mitigation
# (requires qiskit-ibm-runtime or qiskit-aer noise models)

from qiskit_aer import AerSimulator
from qiskit_aer.noise import NoiseModel, ReadoutError
from qiskit import QuantumCircuit, transpile

# Create a noisy simulator to demonstrate
noise_model = NoiseModel()

# Add 5% readout error: P(read 1 | state 0) = 0.05, P(read 0 | state 1) = 0.05
readout_error = ReadoutError([[0.95, 0.05], [0.05, 0.95]])
noise_model.add_all_qubit_readout_error(readout_error)

# Build Bell state
bell = QuantumCircuit(2, 2)
bell.h(0)
bell.cx(0, 1)
bell.measure([0, 1], [0, 1])

# Run with noise
noisy_sim = AerSimulator(noise_model=noise_model)
compiled = transpile(bell, noisy_sim)
job = noisy_sim.run(compiled, shots=10000)
noisy_counts = job.result().get_counts()
print("Noisy results:", noisy_counts)
# You will see some '01' and '10' counts due to readout errors

For production work, IBM’s Sampler primitive includes built-in error mitigation options that handle this automatically.

Circuit Visualization Options

Qiskit provides several ways to draw circuits and visualize results. Knowing your options helps during debugging and when creating figures for presentations.

Drawing Formats

from qiskit import QuantumCircuit

qc = QuantumCircuit(3, 3)
qc.h(0)
qc.cx(0, 1)
qc.cx(1, 2)
qc.measure([0, 1, 2], [0, 1, 2])

# 1. Text (ASCII) - works in any terminal
print(qc.draw('text'))

# 2. Matplotlib - publication-quality figures
fig = qc.draw('mpl')
fig.savefig('ghz_circuit.png', dpi=150, bbox_inches='tight')

# 3. LaTeX source - for academic papers
print(qc.draw('latex_source'))

Circuit Metrics

print(f"Circuit depth: {qc.depth()}")
print(f"Gate counts: {qc.count_ops()}")
print(f"Qubits: {qc.num_qubits}")
print(f"Classical bits: {qc.num_clbits}")

Common Mistakes

Every beginner encounters these issues. Knowing them in advance saves debugging time.

Mistake 1: Forgetting shots for the simulator

# This uses the simulator's default shot count, which may not be what you expect
job = simulator.run(compiled_circuit)

# Always specify shots explicitly
job = simulator.run(compiled_circuit, shots=1000)

AerSimulator has a default shot count, but relying on defaults makes your code less clear and can produce confusing results when the default changes between versions. Always pass shots explicitly.

Mistake 2: Confusing qubit index with measurement index

# The CNOT control is qubit 0, target is qubit 1
bell.cx(0, 1)

# When you measure, qubit 0's result goes to classical bit 0
bell.measure([0, 1], [0, 1])

If you accidentally swap the measurement mapping (e.g., bell.measure([1, 0], [0, 1])), the classical bit assignments will be swapped, and your analysis of “which qubit did what” will be wrong.

Mistake 3: Qiskit’s little-endian bit ordering

This is the single most confusing aspect of Qiskit for beginners. When get_counts() returns a string like '01', the rightmost bit is qubit 0 and the leftmost bit is the highest-numbered qubit. This is little-endian ordering.

# For a 2-qubit circuit:
# String '01' means: qubit 0 = 1, qubit 1 = 0
# String '10' means: qubit 0 = 0, qubit 1 = 1

# For a 3-qubit circuit:
# String '101' means: qubit 0 = 1, qubit 1 = 0, qubit 2 = 1

This is opposite to how most physics textbooks write states. In a textbook, |01⟩ means qubit 0 is 0 and qubit 1 is 1. In Qiskit’s count strings, ‘01’ means qubit 0 is 1 and qubit 1 is 0. Pay close attention to this when interpreting results.

Mistake 4: Not calling transpile before running on hardware

# This may fail or produce incorrect results on real hardware
job = sampler.run([bell], shots=1000)  # Missing transpile!

# Always transpile for the target backend first
transpiled = transpile(bell, backend)
job = sampler.run([transpiled], shots=1000)

The transpiler maps your abstract circuit to the hardware’s native gate set and physical qubit connectivity. Skipping this step can cause errors or silently produce wrong results.

Mistake 5: Expecting exact 50/50 splits

# This assertion will often fail!
assert counts['00'] == 500  # Wrong: quantum measurement is statistical

# Instead, check that the result is approximately correct
total = sum(counts.values())
ratio_00 = counts.get('00', 0) / total
assert 0.4 < ratio_00 < 0.6  # Allow statistical fluctuation

Quantum mechanics gives probabilistic outcomes. Even on a perfect simulator with zero noise, 1000 shots of a 50/50 superposition will not give exactly 500/500 every time. Use approximate comparisons and statistical tests.

Mistake 6: Using job.result() without understanding blocking

# job.result() blocks until the job completes
# For simulator jobs, this is instant
# For hardware jobs, this can take minutes or hours
result = job.result()  # Your program freezes here until done

For hardware jobs, consider saving the job ID and checking status periodically rather than blocking your program. This is especially important in interactive environments like Jupyter notebooks.

Understanding Each Piece

Here is a summary of every component used:

ComponentPurpose
QuantumCircuit(n, m)Creates a circuit with n qubits and m classical bits
.h(qubit)Hadamard gate: creates superposition
.x(qubit)X gate: quantum NOT, flips |0⟩ and |1⟩
.z(qubit)Z gate: phase flip on |1⟩
.s(qubit)S gate: π/2 phase on |1⟩
.t(qubit)T gate: π/4 phase on |1⟩
.cx(control, target)CNOT gate: entangles two qubits
.measure(qubit, cbit)Collapses quantum state, stores result
AerSimulator()Fast local simulation
transpile(circuit, backend)Converts circuit to backend-native gates
shots=1000Number of times to run the circuit
get_counts()Dictionary of outcomes and their frequencies

Next Steps

You have now built, simulated, and submitted a quantum circuit to real hardware. From here, you can explore:

  • More gates: RZ, RX, RY rotation gates for arbitrary angles
  • Quantum algorithms: Grover’s search, quantum teleportation, Deutsch’s algorithm
  • Error mitigation: Techniques to reduce noise on real hardware
  • Qiskit Runtime: IBM’s production-grade quantum execution environment
  • Multi-qubit entanglement: Extend the GHZ state to 5, 10, or even 127 qubits on IBM hardware

The Bell state you built is one of the most important primitives in quantum computing. It appears in quantum teleportation, quantum key distribution, and quantum error correction. The GHZ state extends this to multi-party protocols and fault-tolerant computing. You are working with the real foundations of the field.

Was this tutorial helpful?