Python IIR Filters: Design and Implementation

Infinite Impulse Response (IIR) filters are a fundamental tool in digital signal processing. They are efficient, computationally compact, and capable of producing sharp frequency responses with far fewer coefficients than their FIR counterparts. In this article, you will learn how IIR filters work and how to design and apply them in Python using scipy.signal.

Signal processing shows up everywhere: audio engineering, biomedical instrumentation, telecommunications, vibration analysis, and financial time-series smoothing. At the center of many of those workflows is the digital filter. Understanding IIR filters gives you a powerful, efficient option any time you need to shape a signal's frequency content in Python.

What Is an IIR Filter?

An IIR filter is a recursive digital filter. Unlike a Finite Impulse Response (FIR) filter, which only uses past input samples to compute each output, an IIR filter feeds back past output samples as well. That feedback loop is what gives the filter its "infinite" impulse response: theoretically, the effect of a single input sample never fully decays to zero.

The general difference equation for an IIR filter looks like this:

# General IIR difference equation (conceptual)
# y[n] = b0*x[n] + b1*x[n-1] + ... + bM*x[n-M]
#       - a1*y[n-1] - a2*y[n-2] - ... - aN*y[n-N]
#
# b coefficients: feedforward (numerator)
# a coefficients: feedback    (denominator)
# Order of filter = max(M, N)

The b array holds the numerator (feedforward) coefficients and the a array holds the denominator (feedback) coefficients. In scipy.signal, a[0] is always normalized to 1.0.

Note

The "order" of an IIR filter refers to the length of the feedback path (the number of past output samples used). A higher order produces a steeper frequency roll-off but also introduces more phase delay and greater risk of numerical instability.

IIR vs. FIR: When to Use Which

Both filter types have their place. The choice between them depends on your application constraints.

IIR filters: Lower computational cost for a given roll-off steepness. They require far fewer coefficients to match the frequency selectivity of an FIR filter. The tradeoff is nonlinear phase response, which introduces phase distortion that varies with frequency.

FIR filters: Always stable, always linear phase. They require more coefficients and more computation to achieve the same roll-off, but the phase relationship between frequency components is preserved perfectly.

Pro Tip

Use IIR when processing speed matters and phase distortion is acceptable (e.g., audio effects, real-time control loops). Use FIR when phase linearity is critical (e.g., communications, medical ECG analysis).

Filter Types: Low-Pass, High-Pass, Band-Pass, Band-Stop

Before designing a filter, you need to define what frequencies to keep and what to attenuate. The four standard configurations are:

  • Low-pass: Passes frequencies below a cutoff, attenuates everything above. Used for smoothing and noise removal.
  • High-pass: Passes frequencies above a cutoff, attenuates everything below. Useful for removing DC offset or slow drift.
  • Band-pass: Passes a range of frequencies between two cutoffs. Common in audio equalizers and radio receivers.
  • Band-stop (notch): Attenuates a specific frequency band while passing everything else. The classic use case is removing 50/60 Hz power-line interference from biomedical signals.

In scipy.signal, the btype parameter of filter design functions accepts 'low', 'high', 'band', or 'bandstop'.

Design Families: Butterworth, Chebyshev, Elliptic

IIR filters are typically derived from classical analog filter prototypes. Each family offers a different trade-off between pass-band flatness, stop-band attenuation, transition width, and phase behavior.

Butterworth

The Butterworth design maximizes flatness in the pass band. There is no ripple anywhere in the magnitude response, but the transition from pass to stop band is relatively gradual. It is the most common starting point for general-purpose filtering.

Chebyshev Type I

Allows a controlled amount of ripple in the pass band to achieve a steeper roll-off than Butterworth for the same filter order. The stop band is monotonically decreasing.

Chebyshev Type II

Moves the ripple to the stop band instead, keeping the pass band flat. Useful when you need a flat pass band but a sharper cutoff than Butterworth provides.

Elliptic (Cauer)

Allows ripple in both the pass band and the stop band. In exchange, elliptic filters achieve the sharpest possible transition for a given filter order. They are the go-to choice when computation is tightly constrained.

Warning

Elliptic filters produce the most severe nonlinear phase response of the three families. If you are filtering signals where relative timing of frequency components matters, test carefully or consider a Butterworth first.

Implementing IIR Filters in Python

The scipy.signal module provides everything you need. Install it via pip if you do not already have it:

pip install scipy numpy matplotlib

1. Designing a Butterworth Low-Pass Filter

The workflow is always the same: design the filter coefficients, then apply them to your signal.

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

# Parameters
fs = 1000          # Sampling frequency in Hz
cutoff = 100       # Desired cutoff frequency in Hz
order = 4          # Filter order

# Design the filter
# Wn is the critical frequency normalized to the Nyquist frequency (fs/2)
nyq = fs / 2
Wn = cutoff / nyq

b, a = signal.butter(order, Wn, btype='low', analog=False)

print("Numerator coefficients (b):", b)
print("Denominator coefficients (a):", a)

The function signal.butter() returns two arrays: b (numerator) and a (denominator). These define the transfer function of the filter.

2. Applying the Filter to a Signal

There are two main application functions: signal.lfilter() and signal.filtfilt().

# Create a test signal: 50 Hz sine + 200 Hz noise
t = np.linspace(0, 1.0, fs, endpoint=False)
signal_clean = np.sin(2 * np.pi * 50 * t)
noise = 0.5 * np.sin(2 * np.pi * 200 * t)
signal_noisy = signal_clean + noise

# lfilter: causal, introduces phase delay
filtered_causal = signal.lfilter(b, a, signal_noisy)

# filtfilt: zero-phase (forward + backward pass), no phase distortion
filtered_zerophase = signal.filtfilt(b, a, signal_noisy)

# Plot the results
plt.figure(figsize=(12, 5))
plt.plot(t[:200], signal_noisy[:200], label='Noisy signal', alpha=0.5)
plt.plot(t[:200], filtered_causal[:200], label='lfilter (causal)', linewidth=2)
plt.plot(t[:200], filtered_zerophase[:200], label='filtfilt (zero-phase)', linewidth=2)
plt.xlabel('Time (s)')
plt.ylabel('Amplitude')
plt.title('Butterworth Low-Pass Filter: lfilter vs filtfilt')
plt.legend()
plt.tight_layout()
plt.show()
Pro Tip

Use signal.filtfilt() for offline processing when you have the entire signal available. Use signal.lfilter() for real-time or streaming scenarios where you can only look at data that has already arrived.

3. Chebyshev Type I Band-Pass Filter

import numpy as np
from scipy import signal

fs = 1000       # Sampling frequency in Hz
low_cut = 80    # Low cutoff in Hz
high_cut = 120  # High cutoff in Hz
order = 5
ripple_db = 1.0  # Maximum pass-band ripple in dB

nyq = fs / 2
low = low_cut / nyq
high = high_cut / nyq

b, a = signal.cheby1(order, ripple_db, [low, high], btype='band', analog=False)

# Generate a multi-tone test signal
t = np.linspace(0, 1.0, fs, endpoint=False)
x = (np.sin(2 * np.pi * 50 * t)    # below pass band
   + np.sin(2 * np.pi * 100 * t)   # in pass band
   + np.sin(2 * np.pi * 200 * t))  # above pass band

y = signal.filtfilt(b, a, x)

print("Only the 100 Hz component should survive.")
print("Peak amplitude:", np.max(np.abs(y)))

4. Elliptic High-Pass Filter

import numpy as np
from scipy import signal

fs = 500        # Sampling frequency in Hz
cutoff = 30     # High-pass cutoff in Hz
order = 3
rp = 1.0        # Max pass-band ripple in dB
rs = 40.0       # Min stop-band attenuation in dB

nyq = fs / 2
Wn = cutoff / nyq

b, a = signal.ellip(order, rp, rs, Wn, btype='high', analog=False)

# Example: remove slow drift from a simulated ECG-like signal
t = np.linspace(0, 5.0, int(fs * 5), endpoint=False)
drift = 0.5 * np.sin(2 * np.pi * 0.5 * t)   # 0.5 Hz drift
ecg_like = np.sin(2 * np.pi * 60 * t) + drift

ecg_filtered = signal.filtfilt(b, a, ecg_like)
print("Drift removed. DC content:", np.mean(ecg_filtered).round(4))

5. Inspecting the Frequency Response

Always visualize the frequency response of a filter before deploying it. The signal.freqz() function computes the complex frequency response from the b and a coefficients.

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

fs = 1000
nyq = fs / 2

# Design a 4th-order Butterworth low-pass at 100 Hz
b, a = signal.butter(4, 100 / nyq, btype='low')

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

plt.figure(figsize=(10, 4))
plt.plot(w, 20 * np.log10(np.abs(h)))
plt.axvline(100, color='r', linestyle='--', label='Cutoff: 100 Hz')
plt.axhline(-3, color='gray', linestyle=':', label='-3 dB')
plt.xlabel('Frequency (Hz)')
plt.ylabel('Gain (dB)')
plt.title('Butterworth Low-Pass Frequency Response')
plt.legend()
plt.grid(True, alpha=0.3)
plt.xlim(0, nyq)
plt.ylim(-80, 5)
plt.tight_layout()
plt.show()

The -3 dB point marks where the filter has attenuated the signal to approximately 70.7% of its original amplitude. For a Butterworth filter, this point coincides exactly with the specified cutoff frequency.

Stability and Second-Order Sections

High-order IIR filters stored as a single pair of b and a polynomial arrays can become numerically unstable due to floating-point limitations. Filters of order 8 or higher are particularly vulnerable. The solution is to represent the filter as a cascade of Second-Order Sections (SOS), which keeps coefficient values small and numerically well-conditioned.

import numpy as np
from scipy import signal

fs = 1000
nyq = fs / 2

# Design a high-order Butterworth filter using SOS for numerical stability
sos = signal.butter(10, 150 / nyq, btype='low', output='sos')

# Apply using sosfiltfilt (zero-phase, SOS-based)
t = np.linspace(0, 1.0, fs, endpoint=False)
x = np.sin(2 * np.pi * 50 * t) + 0.3 * np.random.randn(fs)

y = signal.sosfiltfilt(sos, x)

print("SOS shape:", sos.shape)
print("Number of biquad sections:", sos.shape[0])
print("Filtering complete. Output range: [{:.3f}, {:.3f}]".format(y.min(), y.max()))
Note

As a rule of thumb: always use output='sos' and signal.sosfiltfilt() (or signal.sosfilt() for causal filtering) instead of b, a form for any filter of order 5 or higher. It is safer, and there is no performance penalty.

You can also convert an existing b, a filter to SOS form after the fact using signal.tf2sos(b, a), but designing directly in SOS form with output='sos' is the cleaner approach.

import numpy as np
from scipy import signal

# Complete, production-ready IIR filtering pattern
def apply_iir_filter(data, cutoff_hz, fs_hz, order=4, filter_type='low',
                     family='butter', rp=1.0, rs=40.0):
    """
    Apply an IIR filter to 1-D data using SOS form for numerical stability.

    Parameters
    ----------
    data       : array-like  Input signal
    cutoff_hz  : float or list  Cutoff frequency in Hz. Pass a list [low, high]
                                for band-pass or band-stop.
    fs_hz      : float       Sampling frequency in Hz
    order      : int         Filter order (default 4)
    filter_type: str         'low', 'high', 'band', or 'bandstop'
    family     : str         'butter', 'cheby1', 'cheby2', or 'ellip'
    rp         : float       Max pass-band ripple in dB (Chebyshev/Elliptic only)
    rs         : float       Min stop-band attenuation in dB (Cheby2/Elliptic only)

    Returns
    -------
    numpy.ndarray  Zero-phase filtered signal
    """
    nyq = fs_hz / 2.0

    if isinstance(cutoff_hz, (list, tuple)):
        Wn = [f / nyq for f in cutoff_hz]
    else:
        Wn = cutoff_hz / nyq

    design_funcs = {
        'butter': lambda: signal.butter(order, Wn, btype=filter_type, output='sos'),
        'cheby1': lambda: signal.cheby1(order, rp, Wn, btype=filter_type, output='sos'),
        'cheby2': lambda: signal.cheby2(order, rs, Wn, btype=filter_type, output='sos'),
        'ellip':  lambda: signal.ellip(order, rp, rs, Wn, btype=filter_type, output='sos'),
    }

    if family not in design_funcs:
        raise ValueError(f"Unknown filter family '{family}'. "
                         f"Choose from: {list(design_funcs.keys())}")

    sos = design_funcs[family]()
    return signal.sosfiltfilt(sos, np.asarray(data, dtype=float))


# Example usage
if __name__ == '__main__':
    fs = 1000
    t = np.linspace(0, 2.0, int(fs * 2), endpoint=False)
    raw = (np.sin(2 * np.pi * 60 * t)
         + 0.5 * np.sin(2 * np.pi * 300 * t)
         + 0.2 * np.random.randn(len(t)))

    # Low-pass Butterworth: remove everything above 100 Hz
    lp = apply_iir_filter(raw, cutoff_hz=100, fs_hz=fs, order=4, family='butter')

    # Band-pass elliptic: isolate 50-70 Hz
    bp = apply_iir_filter(raw, cutoff_hz=[50, 70], fs_hz=fs, order=4,
                          filter_type='band', family='ellip', rp=0.5, rs=60)

    print("Low-pass peak:", round(np.max(np.abs(lp)), 4))
    print("Band-pass peak:", round(np.max(np.abs(bp)), 4))

Key Takeaways

  1. IIR filters use feedback: The recursive structure lets them achieve steep frequency roll-off with very few coefficients, making them computationally efficient compared to FIR filters.
  2. Choose the right design family: Butterworth for a flat pass band, Chebyshev for steeper roll-off with controlled ripple, and elliptic when you need the sharpest transition and can tolerate ripple in both bands.
  3. Use SOS form for high-order filters: The output='sos' parameter and sosfiltfilt() prevent the numerical instability that plagues high-order filters stored as single polynomial arrays.
  4. lfilter vs. filtfilt: lfilter() is causal and suitable for real-time processing. filtfilt() applies the filter twice in opposite directions, eliminating phase distortion for offline use.
  5. Always inspect the frequency response: Plot the magnitude response with signal.freqz() or signal.sosfreqz() before you commit to any filter design. What looks correct mathematically can still behave unexpectedly at edge cases.

IIR filters are one of those tools that seem abstract at first but become indispensable once you start working with real signals. Whether you are cleaning up sensor data, building an audio effect, or stripping noise from measurements, the patterns in this article give you a solid, numerically stable foundation to build on.

back to articles