Introduction to Quantum Error Correction: The Bit-Flip Code
Learn how quantum error correction works by implementing the 3-qubit bit-flip code in Qiskit - encode a logical qubit, introduce an error, detect it with syndrome measurement, and correct it.
Circuit diagrams
Quantum hardware is noisy. Qubits interact with their environment, accumulating errors through a process called decoherence, and gate operations are imperfect. Without quantum error correction, computations longer than the coherence time simply fail. QEC is the engineering discipline that makes fault-tolerant quantum computing possible, but it comes with a steep overhead: many physical qubits per logical qubit.
This tutorial builds the simplest QEC scheme, the 3-qubit bit-flip code, entirely in Qiskit. You will encode a logical qubit, inject an error, detect it without measuring the logical state, and correct it. From there, we extend to the phase-flip code, the Shor code, the surface code, stabilizer formalism, fault tolerance, and noisy simulation.
Installation
pip install qiskit qiskit-aer matplotlib
Quantum Error Models: A Deep Dive
Before building any error-correcting code, you need to understand what errors actually look like in a quantum computer. Quantum errors differ fundamentally from classical bit flips because qubits live in a continuous state space and can suffer from three distinct types of error.
Bit-Flip Errors (X)
A bit-flip error applies the Pauli X gate to a qubit, mapping |0> to |1> and |1> to |0>. For a superposition state alpha|0> + beta|1>, the error swaps the amplitudes to beta|0> + alpha|1>.
Physical causes: Electromagnetic interference from nearby circuits, stray microwave pulses that accidentally rotate the qubit, or thermal excitation from an insufficiently cold environment.
Phase-Flip Errors (Z)
A phase-flip error applies the Pauli Z gate, mapping |0> to |0> and |1> to -|1>. For a superposition alpha|0> + beta|1>, the state becomes alpha|0> - beta|1>. The populations stay the same, but the relative phase between the two basis states flips sign.
Physical causes: Dephasing from fluctuating magnetic fields, charge noise in superconducting qubits (where the qubit frequency wanders randomly), and stray photons in the resonator environment.
Depolarizing Noise
In practice, errors are rarely pure X or pure Z. Depolarizing noise models the situation where, with probability p, the qubit suffers one of the three Pauli errors (X, Y, or Z) with equal probability p/3 each. The depolarizing channel acts on a density matrix rho as:
E(rho) = (1 - p) * rho + (p/3) * (X * rho * X + Y * rho * Y + Z * rho * Z)
When p = 0, the state is untouched. When p = 3/4, the output is the maximally mixed state I/2 regardless of input. The Y error (Y = iXZ) combines both a bit flip and a phase flip simultaneously.
Typical Error Rates by Hardware Platform
| Platform | Single-Qubit Gate Error | Two-Qubit Gate Error | Measurement Error | T1 / T2 |
|---|---|---|---|---|
| Superconducting (IBM, Google) | 0.01 - 0.1% | 0.1 - 1% | 0.5 - 3% | 50 - 300 us |
| Trapped Ion (IonQ, Quantinuum) | 0.001 - 0.01% | 0.01 - 0.1% | 0.1 - 1% | seconds to minutes |
| Photonic (Xanadu, PsiQuantum) | variable | variable | 1 - 10% | N/A (photon loss dominates) |
| Neutral Atom (QuEra, Pasqal) | 0.01 - 0.1% | 0.1 - 1% | 1 - 5% | 1 - 10 s |
These numbers shift rapidly as hardware improves. The key takeaway: two-qubit gate errors are typically 10x worse than single-qubit gate errors, and measurement errors are often the largest contributor.
Why Classical Repetition Fails for Qubits
The classical repetition code protects a bit by sending it three times: 0 becomes 000, 1 becomes 111. A majority vote corrects any single-bit flip. Simple and effective.
The direct quantum analogue fails for two reasons:
- No-cloning theorem: you cannot copy an arbitrary unknown quantum state |psi> = alpha|0> + beta|1>. A CNOT on |psi>|0> entangles rather than copies.
- Measurement collapses state: measuring each physical qubit to take a majority vote destroys the superposition.
QEC solves both problems with a clever workaround: encode the logical qubit across an entangled state of multiple physical qubits, then measure only the relationships between qubits (parities) rather than the qubits themselves.
The 3-Qubit Bit-Flip Code
The encoding maps logical basis states to:
|0>_L = |000>
|1>_L = |111>
An arbitrary logical state alpha|0>_L + beta|1>_L becomes alpha|000> + beta|111>. This is a GHZ-like entangled state, not three independent copies.
A single X error (bit-flip) on any one qubit produces one of:
alpha|100> + beta|011> (error on qubit 0)
alpha|010> + beta|101> (error on qubit 1)
alpha|001> + beta|110> (error on qubit 2)
Each case is orthogonal to the others and to the code space, so we can distinguish them by measuring parities.
Encoding Circuit
from qiskit import QuantumCircuit, QuantumRegister, ClassicalRegister, transpile
from qiskit_aer import AerSimulator
from qiskit.quantum_info import Statevector
import numpy as np
def encode_bit_flip(alpha, beta):
"""
Encode the logical state alpha|0> + beta|1> into 3 physical qubits.
Qubits 0, 1, 2 hold the logical qubit. Qubit 0 is the 'data' qubit.
Returns a QuantumCircuit (3 qubits, no measurement).
"""
qc = QuantumCircuit(3, name="encode")
# Step 1: prepare qubit 0 in the desired logical state
# For a general state, use initialise; here we parameterise with RY + RZ
theta = 2 * np.arccos(np.clip(abs(alpha), 0, 1))
qc.ry(theta, 0)
if abs(beta) > 1e-9 and abs(alpha) > 1e-9:
phase = np.angle(beta) - np.angle(alpha)
qc.rz(phase, 0)
# Step 2: spread to qubits 1 and 2 using CNOT
qc.cx(0, 1)
qc.cx(0, 2)
return qc
# Encode |0>_L
qc_zero = encode_bit_flip(1.0, 0.0)
sv = Statevector(qc_zero)
print("Encoded |0>_L:")
print(sv) # Should be 1.0|000>
# Encode |+>_L = (|0>_L + |1>_L)/sqrt(2)
qc_plus = QuantumCircuit(3)
qc_plus.h(0)
qc_plus.cx(0, 1)
qc_plus.cx(0, 2)
sv_plus = Statevector(qc_plus)
print("\nEncoded |+>_L:")
print(sv_plus) # Should be (|000> + |111>)/sqrt(2)
The Stabilizer Formalism
Before diving into syndrome measurement circuits, it is worth understanding the mathematical framework that underlies all modern quantum error-correcting codes: the stabilizer formalism.
A stabilizer is a Pauli operator S with the property that S|psi> = +|psi> for every valid codeword |psi> in the code space. The set of all stabilizers for a code forms a group called the stabilizer group.
For the 3-qubit bit-flip code, the stabilizer group is generated by two operators:
S_1 = Z_0 Z_1 (measures parity of qubits 0 and 1)
S_2 = Z_1 Z_2 (measures parity of qubits 1 and 2)
You can verify these are stabilizers of the code space. For |000>:
Z_0 Z_1 |000> = (+1)(+1)|000> = +|000> ✓
Z_1 Z_2 |000> = (+1)(+1)|000> = +|000> ✓
For |111>:
Z_0 Z_1 |111> = (-1)(-1)|111> = +|111> ✓
Z_1 Z_2 |111> = (-1)(-1)|111> = +|111> ✓
Both codewords have eigenvalue +1 for both stabilizers, so any superposition alpha|000> + beta|111> is also a +1 eigenstate.
Why Stabilizer Measurements Reveal Errors Without Revealing the Logical State
When an X error occurs on one qubit, it anti-commutes with some stabilizers and commutes with others. Anti-commutation flips the eigenvalue from +1 to -1. The pattern of eigenvalue flips (the syndrome) identifies the error.
Crucially, measuring a stabilizer projects the state into the +1 or -1 eigenspace of that stabilizer. Both |000> and |111> live in the same (+1) eigenspace, so the measurement cannot distinguish between them. This means the measurement reveals nothing about alpha and beta, preserving the logical information.
Syndrome Table for the 3-Qubit Bit-Flip Code
| Error | Z_0 Z_1 eigenvalue | Z_1 Z_2 eigenvalue | Syndrome |
|---|---|---|---|
| No error (I) | +1 | +1 | 00 |
| X on qubit 0 | -1 | +1 | 10 |
| X on qubit 1 | -1 | -1 | 11 |
| X on qubit 2 | +1 | -1 | 01 |
Each single-qubit X error produces a unique syndrome. The decoder reads this 2-bit string and applies the corresponding correction.
Syndrome Measurement Circuit
Syndrome measurement uses two ancilla qubits (qubits 3 and 4) to measure parities Z_0 Z_1 and Z_1 Z_2 without touching the logical information.
- If Z_0 Z_1 = -1, qubits 0 and 1 disagree (one is flipped)
- If Z_1 Z_2 = -1, qubits 1 and 2 disagree
The combination of the two parity measurements is called the syndrome:
| Syndrome (s1 s0) | Error location |
|---|---|
| 00 | No error |
| 01 | Qubit 0 |
| 11 | Qubit 1 |
| 10 | Qubit 2 |
def add_syndrome_measurement(qc, syndrome_reg):
"""
Append ancilla-based syndrome measurement to a 3-qubit data circuit.
Ancilla qubits are at indices 3 and 4.
syndrome_reg: ClassicalRegister of length 2.
"""
# Parity check Z_0 Z_1: ancilla qubit 3
qc.cx(0, 3)
qc.cx(1, 3)
# Parity check Z_1 Z_2: ancilla qubit 4
qc.cx(1, 4)
qc.cx(2, 4)
qc.measure(3, syndrome_reg[0])
qc.measure(4, syndrome_reg[1])
Full Circuit: Encode, Error, Detect, Correct
def bit_flip_code_demo(error_qubit=None):
"""
Full demonstration circuit.
error_qubit: which qubit to flip (0, 1, or 2), or None for no error.
"""
data = QuantumRegister(3, "data")
ancilla = QuantumRegister(2, "anc")
syndrome_cr = ClassicalRegister(2, "s")
qc = QuantumCircuit(data, ancilla, syndrome_cr)
# Encode |+>_L so we have something nontrivial to protect
qc.h(data[0])
qc.cx(data[0], data[1])
qc.cx(data[0], data[2])
qc.barrier(label="encoded")
# Inject a controlled bit-flip error
if error_qubit is not None:
qc.x(data[error_qubit])
qc.barrier(label="error injected")
# Syndrome measurement
add_syndrome_measurement(qc, syndrome_cr)
qc.barrier(label="syndrome measured")
return qc
# Show the circuit with an error on qubit 1
qc_demo = bit_flip_code_demo(error_qubit=1)
print(qc_demo.draw(output="text", fold=90))
Classical Correction and Decoding
After measuring the syndrome, a classical decoder reads the 2-bit result and applies the corresponding X correction. Then we reverse the encoding to recover the original single-qubit state.
def full_correction_circuit(error_qubit=None):
data = QuantumRegister(3, "data")
ancilla = QuantumRegister(2, "anc")
syndrome_cr = ClassicalRegister(2, "s")
out_cr = ClassicalRegister(1, "out")
qc = QuantumCircuit(data, ancilla, syndrome_cr, out_cr)
# Encode |+>_L
qc.h(data[0])
qc.cx(data[0], data[1])
qc.cx(data[0], data[2])
qc.barrier()
# Inject error
if error_qubit is not None:
qc.x(data[error_qubit])
qc.barrier()
# Syndrome measurement
add_syndrome_measurement(qc, syndrome_cr)
qc.barrier()
# Classically controlled correction using syndrome value as integer.
# Counts strings use format "out syndrome" (leftmost = out register).
# Syndrome 0b01 (=1) -> error on qubit 0
# Syndrome 0b11 (=3) -> error on qubit 1
# Syndrome 0b10 (=2) -> error on qubit 2
with qc.if_test((syndrome_cr, 0b01)):
qc.x(data[0])
with qc.if_test((syndrome_cr, 0b11)):
qc.x(data[1])
with qc.if_test((syndrome_cr, 0b10)):
qc.x(data[2])
qc.barrier()
# Decode: reverse the encoding to get back to a single qubit
qc.cx(data[0], data[2])
qc.cx(data[0], data[1])
# Verify: |+>_L -> H -> should always measure 0
qc.h(data[0])
qc.measure(data[0], out_cr[0])
return qc
sim = AerSimulator()
for err in [None, 0, 1, 2]:
qc = full_correction_circuit(error_qubit=err)
t_qc = transpile(qc, sim)
result = sim.run(t_qc, shots=1024).result()
counts = result.get_counts()
# Count strings have format "out_bit syndrome_bits"; out_bit is the leftmost token
out_zero = sum(v for k, v in counts.items() if k.split(" ")[0] == "0")
label = f"qubit {err}" if err is not None else "no error"
print(f"Error on {label:8s}: correct recovery = {out_zero}/1024 = {out_zero/1024:.3f}")
All four cases should recover with 100% fidelity, confirming the code corrects any single-qubit X error.
Logical Gate Operations
Once a qubit is encoded in an error-correcting code, you need to perform computations on it without decoding first. The key question: what do logical gates look like at the physical level?
Logical X Gate
The logical X gate flips the logical state: |0>_L becomes |1>_L and vice versa. Since |0>_L = |000> and |1>_L = |111>, the logical X must flip all three physical qubits:
X_L = X_0 * X_1 * X_2
Logical Z Gate
The logical Z gate applies a phase flip: |0>_L stays as |0>_L, and |1>_L picks up a minus sign. Since Z|0> = |0> and Z|1> = -|1>, applying Z to just the first qubit is sufficient:
Z_L = Z_0
You can verify: Z_0(alpha|000> + beta|111>) = alpha|000> - beta|111> = alpha|0>_L - beta|1>_L. This works because the Z on qubit 0 checks whether qubit 0 is |0> or |1>, and in the code space this perfectly correlates with the logical state.
Transversal Gates
A transversal gate applies the same operation independently to each physical qubit in the code block. The logical X above is transversal: apply X to each of the three physical qubits. Transversal gates are naturally fault-tolerant because an error on one physical qubit during the gate cannot spread to other physical qubits in the same block.
def logical_gate_demo():
"""Demonstrate logical X and logical Z on the 3-qubit bit-flip code."""
sim = AerSimulator()
# Encode |0>_L, apply logical X, decode, measure
qc_x = QuantumCircuit(3, 1)
# Encode |0>_L = |000>
# (qubit 0 starts in |0>, so no prep needed)
qc_x.cx(0, 1)
qc_x.cx(0, 2)
qc_x.barrier()
# Apply logical X = X_0 X_1 X_2
qc_x.x(0)
qc_x.x(1)
qc_x.x(2)
qc_x.barrier()
# Decode
qc_x.cx(0, 2)
qc_x.cx(0, 1)
qc_x.measure(0, 0)
result = sim.run(transpile(qc_x, sim), shots=1024).result()
print("Logical X on |0>_L:")
print(result.get_counts()) # Should be all '1'
# Encode |+>_L, apply logical Z, decode, measure in X basis
qc_z = QuantumCircuit(3, 1)
qc_z.h(0)
qc_z.cx(0, 1)
qc_z.cx(0, 2)
qc_z.barrier()
# Apply logical Z = Z_0
qc_z.z(0)
qc_z.barrier()
# Decode
qc_z.cx(0, 2)
qc_z.cx(0, 1)
# Measure in X basis: H then measure
qc_z.h(0)
qc_z.measure(0, 0)
result = sim.run(transpile(qc_z, sim), shots=1024).result()
print("\nLogical Z on |+>_L (measured in X basis):")
print(result.get_counts()) # Should be all '1' (Z|+> = |->)
logical_gate_demo()
The 3-Qubit Phase-Flip Code
The bit-flip code corrects X errors but is completely blind to Z errors (phase flips). The phase-flip code is the complementary construction: it protects against Z errors by encoding in the Hadamard (X) basis.
Encoding
The logical codewords are:
|0>_L = |+++>
|1>_L = |--->
where |+> = (|0> + |1>)/sqrt(2) and |-> = (|0> - |1>)/sqrt(2).
The encoding circuit first spreads the qubit using CNOTs (just like the bit-flip code), then applies Hadamard gates to all three qubits:
def encode_phase_flip():
"""
Encode |+>_L in the phase-flip code and demonstrate Z-error correction.
Logical |0>_L = |+++>, Logical |1>_L = |--->.
"""
data = QuantumRegister(3, "data")
ancilla = QuantumRegister(2, "anc")
syndrome_cr = ClassicalRegister(2, "s")
out_cr = ClassicalRegister(1, "out")
qc = QuantumCircuit(data, ancilla, syndrome_cr, out_cr)
# Prepare logical |0>_L = |+++>
# Start with |000>, apply H to all three
qc.h(data[0])
qc.h(data[1])
qc.h(data[2])
qc.barrier(label="encoded |0>_L")
# Inject a Z error on qubit 1
qc.z(data[1])
qc.barrier(label="Z error on q1")
# Syndrome measurement for phase-flip code uses X_0X_1 and X_1X_2 stabilizers.
# To measure X-type parity, apply H before and after the CNOT parity check.
# Equivalently, use the circuit: H on data, then Z-parity check, then H on data.
qc.h(data[0])
qc.h(data[1])
qc.h(data[2])
# Now in computational basis: Z parity checks
qc.cx(data[0], ancilla[0])
qc.cx(data[1], ancilla[0])
qc.cx(data[1], ancilla[1])
qc.cx(data[2], ancilla[1])
qc.h(data[0])
qc.h(data[1])
qc.h(data[2])
qc.measure(ancilla[0], syndrome_cr[0])
qc.measure(ancilla[1], syndrome_cr[1])
qc.barrier(label="syndrome measured")
# Correction: apply Z to the identified qubit
with qc.if_test((syndrome_cr, 0b01)):
qc.z(data[0])
with qc.if_test((syndrome_cr, 0b11)):
qc.z(data[1])
with qc.if_test((syndrome_cr, 0b10)):
qc.z(data[2])
qc.barrier()
# Decode: reverse the encoding (H on all qubits)
qc.h(data[0])
qc.h(data[1])
qc.h(data[2])
# If correction worked, we should get |000>
qc.measure(data[0], out_cr[0])
return qc
sim = AerSimulator()
# Test: Z error on each qubit
for err_q in [0, 1, 2]:
data = QuantumRegister(3, "data")
ancilla = QuantumRegister(2, "anc")
syndrome_cr = ClassicalRegister(2, "s")
out_cr = ClassicalRegister(1, "out")
qc = QuantumCircuit(data, ancilla, syndrome_cr, out_cr)
# Encode |0>_L = |+++>
qc.h(data[0])
qc.h(data[1])
qc.h(data[2])
# Inject Z error
qc.z(data[err_q])
# Syndrome in X basis
qc.h(data[0])
qc.h(data[1])
qc.h(data[2])
qc.cx(data[0], ancilla[0])
qc.cx(data[1], ancilla[0])
qc.cx(data[1], ancilla[1])
qc.cx(data[2], ancilla[1])
qc.h(data[0])
qc.h(data[1])
qc.h(data[2])
qc.measure(ancilla[0], syndrome_cr[0])
qc.measure(ancilla[1], syndrome_cr[1])
# Correction
with qc.if_test((syndrome_cr, 0b01)):
qc.z(data[0])
with qc.if_test((syndrome_cr, 0b11)):
qc.z(data[1])
with qc.if_test((syndrome_cr, 0b10)):
qc.z(data[2])
# Decode and measure
qc.h(data[0])
qc.h(data[1])
qc.h(data[2])
qc.measure(data[0], out_cr[0])
t_qc = transpile(qc, sim)
result = sim.run(t_qc, shots=1024).result()
counts = result.get_counts()
out_zero = sum(v for k, v in counts.items() if k.split(" ")[0] == "0")
print(f"Phase-flip code, Z error on qubit {err_q}: recovery = {out_zero}/1024")
# Demonstrate that X errors are NOT corrected by the phase-flip code
print("\nPhase-flip code vs X error (should fail):")
data = QuantumRegister(3, "data")
ancilla = QuantumRegister(2, "anc")
syndrome_cr = ClassicalRegister(2, "s")
out_cr = ClassicalRegister(1, "out")
qc = QuantumCircuit(data, ancilla, syndrome_cr, out_cr)
qc.h(data[0])
qc.h(data[1])
qc.h(data[2])
qc.x(data[1]) # X error instead of Z
qc.h(data[0])
qc.h(data[1])
qc.h(data[2])
qc.cx(data[0], ancilla[0])
qc.cx(data[1], ancilla[0])
qc.cx(data[1], ancilla[1])
qc.cx(data[2], ancilla[1])
qc.h(data[0])
qc.h(data[1])
qc.h(data[2])
qc.measure(ancilla[0], syndrome_cr[0])
qc.measure(ancilla[1], syndrome_cr[1])
with qc.if_test((syndrome_cr, 0b01)):
qc.z(data[0])
with qc.if_test((syndrome_cr, 0b11)):
qc.z(data[1])
with qc.if_test((syndrome_cr, 0b10)):
qc.z(data[2])
qc.h(data[0])
qc.h(data[1])
qc.h(data[2])
qc.measure(data[0], out_cr[0])
t_qc = transpile(qc, sim)
result = sim.run(t_qc, shots=1024).result()
counts = result.get_counts()
out_zero = sum(v for k, v in counts.items() if k.split(" ")[0] == "0")
print(f"X error on qubit 1: recovery = {out_zero}/1024 (expect ~50% failure)")
The phase-flip code corrects Z errors perfectly but cannot detect X errors, confirming it is the dual of the bit-flip code.
The Shor Code: Correcting Arbitrary Errors with 9 Qubits
Neither the bit-flip code nor the phase-flip code alone can protect against arbitrary single-qubit errors. Peter Shor’s insight was to concatenate them: use the phase-flip code as the outer code, then protect each of its three physical qubits with an inner bit-flip code.
Structure
The outer code encodes one logical qubit into three “blocks” using the phase-flip encoding. Each block is then encoded into three physical qubits using the bit-flip encoding. This requires 3 x 3 = 9 physical qubits total.
The logical codewords are:
|0>_L = (|000> + |111>) (|000> + |111>) (|000> + |111>) / 2*sqrt(2)
|1>_L = (|000> - |111>) (|000> - |111>) (|000> - |111>) / 2*sqrt(2)
Each three-qubit block (|000> + |111>)/sqrt(2) or (|000> - |111>)/sqrt(2) is a GHZ state that encodes one qubit of the outer phase-flip code.
How It Corrects Both Error Types
- X errors (bit flips) affect one physical qubit within a block. The inner bit-flip code detects and corrects them using Z-parity checks within each block.
- Z errors (phase flips) on one physical qubit within a block effectively flip the sign of that block’s GHZ state. The outer phase-flip code detects and corrects this using X-parity checks across blocks.
- Y errors (combined bit + phase flip) are handled because Y = iXZ, and the code independently corrects both the X and Z components.
Encoding Circuit Structure
Step 1: Prepare qubit 0 in state alpha|0> + beta|1>
Step 2: Outer encoding (phase-flip code)
CNOT: qubit 0 -> qubit 3
CNOT: qubit 0 -> qubit 6
H on qubits 0, 3, 6
Step 3: Inner encoding (bit-flip code for each block)
Block 0: CNOT 0->1, CNOT 0->2
Block 1: CNOT 3->4, CNOT 3->5
Block 2: CNOT 6->7, CNOT 6->8
The result is a 9-qubit entangled state where any single-qubit Pauli error can be detected and corrected. The Shor code is historically significant as the first quantum code to achieve this, but it is not efficient: 9 physical qubits per logical qubit and it has a low threshold. Modern codes like the Steane code (7 qubits) and the surface code are more practical.
The Error Threshold Theorem
The threshold theorem is the most important theoretical result in quantum error correction. It states:
If the physical error rate p per gate is below a threshold p_th, then by using a concatenated code of sufficient depth d, the logical error rate can be made exponentially small.
The logical error rate scales as:
p_L ~ (p / p_th)^(2^d)
where d is the number of concatenation levels.
Concrete Example
Suppose p = 0.1% (physical error rate) and p_th = 1% (threshold). Then p / p_th = 0.1.
| Concatenation Level (d) | Logical Error Rate |
|---|---|
| 0 (no QEC) | 10^-3 |
| 1 | (0.1)^2 = 10^-2… wait, let’s be precise |
| 1 | ~ 0.1^2 = 10^-2 per logical gate |
| 2 | ~ 0.1^4 = 10^-4 |
| 3 | ~ 0.1^8 = 10^-8 |
| 4 | ~ 0.1^16 = 10^-16 |
With just 3 levels of concatenation, the logical error rate drops to about 10^-8, which is sufficient for many useful quantum algorithms. The catch: each concatenation level multiplies the number of physical qubits. For the Shor code, d=3 requires 9^3 = 729 physical qubits per logical qubit.
Current Hardware Status
Most current quantum hardware operates with two-qubit gate error rates of 0.1% to 1%, which is right at the threshold boundary. This means we are at the transition point where quantum error correction begins to help rather than hurt. Devices below threshold can leverage QEC; devices above threshold cannot.
Surface Code Introduction
The surface code is the leading candidate for practical quantum error correction. It arranges physical qubits on a 2D lattice with only nearest-neighbor interactions, making it compatible with superconducting qubit hardware.
Structure
The surface code places data qubits on the edges of a 2D grid and syndrome qubits (ancillas) on the faces and vertices. Two types of stabilizer checks alternate in a checkerboard pattern:
- X-type stabilizers (plaquette operators): products of X on the four data qubits surrounding a face. These detect Z errors.
- Z-type stabilizers (vertex operators): products of Z on the four data qubits adjacent to a vertex. These detect X errors.
ASCII Diagram: Distance-3 Surface Code
Z---d---Z---d---Z
| | |
d X d X d
| | |
Z---d---Z---d---Z
| | |
d X d X d
| | |
Z---d---Z---d---Z
d = data qubit
X = X-type stabilizer (ancilla)
Z = Z-type stabilizer (ancilla)
In this d=3 surface code, there are 9 data qubits and 8 ancilla qubits (4 X-type, 4 Z-type), totaling 17 qubits. More generally, a distance-d surface code requires d^2 data qubits and (d-1)^2 + (d-1)^2 = roughly d^2 - 1 ancilla qubits, giving approximately 2d^2 physical qubits per logical qubit.
Why the Surface Code Dominates
- High threshold (~1%): the surface code tolerates physical error rates up to about 1% per gate, the highest threshold of any known code with local stabilizers.
- Nearest-neighbor connectivity: all gates are between adjacent qubits on a 2D grid. No long-range interactions needed.
- Efficient decoding: the minimum-weight perfect matching (MWPM) decoder runs in polynomial time and achieves near-optimal error correction.
Qubit Overhead
For a distance-d surface code:
| Distance d | Data Qubits | Total Physical Qubits (approx) | Logical Error Suppression |
|---|---|---|---|
| 3 | 9 | 17 | Minimal (proof of concept) |
| 5 | 25 | 49 | ~10x per distance increase |
| 7 | 49 | 97 | ~100x |
| 11 | 121 | 241 | ~10,000x |
| 21 | 441 | 881 | Sufficient for many algorithms |
The often-cited “1000 physical qubits per logical qubit” assumes d ~ 21-23, which gives logical error rates low enough for algorithms like Shor’s factoring.
Noisy Simulation with AerSimulator
Real hardware has noise. Let us add realistic depolarizing noise to the bit-flip correction circuit and measure how the code performs as the error rate changes.
from qiskit_aer.noise import NoiseModel, depolarizing_error, ReadoutError
from qiskit import QuantumCircuit, QuantumRegister, ClassicalRegister, transpile
from qiskit_aer import AerSimulator
import numpy as np
def build_noisy_bit_flip_circuit():
"""
Build a bit-flip code circuit that encodes |0>_L, applies no intentional
error, performs syndrome measurement and correction, decodes, and measures.
Noise is added via the noise model, not manually.
"""
data = QuantumRegister(3, "data")
ancilla = QuantumRegister(2, "anc")
syndrome_cr = ClassicalRegister(2, "s")
out_cr = ClassicalRegister(1, "out")
qc = QuantumCircuit(data, ancilla, syndrome_cr, out_cr)
# Encode |0>_L
qc.cx(data[0], data[1])
qc.cx(data[0], data[2])
qc.barrier()
# Syndrome measurement
qc.cx(data[0], ancilla[0])
qc.cx(data[1], ancilla[0])
qc.cx(data[1], ancilla[1])
qc.cx(data[2], ancilla[1])
qc.measure(ancilla[0], syndrome_cr[0])
qc.measure(ancilla[1], syndrome_cr[1])
qc.barrier()
# Correction
with qc.if_test((syndrome_cr, 0b01)):
qc.x(data[0])
with qc.if_test((syndrome_cr, 0b11)):
qc.x(data[1])
with qc.if_test((syndrome_cr, 0b10)):
qc.x(data[2])
qc.barrier()
# Decode
qc.cx(data[0], data[2])
qc.cx(data[0], data[1])
qc.measure(data[0], out_cr[0])
return qc
def build_unencoded_circuit():
"""Single physical qubit, prepared in |0>, measured directly."""
qc = QuantumCircuit(1, 1)
qc.measure(0, 0)
return qc
def run_noisy_comparison(error_rates):
"""
Compare the logical error rate of the 3-qubit bit-flip code against
a single unencoded qubit across a range of physical error rates.
"""
shots = 10000
results = []
for p in error_rates:
# Build noise model: depolarizing error on all gates, readout error
noise_model = NoiseModel()
if p > 0:
# Single-qubit gate error
error_1q = depolarizing_error(p / 10, 1)
# Two-qubit gate error (typically 10x worse)
error_2q = depolarizing_error(p, 2)
noise_model.add_all_qubit_quantum_error(error_1q, ['x', 'h', 'z'])
noise_model.add_all_qubit_quantum_error(error_2q, ['cx'])
# Readout error
p_read = p / 2
read_err = ReadoutError([[1 - p_read, p_read], [p_read, 1 - p_read]])
noise_model.add_all_qubit_readout_error(read_err)
sim = AerSimulator(noise_model=noise_model)
# Encoded circuit
qc_enc = build_noisy_bit_flip_circuit()
t_enc = transpile(qc_enc, sim)
res_enc = sim.run(t_enc, shots=shots).result()
counts_enc = res_enc.get_counts()
# Logical error = fraction of shots where output bit is 1
err_enc = sum(v for k, v in counts_enc.items() if k.split(" ")[0] == "1")
logical_err_encoded = err_enc / shots
# Unencoded circuit
qc_raw = build_unencoded_circuit()
t_raw = transpile(qc_raw, sim)
res_raw = sim.run(t_raw, shots=shots).result()
counts_raw = res_raw.get_counts()
err_raw = counts_raw.get("1", 0)
logical_err_unencoded = err_raw / shots
results.append((p, logical_err_unencoded, logical_err_encoded))
return results
error_rates = [0.0, 0.005, 0.01, 0.02, 0.05]
results = run_noisy_comparison(error_rates)
print(f"{'Phys Error Rate':>16s} | {'Unencoded':>12s} | {'3-Qubit Code':>12s} | {'Code Helps?':>12s}")
print("-" * 60)
for p, err_raw, err_enc in results:
helps = "YES" if err_enc < err_raw else ("TIE" if err_enc == err_raw else "NO")
print(f"{p:>16.3f} | {err_raw:>12.4f} | {err_enc:>12.4f} | {helps:>12s}")
At low physical error rates, the encoded circuit has a lower logical error rate than the unencoded qubit. As the physical error rate rises, the overhead of the encoding circuit (which adds more noisy gates) eventually makes the code perform worse than no encoding at all. This crossover point is the code’s effective threshold.
QEC vs. Decoherence: The Race Condition
Quantum error correction is a race against time. Errors accumulate continuously as qubits decohere, characterized by two timescales:
- T1 (energy relaxation): the time for a qubit in |1> to decay to |0>. Governs bit-flip-like errors.
- T2 (dephasing): the time for the relative phase of a superposition to randomize. Always T2 <= 2*T1.
Meanwhile, a QEC cycle (syndrome extraction + classical decoding + correction) takes a finite time t_cycle. For QEC to work, t_cycle must be much shorter than T1 and T2, so the probability of an error accumulating between consecutive syndrome measurements is small.
Numerical Example
| Parameter | Value |
|---|---|
| T1 | 100 microseconds |
| T2 | 80 microseconds |
| Syndrome cycle time (t_cycle) | 1 microsecond |
| Error probability per cycle | ~ t_cycle / T1 = 0.01 |
| Syndrome cycles per T1 | 100 |
With 100 syndrome cycles per T1 time, each cycle has about a 1% chance of encountering an error. The code catches and corrects most of these errors before the next one arrives. If the per-cycle error rate is below the code’s threshold, the logical qubit survives far longer than any single physical qubit.
If instead t_cycle = 10 microseconds (perhaps due to a slow classical decoder), the error probability per cycle jumps to 10%, likely above threshold, and the code fails to extend the qubit’s lifetime.
Fault Tolerance vs. Error Correction
Error correction and fault tolerance are related but distinct concepts that are often confused.
Error correction detects and fixes errors on data qubits, assuming the syndrome measurement itself is perfect. Everything we have built so far assumes ideal ancilla qubits and perfect CNOT gates in the syndrome extraction.
Fault tolerance goes further: it ensures that errors occurring during the syndrome measurement (on ancilla qubits, in CNOT gates, during classical processing) do not cascade into uncorrectable multi-qubit errors on the data.
The Problem: Error Propagation Through Syndrome Extraction
Consider a CNOT gate used during syndrome measurement, with the ancilla as control and a data qubit as target. If the ancilla has an X error before the CNOT, that error propagates to the data qubit:
CNOT * (X_ancilla |psi>_data |0>_ancilla) = X_ancilla X_data |psi>_data |1>_ancilla
A single error on the ancilla has become two errors: one on the ancilla and one on a data qubit. If this happens with two different data qubits, you get a two-qubit data error that a distance-3 code cannot correct.
Shor-Style Syndrome Measurement
Shor’s solution is straightforward: repeat the syndrome measurement multiple times and take a majority vote. If the syndrome is measured 3 times and one measurement is faulty, the other two still give the correct syndrome. This adds overhead (more ancilla qubits, more time) but prevents single ancilla errors from corrupting the data.
Flag Qubits: A Modern Alternative
Flag qubit protocols use additional ancilla qubits (“flags”) that detect when an error has propagated dangerously. If the flag triggers, the decoder knows to look for a specific correlated error pattern. This approach uses fewer ancilla qubits than full syndrome repetition and is particularly efficient for small codes.
Surface Code: State-of-the-Art Experimental Results
Quantum error correction has moved from theory to experiment. Several landmark results demonstrate that the field is making rapid progress toward fault-tolerant quantum computing.
Google (2023)
Google’s quantum AI team demonstrated that increasing the surface code distance from d=3 to d=5 on their superconducting processor reduced the logical error rate, achieving “below-threshold” operation. This was the first convincing demonstration that adding more qubits to a QEC code actually helps rather than hurts on real hardware. The per-round logical error rate dropped by a factor of about 2x when moving from d=3 to d=5.
Harvard/QuEra (2023)
A collaboration between Harvard and QuEra demonstrated 48 logical qubits encoded in a neutral atom quantum computer. They performed transversal entangling gates between logical qubits, achieving the first large-scale demonstration of logical operations. The neutral atom platform allows reconfigurable qubit connectivity by physically moving atoms, which simplifies many aspects of surface code implementation.
IBM Roadmap
IBM targets a system capable of running 100 million gates on 200 logical qubits by 2029, using their “Heron” and subsequent processor generations combined with improved QEC protocols. Their approach emphasizes modular architectures where multiple quantum chips are linked together.
Context and Perspective
These milestones are significant, but practical fault-tolerant quantum computing requires thousands of logical qubits running circuits with millions of logical gates. Current demonstrations involve tens of logical qubits running shallow circuits. The gap between proof-of-concept QEC and useful fault-tolerant computation is still large, though it is closing faster than many expected.
Common Mistakes in Quantum Error Correction
1. Measuring Data Qubits Directly
The most fundamental mistake: measuring data qubits to check for errors. This collapses the superposition and destroys the logical state. Always use ancilla qubits for syndrome extraction. The ancilla-based parity measurement projects the state into an error subspace without revealing the logical information.
2. Thinking QEC “Erases” Errors
QEC does not make errors disappear. It detects errors probabilistically through syndrome measurement and applies corrections. If errors occur faster than the code can detect them, or if multiple errors conspire to mimic a different correctable error (a logical error), the correction fails. QEC reduces the logical error rate, it does not eliminate it.
3. Confusing Error Detection with Error Correction
Error detection tells you that something went wrong. Error correction identifies what went wrong and fixes it. A code that can detect 2 errors may only be able to correct 1. Detecting an uncorrectable error lets you discard the computation and retry, but it does not recover the state. These are different capabilities with different resource requirements.
4. Applying QEC to NISQ Algorithms Without Fault Tolerance
On current noisy intermediate-scale quantum (NISQ) devices, naively wrapping a circuit in an error-correcting code usually makes performance worse. The encoding circuit adds many additional noisy gates, and without fault-tolerant syndrome extraction, errors in the QEC machinery itself introduce more noise than they remove. QEC only helps when the physical error rate is below the code’s threshold and the syndrome extraction is fault-tolerant.
5. Forgetting That Syndrome Measurements Have Errors
In textbook presentations, syndrome measurements are assumed perfect. In reality, ancilla preparation, CNOT gates, and measurement all have errors. A single round of syndrome measurement can give the wrong answer. Fault-tolerant protocols address this by repeating syndrome measurements and using classical decoding over multiple rounds, but beginners often overlook this requirement.
What to Try Next
- Implement the full 9-qubit Shor code in Qiskit and verify it corrects X, Z, and Y errors
- Add depolarizing noise to the phase-flip code simulation and compare its threshold to the bit-flip code
- Explore
qiskit.quantum_info.StabilizerStateto work with stabilizer codes algebraically - Implement a distance-3 repetition code with multiple rounds of syndrome measurement and majority-vote decoding
- Read about the Steane code (7 qubits), the smallest code that corrects arbitrary single-qubit errors with transversal Clifford gates
- See the quantum error correction glossary entry for more on the stabilizer formalism and the logical qubit entry for threshold theorems
- Explore the
stimlibrary for fast stabilizer circuit simulation, particularly useful for surface code research
Was this tutorial helpful?