Quantum Error Correction: Building a 3-Qubit Repetition Code in Qiskit
Implement a 3-qubit bit-flip repetition code from scratch in Qiskit: encoding, error injection, syndrome measurement with ancilla qubits, decoding logic, and fidelity benchmarking across error rates.
Circuit diagrams
Quantum error correction (QEC) is the technology that separates NISQ-era computation from fault-tolerant quantum computing. Without it, noise accumulates faster than useful computation can proceed. The 3-qubit repetition code is the simplest possible QEC code. It cannot correct all error types (it handles only bit-flip errors, not phase flips), but it contains every conceptual ingredient found in production codes: encoding, syndrome measurement, classical decoding, and correction. Building it from scratch in Qiskit makes the logical qubit concept concrete.
The Logical Qubit Concept
Classical computers correct errors by storing redundant copies. The bit “0” might be stored as 000, so that a single flip to 001 is detectable and correctable by majority vote. Quantum mechanics forbids copying an arbitrary quantum state (the no-cloning theorem), so we cannot store redundant copies of |psi>.
What we can do is entangle the logical state across multiple physical qubits in a way that preserves quantum information while making errors detectable. The 3-qubit bit-flip code encodes:
|0_L> = |000>
|1_L> = |111>
An arbitrary logical qubit state alpha|0_L> + beta|1_L> = alpha|000> + beta|111> is encoded across 3 physical qubits. The amplitudes alpha and beta are never copied; they are distributed through entanglement. A bit flip on any one qubit breaks the symmetry and can be identified without learning alpha or beta.
The Encoding Circuit
The encoding circuit takes a single-qubit state alpha|0> + beta|1> on qubit 0 and produces alpha|000> + beta|111> across all three qubits:
from qiskit import QuantumCircuit, QuantumRegister, ClassicalRegister
import numpy as np
def encoding_circuit(initial_state=None):
"""
Encode a single qubit into the 3-qubit repetition code.
initial_state: optional (theta, phi) for Bloch sphere initialization.
If None, encodes |0>.
"""
qr = QuantumRegister(3, 'data')
qc = QuantumCircuit(qr)
# Optionally prepare a non-trivial logical state
if initial_state is not None:
theta, phi = initial_state
qc.ry(theta, qr[0])
qc.rz(phi, qr[0])
# Encoding: spread the state via CNOT
qc.cx(qr[0], qr[1])
qc.cx(qr[0], qr[2])
return qc
# Encode logical |+> = (|0> + |1>)/sqrt(2)
qc = encoding_circuit(initial_state=(np.pi/2, 0))
print(qc.draw(output='text'))
After the two CNOT gates, qubit 0 is entangled with qubits 1 and 2. An X error on any single qubit now flips that qubit out of sync with the others.
Injecting Errors
Real hardware errors are probabilistic. We model a bit-flip channel with error probability p by randomly applying X gates:
import random
def inject_bit_flip_errors(qc, qr, p):
"""Apply X gate to each qubit independently with probability p."""
for i in range(len(qr)):
if random.random() < p:
qc.x(qr[i])
print(f" [Injected X error on qubit {i}]")
return qc
For a single round of correction, we can also inject a fixed error for testing:
def inject_fixed_error(qc, qr, qubit_index):
qc.x(qr[qubit_index])
return qc
Syndrome Measurement with Ancilla Qubits
The crucial constraint: measuring the data qubits directly would collapse the superposition and destroy the logical state. Instead, we measure syndromes: parities of pairs of qubits. Two ancilla qubits serve as the measurement registers.
- Ancilla 0 measures qubit 0 XOR qubit 1 (are they the same?)
- Ancilla 1 measures qubit 1 XOR qubit 2 (are they the same?)
The syndrome measurement circuit:
def syndrome_measurement_circuit(data_qr, anc_qr, syndrome_cr):
"""
Measure bit-flip syndromes without collapsing the logical state.
data_qr: 3 data qubits
anc_qr: 2 ancilla qubits
syndrome_cr: 2 classical bits for syndrome readout
"""
qc = QuantumCircuit(data_qr, anc_qr, syndrome_cr)
# Ancilla 0 detects mismatch between data[0] and data[1]
qc.cx(data_qr[0], anc_qr[0])
qc.cx(data_qr[1], anc_qr[0])
# Ancilla 1 detects mismatch between data[1] and data[2]
qc.cx(data_qr[1], anc_qr[1])
qc.cx(data_qr[2], anc_qr[1])
# Measure ancilla qubits only
qc.measure(anc_qr[0], syndrome_cr[0])
qc.measure(anc_qr[1], syndrome_cr[1])
return qc
The syndrome table tells us exactly which qubit (if any) was flipped:
| Syndrome (s1, s0) | Error location |
|---|---|
| 00 | No error |
| 01 | Qubit 0 |
| 11 | Qubit 1 |
| 10 | Qubit 2 |
Decoding and Correction
Classical decoding maps the syndrome to a correction:
def decode_and_correct(qc, data_qr, syndrome):
"""Apply X correction based on the 2-bit syndrome."""
s0, s1 = syndrome[0], syndrome[1]
if s0 == 1 and s1 == 0:
qc.x(data_qr[0]) # flip qubit 0 back
elif s0 == 1 and s1 == 1:
qc.x(data_qr[1]) # flip qubit 1 back
elif s0 == 0 and s1 == 1:
qc.x(data_qr[2]) # flip qubit 2 back
# s0==0, s1==0: no correction needed
return qc
After correction, the data qubits are returned to the encoded state. Measurement of the data qubits (decoding the logical qubit) then uses majority vote:
def decode_logical_qubit(measurement_outcome):
"""Majority vote over 3 qubit measurements."""
bits = [int(b) for b in measurement_outcome]
return 1 if sum(bits) >= 2 else 0
Full Pipeline: Fidelity vs. Error Rate
Let us run the complete encode-error-correct-decode pipeline and measure logical qubit fidelity at different physical error rates:
from qiskit import QuantumCircuit, QuantumRegister, ClassicalRegister
from qiskit_aer import AerSimulator
import numpy as np
def run_qec_trial(p_error, shots=2000, use_qec=True):
"""
Run the 3-qubit repetition code pipeline.
Returns fraction of shots where the logical qubit is correctly decoded.
"""
data = QuantumRegister(3, 'd')
anc = QuantumRegister(2, 'a')
syn = ClassicalRegister(2, 'syn')
out = ClassicalRegister(3, 'out')
qc = QuantumCircuit(data, anc, syn, out)
# Encode logical |1>
qc.x(data[0])
qc.cx(data[0], data[1])
qc.cx(data[0], data[2])
qc.barrier()
# Error channel: X gate with probability p_error on each qubit
# (Aer can apply a depolarizing/bit_flip noise model; here we use
# Aer's built-in pauli_error for a clean demonstration)
from qiskit_aer.noise import NoiseModel, pauli_error
noise_model = NoiseModel()
bit_flip = pauli_error([('X', p_error), ('I', 1 - p_error)])
noise_model.add_all_qubit_quantum_error(bit_flip, ['id'])
# Insert identity gates as placeholders for the error channel
for i in range(3):
qc.id(data[i])
qc.barrier()
if use_qec:
# Syndrome measurement
qc.cx(data[0], anc[0])
qc.cx(data[1], anc[0])
qc.cx(data[1], anc[1])
qc.cx(data[2], anc[1])
qc.measure(anc[0], syn[0])
qc.measure(anc[1], syn[1])
# Classical feedforward correction using if/else
with qc.if_test((syn, 0b01)):
qc.x(data[0])
with qc.if_test((syn, 0b11)):
qc.x(data[1])
with qc.if_test((syn, 0b10)):
qc.x(data[2])
qc.measure(data, out)
sim = AerSimulator(noise_model=noise_model)
result = sim.run(qc, shots=shots).result()
counts = result.get_counts()
correct = 0
for outcome, count in counts.items():
# outcome format: 'out syn' (registers printed in order of circuit addition,
# last-added register appears leftmost in the string)
data_bits = outcome.split(' ')[0] # the 'out' register (added last, printed first)
logical = 1 if data_bits.count('1') >= 2 else 0
if logical == 1: # we encoded |1_L>
correct += count
return correct / shots
# Sweep error rates
error_rates = [0.01, 0.02, 0.05, 0.08, 0.10, 0.15, 0.20]
print(f"{'p_error':>10} {'Fidelity (no QEC)':>20} {'Fidelity (QEC)':>18}")
print("-" * 52)
for p in error_rates:
fid_raw = run_qec_trial(p, use_qec=False)
fid_qec = run_qec_trial(p, use_qec=True)
print(f"{p:>10.2f} {fid_raw:>20.4f} {fid_qec:>18.4f}")
Expected output pattern (values are stochastic; run with more shots for stability):
p_error Fidelity (no QEC) Fidelity (QEC)
----------------------------------------------------
0.01 1.0000 0.9998
0.02 0.9983 0.9989
0.05 0.9911 0.9937
0.08 0.9826 0.9817
0.10 0.9729 0.9688
0.15 0.9376 0.9418
0.20 0.8953 0.8983
The noise model applies bit-flip errors only to the id (identity) gates used as error placeholders. The CNOT and X gates in the syndrome measurement circuit are noiseless in this simulation, so the QEC improvement is modest. In a realistic noise model where all gates carry errors, the syndrome extraction itself introduces additional noise, and the QEC advantage emerges only when the physical error rate is well below the code threshold (~16% for the repetition code with majority vote decoding). For a clean demonstration of the QEC advantage, compare the theoretical logical error rates: without QEC the logical error rate is approximately 3p(1-p)^2 + p^3; with the repetition code it is 3p^2(1-p) + p^3 (failures require two or more simultaneous errors).
Why This Code Has Limits
The 3-qubit bit-flip code corrects any single X error but is completely blind to Z (phase flip) errors. Because X and Z operators do not commute, the syndrome measurement used here does not detect phase flips. Real quantum computation requires codes that handle both X and Z errors simultaneously, such as the Steane [[7,1,3]] code or the surface code.
The repetition code nonetheless builds every intuition you need:
- Logical qubits are encoded states spanning multiple physical qubits.
- Syndrome measurement extracts error information without collapsing the logical state.
- Decoding maps syndromes to corrections classically.
- Fidelity improves with the code as long as the physical error rate is below the code threshold.
From here, the natural next steps are the Steane code (which adds Z stabilizers to handle phase flips) and the surface code (which scales to arbitrary distance and has the highest known threshold).
Was this tutorial helpful?