Python SciPy IIR Filter: Design, Plot and Apply

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.

Matplotlib plot window showing a raw HVAC vibration signal and the smooth 4 Hz curve after a SciPy IIR low-pass filter
The grey raw signal is fuzzy with 60 Hz hum; the filtered line is a clean 4 Hz wave with no time shift.

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
Command Prompt output of SciPy iirdesign and buttord showing the minimum IIR filter order for Butterworth, Chebyshev and elliptic designs
The same spec needs order 6 for Butterworth, 4 for both Chebyshev types and only 3 for elliptic.

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
Matplotlib plot window of the frequency response in dB for 4th-order Butterworth, Chebyshev I and elliptic IIR low-pass filters with a red line at 60 Hz
At 60 Hz, Butterworth cuts 38.6 dB, Chebyshev I 49.8 dB and elliptic 51.0 dB, but the elliptic floor stays near -40 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
Command Prompt output comparing the time lag of SciPy sosfilt and sosfiltfilt on a filtered 4 Hz sensor signal
One pass with sosfilt() lags the clean signal by 20 ms; sosfiltfilt() lines up exactly.

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
Command Prompt output showing a 10th-order SciPy Butterworth filter in b, a form becoming unstable while the SOS version stays bounded
Rounding pushes a pole outside the unit circle, so lfilter() blows up to around 1018; the SOS filter stays small.

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?

TaskUseWhy
Known order and cutoffiirfilter(..., output="sos")One call for every design type
Spec in dB, order unknowniirdesign()Finds the lowest order
Recorded datasosfiltfilt()Zero time shift
Live streamsosfilt() with ziWorks 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 padlen or 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 the zi argument.

Want more SciPy and plotting practice? Try these next: