Bode Plot: Reading and Creating Frequency Response Diagrams with Python Implementation

Bode plot frequency response (magnitude [dB], phase [deg]) on a log axis for filter design (Butterworth, Chebyshev, highpass) with Python and scipy.signal.

Introduction

A Bode plot visualizes the frequency response \(H(j\omega)\) of a linear time-invariant system as two stacked plots: magnitude [dB] and phase [degrees] on a logarithmic frequency axis. Systematized by Hendrik Bode in the 1930s for control engineering, it is now the standard tool for filter design, transfer function analysis, and stability evaluation across signal processing and control.

For filter design specifically, every key trait of Butterworth (maximally flat), Chebyshev (equiripple), highpass and lowpass (roll-off rate) is visible on a Bode plot. This article consolidates the math, reading techniques, and Python implementation so you can use the Bode plot as a cross-cutting hub for understanding all of these filters.

Mathematical Background

Transfer Function and Frequency Response

For a continuous-time LTI system with transfer function \(H(s)\) , the frequency response is obtained by substituting \(s = j\omega\) :

\[H(j\omega) = |H(j\omega)| \, e^{j\angle H(j\omega)} \tag{1}\]

A Bode plot decomposes this complex-valued function into two plots:

  • Magnitude plot: \(20\log_{10}|H(j\omega)|\) vs. \(\log_{10}\omega\)
  • Phase plot: \(\angle H(j\omega)\) (degrees) vs. \(\log_{10}\omega\)

The Decibel Definition

Expressing magnitude in decibels turns multiplication into addition. For two systems \(H_1, H_2\) in series:

\[20\log_{10}|H_1 H_2| = 20\log_{10}|H_1| + 20\log_{10}|H_2| \tag{2}\]

This means a Bode plot of a higher-order filter is the sum of the plots of its first-order factors, which makes complex filters tractable by superposition of simple shapes.

Phase Delay and Group Delay

Two key quantities readable from the phase plot are phase delay and group delay:

\[\tau_p(\omega) = -\frac{\angle H(j\omega)}{\omega}, \quad \tau_g(\omega) = -\frac{d\angle H(j\omega)}{d\omega} \tag{3}\]

Group delay \(\tau_g\) is directly the slope of the phase plot and indicates waveform distortion. A linear-phase FIR filter has a constant group delay, which appears on a Bode plot as a phase that is a strictly linear function of frequency.

Bode Plots of 1st- and 2nd-Order Systems

First-Order Lowpass

\[H(s) = \frac{1}{1 + s/\omega_c} \tag{4}\]

with magnitude

\[|H(j\omega)| = \frac{1}{\sqrt{1 + (\omega/\omega_c)^2}} \tag{5}\]

reads asymptotically as

  • \(\omega \ll \omega_c\) : \(|H|_{dB} \approx 0\) dB (passband)
  • \(\omega = \omega_c\) : \(|H|_{dB} = -3\) dB (cutoff)
  • \(\omega \gg \omega_c\) : \(|H|_{dB} \approx -20\log_{10}(\omega/\omega_c)\) dB (-20 dB/dec roll-off)

The phase passes through \(-45^\circ\) at \(\omega_c\) and asymptotes to \(-90^\circ\) . A first-order highpass is the mirror image: a \(+20\) dB/dec rise in the low band and a phase rotation from \(+90^\circ\) down to \(0^\circ\) .

Second-Order Resonance

For a second-order system

\[H(s) = \frac{\omega_n^2}{s^2 + 2\zeta\omega_n s + \omega_n^2} \tag{6}\]

the magnitude exhibits a resonant peak when \(\zeta < 1/\sqrt{2}\) , with peak value

\[M_p = \frac{1}{2\zeta\sqrt{1-\zeta^2}} \tag{7}\]

Smaller \(\zeta\) gives a sharper peak. The passband ripple of Chebyshev filters and both-band ripple of elliptic filters can be seen as the combined effect of multiple such second-order resonances. The high-frequency roll-off is -40 dB/dec, twice as steep as the first-order case.

Filter Types and Their Bode Plot Signatures

FilterPassbandRoll-offPhase
Butterworth (order \(N\) )Maximally flat\(-20N\) dB/decRelatively smooth
Chebyshev Type IEquiripple\(-20N\) dB/decSteep near band edge
Elliptic (Cauer)Both-band rippleSteepestSteepest
HighpassFlat for \(f > f_c\)\(-20N\) /dec low\(+90N^\circ \to 0^\circ\)
BandpassFlat inside band\(\pm 20N\) /dec\(0^\circ\) at center
NotchDeep dip at \(f_0\)Very steep\(\pm 90^\circ\) flip at \(f_0\)

The Bode plot thus serves as a visual dictionary of filter species: order \(N\) is read from the roll-off slope, attenuation type from the passband/stopband ripple shape, and phase distortion from the curvature of the phase plot.

Python Implementation

Standard Approach with scipy.signal.bode

scipy.signal.bode generates Bode plot data directly from a continuous-time transfer function.

import numpy as np
import matplotlib.pyplot as plt
from scipy import signal

# --- 1st-order lowpass: H(s) = 1 / (1 + s/wc) ---
wc = 2 * np.pi * 100        # Cutoff angular frequency [rad/s] (100 Hz)
num = [1.0]
den = [1.0 / wc, 1.0]
system = signal.TransferFunction(num, den)

# bode returns magnitude [dB] and phase [deg]
w, mag, phase = signal.bode(system, w=np.logspace(0, 4, 1000))
f = w / (2 * np.pi)         # Convert to Hz

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 7), sharex=True)

ax1.semilogx(f, mag, linewidth=2)
ax1.axhline(-3, color='gray', linestyle=':', label='-3 dB')
ax1.axvline(wc / (2 * np.pi), color='r', linestyle='--', label=f'$f_c$ = {wc/(2*np.pi):.0f} Hz')
ax1.set_ylabel('Magnitude [dB]')
ax1.set_title('Bode Plot - 1st-order Lowpass')
ax1.grid(True, which='both', alpha=0.3)
ax1.legend()

ax2.semilogx(f, phase, linewidth=2, color='orange')
ax2.axvline(wc / (2 * np.pi), color='r', linestyle='--')
ax2.axhline(-45, color='gray', linestyle=':', label='-45°')
ax2.set_xlabel('Frequency [Hz]')
ax2.set_ylabel('Phase [degrees]')
ax2.grid(True, which='both', alpha=0.3)
ax2.legend()

plt.tight_layout()
plt.show()

Using semilogx is essential: a linear axis crushes the low-frequency structure, eliminating the Bode plot’s main strength of viewing multi-decade behavior at a glance.

IIR Digital Filters

For IIR digital filters such as Butterworth and Chebyshev, use scipy.signal.freqz instead.

import numpy as np
import matplotlib.pyplot as plt
from scipy import signal

fs = 1000.0           # Sampling frequency [Hz]
fc = 100.0            # Cutoff [Hz]
orders = [2, 4, 8]

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8), sharex=True)

for N in orders:
    sos = signal.butter(N, fc, btype='low', fs=fs, output='sos')
    w, h = signal.sosfreqz(sos, worN=4096, fs=fs)

    # Magnitude (log freq + dB)
    ax1.semilogx(w, 20 * np.log10(np.abs(h) + 1e-12), label=f'N={N}', linewidth=2)
    # Phase (unwrap to remove 360° jumps)
    ax2.semilogx(w, np.degrees(np.unwrap(np.angle(h))), label=f'N={N}', linewidth=2)

ax1.axhline(-3, color='gray', linestyle=':', label='-3 dB')
ax1.axvline(fc, color='r', linestyle='--', alpha=0.5)
ax1.set_ylabel('Magnitude [dB]')
ax1.set_title('Bode Plot - Butterworth Lowpass (digital)')
ax1.set_ylim(-100, 5)
ax1.grid(True, which='both', alpha=0.3)
ax1.legend()

ax2.axvline(fc, color='r', linestyle='--', alpha=0.5)
ax2.set_xlabel('Frequency [Hz]')
ax2.set_ylabel('Phase [degrees]')
ax2.set_xlim(1, fs / 2)
ax2.grid(True, which='both', alpha=0.3)
ax2.legend()

plt.tight_layout()
plt.show()

Each unit increase in order \(N\) steepens the high-frequency roll-off by 20 dB/dec and deepens the phase rotation by an additional \(-90^\circ\) . This is the best exercise to develop intuition for predicting filter characteristics from order alone.

Side-by-Side Comparison of Multiple Filters

Computing each filter manually lets us overlay their Bode plots for direct comparison.

import numpy as np
import matplotlib.pyplot as plt
from scipy import signal

fs = 1000.0
fc = 100.0
N = 4
f = np.logspace(0, np.log10(fs / 2), 2000)

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8), sharex=True)

filters = {
    'Butterworth':  signal.butter(N, fc, btype='low', fs=fs, output='ba'),
    'Chebyshev I':  signal.cheby1(N, 1.0, fc, btype='low', fs=fs, output='ba'),
    'Chebyshev II': signal.cheby2(N, 40.0, fc, btype='low', fs=fs, output='ba'),
    'Elliptic':     signal.ellip(N, 1.0, 40.0, fc, btype='low', fs=fs, output='ba'),
}

for name, (b, a) in filters.items():
    _, h = signal.freqz(b, a, worN=f, fs=fs)
    ax1.semilogx(f, 20 * np.log10(np.abs(h) + 1e-12), label=name, linewidth=2)
    ax2.semilogx(f, np.degrees(np.unwrap(np.angle(h))), label=name, linewidth=2)

ax1.axvline(fc, color='r', linestyle='--', alpha=0.5, label=f'$f_c$ = {fc} Hz')
ax1.axhline(-3, color='gray', linestyle=':', alpha=0.5)
ax1.set_ylabel('Magnitude [dB]')
ax1.set_title(f'Bode Plot Comparison (N={N})')
ax1.set_ylim(-80, 5)
ax1.grid(True, which='both', alpha=0.3)
ax1.legend()

ax2.set_xlabel('Frequency [Hz]')
ax2.set_ylabel('Phase [degrees]')
ax2.grid(True, which='both', alpha=0.3)
ax2.legend()

plt.tight_layout()
plt.show()

At identical order and cutoff, the Butterworth’s flat passband, Chebyshev I’s passband ripple, Chebyshev II’s stopband ripple, and the elliptic filter’s both-band ripple can be directly compared on the same Bode plot.

Design Guidelines

Choosing the Order from Cutoff Specifications

Given a passband edge \(f_p\) with maximum attenuation \(A_p\) [dB] and stopband edge \(f_s\) with minimum attenuation \(A_s\) [dB], the Butterworth order is

\[N \geq \frac{\log_{10}\!\left(\dfrac{10^{A_s/10} - 1}{10^{A_p/10} - 1}\right)}{2\log_{10}(f_s / f_p)} \tag{8}\]

which is essentially “from two points on the Bode plot, infer the slope and back out the required roll-off rate.scipy.signal.buttord automates this.

Watch the Phase

A steeper magnitude roll-off comes at the cost of stronger phase rotation and more frequency-dependent group delay:

  • Audio and medical signals (avoid phase distortion): use FIR for linear phase, or apply filtfilt for zero-phase processing
  • Real-time control (minimize group delay): Bessel filter or low-order Butterworth
  • Passband flatness is top priority: Butterworth
  • Sharpest transition is top priority: Chebyshev or elliptic

The phase plot of a Bode diagram captures this magnitude/phase trade-off at a single glance.

Relation to Adaptive Filters

Bode plots only make sense for time-invariant fixed filters. Adaptive filters such as LMS/RLS have coefficients that vary over time, so a Bode plot is only a snapshot of one instant. Conversely, the Bode plot of a target response can be the goal that an adaptive filter learns to approximate.

Connection to FFT and PSD

A Bode plot describes the filter itself. To see how a real signal behaves after passing through it, combine the filter with FFT and windowing + PSD estimation. From input spectrum \(X(f)\) and output spectrum \(Y(f) = H(f) X(f)\) , estimating \(|H(f)| = |Y(f)/X(f)|\) gives an empirical Bode plot, which is the fundamental principle of system identification.

Summary

  • A Bode plot shows the frequency response \(H(j\omega)\) as magnitude [dB] + phase [deg] on a log frequency axis
  • Decibels convert serial cascading into addition; the log axis makes multi-decade behavior uniformly readable
  • A first-order pole/zero gives \(\pm 20\) dB/dec, a second-order resonance gives \(\pm 40\) dB/dec, and an \(N\) -th order filter gives \(\pm 20N\) dB/dec
  • The unique signatures of Butterworth, Chebyshev, elliptic, highpass, bandpass, and notch filters all appear directly on a Bode plot
  • In Python, scipy.signal.bode (continuous-time) and scipy.signal.freqz (discrete-time) are the standard tools

See the related articles below for the design theory of each specific filter type.

References