If you need to strip 60 Hz hum and noise from a sensor reading, design the filter with scipy.signal.iirfilter(..., output="sos") and apply it with sosfiltfilt(). That’s a Python SciPy IIR filter in two lines, and it won’t shift your signal in time.
People get stuck on the details: which type, what order, and why the old b, a form explodes.
In this tutorial, I’ll walk you through how to build a Python SciPy IIR filter, pick its order from a spec, plot its frequency response and avoid the two classic traps. I ran every example on Python 3.12.5, NumPy 2.5.3, SciPy 1.18.1 and Matplotlib 3.11.2.
The sample sensor signal we’ll filter
Every example imports this signal from sensor.py. It’s a 2-second vibration reading from an HVAC fan, sampled 1,000 times a second:
# Vibration sensor on an HVAC fan in Chicago, logged Nov 3, 2026
import numpy as np
fs = 1000 # samples per second (Hz)
t = np.arange(0, 2, 1 / fs) # 2 seconds of data
rng = np.random.default_rng(2026)
clean = 1.5 * np.sin(2 * np.pi * 4 * t) # 4 Hz fan wobble
hum = 0.8 * np.sin(2 * np.pi * 60 * t) # 60 Hz US mains hum
x = clean + hum + 0.3 * rng.standard_normal(t.size)
The part we want is the slow 4 Hz wobble. On top sit 60 Hz hum from US power lines and random noise from NumPy random.
An IIR (infinite impulse response) filter feeds its past outputs back in, so even a low order gives a sharp cutoff.
Method 1: Build a low-pass IIR filter with iirfilter() (Recommended)
Use iirfilter() when you know the order and cutoff you want. Same sensor data here, now with a 4th-order Butterworth low-pass at 20 Hz:
import numpy as np
import matplotlib.pyplot as plt
from scipy import signal
from sensor import fs, t, clean, x
sos = signal.iirfilter(4, 20, btype="lowpass", ftype="butter",
fs=fs, output="sos")
y = signal.sosfiltfilt(sos, x)
rms = lambda v: np.sqrt(np.mean(v ** 2))
print("SOS shape:", sos.shape)
print(f"Error before: {rms(x - clean):.3f}")
print(f"Error after: {rms(y - clean):.3f}")
print("Same as butter():",
np.allclose(sos, signal.butter(4, 20, fs=fs, output="sos")))
n = 500 # first 0.5 s
plt.plot(t[:n], x[:n], color="0.7", label="raw sensor")
plt.plot(t[:n], y[:n], lw=2, label="IIR low-pass (20 Hz)")
plt.xlabel("Time (s)"); plt.ylabel("Amplitude")
plt.title("HVAC fan vibration before and after the IIR filter")
plt.legend(); plt.tight_layout(); plt.show()
Output:
SOS shape: (2, 6)
Error before: 0.644
Error after: 0.061
Same as butter(): True
The error against the clean wobble drops from 0.644 to 0.061.

How does this code work?
iirfilter(4, 20, ...) takes the order N and the cutoff Wn. Because I pass fs=fs, the cutoff is in hertz; without fs you’d give it as a fraction of the Nyquist frequency.
ftype picks the design (butter, cheby1, cheby2, ellip, bessel) and btype the band type. Each of the two SOS rows is one second-order section.
butter() is a shortcut for the Butterworth case and returns the same coefficients. For high-pass and band-pass recipes, see my SciPy Butterworth filter guide.
Important: the cutoff must stay below fs / 2 (500 Hz here), or SciPy raises a ValueError.
Method 2: Let iirdesign() choose the filter order
If you have a spec instead of an order, use iirdesign(). You say which band to keep, which to block and by how much, and it finds the smallest order that meets it. Same sensor data and sample rate, four filter types:
from scipy import signal
from sensor import fs
# pass 0-20 Hz (max 1 dB loss), block 50 Hz and up (40 dB down)
wp, ws, gpass, gstop = 20, 50, 1, 40
orders = {"butter": signal.buttord, "cheby1": signal.cheb1ord,
"cheby2": signal.cheb2ord, "ellip": signal.ellipord}
for ftype, ord_fn in orders.items():
sos = signal.iirdesign(wp, ws, gpass, gstop, ftype=ftype,
fs=fs, output="sos")
n, _ = ord_fn(wp, ws, gpass, gstop, fs=fs)
print(f"{ftype:7} order {n} SOS sections {len(sos)}")
Output:
butter order 6 SOS sections 3
cheby1 order 4 SOS sections 2
cheby2 order 4 SOS sections 2
ellip order 3 SOS sections 2

How does this code work?
wp and ws are the band edges; gpass=1 allows 1 dB loss below 20 Hz and gstop=40 demands 40 dB from 50 Hz up, covering the hum.
The buttord()-style helpers report the order iirdesign() picked. Elliptic needs only 3 because it accepts ripple; Butterworth stays flat and pays with 6.
Not sure where the noise sits? Run an FFT in Python on the raw signal first and set ws just below the lowest unwanted peak.
Plot the IIR frequency response with freqz_sos()
Before you trust a filter, look at its gain curve. freqz_sos() returns the frequencies and complex response of an SOS filter:
import numpy as np
import matplotlib.pyplot as plt
from scipy import signal
from sensor import fs
designs = {"Butterworth": dict(ftype="butter"),
"Chebyshev I": dict(ftype="cheby1", rp=1),
"Elliptic": dict(ftype="ellip", rp=1, rs=40)}
for name, kw in designs.items():
sos = signal.iirfilter(4, 20, btype="lowpass", fs=fs,
output="sos", **kw)
w, h = signal.freqz_sos(sos, worN=4096, fs=fs)
db = 20 * np.log10(np.maximum(np.abs(h), 1e-10))
print(f"{name:12} at 60 Hz: {db[np.argmin(abs(w - 60))]:.1f} dB")
plt.plot(w, db, label=name)
plt.axvline(60, color="red", ls="--", lw=1)
plt.xlim(0, 150); plt.ylim(-80, 5)
plt.xlabel("Frequency (Hz)"); plt.ylabel("Gain (dB)")
plt.title("4th-order IIR low-pass filters, 20 Hz cutoff")
plt.legend(); plt.grid(alpha=0.3); plt.tight_layout(); plt.show()
Output:
Butterworth at 60 Hz: -38.6 dB
Chebyshev I at 60 Hz: -49.8 dB
Elliptic at 60 Hz: -51.0 dB

How does this code work?
worN=4096 sets the number of frequency points and fs=fs puts the x-axis in hertz. The gain becomes decibels with 20 * log10 of the NumPy absolute value.
Note: freqz_sos() replaced the older sosfreqz() name; new code should use it. Styling the chart itself is covered in plotting a line in Matplotlib.
Why does sosfilt() delay my filtered signal?
Every one-pass IIR filter delays the signal. Same sensor data again, with the Butterworth filter applied once with sosfilt() and forward-backward with sosfiltfilt():
import numpy as np
from scipy import signal
from sensor import fs, clean, x
sos = signal.butter(4, 20, fs=fs, output="sos")
def lag_ms(y):
c = np.correlate(y - y.mean(), clean, "full")
return (c.argmax() - (len(clean) - 1)) * 1000 / fs
print(f"sosfilt lag: {lag_ms(signal.sosfilt(sos, x)):.0f} ms")
print(f"sosfiltfilt lag: {lag_ms(signal.sosfiltfilt(sos, x)):.0f} ms")
Output:
sosfilt lag: 20 ms
sosfiltfilt lag: 0 ms

How does this code work?
I cross-correlate each output with the clean wave and read where the peak lands. sosfiltfilt() runs forward, then backward, so the phase shifts cancel (and the attenuation doubles).
Use sosfiltfilt() for recorded data and sosfilt() for live streams, where you can’t look ahead. An FIR filter is plain convolution, which I explain in SciPy convolve2d.
Why use output=”sos” instead of b, a coefficients?
The default output of iirfilter() and butter() is b, a, and SciPy’s docs warn that form can be numerically unstable. A 10th-order filter with a low 2 Hz cutoff shows it:
import numpy as np
from scipy import signal
from sensor import fs, x
b, a = signal.butter(10, 2, fs=fs) # output="ba"
sos = signal.butter(10, 2, fs=fs, output="sos")
print("b, a poles inside unit circle:",
bool(np.all(np.abs(np.roots(a)) < 1)))
print(f"lfilter peak: {np.abs(signal.lfilter(b, a, x)).max():.3g}")
print(f"sosfilt peak: {np.abs(signal.sosfilt(sos, x)).max():.3g}")
Output:
b, a poles inside unit circle: False
lfilter peak: 5.93e+18
sosfilt peak: 0.296

Second-order sections keep each piece well-conditioned, so I use output="sos" above order 2. If a result turns into NaN, see checking for NaN in Python.
Which IIR method should you use?
| Task | Use | Why |
|---|---|---|
| Known order and cutoff | iirfilter(..., output="sos") | One call for every design type |
| Spec in dB, order unknown | iirdesign() | Finds the lowest order |
| Recorded data | sosfiltfilt() | Zero time shift |
| Live stream | sosfilt() with zi | Works sample block by block |
Things to keep in mind with SciPy IIR filters
- Pass
fs. Then cutoffs are in hertz, not fractions of Nyquist. - Short signals fail in sosfiltfilt(). Lower
padlenor use a longer recording. - One narrow hum frequency? A notch filter is gentler; the SciPy signal guide shows
iirnotch(). - Streaming in chunks? Carry state with
sosfilt_zi()and theziargument.
Want more SciPy and plotting practice? Try these next:
- SciPy interpolate in Python
- Matplotlib log-log plot
- Save a Matplotlib chart as PNG
- SciPy interview questions and answers