Digital filters are one of the most practical tools in scientific computing. Whether you are cleaning up sensor noise, isolating a frequency band in audio, or smoothing time series data before analysis, scipy.signal gives you the full toolkit to design, inspect, and apply filters in Python with just a few lines of code.
This article walks through the full filter design workflow in scipy.signal: picking a filter family, constructing coefficients, inspecting the frequency response, and applying the filter to real signals. All examples use SciPy v1.17.0, the current stable release as of early 2026.
IIR vs. FIR: Choosing Your Filter Type
The first decision in any filter design task is whether to use an Infinite Impulse Response (IIR) filter or a Finite Impulse Response (FIR) filter. They achieve the same goal through different mechanisms, and the right choice depends on what matters most for your application.
An IIR filter uses feedback — its output depends on both current and past inputs, as well as past outputs. This recursive structure means a low-order IIR filter can produce a very sharp frequency cutoff, making IIR filters computationally efficient. The trade-off is that IIR filters introduce phase distortion, meaning different frequency components of your signal arrive at the output shifted in time by different amounts. For many engineering and scientific applications this is acceptable, but in audio or EEG analysis it can be a problem.
A FIR filter has no feedback. The output is a weighted sum of the current and past input samples only. This means FIR filters always have a linear phase response: all frequencies experience the same time delay. They are inherently stable, which eliminates a class of numerical problems. The cost is that achieving a sharp frequency cutoff typically requires many more coefficients than an equivalent IIR filter, which increases computation.
"Use IIR when efficiency matters and phase is not critical. Use FIR when you need linear phase or guaranteed stability in real-time systems."
In scipy.signal, the primary IIR design functions are butter, cheby1, cheby2, and ellip. For FIR, the main entry point is firwin, with firwin2 available for arbitrary frequency response shapes.
IIR Filter Design with butter, cheby1, cheby2, and ellip
Each IIR filter family represents a different engineering trade-off between passband flatness, stopband attenuation, and transition band steepness.
Butterworth: Maximally Flat Passband
The Butterworth filter is the go-to choice when you want a smooth, ripple-free passband. It sacrifices a steeper rolloff for flatness, meaning you may need a higher order to meet strict attenuation requirements in the stopband. The butter function takes a filter order, a cutoff frequency, and the filter type ('low', 'high', 'band', or 'bandstop').
import numpy as np
from scipy import signal
# Design a 6th-order Butterworth low-pass filter
# Cutoff at 500 Hz, sampling rate 4000 Hz
fs = 4000
cutoff = 500
sos = signal.butter(6, cutoff, btype='low', fs=fs, output='sos')
print(sos.shape) # (3, 6) -- three second-order sections
Always pass fs directly to the design functions rather than manually normalizing cutoff frequencies by the Nyquist. Passing fs keeps your code readable and avoids off-by-one errors. The parameter was added to all design functions in modern SciPy releases for exactly this reason.
Chebyshev Type I: Ripple in the Passband
Chebyshev Type I allows a controlled amount of ripple in the passband in exchange for a steeper rolloff than Butterworth at the same filter order. You specify the maximum allowable passband ripple in decibels via the rp parameter. A common value is 0.5 dB or 1 dB.
from scipy import signal
fs = 4000
cutoff = 500
# 5th-order Chebyshev Type I, 1 dB passband ripple
sos_cheby1 = signal.cheby1(5, rp=1, Wn=cutoff, btype='low', fs=fs, output='sos')
Chebyshev Type II: Ripple in the Stopband
Chebyshev Type II moves the ripple to the stopband instead of the passband, leaving the passband response monotonically smooth like a Butterworth filter. You specify the minimum stopband attenuation in decibels with the rs parameter. This makes Type II useful when you need a clean passband but can tolerate some variation in how deeply the stopband is attenuated.
from scipy import signal
fs = 4000
cutoff = 500
# 5th-order Chebyshev Type II, 40 dB stopband attenuation
sos_cheby2 = signal.cheby2(5, rs=40, Wn=cutoff, btype='low', fs=fs, output='sos')
Elliptic: Sharpest Rolloff
The elliptic filter (also called a Cauer filter) achieves the steepest possible transition between passband and stopband for a given filter order. It does this by allowing ripple in both the passband and the stopband. When you need the sharpest cutoff in the fewest coefficients, elliptic is the answer. It requires both rp and rs.
from scipy import signal
fs = 4000
cutoff = 500
# 4th-order elliptic, 0.5 dB passband ripple, 60 dB stopband attenuation
sos_ellip = signal.ellip(4, rp=0.5, rs=60, Wn=cutoff, btype='low', fs=fs, output='sos')
Use signal.buttord, signal.cheb1ord, signal.cheb2ord, or signal.ellipord to automatically compute the minimum filter order that meets your passband and stopband specifications. Pass the result directly into the corresponding design function.
Designing Bandpass and Bandstop Filters
All four IIR design functions accept btype='bandpass' or btype='bandstop' with a two-element list for Wn specifying the lower and upper band edges.
from scipy import signal
fs = 4000
low_cut = 300
high_cut = 800
# 4th-order Butterworth bandpass filter (300-800 Hz)
sos_bp = signal.butter(4, [low_cut, high_cut], btype='band', fs=fs, output='sos')
# 4th-order Butterworth bandstop (notch region 300-800 Hz)
sos_bs = signal.butter(4, [low_cut, high_cut], btype='bandstop', fs=fs, output='sos')
The SOS Representation and Why It Matters
You have probably noticed that every example above uses output='sos'. This is the second-order sections (SOS) representation, and it is the recommended format for nearly all IIR filtering in modern SciPy.
The traditional way to represent a digital filter is as a pair of polynomial coefficient arrays, b (numerator) and a (denominator). The b/a form is simple to understand, but for high-order filters it becomes numerically unstable. Small rounding errors in the coefficients can cause the filter to behave incorrectly or even become unstable, producing outputs that grow without bound.
The SOS format avoids this by decomposing the filter into a cascade of second-order (biquad) sections. Each section only needs to represent a low-order piece of the overall transfer function, which keeps the arithmetic well-conditioned. The SOS array has shape (n_sections, 6) where each row holds the six coefficients of one biquad.
The b/a transfer function form is known to produce incorrect or unstable results for Butterworth bandpass filters of order 10 or higher. The SciPy documentation explicitly warns against using lfilter or filtfilt with b/a coefficients for high-order designs. Use output='sos' and the sosfilt or sosfiltfilt functions instead.
FIR Filter Design with firwin
FIR filters in scipy.signal are designed with firwin using the window method. You specify the number of taps (the filter length), the cutoff frequency, and an optional window function. More taps produce a sharper frequency cutoff but increase the computational cost.
The firwin function designs filters with linear phase. If the number of taps is odd, the result is a Type I FIR filter; if even, it is Type II. Type II filters always have zero response at the Nyquist frequency, so an even tap count will raise a ValueError if your design includes a passband up to Nyquist. Use an odd number of taps when in doubt.
import numpy as np
from scipy import signal
fs = 4000
cutoff = 500
num_taps = 101 # odd number of taps for Type I FIR
# Low-pass FIR filter using the default Hamming window
h = signal.firwin(num_taps, cutoff, window='hamming', fs=fs)
print(f"Number of coefficients: {len(h)}") # 101
The Hamming window is the default and provides about 53 dB of stopband attenuation with small passband ripple. For steeper rolloff you can use the Blackman window (about 74 dB) or the Kaiser window, which lets you tune the trade-off between main lobe width and side lobe attenuation through a beta parameter.
import numpy as np
from scipy import signal
fs = 4000
cutoff = 500
num_taps = 101
# Kaiser window: beta controls stopband attenuation
# beta=8.6 gives approximately 60 dB of stopband attenuation
h_kaiser = signal.firwin(num_taps, cutoff, window=('kaiser', 8.6), fs=fs)
# Bandpass FIR filter (300-800 Hz)
h_bp = signal.firwin(num_taps, [300, 800], pass_zero=False, window='hamming', fs=fs)
For a bandpass FIR filter, set pass_zero=False. This tells firwin that the DC component (zero frequency) should be in the stopband, not the passband. Forgetting this argument is a common source of confusion when the filter output looks wrong.
Frequency Response Analysis with freqz
Before applying any filter to real data, it is good practice to inspect its frequency response. The signal.freqz function computes the frequency response of a filter given its b/a coefficients. For SOS filters, use signal.sosfreqz, which avoids the numerical problems of converting back to b/a form.
import numpy as np
from scipy import signal
import matplotlib.pyplot as plt
fs = 4000
cutoff = 500
# Design a Butterworth low-pass filter in SOS form
sos = signal.butter(6, cutoff, btype='low', fs=fs, output='sos')
# Compute frequency response directly from SOS
w, h = signal.sosfreqz(sos, worN=2048, fs=fs)
# Plot magnitude response in dB
fig, axes = plt.subplots(2, 1, figsize=(10, 6))
axes[0].plot(w, 20 * np.log10(np.abs(h)))
axes[0].set_ylabel('Magnitude (dB)')
axes[0].set_xlabel('Frequency (Hz)')
axes[0].set_title('Butterworth Low-Pass Filter: Frequency Response')
axes[0].axvline(cutoff, color='red', linestyle='--', label=f'Cutoff: {cutoff} Hz')
axes[0].set_ylim(-80, 5)
axes[0].grid(True, alpha=0.3)
axes[0].legend()
# Plot phase response
axes[1].plot(w, np.unwrap(np.angle(h)) * 180 / np.pi)
axes[1].set_ylabel('Phase (degrees)')
axes[1].set_xlabel('Frequency (Hz)')
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
For a FIR filter stored as a coefficient array h, pass it as the b argument to freqz with a=1 (or just freqz(h)):
import numpy as np
from scipy import signal
import matplotlib.pyplot as plt
fs = 4000
num_taps = 101
h_fir = signal.firwin(num_taps, 500, window='hamming', fs=fs)
w, H = signal.freqz(h_fir, worN=2048, fs=fs)
plt.figure(figsize=(10, 4))
plt.plot(w, 20 * np.log10(np.abs(H)))
plt.ylabel('Magnitude (dB)')
plt.xlabel('Frequency (Hz)')
plt.title('FIR Low-Pass Filter: Frequency Response')
plt.axvline(500, color='red', linestyle='--', label='Cutoff: 500 Hz')
plt.ylim(-80, 5)
plt.grid(True, alpha=0.3)
plt.legend()
plt.tight_layout()
plt.show()
Always plot at least the magnitude response before applying a filter to real data. A design error like forgetting pass_zero=False or passing the wrong cutoff unit will be immediately obvious in the frequency response plot, but completely opaque from the filtered output alone.
Applying Filters: sosfilt, sosfiltfilt, and lfilter
Once your filter is designed, SciPy provides several functions to apply it to data. The right choice depends on whether you need causal (real-time compatible) filtering or zero-phase (offline) filtering.
sosfilt: Causal Filtering
signal.sosfilt applies a filter in SOS form to a signal in a single forward pass. It is causal — the output at each sample depends only on current and past inputs — which makes it suitable for real-time or streaming applications. It introduces phase delay, which is unavoidable in causal filters.
import numpy as np
from scipy import signal
fs = 4000
t = np.linspace(0, 1, fs)
# Composite signal: 100 Hz + 1200 Hz (noise)
x = np.sin(2 * np.pi * 100 * t) + 0.5 * np.sin(2 * np.pi * 1200 * t)
# Design and apply a low-pass Butterworth filter
sos = signal.butter(6, 500, btype='low', fs=fs, output='sos')
y = signal.sosfilt(sos, x)
# y contains the filtered signal with the 1200 Hz component attenuated
sosfiltfilt: Zero-Phase Filtering
signal.sosfiltfilt applies the filter twice — once forward through the data and once backward — so that the phase delays from both passes cancel out exactly. The result is a zero-phase filtered signal with no time shift. This is the preferred approach for offline signal analysis when phase integrity matters. The effective filter order is doubled because the filter is applied twice.
import numpy as np
from scipy import signal
import matplotlib.pyplot as plt
fs = 4000
t = np.linspace(0, 1, fs)
# Composite signal: 100 Hz + 1200 Hz (noise)
x = np.sin(2 * np.pi * 100 * t) + 0.5 * np.sin(2 * np.pi * 1200 * t)
# Design low-pass filter
sos = signal.butter(4, 500, btype='low', fs=fs, output='sos')
# Causal filtering (introduces phase delay)
y_causal = signal.sosfilt(sos, x)
# Zero-phase filtering (no phase delay)
y_zero_phase = signal.sosfiltfilt(sos, x)
# Plot comparison over first 50ms
n = int(0.05 * fs)
plt.figure(figsize=(12, 4))
plt.plot(t[:n], x[:n], label='Original', alpha=0.5)
plt.plot(t[:n], y_causal[:n], label='sosfilt (causal)', linewidth=2)
plt.plot(t[:n], y_zero_phase[:n], label='sosfiltfilt (zero-phase)', linewidth=2, linestyle='--')
plt.xlabel('Time (s)')
plt.ylabel('Amplitude')
plt.title('Causal vs Zero-Phase Filtering')
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
Applying FIR Filters
FIR coefficients are stored as a 1D array, not in SOS form. You apply them using signal.lfilter(h, 1, x) for causal filtering, or signal.filtfilt(h, 1, x) for zero-phase filtering. Alternatively, you can use np.convolve(h, x) directly, keeping in mind that the output length will be len(h) + len(x) - 1.
import numpy as np
from scipy import signal
fs = 4000
t = np.linspace(0, 1, fs)
x = np.sin(2 * np.pi * 100 * t) + 0.5 * np.sin(2 * np.pi * 1200 * t)
# Design FIR low-pass filter
h = signal.firwin(101, 500, window='hamming', fs=fs)
# Causal FIR filtering
y_causal = signal.lfilter(h, 1, x)
# Zero-phase FIR filtering
y_zero_phase = signal.filtfilt(h, 1, x)
# Direct convolution (full output)
y_conv = np.convolve(h, x, mode='same')
The SciPy documentation explicitly recommends sosfiltfilt over filtfilt for IIR filters because the SOS representation has fewer numerical problems. For FIR filters, filtfilt with b/a coefficients is fine because FIR filters are inherently stable regardless of order.
A Full End-to-End Example
Here is a complete workflow that puts all the pieces together: design a Butterworth bandpass filter, inspect its frequency response, and apply it to a noisy synthetic signal.
import numpy as np
from scipy import signal
import matplotlib.pyplot as plt
# --- Signal Setup ---
fs = 4000 # sampling rate in Hz
t = np.linspace(0, 2, 2 * fs) # 2 seconds
# Signal: 400 Hz target frequency + low-frequency drift + high-frequency noise
x = (np.sin(2 * np.pi * 400 * t)
+ 0.8 * np.sin(2 * np.pi * 60 * t)
+ 0.4 * np.random.default_rng(42).standard_normal(len(t)))
# --- Filter Design ---
low_cut = 300
high_cut = 600
# Butterworth bandpass, 5th order
sos = signal.butter(5, [low_cut, high_cut], btype='band', fs=fs, output='sos')
# --- Frequency Response ---
w, h = signal.sosfreqz(sos, worN=4096, fs=fs)
fig, axes = plt.subplots(3, 1, figsize=(12, 9))
axes[0].plot(w, 20 * np.log10(np.clip(np.abs(h), 1e-10, None)))
axes[0].axvline(low_cut, color='red', linestyle='--', alpha=0.7, label=f'{low_cut} Hz')
axes[0].axvline(high_cut, color='red', linestyle='--', alpha=0.7, label=f'{high_cut} Hz')
axes[0].set_title('Bandpass Filter Frequency Response')
axes[0].set_ylabel('Magnitude (dB)')
axes[0].set_xlabel('Frequency (Hz)')
axes[0].set_ylim(-60, 5)
axes[0].legend()
axes[0].grid(True, alpha=0.3)
# --- Apply Filter ---
y = signal.sosfiltfilt(sos, x)
# Plot signals (first 50 ms)
n = int(0.05 * fs)
axes[1].plot(t[:n], x[:n], label='Noisy input', alpha=0.6)
axes[1].plot(t[:n], y[:n], label='Filtered output', linewidth=2)
axes[1].set_title('Time Domain: Input vs Filtered Output')
axes[1].set_xlabel('Time (s)')
axes[1].set_ylabel('Amplitude')
axes[1].legend()
axes[1].grid(True, alpha=0.3)
# Spectrogram of filtered output
f_spec, t_spec, Sxx = signal.spectrogram(y, fs=fs, nperseg=256)
axes[2].pcolormesh(t_spec, f_spec, 10 * np.log10(Sxx + 1e-10), shading='gouraud', cmap='viridis')
axes[2].set_ylim(0, 1000)
axes[2].set_title('Spectrogram of Filtered Signal')
axes[2].set_xlabel('Time (s)')
axes[2].set_ylabel('Frequency (Hz)')
plt.tight_layout()
plt.show()
Key Takeaways
- Always use
output='sos'for IIR filters. The SOS representation avoids the numerical instability that affects the traditionalb/apolynomial form at higher filter orders. Pair it withsosfiltorsosfiltfilt. - Match your filtering function to your use case. Use
sosfiltwhen you need causal, real-time compatible processing. Usesosfiltfiltfor offline analysis when you cannot afford phase distortion. - Choose the IIR family that fits your tolerance for ripple. Butterworth gives a flat passband but requires a higher order for sharp rolloff. Elliptic gives the sharpest rolloff but introduces ripple in both bands. Chebyshev types sit in between.
- FIR filters guarantee linear phase and stability. They are the right choice when phase integrity matters, even though they require more taps than an equivalent IIR design to achieve the same sharpness.
- Always inspect the frequency response before applying to data.
signal.sosfreqzfor IIR andsignal.freqzfor FIR give you the full magnitude and phase picture so you can catch design mistakes before they corrupt your data.
Filter design in SciPy rewards a methodical approach: understand the trade-offs between filter families, commit to the SOS representation for IIR work, verify your design through sosfreqz, and choose between causal and zero-phase application based on whether your data is processed in real time or offline. With those four decisions made deliberately, the rest of the implementation falls into place cleanly.