There’s a specific kind of learning gap in quantum computing education: plenty of resources explain the theory of superposition and entanglement, and plenty of API references list Qiskit’s methods, but not many walk through the actual mental model of going from “I understand qubits conceptually” to “I just ran a real circuit on real quantum hardware and here’s what happened.” This tutorial is meant to close that gap directly, building up a working Bell-state circuit from scratch, running it on a simulator, and then explaining what changes when you point the same circuit at real IBM hardware.
I’ll assume you’re comfortable with Python but have never touched Qiskit before. I’ll also assume you know the basic notation for qubit states ($|0\rangle$, $|1\rangle$, superposition) but haven’t necessarily worked through gate matrices before — I’ll explain those as they come up.
Setting Up
Qiskit is a Python package, installed the way you’d expect:
pip install qiskit qiskit-aer qiskit-ibm-runtime
qiskit gives you the core circuit-building tools. qiskit-aer gives you IBM’s high-performance classical simulator, which is what you’ll use for most development and testing before ever touching real hardware. qiskit-ibm-runtime is what lets you authenticate and submit jobs to IBM’s actual cloud-hosted quantum processors.
To run on real hardware later, you’ll need a free IBM Quantum account (available at quantum.ibm.com), which gives you an API token and some amount of free monthly access to real devices — enough for a tutorial like this one, though not enough for heavy production workloads.
Step One: Build a Single-Qubit Circuit
Let’s start about as simply as possible — a single qubit, put into superposition, then measured.
from qiskit import QuantumCircuit
qc = QuantumCircuit(1, 1) # 1 qubit, 1 classical bit
qc.h(0) # Hadamard gate on qubit 0
qc.measure(0, 0) # measure qubit 0 into classical bit 0
print(qc)
The QuantumCircuit(1, 1) call allocates one qubit (which starts, by convention, in the $|0\rangle$ state) and one classical bit to store a measurement result in.
The Hadamard gate qc.h(0) is the workhorse of quantum circuit construction — it’s the gate responsible for creating superposition. Mathematically, it’s represented by the matrix:
$$H = \frac{1}{\sqrt{2}}\begin{pmatrix} 1 & 1 \ 1 & -1 \end{pmatrix}$$
Applying it to the starting state $|0\rangle = \begin{pmatrix} 1 \ 0 \end{pmatrix}$ gives:
$$H|0\rangle = \frac{1}{\sqrt{2}}\begin{pmatrix} 1 & 1 \ 1 & -1 \end{pmatrix}\begin{pmatrix} 1 \ 0 \end{pmatrix} = \frac{1}{\sqrt{2}}\begin{pmatrix} 1 \ 1 \end{pmatrix} = \frac{1}{\sqrt{2}}(|0\rangle + |1\rangle)$$
This is an equal superposition — measuring this qubit will return $0$ with probability $|1/\sqrt{2}|^2 = 0.5$ and $1$ with probability $0.5$. It’s the quantum equivalent of a perfectly fair coin flip, though it’s important to be precise about what’s actually happening: before measurement, the qubit isn’t “either 0 or 1 with unknown probability” the way a hidden classical coin would be — it’s in a genuine superposition of both states simultaneously, and the probabilities only become meaningful at the moment of measurement, which collapses the superposition to one definite outcome.
Step Two: Run It on a Simulator
Before touching real hardware, you should always validate your circuit logic on a simulator — it’s faster, free, and noise-free, which makes debugging your circuit logic far easier than trying to debug against a noisy real device.
from qiskit_aer import AerSimulator
simulator = AerSimulator()
job = simulator.run(qc, shots=1000)
result = job.result()
counts = result.get_counts()
print(counts)
The shots=1000 parameter matters conceptually: a single run of a quantum circuit gives you exactly one measurement outcome (a single bit, in this case), because measurement collapses the superposition. To actually estimate the underlying probability distribution, you need to run the identical circuit many times and look at the statistics — this is fundamentally different from classical debugging, where running the same deterministic code twice gives you the same answer. Running this, you should see output close to {'0': ~500, '1': ~500} — roughly an even split, confirming the fair-coin behavior predicted by the math above. The exact numbers will vary slightly each run due to genuine statistical sampling.
Step Three: Build a Bell State (Entanglement)
A single superposed qubit is a nice demonstration, but it doesn’t show anything a classical random bit generator couldn’t fake. Entanglement is where quantum circuits start doing something genuinely non-classical, so let’s build the canonical example: a two-qubit Bell state.
from qiskit import QuantumCircuit
qc = QuantumCircuit(2, 2)
qc.h(0) # superposition on qubit 0
qc.cx(0, 1) # CNOT: qubit 0 controls, qubit 1 is target
qc.measure([0, 1], [0, 1])
print(qc)
The new gate here is cx, the controlled-NOT (CNOT) gate. It’s a two-qubit gate that flips the target qubit (qubit 1) if and only if the control qubit (qubit 0) is in state $|1\rangle$. Applied to a superposition, this creates entanglement. Walking through the math: after the Hadamard, the joint state of the two qubits is:
$$\frac{1}{\sqrt{2}}(|0\rangle + |1\rangle) \otimes |0\rangle = \frac{1}{\sqrt{2}}(|00\rangle + |10\rangle)$$
Applying CNOT (which flips the second qubit whenever the first is $|1\rangle$) transforms $|10\rangle$ into $|11\rangle$ while leaving $|00\rangle$ unchanged, giving:
$$|\Phi^+\rangle = \frac{1}{\sqrt{2}}(|00\rangle + |11\rangle)$$
This is one of the four maximally entangled two-qubit Bell states. The key experimental signature of entanglement here is correlation: if you run this circuit and look at the measurement outcomes, you should see only 00 and 11 results — never 01 or 10 — because the two qubits’ outcomes are perfectly correlated, even though each individual qubit’s outcome is still random. Run the simulator the same way as before:
from qiskit_aer import AerSimulator
simulator = AerSimulator()
job = simulator.run(qc, shots=1000)
counts = job.result().get_counts()
print(counts)
You should see output roughly like {'00': ~500, '11': ~500}, with 01 and 10 essentially absent. This correlation — measured on two physically separate qubits — is the experimentally verifiable signature that distinguishes real quantum entanglement from any classical correlated-randomness scheme, and it’s the phenomenon Bell inequality tests are specifically designed to probe.
Step Four: Visualize the Circuit and Results
Qiskit includes visualization helpers that are genuinely useful for building intuition, especially early on.
from qiskit.visualization import plot_histogram
plot_histogram(counts)
This produces a bar chart of measurement outcomes — useful for quickly sanity-checking that your circuit is producing the distribution you expect. You can also draw the circuit diagram itself:
qc.draw('mpl')
which renders a visual gate diagram, reading left to right, showing exactly which gates are applied to which qubits and in what order — very useful once circuits get more complex than the two-gate examples here.
Step Five: Running on Real IBM Quantum Hardware
This is where things get genuinely interesting, because it’s also where the gap between simulated and real quantum computing becomes visible. First, authenticate with your IBM Quantum account:
from qiskit_ibm_runtime import QiskitRuntimeService
QiskitRuntimeService.save_account(
channel="ibm_quantum",
token="YOUR_API_TOKEN"
)
service = QiskitRuntimeService()
Next, you need to select a real backend and, critically, transpile your circuit for it. This step is easy to skip when working purely with simulators, but it’s essential for real hardware:
backend = service.least_busy(operational=True, simulator=False)
from qiskit import transpile
qc_transpiled = transpile(qc, backend=backend, optimization_level=3)
Transpilation matters because your abstract circuit — expressed in terms of Hadamard and CNOT gates — almost certainly doesn’t match the native gate set or physical connectivity of the real chip. Real IBM hardware typically has a native gate set built around single-qubit rotations and a specific two-qubit entangling gate (historically the echoed cross-resonance gate on Eagle/Condor-generation chips), and qubits are only physically connected to a handful of neighbors, not to every other qubit on the chip. The transpiler rewrites your circuit into the equivalent sequence of native operations, and inserts SWAP gates if your circuit needs two qubits to interact that aren’t physically adjacent on the chip. The optimization_level=3 argument tells the transpiler to work hardest at minimizing the resulting gate count and depth — important, because every additional native gate is an additional opportunity for error on real, noisy hardware.
Now submit the job using the runtime Sampler primitive:
from qiskit_ibm_runtime import SamplerV2 as Sampler
sampler = Sampler(mode=backend)
job = sampler.run([qc_transpiled], shots=1000)
result = job.result()
counts = result[0].data.c.get_counts()
print(counts)
What Changes on Real Hardware
If you run this and compare to your simulator results, you’ll almost certainly see something like {'00': ~460, '11': ~470, '01': ~35, '10': ~35} rather than the clean 00/11-only split from the ideal simulation. Those 01 and 10 counts that “shouldn’t” exist are the direct, visible fingerprint of real hardware noise — gate errors, decoherence during the (however brief) time between gate application and measurement, and readout errors where the measurement apparatus itself occasionally misreports a qubit’s state. This is genuinely the most important lesson a first real-hardware run teaches: the theoretical circuit and the physical execution of that circuit are not the same thing, and the gap between them — quantified by metrics like gate fidelity, T1/T2 coherence times, and readout error rates — is exactly the gap the entire field is working to close through better qubit design and quantum error correction.
A Note on Noisy Simulation
Before spending your limited free hardware time, it’s worth knowing that Qiskit Aer supports noise-model simulation — running the ideal circuit through a simulator configured to mimic the specific noise characteristics of a real backend, using calibration data pulled from that backend:
from qiskit_aer.noise import NoiseModel
noise_model = NoiseModel.from_backend(backend)
noisy_simulator = AerSimulator(noise_model=noise_model)
job = noisy_simulator.run(qc_transpiled, shots=1000)
counts = job.result().get_counts()
This gives you a realistic preview of roughly what error rates to expect before committing real hardware time to a job, which is genuinely useful practice for developing and debugging larger circuits efficiently.
Step Six: A Parametrized Circuit, the Building Block of Real Algorithms
Before wrapping up, it’s worth extending the tutorial one step further, because nearly every practically interesting quantum algorithm — VQE, QAOA, quantum machine learning circuits — is built from parametrized circuits, where gate rotation angles are variables rather than fixed numbers, adjusted by a classical optimizer across many circuit executions. Here’s a minimal example that should feel like a natural next step from the Bell-state circuit above:
from qiskit import QuantumCircuit
from qiskit.circuit import Parameter
theta = Parameter('theta')
qc = QuantumCircuit(1, 1)
qc.ry(theta, 0)
qc.measure(0, 0)
The Parameter object lets you build the circuit’s structure once and then bind different numerical values to theta across many executions, without rebuilding the circuit from scratch each time — an important efficiency consideration once you’re running hundreds or thousands of circuit evaluations inside an optimization loop, since re-transpiling an entire circuit for every single parameter value would be wasteful. Binding a specific value and running it looks like this:
import numpy as np
bound_qc = qc.assign_parameters({theta: np.pi / 3})
job = simulator.run(bound_qc, shots=1000)
counts = job.result().get_counts()
Sweeping theta across a range of values and plotting the resulting probability of measuring 1 traces out the expected $\sin^2(\theta/2)$ dependence predicted by the $R_y(\theta)$ rotation matrix, which is a genuinely useful exercise for building intuition about how continuous rotation angles map onto measurement probabilities — the exact relationship that underlies how a classical optimizer, adjusting these angles step by step, drives a variational quantum algorithm toward a lower-energy or lower-loss solution.
Common Early Mistakes Worth Knowing About in Advance
A few pitfalls come up often enough for people new to Qiskit that it’s worth flagging them directly, since debugging them from scratch tends to eat disproportionate time relative to how simple the underlying issue usually is. Forgetting to measure: a circuit with no measurement instructions will simulate fine and even run on hardware, but get_counts() will fail or return an unhelpful empty result, since there’s no classical bit register to report — every circuit intended to return classical results needs explicit measurement instructions mapped to classical bits. Confusing qubit ordering: Qiskit uses a specific bit-ordering convention (least-significant qubit first, sometimes surprising to people used to reading left-to-right) when displaying multi-qubit measurement outcomes as strings, which can cause confusion when interpreting counts dictionaries for circuits with three or more qubits unless you deliberately check which convention is in effect. Skipping transpilation before hardware runs: as covered above, submitting an untranspiled circuit directly to real hardware can fail outright, or silently produce a working but far less efficient set of native gates than an explicit transpile() call with a higher optimization level would produce — always transpile explicitly for real-hardware submissions rather than relying on any default, implicit transpilation. Assuming simulator results predict hardware results closely: as this tutorial’s Bell-state example demonstrated directly, ideal noiseless simulation and real noisy hardware execution can diverge substantially, and treating simulator output as a reliable stand-in for real-hardware behavior, without at least a noise-model simulation as an intermediate check, is one of the most common sources of surprise for people transitioning from tutorial-level work to anything resembling production quantum software development.
Understanding Your Job’s Place in the Queue
One practical aspect of running on real hardware that catches newcomers off guard: unlike a simulator, which runs on your own machine (or a cloud compute instance dedicated to you) and returns results essentially immediately, a job submitted to real IBM Quantum hardware enters a shared queue, since actual quantum processors are scarce, expensive shared resources serving many users simultaneously across the world. Depending on backend popularity and your account tier, queue wait times can range from under a minute to, at busier times on the free tier, considerably longer. The job.status() method lets you poll a submitted job’s current state (queued, running, or completed) without blocking your script:
print(job.status())
For real workflows involving many circuit variations — sweeping a parameter across many values, for instance, as in the variational circuit example above — it’s considerably more efficient to batch multiple circuits into a single job submission (both Sampler and Estimator accept lists of circuits) rather than submitting each variation as a separate job, since each individual job submission carries its own queue overhead. This batching consideration becomes especially relevant once you move from simple tutorial circuits toward anything resembling a real variational algorithm, where a single optimization run might involve dozens or hundreds of circuit evaluations with different parameter values.
Estimator: The Other Core Primitive
Everything covered so far in this tutorial has used the Sampler primitive, which returns raw measurement outcome counts — appropriate when what you actually want is the distribution of bitstrings a circuit produces. But a large fraction of real quantum algorithms, including the variational methods covered in the companion article on quantum simulation, actually want something different: the expectation value of some observable (typically related to a system’s energy) with respect to the circuit’s output state, rather than raw measurement counts. Qiskit Runtime’s Estimator primitive is built specifically for this pattern:
from qiskit_ibm_runtime import EstimatorV2 as Estimator
from qiskit.quantum_info import SparsePauliOp
observable = SparsePauliOp("ZZ")
estimator = Estimator(mode=backend)
job = estimator.run([(qc_transpiled, observable)])
result = job.result()
print(result[0].data.evs)
Here, SparsePauliOp("ZZ") defines the observable — in this case, the tensor product of Pauli-Z operators on both qubits, whose expectation value on the Bell state constructed earlier in this tutorial should come out close to $+1$ in the ideal noiseless case, reflecting the perfect 00/11 correlation built into that state. Internally, the Estimator primitive handles the work of decomposing the requested observable into a set of measurement bases, running the necessary circuit variants, and combining the resulting statistics into a single expectation value — sparing you from having to manually implement that decomposition and averaging logic yourself, which becomes considerably more involved once observables get more complex than the simple two-qubit example shown here.
Where to Go From Here
The two circuits in this tutorial — a single superposed qubit and a two-qubit Bell state — are intentionally minimal, but they exercise the complete conceptual pipeline you’ll use for any circuit: build with gates, simulate to validate logic, transpile for a real target, execute, and interpret results in light of real-world noise. From here, the natural next steps are exploring parametrized circuits (used heavily in variational algorithms like VQE and QAOA), Qiskit’s Estimator primitive for expectation-value-based algorithms rather than raw measurement counts, and IBM’s growing library of pre-built application modules for chemistry, optimization, and machine learning. But the fundamentals covered here — allocate qubits, apply gates, transpile, run, interpret noisy results — are the same fundamentals underlying essentially all of that more advanced work.
