Interactive exploration of how pole and zero placement shapes the frequency response

The frequency response of a digital filter is completely determined by the locations of its poles and zeros in the z-plane. This interactive tool lets you explore that relationship by placing and moving poles and zeros, watching the magnitude and phase response change in real time.

Prerequisites

This page assumes familiarity with:

  • The z-domain: transfer functions, poles, zeros, and the unit circle
  • Filter design: how filter specifications map to pole-zero placement

Interactive explorer

Use the sliders below to position poles and zeros in the z-plane. The transfer function is:

\[ H(z) = \frac{\prod_i (z - z_i)}{\prod_i (z - p_i)} \]

where the frequency response is \(H(e^{j\omega})\), evaluated on the unit circle. Since we want real-valued filter coefficients, poles and zeros appear in conjugate pairs.

Controls

Preset configurations

Preset hint

Z-plane and frequency response

Guided explorations

Try these configurations and observe how the frequency response changes.

Resonance sharpness

Set the pole angle to 45 degrees and sweep the radius from 0.5 to 0.99. Watch how the resonance peak narrows and grows as the pole approaches the unit circle. At radius 0.99, the peak is extremely sharp – this is how a digital resonator works.

Notch depth

Place a zero on the unit circle (radius = 1.0) at any angle. The magnitude drops to exactly zero at that frequency – a perfect notch. Now move the zero slightly off the unit circle (radius = 0.95 or 1.05) and watch the notch become shallower. Only zeros exactly on the unit circle produce true nulls.

Instability

Move the pole outside the unit circle (radius > 1.0). The system becomes unstable: the magnitude response diverges. In a real filter, this would cause the output to grow without bound. This is why filter design tools always constrain poles to lie strictly inside the unit circle.

DC blocker

Classic high-pass configuration: set the zero at angle 0 degrees, radius 1.0 (a zero at \(z = 1\)), and the pole at angle 0 degrees, radius 0.995. This blocks DC while passing everything else. The closer the pole is to the zero, the narrower the transition band.

Allpass filter

Set a zero outside the unit circle (e.g., radius 1.11, angle 60 degrees) and a pole inside at the reciprocal radius (radius 0.9, angle 60 degrees). The magnitude response is approximately flat – only the phase changes. This is the allpass principle: for every zero at radius \(r\), place a pole at \(1/r\) at the same angle.

Connection to filter design

Classical filter design functions like scipy.signal.butter, cheby1, and ellip work by computing pole and zero locations that satisfy a given magnitude specification:

  • Butterworth places poles evenly on a circle in the s-plane, then maps them to the z-plane via the bilinear transform. For a lowpass design, all zeros end up at \(z = -1\) (Nyquist). The result is a maximally flat passband.
  • Chebyshev Type I allows ripple in the passband, which lets the poles move onto an ellipse – achieving a sharper transition for the same filter order.
  • Elliptic (Cauer) adds zeros in the stopband too, producing equiripple in both passband and stopband. This gives the sharpest transition of any classical design for a given order.

The explorer above shows the fundamental mechanism: every feature in the frequency response – every peak, notch, and slope – traces back to a specific pole or zero in the z-plane.

For the full treatment, see Filter design.

Python equivalent

For reproducible work, here is the same computation in Python using scipy.signal.

Static pole-zero plot in Python
import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import freqz, tf2zpk

# Define a simple filter: DC blocker
# H(z) = (1 - z^{-1}) / (1 - 0.995 z^{-1})
b = [1, -1]           # numerator coefficients (zero at z=1)
a = [1, -0.995]       # denominator coefficients (pole at z=0.995)

# Get poles and zeros
z, p, k = tf2zpk(b, a)

# Frequency response
w, h = freqz(b, a, worN=1024)

fig, axes = plt.subplots(1, 3, figsize=(14, 4))

# Z-plane
ax = axes[0]
theta = np.linspace(0, 2 * np.pi, 200)
ax.plot(np.cos(theta), np.sin(theta), 'k--', alpha=0.3, linewidth=0.8)
ax.plot(np.real(z), np.imag(z), 'o', markersize=10,
        markerfacecolor='none', markeredgecolor='steelblue', markeredgewidth=2,
        label='Zeros')
ax.plot(np.real(p), np.imag(p), 'x', markersize=10,
        markeredgecolor='orangered', markeredgewidth=2,
        label='Poles')
ax.axhline(0, color='grey', linewidth=0.5)
ax.axvline(0, color='grey', linewidth=0.5)
ax.set_xlim(-1.5, 1.5)
ax.set_ylim(-1.5, 1.5)
ax.set_aspect('equal')
ax.set_xlabel('Real')
ax.set_ylabel('Imaginary')
ax.set_title('Z-plane')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)

# Magnitude
ax = axes[1]
ax.plot(w / np.pi, 20 * np.log10(np.abs(h)), 'steelblue', linewidth=1.5)
ax.set_xlabel('Normalized frequency (× π rad/sample)')
ax.set_ylabel('Magnitude (dB)')
ax.set_title('Magnitude response')
ax.set_xlim(0, 1)
ax.grid(True, alpha=0.3)

# Phase
ax = axes[2]
ax.plot(w / np.pi, np.angle(h, deg=True), 'orangered', linewidth=1.5)
ax.set_xlabel('Normalized frequency (× π rad/sample)')
ax.set_ylabel('Phase (degrees)')
ax.set_title('Phase response')
ax.set_xlim(0, 1)
ax.grid(True, alpha=0.3)

fig.suptitle('DC blocker: zero at z = 1, pole at z = 0.995', fontsize=12, y=1.02)
fig.tight_layout()
plt.show()
/tmp/ipykernel_3380/1530387719.py:41: RuntimeWarning: divide by zero encountered in log10
  ax.plot(w / np.pi, 20 * np.log10(np.abs(h)), 'steelblue', linewidth=1.5)

To explore interactively in a Jupyter notebook, wrap the above in a function and use ipywidgets sliders – or simply adjust b and a and re-run the cell.