Qiskit Intermediate Free 9/61 in series 16 min read

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.

What you'll learn

  • quantum error correction
  • repetition code
  • bit flip
  • syndrome measurement
  • ancilla qubits

Prerequisites

  • Python proficiency
  • Beginner quantum computing concepts (superposition, entanglement)
  • Linear algebra basics

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
00No error
01Qubit 0
11Qubit 1
10Qubit 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?