Crank–Nicolson Method for the Time-Dependent Schrödinger Equation: Step-by-Step Guide

Last Updated on November 30, 2025 by Dr. Sushanta Barman

Numerical simulation of wave packet dynamics plays an important role in understanding quantum systems. It helps us study how particles behave at very small scales. These simulations support the development of tools and technologies used in quantum optics, chemical physics, and nanotechnology.

Spectral methods, such as the split-step Fourier method, work very well when the Hamiltonian can be separated into kinetic and potential energy terms. However, this is not always possible for complex potentials. In such cases, finite-difference methods like the Crank–Nicolson (CN) scheme are very useful. CN is unconditionally stable and provides high accuracy for a wide range of potentials.

The Crank–Nicolson method was introduced by John Crank and Phyllis Nicolson in 1947 for solving the heat equation [1]. It extended earlier implicit finite-difference ideas by providing stability and second-order accuracy in time. Since then, it has become a widely used numerical scheme for partial differential equations in physics and engineering.

In this tutorial, we will derive the CN discretization of the one-dimensional time-dependent Schrödinger equation (TDSE), show how it leads to a tridiagonal linear system, and implement the Thomas algorithm for efficient time stepping.

Finally, we will go through an example using Python code to propagate a Gaussian wave packet towards a square potential barrier and calculate its transmission probability.

The time-dependent schrödinger equation (TDSE) in one dimension is given by,

\[i\hbar\,\frac{\partial\psi(x,t)}{\partial t}\;=\;-\frac{\hbar^2}{2m}\,\frac{\partial^2\psi(x,t)}{\partial x^2}\;+\;V(x)\,\psi(x,t),\,\,\,\,\,(1)\]

where \(\psi(x,t)\) denotes the wave function, \(m\) is the particle mass, \(\hbar\) is the reduced Planck constant, and \(V(x)\) represents the potential energy.

The TDSE is also written as,

\[ i\hbar\,\frac{\partial \psi(x,t)}{\partial t} = H\psi(x,t),\,\,\,\,\,(2)\]

where \( H \) is the Hamiltonian operator given by, \( H = -\frac{\hbar^2}{2m}\,\frac{\partial^2}{\partial x^2} + V(x). \)

We seek a stable, accurate scheme to march \(\psi\) forward in time by discrete steps \(\Delta t\).

Idea behind CN scheme

In this method, we estimate the derivative at the midpoint in time as,

\[\frac{\partial \psi}{\partial t} \approx \frac{\psi^{n+1}-\psi^n}{\Delta t}.\]

But the right-hand side uses an average of Hamiltonian terms:

\[H\psi \approx \frac{1}{2}(H\psi^{n+1} + H\psi^n)\]

Inserting it into the TDSE (Eq. (2)),

\[i\hbar \frac{\psi^{n+1}-\psi^n}{\Delta t} = \frac{1}{2}H(\psi^{n+1} + \psi^n)\]

After rearranging,

\[\underbrace{\left(I + \frac{i\Delta t}{2\hbar}H\right)}_{A} \psi^{n+1} = \underbrace{\left(I – \frac{i\Delta t}{2\hbar}H\right)}_{B} \psi^n\]

This gives a linear equation,

\[A\psi^{n+1} = B\psi^n.\,\,\,\,\,(3)\]

The matrices \(A\) and \(B\) are tridiagonal (due to second-order spatial finite-difference), so solving is very fast!

To advance the wave function from time step \(n\) to \(n+1\), the Crank–Nicolson scheme solves the above linear matrix equation (Eq. (3)).

Explicit form of the linear system

We first define the constants:

\[\alpha = \frac{i\hbar \Delta t}{4m \Delta x^2}, \qquad \beta = \frac{i \Delta t}{2\hbar}, \qquad t_0 = \frac{\hbar^2}{m\Delta x^2} \]

Matrix \(A\): Implicit operator (left-hand side) of Eq. (3)

The matrix \(A\) contains the future-time wave function values and ensures numerical stability. Its entries are

\[A_{j,j} = 1 + \beta\left(t_0 + V_j\right), \qquad A_{j,j\pm1} = -\alpha\]

Thus, \(A\) is a tridiagonal matrix,

\[A =\begin{pmatrix}1+\beta(t_0+V_1) & -\alpha & 0 & \cdots & 0 \\-\alpha & 1+\beta(t_0+V_2) & -\alpha & \cdots & 0 \\0 & -\alpha & 1+\beta(t_0+V_3) & \cdots & 0 \\\vdots & \vdots & \vdots & \ddots & -\alpha \\0 & 0 & 0 & -\alpha & 1+\beta(t_0+V_{N-1})\end{pmatrix}\]

Matrix \(B\): Explicit operator (right-hand side) of Eq. (3)

The matrix \(B\) contains values from the current time step and plays the role of a prediction step. Its entries are

\[ B_{j,j} = 1 – \beta\left(t_0 + V_j\right), \qquad B_{j,j\pm1} = +\alpha \]

The matrix form is

\[B =\begin{pmatrix}1-\beta(t_0+V_1) & +\alpha & 0 & \cdots & 0 \\+\alpha & 1-\beta(t_0+V_2) & +\alpha & \cdots & 0 \\0 & +\alpha & 1-\beta(t_0+V_3) & \cdots & 0 \\\vdots & \vdots & \vdots & \ddots & +\alpha \\0 & 0 & 0 & +\alpha & 1-\beta(t_0+V_{N-1})\end{pmatrix}\]

Finally, Eq. (3) must be solved at every time step to obtain \(\psi^{n+1}\). Because \(A\) is tridiagonal, the system can be efficiently solved using the Thomas algorithm, which scales as \(O(N)\).

Boundary Conditions

To enforce hard-wall boundaries (infinite potential well condition):

\[ \psi_0 = 0, \qquad \psi_N = 0\]

Thus, the first and last components are fixed and are not included in the solved system.

Once the Crank-Nicolson discretization is written in matrix form (Eq. (3)), our task at each time step is to solve for the new wave function \(\psi^{n+1}\).

Directly inverting \(A\) would require \(\mathcal{O}(N^3)\) computational effort, which becomes extremely expensive for realistic grid sizes.

However, the tridiagonal structure allows us to use the Thomas algorithm, a simplified version of Gaussian elimination that reduces the cost to only \(\mathcal{O}(N)\).

This is what makes the CN method fast enough for real quantum simulations.

What the Thomas algorithm does

The equation,

\[A \psi^{n+1} = d, \]

where \(d = B\psi^n\) represents a system of (N-1) linear equations in (N-1) unknowns (excluding boundary nodes). The Thomas algorithm solves this in two phases:

(1) Forward sweep: Elimination

We eliminate the lower diagonal elements of \(A\), creating modified coefficients:

\[c’_j, \text{ and } d’_j\]

These represent the updated upper-diagonal and the updated right-hand side values after elimination. All operations move from left to right (from index \(j=1\) to \(N-1\)).

This step transforms the system into an upper triangular matrix, making back-substitution possible.

(2) Backward substitution

Once elimination is complete in the first step, we solve for the wave function elements,

\[\psi^{n+1}_{N-1},\ \psi^{n+1}_{N-2},\ \dots,\ \psi^{n+1}_{1}\]

All operations move from right to left. This phase efficiently reconstructs \(\psi^{n+1}\) without explicitly inverting \(A\).

Why we precompute coefficients

The matrix \(A\) does not change in time for a time-independent potential \(V(x)\).
Therefore, the forward-sweep coefficients \(c’_j\) can be computed once before the time-loop begins. This significantly speeds up the full simulation.

Full CN + Thomas update at each time step

\[\bbox[5px,border:1px solid #000]{\text{Given }\psi^n \Rightarrow \begin{cases}d = B \psi^n & \text{(construct RHS)} \\A \psi^{n+1} = d & \text{(solve using Thomas algorithm)}\end{cases}}\]

Finally, enforce Dirichlet boundary conditions,

\[\psi_0^{n+1} = \psi_N^{n+1} = 0.\]

Key advantages for CN

The Crank–Nicolson method offers several key computational advantages: (i) it has a linear computation cost of \(\mathcal{O}(N)\), (ii) it avoids explicit matrix inversion, which makes the algorithm more stable and efficient, and (iii) it scales well for large spatial grids, typically in the range of \(10^5 – 10^6\) points.

The numerical example considers a free electron wave packet traveling toward a square potential barrier located at the center of a one-dimensional spatial domain, as shown in Fig. 1.

The initial state is a normalized Gaussian wave packet with width \(\sigma_x = 20\ \text{nm}\), centered at \(x_0 = -200\ \text{nm}\), and assigned a positive momentum \(p_0 = \hbar k_0\), corresponding to a de Broglie wavelength of \(5\ \text{nm}\). This ensures the packet moves from left to right toward the barrier.

The potential consists of a finite-width rectangular barrier of width \(10\ \text{nm}\) and height \(1.02,(\hbar^2 k_0^2 / 2m)\), as shown in Fig. 1. Since the barrier height is slightly above the kinetic energy of the electron, the wave packet undergoes quantum tunneling, resulting in partial transmission and partial reflection.

Hard-wall boundary conditions \(\psi = 0\) at both edges mimic an infinite potential well that prevents the wave packet from leaving the simulation domain.

Figure 1: Normalized probability density of an initial Gaussian electron wave packet (blue) approaching a square potential barrier (black). The wave packet is centered near \(x \approx -200,\text{nm}\) and is assigned a positive momentum \(p_0 = \hbar k_0\), indicating propagation toward the barrier located at \(x = 0\). The vertical scale is normalized, and the potential barrier height is shown in arbitrary units. This corresponds to the initial time step \((t = 0)\) of the Crank–Nicolson simulation.

Below is a complete Python implementation. Comments highlight key steps.

Wave Packet Simulation
📄 Show Code

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from matplotlib.animation import FFMpegWriter

# --------------------------------------------
# Global style for plots
# --------------------------------------------
plt.rcParams.update({
    "font.family": "serif",
    "mathtext.fontset": "stix",
    "font.size": 16,
    "axes.labelsize": 16,
    "axes.titlesize": 16,
    "axes.linewidth": 1.2,
    "xtick.direction": "in",
    "ytick.direction": "in",
    "xtick.top": True,
    "ytick.right": True,
    "xtick.major.size": 6,
    "ytick.major.size": 6,
    "legend.frameon": False
})

# --------------------------------------------
# 1. Physical constants and basic parameters
# --------------------------------------------
hbar = 1.0545718e-34
m_e  = 9.10938356e-31

wavelength = 5e-9
k0 = 2 * np.pi / wavelength

# --------------------------------------------
# 2. Spatial and temporal grids
# --------------------------------------------
Lx = 1e-6
Nx = 4024
dx = Lx / Nx
x  = np.linspace(-Lx/2, Lx/2, Nx)

dt = 0.2e-15
Nt = 16000

# --------------------------------------------
# 3. Initial Gaussian wave packet
# --------------------------------------------
sigma_x = 20e-9
x0      = -2e-7
kx0     = k0

def initial_wavefunction():
    psi_init = np.exp(-(x - x0)**2 / (2 * sigma_x**2)) * np.exp(1j * kx0 * x)
    norm_init = np.sqrt(np.sum(np.abs(psi_init)**2) * dx)
    psi_init /= norm_init
    return psi_init

psi = initial_wavefunction()

# --------------------------------------------
# 4. Potential barrier
# --------------------------------------------
barrier_width  = 10e-9
barrier_height = 1.02 * hbar**2 * k0**2 / (2 * m_e)

V = np.zeros_like(x)
V[np.abs(x) < barrier_width / 2] = barrier_height

# --------------------------------------------
# 5. Crank–Nicolson
# --------------------------------------------
alpha = 1j * hbar * dt / (4 * m_e * dx**2)
beta  = 1j * dt / (2 * hbar)
t0    = hbar**2 / (m_e * dx**2)

off_diag_A = -alpha
off_diag_B =  alpha

diag_A = 1 + beta * (t0 + V)
diag_B = 1 - beta * (t0 + V)

a = np.full(Nx - 1, off_diag_A, dtype=complex)
b = diag_A.copy()
c = np.full(Nx - 1, off_diag_A, dtype=complex)

c_prime = np.zeros(Nx - 1, dtype=complex)
c_prime[0] = c[0] / b[0]
for i in range(1, Nx - 1):
    denom = b[i] - a[i - 1] * c_prime[i - 1]
    c_prime[i] = c[i] / denom

def thomas_solve(d):
    d_prime = np.zeros_like(d, dtype=complex)
    d_prime[0] = d[0] / b[0]
    for i in range(1, Nx):
        denom = b[i] - a[i - 1] * c_prime[i - 1]
        d_prime[i] = (d[i] - a[i - 1] * d_prime[i - 1]) / denom

    psi_new = np.zeros_like(d, dtype=complex)
    psi_new[-1] = d_prime[-1]
    for i in range(Nx - 2, -1, -1):
        psi_new[i] = d_prime[i] - c_prime[i] * psi_new[i + 1]
    return psi_new

# --------------------------------------------
# 7. Animation setup
# --------------------------------------------
fig, ax = plt.subplots(figsize=(8, 4.5))

rho = np.abs(psi)**2
rho_line, = ax.plot(
    x * 1e9,
    rho / np.max(rho),
    color='b',
    lw=2.0,
    label=r"$|\psi(x,t)|^2$"
)

V_scaled = V / np.max(V) if np.max(V) != 0 else V
V_line,  = ax.plot(
    x * 1e9,
    V_scaled,
    color='k',
    linestyle="-",
    lw=2.0,
    label=r"$V(x)$ (scaled)"
)

ax.set_xlabel(r"$x\ \mathrm{(nm)}$")
ax.set_ylabel(r"Probability density (arb. units)")
title = ax.set_title(f"Step 0 / {Nt}   Transmission = 0.0000")
ax.legend(loc="upper right")
ax.set_ylim(-0.05, 1.5)

fig.tight_layout()
fig.savefig("wavepacket_initial_t0.png", dpi=400, bbox_inches="tight")
print("Initial plot saved as: wavepacket_initial_t0.png")

steps_per_frame = 50
n_blocks = Nt // steps_per_frame
n_frames = n_blocks + 1

def update(frame):
    global psi

    if frame == 0:
        psi = initial_wavefunction()
        current_step = 0
    else:
        for _ in range(steps_per_frame):
            d = diag_B * psi
            d[1:]  += off_diag_B * psi[:-1]
            d[:-1] += off_diag_B * psi[1:]

            psi = thomas_solve(d)
            psi[0]  = 0.0 + 0.0j
            psi[-1] = 0.0 + 0.0j

            norm = np.sqrt(np.sum(np.abs(psi)**2) * dx)
            psi /= norm

        current_step = frame * steps_per_frame
        if current_step > Nt:
            current_step = Nt

    rho = np.abs(psi)**2
    transmission = np.sum(rho[x > barrier_width / 2]) * dx

    rho_line.set_ydata(rho / np.max(rho))
    title.set_text(f"Step {current_step} / {Nt}   Transmission = {transmission:.4f}")

    return rho_line, V_line, title

anim = FuncAnimation(
    fig,
    update,
    frames=n_frames,
    interval=100,
    blit=False,
    repeat=False
)

fig.tight_layout()
plt.show()

# --------------------------------------------
# 8. Final figure
# --------------------------------------------
rho_final = np.abs(psi)**2

fig_final, ax_final = plt.subplots(figsize=(4, 2.25))
ax_final.plot(
    x * 1e9,
    rho_final / np.max(rho_final),
    lw=2.0,
    color='b',
    label=r"$|\psi(x,t_{\mathrm{final}})|^2$"
)
ax_final.plot(
    x * 1e9,
    V_scaled,
    color='k',
    linestyle="-",
    lw=2,
    label=r"$V(x)$ (scaled)"
)

ax_final.set_xlabel(r"$x\ \mathrm{(nm)}$")
ax_final.set_ylabel(r"Probability density (arb. units)")
ax_final.set_title(rf"Final state after {Nt} time steps")
ax_final.legend(loc="upper right")
ax_final.set_ylim(-0.05, 1.5)
fig_final.tight_layout()

fig_final.savefig(f"wavepacket_final_t{Nt}.png", dpi=400, bbox_inches="tight")
print(f"Final plot saved as: wavepacket_final_t{Nt}.png")

# --------------------------------------------
# 9. Save animation
# --------------------------------------------
writer = FFMpegWriter(fps=30)
anim.save("cn_wavepacket_tunneling.mp4", writer=writer)
print("Animation saved as: cn_wavepacket_tunneling.mp4")

            
ParameterValue
Domain length (\(L_x\))1 μm
Number of grid points (\(N_x\))4024
Spatial resolution (\(\Delta x\))≈ 0.25 nm
Time step (\(\Delta t\))0.2 fs
Total number of steps (\(N_t\))16000
Initial position (\(x_0\))−200 nm
Wave-packet width (\(\sigma_x\))20 nm
Wave number (\(k_0\))\(2\pi/5\,\mathrm{nm}^{-1}\)
Barrier width10 nm
Barrier height\(1.02\,(\hbar^2 k_0^2 / 2m)\)
Table I: Values of the simulation parameters used in the above code. These choices ensure good spatial resolution of the wave packet and stable time stepping.
Video 1: Time evolution of a Gaussian electron wave packet incident on a square potential barrier, simulated using the Crank–Nicolson method. The animation shows partial reflection and quantum tunneling, with the transmission probability tracked throughout the propagation.

The simulation result is presented as an animation in Video 1. The Crank–Nicolson method smoothly evolves the electron wave packet as it moves toward the potential barrier. The results show two clear quantum behaviors: wave-packet spreading and tunneling.

At the start, the Gaussian wave packet travels freely from the left. It moves toward the barrier due to its initial momentum. When the wave packet reaches the barrier, the interaction divides it into two parts:

(a) Reflection
A portion of the wave packet is reflected back to the left, as shown in Video 1. The reflected part spreads more due to the interference between the incoming and reflected waves.

(b) Transmission (Quantum Tunneling)
Another part of the wave packet tunnels through the barrier and appears on the right side, as shown in Video 1. Its amplitude is smaller because the wave decays while crossing the barrier. This confirms that the particle has less energy than the barrier height, but still has a finite probability of crossing it.

During the simulation, the total probability remains constant, showing that the Crank–Nicolson method preserves normalization. The wave-function density evolves smoothly without numerical instability or unwanted oscillations. This demonstrates that the method is stable, accurate, and suitable for simulating quantum tunneling problems.

The Crank–Nicolson scheme provides a robust, accurate, and stable framework for simulating time-dependent quantum dynamics with arbitrary potentials. By reducing each time‑step update to solving a simple tridiagonal linear system, one achieves \(O(N)\) performance and unconditional stability.

This tutorial walked through the mathematical derivation, implementation via the Thomas algorithm, and a complete Python code example that tracks a Gaussian wave packet interacting with a square barrier.

You can modify the code to try different potentials or simulate higher-dimensional systems to study various quantum effects.

  1. J. Crank, P. Nicolson, “A practical method for numerical evaluation of solutions of partial differential equations of the heat-conduction type,” Mathematical Proceedings of the Cambridge Philosophical Society 43, 50-67 (1947). doi:10.1017/S0305004100023197
  2. Sushanta Barman, “Why Physicists Love Gaussian Wave Packets” MatterWaveX.Com, May 25, (2025).
  3. Sushanta Barman, “Split-Step Fourier Method for TDSE: Step-by-Step Guide”  MatterWaveX.Com, August 18, (2025).
  4. Sushanta Barman, “Schrödinger Equation for Matter-Wave dynamicsMatterWaveX.Com, September 7, (2024).

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top