In this tutorial, we explain how to draw a Bode plot of a second order transfer function. The YouTube tutorial given below explains how to derive and sketch Bode plots by hand.
We consider a prototype of a second order transfer function
(1) ![]()
where
is the complex Laplace variable
is the natural undamped frequency
is the damping ratio
For example, consider the following transfer function
(2) ![]()
We can write this transfer function as follows
(3) ![]()
From this equation, we obtain
(4) 
The first step is to transform the transfer function as follows
(5) 
Next, we obtain the sinusoidal transfer function, by replacing
, where
is the angular frequency, and
:
(6) 
Next, let us recapitulate the polar form of a complex number. For example, consider a complex number
(7) ![]()
This complex number can be written in the polar form as
(8) ![]()
(9) ![]()
To derive the Bode plot, we need to write the sinusoidal transfer function in the polar form, we have
(10) 
The complex number in the denominator can be written in the polar form
(11) 
Consequently, the sinusoidal transfer function can be written as
(12) ![]()
Where the magnitude function
is
(13) 
and the phase function is
(14) 
The Bode plot consists of the log manitude function
(15) 
and phase
(16) 
The Bode plot for different values of
and
is given below.



The Python code is given below.
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
from scipy import signal
# Explicitly maintain the color cycle from the previous plots
colors = ['C0', 'C1', 'C2', 'C3', 'C4', 'C5']
zeta_values = [0.1, 0.2, 0.3, 0.5, 0.7, 1.0]
omega_n = 1.0
# Frequency range matching the textbook layout (0.1 to 10)
w = np.logspace(-1, 1, 1000)
# Setup common ticks for the frequency (x-axis)
x_ticks = [0.1, 0.2, 0.4, 0.6, 0.8, 1, 2, 4, 6, 8, 10]
# Geometric configurations for annotations
mag_annotation_config = {
0.1: {'text_pos': (2.4, 13), 'target_w': 1.15},
0.2: {'text_pos': (2.4, 9), 'target_w': 1.25},
0.3: {'text_pos': (2.4, 5), 'target_w': 1.35},
0.5: {'text_pos': (2.4, 1), 'target_w': 1.45},
0.7: {'text_pos': (2.4, -3), 'target_w': 1.55},
1.0: {'text_pos': (2.4, -7), 'target_w': 1.65}
}
phase_annotation_config = {
0.1: {'text_pos': (0.22, -15), 'target_w': 0.48},
0.2: {'text_pos': (0.22, -25), 'target_w': 0.54},
0.3: {'text_pos': (0.22, -35), 'target_w': 0.60},
0.5: {'text_pos': (0.22, -45), 'target_w': 0.66},
0.7: {'text_pos': (0.22, -55), 'target_w': 0.72},
1.0: {'text_pos': (0.22, -65), 'target_w': 0.78}
}
def apply_dense_numbered_grid(ax, is_mag=True):
"""Formats the axes with custom x-ticks and a highly dense, fully-numbered y-grid."""
ax.set_xscale('log')
ax.set_xticks(x_ticks)
ax.get_xaxis().set_major_formatter(plt.ScalarFormatter())
if is_mag:
# Places a grid line and a number label every 2 dB
ax.yaxis.set_major_locator(ticker.MultipleLocator(2))
else:
# Places a grid line and a number label every 15 degrees
ax.yaxis.set_major_locator(ticker.MultipleLocator(15))
# Style the grid lines and adjust tick label size slightly for readability
ax.grid(True, which='major', linestyle='--', color='gray', alpha=0.5, lw=0.8)
ax.tick_params(axis='y', labelsize=9)
# =================================================================
# 1. GENERATE COMBINED GRAPH
# =================================================================
fig, (ax_mag, ax_phase) = plt.subplots(2, 1, figsize=(10, 13), sharex=True)
# Plot Asymptotes on Combined
w_asymp_low = np.logspace(-1, 0, 100)
w_asymp_high = np.logspace(0, 1, 100)
ax_mag.plot(w_asymp_low, np.zeros_like(w_asymp_low), 'k--', lw=1.5)
ax_mag.plot(w_asymp_high, -40 * np.log10(w_asymp_high / omega_n), 'k--', lw=1.5)
ax_mag.annotate('Asymptotes', xy=(0.95, -0.5), xytext=(0.25, -5),
arrowprops=dict(arrowstyle="->", color="black", lw=1), fontsize=11)
ax_mag.annotate('', xy=(1.4, -6), xytext=(0.35, -5),
arrowprops=dict(arrowstyle="->", color="black", lw=1))
handles = []
for zeta, color in zip(zeta_values, colors):
num = [omega_n**2]
den = [1, 2 * zeta * omega_n, omega_n**2]
sys = signal.TransferFunction(num, den)
w_out, mag, phase = signal.bode(sys, w)
line, = ax_mag.plot(w_out, mag, color=color, lw=2.5, label=f'
')
handles.append(line)
ax_phase.plot(w_out, phase, color=color, lw=2.5)
# Magnitude Annotations
m_cfg = mag_annotation_config[zeta]
mag_target_y = np.interp(m_cfg['target_w'], w_out, mag)
ax_mag.text(m_cfg['text_pos'][0], m_cfg['text_pos'][1], f'
',
fontsize=11, va='center', ha='left', color='black')
ax_mag.annotate('', xy=(m_cfg['target_w'], mag_target_y), xytext=(m_cfg['text_pos'][0], m_cfg['text_pos'][1]),
arrowprops=dict(arrowstyle="->", color=color, lw=1.5))
# Phase Annotations
p_cfg = phase_annotation_config[zeta]
phase_target_y = np.interp(p_cfg['target_w'], w_out, phase)
ax_phase.text(p_cfg['text_pos'][0], p_cfg['text_pos'][1], f'
',
fontsize=11, va='center', ha='right', color='black')
ax_phase.annotate('', xy=(p_cfg['target_w'], phase_target_y), xytext=(p_cfg['text_pos'][0], p_cfg['text_pos'][1]),
arrowprops=dict(arrowstyle="->", color=color, lw=1.5))
# Apply the dense numbered grids
apply_dense_numbered_grid(ax_mag, is_mag=True)
apply_dense_numbered_grid(ax_phase, is_mag=False)
ax_mag.set_ylabel('
(dB)', fontsize=13)
ax_mag.set_ylim(-25, 21) # Expanded slightly to align nicely with a 2dB step
ax_mag.axhline(0, color='black', lw=1.2)
ax_mag.legend(handles=handles, loc='upper right', frameon=True, fontsize=10)
ax_mag.set_title('Bode Plot: Second-Order System Frequency Response', fontsize=14, pad=15)
ax_phase.set_xlabel('
', fontsize=14)
ax_phase.set_ylabel('
(degrees)', fontsize=13)
ax_phase.set_ylim(-195, 15)
ax_phase.axhline(0, color='black', lw=1.2)
ax_phase.axhline(-90, color='gray', linestyle=':', lw=1.2)
plt.tight_layout()
plt.savefig("bode_plot_combined.png", dpi=600, bbox_inches='tight')
plt.close()
# =================================================================
# 2. GENERATE STANDALONE LOG-MAGNITUDE GRAPH
# =================================================================
fig_mag, ax_m = plt.subplots(figsize=(10, 7))
ax_m.plot(w_asymp_low, np.zeros_like(w_asymp_low), 'k--', lw=1.5)
ax_m.plot(w_asymp_high, -40 * np.log10(w_asymp_high / omega_n), 'k--', lw=1.5)
ax_m.annotate('Asymptotes', xy=(0.95, -0.5), xytext=(0.25, -5),
arrowprops=dict(arrowstyle="->", color="black", lw=1), fontsize=11)
ax_m.annotate('', xy=(1.4, -6), xytext=(0.35, -5),
arrowprops=dict(arrowstyle="->", color="black", lw=1))
for zeta, color in zip(zeta_values, colors):
num = [omega_n**2]
den = [1, 2 * zeta * omega_n, omega_n**2]
sys = signal.TransferFunction(num, den)
w_out, mag, _ = signal.bode(sys, w)
ax_m.plot(w_out, mag, color=color, lw=2.5)
m_cfg = mag_annotation_config[zeta]
mag_target_y = np.interp(m_cfg['target_w'], w_out, mag)
ax_m.text(m_cfg['text_pos'][0], m_cfg['text_pos'][1], f'
',
fontsize=11, va='center', ha='left', color='black')
ax_m.annotate('', xy=(m_cfg['target_w'], mag_target_y), xytext=(m_cfg['text_pos'][0], m_cfg['text_pos'][1]),
arrowprops=dict(arrowstyle="->", color=color, lw=1.5))
apply_dense_numbered_grid(ax_m, is_mag=True)
ax_m.set_xlabel('
', fontsize=14)
ax_m.set_ylabel('
(dB)', fontsize=13)
ax_m.set_ylim(-25, 21)
ax_m.axhline(0, color='black', lw=1.2)
ax_m.set_title('Bode Log-Magnitude Response', fontsize=14, pad=15)
plt.tight_layout()
plt.savefig("bode_plot_magnitude.png", dpi=600, bbox_inches='tight')
plt.close()
# =================================================================
# 3. GENERATE STANDALONE PHASE GRAPH
# =================================================================
fig_phase, ax_p = plt.subplots(figsize=(10, 7))
for zeta, color in zip(zeta_values, colors):
num = [omega_n**2]
den = [1, 2 * zeta * omega_n, omega_n**2]
sys = signal.TransferFunction(num, den)
w_out, _, phase = signal.bode(sys, w)
ax_p.plot(w_out, phase, color=color, lw=2.5)
p_cfg = phase_annotation_config[zeta]
phase_target_y = np.interp(p_cfg['target_w'], w_out, phase)
ax_p.text(p_cfg['text_pos'][0], p_cfg['text_pos'][1], f'
',
fontsize=11, va='center', ha='right', color='black')
ax_p.annotate('', xy=(p_cfg['target_w'], phase_target_y), xytext=(p_cfg['text_pos'][0], p_cfg['text_pos'][1]),
arrowprops=dict(arrowstyle="->", color=color, lw=1.5))
apply_dense_numbered_grid(ax_p, is_mag=False)
ax_p.set_xlabel('
', fontsize=14)
ax_p.set_ylabel('
(degrees)', fontsize=13)
ax_p.set_ylim(-195, 15)
ax_p.axhline(0, color='black', lw=1.2)
ax_p.axhline(-90, color='gray', linestyle=':', lw=1.2)
ax_p.set_title('Bode Phase Response', fontsize=14, pad=15)
plt.tight_layout()
plt.savefig("bode_plot_phase.png", dpi=600, bbox_inches='tight')
plt.close()
print("All plots generated with fully labeled, dense y-grids and saved at 600 DPI.")