Bandpass Filter Design: Theory and Python Implementation

Bandpass filter design in Python: scipy.signal.butter, sosfiltfilt, and firwin with passband, stopband, and center frequency theory, Butterworth IIR vs FIR comparison, zero-phase filtering, frequency response plots, and vibration, audio, and ECG applications.

What Is a Bandpass Filter?

A bandpass filter (BPF) passes signals within a specified frequency band (passband) and attenuates signals outside that band (stopband).

Applications include radio channel selection, speech processing, and biomedical signal filtering (ECG, EEG noise removal).

Key Parameters

ParameterDescription
\(f_l\)Lower cutoff frequency (-3 dB point)
\(f_h\)Upper cutoff frequency (-3 dB point)
\(f_0 = \sqrt{f_l f_h}\)Center frequency (geometric mean)
\(BW = f_h - f_l\)Bandwidth
\(Q = f_0 / BW\)Q factor (selectivity)

A higher Q value means a narrower, more selective passband.

Frequency Response Derivation

Lowpass-to-Bandpass Transformation

A bandpass filter can be designed from a lowpass prototype (LPF) via a frequency transformation. In the analog domain, replace the complex variable \(s\) as:

\[ s \rightarrow \frac{s^2 + \omega_l \omega_h}{s(\omega_h - \omega_l)} \tag{1}\]

where \(\omega_l = 2\pi f_l\) and \(\omega_h = 2\pi f_h\) .

Applying transformation \((1)\) to a 1st-order Butterworth LPF \(H_{LP}(s) = \frac{\omega_c}{s + \omega_c}\) yields a 2nd-order bandpass filter:

\[ H_{BP}(s) = \frac{BW \cdot s}{s^2 + BW \cdot s + \omega_0^2} \tag{2} \]

where \(BW = \omega_h - \omega_l\) and \(\omega_0 = \sqrt{\omega_l \omega_h}\) .

Why This Transformation Produces a Passband

Substitution \((1)\) looks like it comes out of nowhere, but tracing how it actually acts on the real analog frequency axis makes clear why it produces bandpass behavior.

Let \(p\) be the complex variable of the normalized lowpass prototype, whose passband is \(|\Omega| \le 1\) on \(s = j\Omega\) . Substituting the real frequency \(s = j\omega\) (\(\omega > 0\) ) into transformation \((1)\) gives

\[ p = \frac{(j\omega)^2 + \omega_l \omega_h}{j\omega(\omega_h - \omega_l)} \]

Writing \(\omega_0^2 = \omega_l \omega_h\) and \(B = \omega_h - \omega_l\) , the numerator becomes \(\omega_0^2 - \omega^2\) and the denominator \(jB\omega\) , so

\[ p = \frac{\omega_0^2 - \omega^2}{jB\omega} = j\,\frac{\omega^2 - \omega_0^2}{B\omega} \]

This has the form \(p = j\Omega\) , so the prototype-side normalized frequency corresponding to the real frequency \(\omega\) is

\[ \Omega(\omega) = \frac{\omega^2 - \omega_0^2}{B\omega} = \frac{1}{B}\left(\omega - \frac{\omega_0^2}{\omega}\right) \]

Checking the boundary values: at \(\omega=\omega_0\) , \(\Omega(\omega_0)=0\) . At \(\omega=\omega_h\) , since \(\omega_h^2-\omega_0^2=\omega_h^2-\omega_l\omega_h=\omega_h(\omega_h-\omega_l)=\omega_h B\) , we get \(\Omega(\omega_h)=1\) . At \(\omega=\omega_l\) , similarly \(\omega_l^2-\omega_0^2=-\omega_l B\) gives \(\Omega(\omega_l)=-1\) . The derivative is

\[ \frac{d\Omega}{d\omega} = \frac{1}{B}\left(1 + \frac{\omega_0^2}{\omega^2}\right) > 0 \quad (\omega>0) \]

so \(\Omega(\omega)\) is strictly increasing for \(\omega>0\) . Therefore, as \(\omega\) sweeps from \(\omega_l\) to \(\omega_h\) , \(\Omega\) sweeps monotonically from \(-1\) to \(1\) , exactly covering the prototype passband \(|\Omega|\le 1\) . Conversely, for \(\omega<\omega_l\) or \(\omega>\omega_h\) , monotonicity immediately gives \(|\Omega|>1\) — the prototype stopband.

In other words, this transformation maps the positive half of the real frequency axis \((0,\infty)\) bijectively and monotonically onto the entire prototype frequency axis \((-\infty,\infty)\) , sending the band \((\omega_l,\omega_h)\) around the center frequency \(\omega_0\) exactly onto the prototype passband, and both sides outside it (the low side \(\omega<\omega_l\) and the high side \(\omega>\omega_h\) ) onto the prototype’s single stopband \(|\Omega|>1\) . This is why a lowpass prototype with a stopband on only one side yields a bandpass filter with stopbands on both sides.

Why the order doubles: The calculation above goes from \(s\) to \(p\) . To find the \(s\) that corresponds to one prototype-side pole (or zero) \(p\) , we instead need to solve transformation \((1)\) for \(s\) :

\[ Bps = s^2 + \omega_0^2 \quad\Longrightarrow\quad s^2 - Bp\,s + \omega_0^2 = 0 \]

This is a quadratic in \(s\) , so each single \(p\) maps to two roots

\[ s_{1,2} = \frac{Bp \pm \sqrt{(Bp)^2 - 4\omega_0^2}}{2} \]

Hence an \(N\) th-order lowpass prototype (\(N\) poles) becomes a \(2N\) th-order bandpass filter (\(2N\) poles) under this transformation. The 1st-order Butterworth LPF (1 pole) producing the 2nd-order bandpass filter (2 poles) in \((2)\) is the \(N=1\) instance of this general rule.

Discrete-Time Bandpass Filter (Bilinear Transform)

Equation \((2)\) is converted to the digital domain via the bilinear transform:

\[s = \frac{2}{T} \cdot \frac{1 - z^{-1}}{1 + z^{-1}} \tag{3}\]

Substituting \((3)\) into \((2)\) yields the digital BPF transfer function:

\[ H(z) = \frac{b_0 + b_1 z^{-1} + b_2 z^{-2}}{1 + a_1 z^{-1} + a_2 z^{-2}} \tag{4}\]

In practice, scipy.signal handles this computation automatically.

Q Factor and the Selectivity Trade-off

The Q factor is defined as \(Q=f_0/BW=\omega_0/B\) , but it is not merely a “sharpness index” — it is directly tied to the filter’s transient response (the time needed to reach steady state). We derive this relationship from the 2nd-order bandpass prototype in \((2)\) .

\[ H_{BP}(s) = \frac{Bs}{s^2+Bs+\omega_0^2} \]

has poles at

\[ s = -\frac{B}{2} \pm j\sqrt{\omega_0^2 - \left(\frac{B}{2}\right)^2} \]

For \(B \ll \omega_0\) (high Q), the imaginary part of the poles is approximately \(\pm\omega_0\) and the real part is \(-B/2\) . The magnitude of this real part governs how fast the amplitude envelope of the free response to resonance decays exponentially.

\[ |y(t)| \propto e^{-(B/2)t} \]

So the time constant for the envelope to decay to \(1/e\) , substituting \(B=2\pi \cdot BW\) , is

\[ \tau = \frac{2}{B} = \frac{1}{\pi \cdot BW} \]

and the number of oscillation cycles at the center frequency that occur within this time is

\[ \tau f_0 = \frac{f_0}{\pi \cdot BW} = \frac{Q}{\pi} \approx 0.318\,Q \]

In other words, the higher the Q (narrower band, higher selectivity), the more oscillation cycles are needed for the transient response to settle, growing proportionally to Q. Resolving a narrow frequency band requires observing over a proportionally longer time (more oscillation cycles) — a form of the time-frequency uncertainty relationship.

Experiment: Q vs. Transient Response and Selectivity Accuracy

We verified this relationship by applying a \(f_0=100\) Hz sine wave to bandpass filters designed with scipy.signal.butter (4th order Butterworth, output='sos'), and measuring the time for the envelope (the absolute value of the Hilbert transform) to settle within 10% of its steady-state value (\(f_s=2000\) Hz, center frequency fixed, only bandwidth varied).

QDesign BW [Hz]Measured -3dB BW [Hz]Measured/Design ratioTime to 90% [ms]Settling time (10% band) [ms]Cycles to settle
1100.00099.9400.999413.5013.501.4
250.00049.9650.999326.0039.003.9
520.00019.9800.999064.0096.009.6
1010.0009.9900.9990127.00191.5019.1
205.0004.9900.9980253.50382.5038.2
502.0001.9900.9950633.50957.0095.7

Settling time grows nearly proportionally with Q (cycles-to-settle rise from 1.4 to 95.7 — about 68x — as Q goes from 1 to 50), qualitatively matching the “cycles \(\propto Q\) ” relationship derived above. Since a 4th-order Butterworth bandpass filter is a cascade of four 2nd-order resonant sections, the proportionality constant for the 10% settling criterion is larger than the ideal 2nd-order theoretical value of \(1/\pi\approx0.318\) (a rough guide to 1/e decay).

Also notable: the measured -3dB bandwidth drifts slightly away from the design value as Q increases (the ratio gradually drops from 0.9994 to 0.9950). This happens because the nonlinearity of bilinear-transform frequency prewarping and the numerical error of locating a peak at finite frequency resolution both become relatively more significant as the band narrows. At Q=50 (BW/f0 = 2%) the drift is still only about 0.5%, but at narrower bandwidths this error becomes non-negligible, as the next section shows.

Numerical Difficulty of Narrowband Design

As Q increases (bandwidth narrows), the poles of the digital filter cluster closer together near a particular angle on the unit circle. The closer the poles sit to the unit circle, the more small rounding errors in the transfer function coefficients affect the frequency response.

Experiment: Pole Placement and Sensitivity to Coefficient Perturbation

For a 4th-order Butterworth bandpass filter (realized as 8th order / 8 poles by the transformation in the previous section), with \(f_0=100\) Hz fixed and Q varied, we measured the minimum distance of the poles from the unit circle, \(\min(1-|z_k|)\) , and the amplification of the frequency response when the transfer-function coefficients are perturbed by a relative \(10^{-6}\) (averaged over 20 random trials).

QB/f0\(\min(1-\|z_k\|)\)TF-form (b, a) amplificationSOS-form (biquads) amplification
11.00006.30×10⁻²22,68843.0
50.20002.18×10⁻²2,609,95381.8
200.05005.87×10⁻³1,001,461358.2
1000.01001.20×10⁻³1,000,0011,515.2
5000.00202.33×10⁻⁴1,000,0005,004.5

The minimum pole distance from the unit circle shrinks in inverse proportion to Q (about 0.063 at Q=1, down to about 2.3×10⁻⁴ at Q=500). The closer a pole is to the unit circle, the more sensitive the corresponding root of the denominator polynomial becomes, so small coefficient errors (including floating-point rounding error) are amplified in the output.

Indeed, in polynomial form (the direct b, a representation), a mere 10⁻⁶ relative perturbation of the coefficients changes the frequency response by up to nearly a million times the perturbation — a serious numerical instability. In contrast, decomposing into second-order sections (SOS) caps the amplification at around 5,000x for the same perturbation — two to three orders of magnitude more robust than the TF form (though the amplification itself still grows with Q).

Even before any perturbation is applied, the frequency responses computed by the TF form and the SOS form already disagree non-negligibly.

QRelative discrepancy between TF-form and SOS-form response (no perturbation)
15.8×10⁻¹²
53.4×10⁻¹⁰
208.6×10⁻⁸
1002.6×10⁻⁵
5004.8×10⁻³

At Q=500 (bandwidth = 0.2% of the center frequency), the TF-form response disagrees with the SOS-form response (a higher-precision reference) by 0.48%, with no perturbation applied at all. This discrepancy comes purely from floating-point rounding error, and shows the real risk of using scipy.signal.butter’s default output (output='ba', i.e. TF form) directly for high-Q bandpass designs: the implemented filter can end up with unintended characteristics.

Q factor and the numerical trade-offs of narrowband design

The figure above shows, on the left, the coefficient-perturbation sensitivity from this section (TF form vs. SOS form), and on the right, the Q-vs-settling-time relationship measured in the previous section. Both are close to straight lines on log-log axes, visually confirming that the numerical difficulty and the slowness of the transient response both worsen with a power-law dependence on Q.

Practical mitigations: use the SOS form (scipy.signal.butter(..., output='sos')), avoid raising the order unnecessarily, avoid making the bandwidth extremely narrow relative to the center frequency when possible (a rough guideline is to keep Q ≲ 100), and consider other numerically robust realizations such as state-space or lattice structures.

Python Implementation

IIR Bandpass Filter with scipy.signal

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

# --- Parameters ---
fs = 1000.0          # Sampling frequency [Hz]
f_low = 50.0         # Lower cutoff [Hz]
f_high = 150.0       # Upper cutoff [Hz]
order = 4            # Filter order

# --- Design Butterworth bandpass filter ---
nyq = fs / 2.0
low = f_low / nyq
high = f_high / nyq

b, a = signal.butter(order, [low, high], btype='bandpass')

# --- Compute frequency response ---
w, h = signal.freqz(b, a, worN=8000, fs=fs)

# --- Plot ---
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8))

# Gain (dB)
ax1.semilogx(w, 20 * np.log10(np.abs(h) + 1e-10))
ax1.axvline(f_low, color='r', linestyle='--', label=f'$f_l$ = {f_low} Hz')
ax1.axvline(f_high, color='g', linestyle='--', label=f'$f_h$ = {f_high} Hz')
ax1.axhline(-3, color='gray', linestyle=':', label='-3 dB')
ax1.set_xlabel('Frequency [Hz]')
ax1.set_ylabel('Gain [dB]')
ax1.set_title(f'Butterworth Bandpass Filter (order={order})')
ax1.legend()
ax1.grid(True, which='both', alpha=0.3)
ax1.set_xlim([1, nyq])
ax1.set_ylim([-80, 5])

# Phase
ax2.semilogx(w, np.angle(h, deg=True))
ax2.axvline(f_low, color='r', linestyle='--')
ax2.axvline(f_high, color='g', linestyle='--')
ax2.set_xlabel('Frequency [Hz]')
ax2.set_ylabel('Phase [degrees]')
ax2.set_title('Phase Response')
ax2.grid(True, which='both', alpha=0.3)
ax2.set_xlim([1, nyq])

plt.tight_layout()
plt.savefig('bandpass_response.png', dpi=150, bbox_inches='tight')
plt.show()

FIR Bandpass Filter Design

IIR filters have nonlinear phase, but FIR filters can achieve linear phase. This is important in audio processing where phase distortion is audible.

from scipy.signal import firwin, freqz

# --- FIR bandpass filter ---
numtaps = 101        # Number of taps (odd number recommended)
fir_bpf = firwin(
    numtaps,
    [f_low, f_high],
    pass_zero=False,  # Bandpass specification
    fs=fs,
    window='hamming'
)

w_fir, h_fir = freqz(fir_bpf, worN=8000, fs=fs)

# IIR vs FIR comparison
plt.figure(figsize=(10, 5))
plt.semilogx(w, 20 * np.log10(np.abs(h) + 1e-10), label='IIR Butterworth (order=4)', linewidth=2)
plt.semilogx(w_fir, 20 * np.log10(np.abs(h_fir) + 1e-10), label=f'FIR Hamming (taps={numtaps})', linewidth=2, linestyle='--')
plt.axvline(f_low, color='r', linestyle=':', alpha=0.7, label=f'$f_l$ = {f_low} Hz')
plt.axvline(f_high, color='g', linestyle=':', alpha=0.7, label=f'$f_h$ = {f_high} Hz')
plt.axhline(-3, color='gray', linestyle=':', alpha=0.7)
plt.xlabel('Frequency [Hz]')
plt.ylabel('Gain [dB]')
plt.title('Bandpass Filter Comparison: IIR vs FIR')
plt.legend()
plt.grid(True, which='both', alpha=0.3)
plt.xlim([1, nyq])
plt.ylim([-80, 5])
plt.tight_layout()
plt.show()

Applying the Filter to a Signal

# --- Generate test signal ---
t = np.linspace(0, 1.0, int(fs), endpoint=False)

# Target signal: 100 Hz sine wave
signal_pure = np.sin(2 * np.pi * 100 * t)

# Noise: 10 Hz low-freq + 300 Hz high-freq + white noise
noise = (
    0.5 * np.sin(2 * np.pi * 10 * t) +
    0.5 * np.sin(2 * np.pi * 300 * t) +
    0.3 * np.random.randn(len(t))
)

x_noisy = signal_pure + noise

# --- Apply filter ---
# lfilter: causal filter (real-time, has phase delay)
y_lfilter = signal.lfilter(b, a, x_noisy)

# filtfilt: zero-phase filter (offline, no phase delay)
y_filtfilt = signal.filtfilt(b, a, x_noisy)

# --- Comparison plot ---
fig, axes = plt.subplots(3, 1, figsize=(12, 8), sharex=True)

axes[0].plot(t[:200], x_noisy[:200], alpha=0.8, label='Noisy signal')
axes[0].set_ylabel('Amplitude')
axes[0].set_title('Input Signal (noisy)')
axes[0].legend()
axes[0].grid(True, alpha=0.3)

axes[1].plot(t[:200], y_lfilter[:200], color='orange', label='lfilter (causal)')
axes[1].plot(t[:200], signal_pure[:200], color='gray', linestyle='--', alpha=0.6, label='True signal')
axes[1].set_ylabel('Amplitude')
axes[1].set_title('After lfilter (with phase delay)')
axes[1].legend()
axes[1].grid(True, alpha=0.3)

axes[2].plot(t[:200], y_filtfilt[:200], color='green', label='filtfilt (zero-phase)')
axes[2].plot(t[:200], signal_pure[:200], color='gray', linestyle='--', alpha=0.6, label='True signal')
axes[2].set_ylabel('Amplitude')
axes[2].set_xlabel('Time [s]')
axes[2].set_title('After filtfilt (zero-phase)')
axes[2].legend()
axes[2].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('bandpass_filtering_result.png', dpi=150, bbox_inches='tight')
plt.show()

IIR vs FIR Design Comparison

PropertyIIR (Butterworth, etc.)FIR
Phase responseNonlinear (phase distortion)Linear phase achievable
Number of coefficientsSmall (\(2N+1\) approx.)Large (tens to hundreds)
Computational costLowHigh (depends on tap count)
StabilityRisk with high ordersAlways stable
Stopband roll-offSteep at low orderImproves with tap count
Group delayNon-constantConstant (\((N-1)/2\) samples)
Typical useReal-time processingAudio, medical signals

Design guidelines:

  • Low computational cost in real-time → IIR (Butterworth / Chebyshev)
  • Linear phase required (audio, medical) → FIR (Hamming / Kaiser window)
  • Offline processing, zero phase delay → filtfilt (works with both IIR and FIR)

IIR vs. FIR Order Comparison for an Identical Spec

The comparison table above summarizes qualitative characteristics, but comparing the actual order/tap count required to meet an identical specification makes the trade-off much more concrete.

We found the minimum order/tap count for IIR (Butterworth / Chebyshev I / Chebyshev II / elliptic) and FIR (window method / Parks-McClellan) to meet the following spec (\(f_s=1000\) Hz):

  • Passband: 50–150 Hz, passband ripple ≤ 1 dB
  • Stopband: ≤ 30 Hz and ≥ 170 Hz, stopband attenuation ≥ 40 dB
  • Transition width: 20 Hz on each side

Using scipy.signal.buttord / cheb1ord / cheb2ord / ellipord to find the minimum order meeting this spec gives:

Design methodMinimum order (prototype)Realized bandpass poles (order × 2)
Butterworth2142
Chebyshev Type I816
Chebyshev Type II816
Elliptic (Cauer)510

Even for the same spec, Butterworth’s insistence on a maximally flat passband requires a very high order (21, realized as 42 poles), while Chebyshev — which allows ripple in either the passband or stopband — only needs order 8, and elliptic — which allows ripple in both — needs as few as order 5. This confirms that when a sharp transition band is required, trading away maximal flatness for equiripple behavior is far more efficient (consistent with the ripple/roll-off trade-off discussed in Chebyshev Filter Design: Theory and Python Implementation ).

For FIR, estimating the required tap count with scipy.signal.kaiserord (Kaiser window) gave 113 taps (\(\beta=3.395\) ). However, actually checking the realized frequency response, the achieved passband ripple was 6.08 dB — far exceeding the 1 dB target (stopband attenuation of 47.8 dB on the low side and 45.8 dB on the high side did meet the target). This is because the Kaiser window design formula estimates the tap count approximately from stopband attenuation and transition width, and does not directly guarantee passband ripple.

By contrast, using the equiripple Parks-McClellan method (scipy.signal.remez) to search for the minimum tap count meeting the same spec found a solution at 107 taps, achieving a passband ripple of 0.17 dB and stopband attenuation of 40.0 dB (low) / 40.1 dB (high) — meeting the spec tightly with no wasted margin.

Three practical takeaways from this experiment:

  1. For IIR, the required order for the same spec varies enormously with the design method (maximally flat vs. equiripple). When a sharp transition band is required, it is worth considering Chebyshev or elliptic designs.
  2. FIR requires far more taps (around 100 here) than IIR’s order (5 to 21 in this example), so IIR is more efficient per-sample computationally. FIR’s advantage is exact linear phase.
  3. The windowed method is simple but can fail to precisely meet some specs (such as ripple). When exact spec compliance must be verified, the Parks-McClellan (equiripple) method is more reliable.

Effect of Filter Order

fig, ax = plt.subplots(figsize=(10, 6))

for ord_n in [2, 4, 6, 8]:
    b_n, a_n = signal.butter(ord_n, [low, high], btype='bandpass')
    w_n, h_n = signal.freqz(b_n, a_n, worN=8000, fs=fs)
    ax.semilogx(w_n, 20 * np.log10(np.abs(h_n) + 1e-10), label=f'order={ord_n}')

ax.axhline(-3, color='gray', linestyle=':', label='-3 dB')
ax.axvline(f_low, color='r', linestyle='--', alpha=0.5)
ax.axvline(f_high, color='g', linestyle='--', alpha=0.5)
ax.set_xlabel('Frequency [Hz]')
ax.set_ylabel('Gain [dB]')
ax.set_title('Effect of Filter Order on Bandpass Response')
ax.legend()
ax.grid(True, which='both', alpha=0.3)
ax.set_xlim([1, nyq])
ax.set_ylim([-80, 5])
plt.tight_layout()
plt.show()

Higher order yields steeper roll-off, but IIR filters become numerically unstable at very high orders. Order ≤ 8 is a practical guideline.

Bandpass filters are a classical signal-processing element, but research in wireless communication and RF front ends remains active.

  • On-chip adjustable RF bandpass filters: IEEE Microwave Magazine (January 2024) published a review of on-chip frequency-adjustable RF bandpass filters, surveying designs that let a single circuit electrically switch its center frequency for multiband radios and software-defined radio (SDR).
  • Widely tunable microstrip bandpass filters: A 2024 paper in MDPI Electronics reports a compact microstrip bandpass filter whose center frequency can be continuously tuned from 1.1 to 3.1 GHz using only a single bias voltage, reflecting the need for reconfigurable RF front ends for the broadband, multiband operation demanded by standards such as 5G/6G.
  • Machine-learning-driven filter design optimization: A 2025 paper in Physica Scripta reports a machine-learning-based approach to bandpass filter design and optimization, and a 2025 MDPI Electronics paper describes using machine-learning models such as XGBoost to predict coupling matrices and physical parameters for microwave filters, reducing the number of design iterations. In the digital IIR/FIR domain, metaheuristic techniques such as differential evolution and grey wolf optimization have also been used to optimize filter coefficients (e.g., a 2024 Scientific Reports paper).

A common thread across these developments is combining classical analytical design (such as the Butterworth transformation and window methods covered in this article) with tunability and machine-learning-driven design automation and acceleration. Note, however, that most of these techniques target microwave/RF circuits (analog hardware); directly applying ML optimization to digital-signal-processing bandpass filter design of the kind covered in this article remains, as of this writing, a less-developed area.

References