Place, drag and delete poles and zeros; watch the frequency response follow

click adds preset
z-plane
Click empty space to place · drag a marker to move it · right-click or Alt-click a marker to delete it. Off-axis markers are placed as conjugate pairs, so the coefficients stay real.
magnitude
phase

What you are looking at

Click in the z-plane to place a pole pair or a zero pair, drag them around, and the magnitude and phase response follow. Hover a response plot to see where that frequency sits on the unit circle, and which distances produce the value there.

The transfer function built from the markers is

\[ H(z) = \frac{\prod_i \left(1 - z_i z^{-1}\right)}{\prod_i \left(1 - p_i z^{-1}\right)}, \]

with \(z_i\) the zeros and \(p_i\) the poles. Writing it in \(z^{-1}\) rather than \(z\) matters: expanding the two products gives exactly the b and a coefficient arrays shown under the z-plane, the ones scipy.signal.freqz(b, a) and lfilter(b, a, x) take. The frequency response is this evaluated on the unit circle, \(H(e^{j\omega})\), which is why the hover marker walks around that circle as you sweep the response plots.

Because \(|e^{-j\omega}| = 1\), each factor has magnitude \(|1 - z_i e^{-j\omega}| = |e^{j\omega} - z_i|\): the distance from the frequency point on the unit circle to that zero. So

\[ \left|H(e^{j\omega})\right| = \frac{\prod_i \left|e^{j\omega} - z_i\right|}{\prod_i \left|e^{j\omega} - p_i\right|}, \]

which is what the dashed lines draw while you hover. Every distance to a zero multiplies the magnitude; every distance to a pole divides it. Drag a pole toward the circle and its distance shrinks toward zero at that angle, so the magnitude there blows up. Put a zero on the circle and one distance becomes exactly zero, so the magnitude does too.

Off-axis markers are placed as conjugate pairs because real filter coefficients require it: a lone complex pole would give complex b or a. Markers snapped onto the real axis stand alone, which is the exception that proves the rule.

Guided explorations

Turn snapping off for the radial ones

Snapping is what makes a zero land exactly on the unit circle, so it is on by default. But it also means a marker within about 0.045 of the circle jumps back onto it, so a small radial nudge does nothing. Every exploration below that asks you to move a marker slightly in or out wants the snap checkbox cleared first.

Resonance sharpness

Start from the resonator preset, clear snap, and drag the pole slowly outward along its radius. The peak narrows and grows as the pole approaches the unit circle, and the phase transition across it steepens at the same rate. This is the digital resonator, and it is also why a high-Q filter is numerically delicate: the response becomes hypersensitive to the pole’s exact position, and therefore to coefficient rounding.

Notch depth

With snap on, place a zero pair exactly on the unit circle and watch the magnitude plunge off the bottom of the axis: a true null. Now clear snap and drag it slightly inward or outward. The notch fills in immediately. Only zeros exactly on the circle produce genuine nulls, which is why finite coefficient precision limits how deep a real notch filter can go.

What a nearby pole does to a notch

From the notch preset, clear snap and drag the pole toward its zero. The notch stays deep but gets narrower, because the pole cancels the zero’s influence everywhere except very close to the null. This pole-near-zero trick is how narrow notch filters are built: the zero sets the depth, the pole sets the width. Take the pole all the way onto the zero and they cancel exactly: the response goes flat and the notch vanishes, which is the degenerate end of the same story.

Instability

Drag a pole outside the unit circle. The status line flips to UNSTABLE and the marker gets a dashed ring. The magnitude plot still draws a curve, because \(H(e^{j\omega})\) is defined there arithmetically, but for a causal filter it no longer means anything: the impulse response grows without bound and the actual output diverges. Reading a frequency response off an unstable pole-zero plot is a classic way to fool yourself.

Stop on the circle on the way out and the status reads MARGINALLY STABLE instead. That is a third state, not a shade of unstable: the mode neither decays nor grows. The magnitude is genuinely infinite at that frequency, so the curve runs off the top of the plot rather than the axis rescaling to chase it.

Allpass

The allpass preset puts a zero at radius \(1.25\) and a pole at radius \(0.8\) on the same ray, and \(1/1.25 = 0.8\) exactly. The magnitude is flat: constant at \(20\log_{10}(1/0.8^2) = 3.88\) dB, since the leading coefficient is fixed at \(b_0 = 1\) rather than normalised. Only the phase moves.

The flatness lives entirely in that pairing, \(z_i = 1/\overline{p_i}\), and the widget does not enforce it: drag either marker on its own and the magnitude bulges immediately. Rotating the pole alone to \(\omega = \pi/2\) while the zero stays put swings the response from \(-5.7\) to \(+12.2\) dB. Move both, keeping the same angle and reciprocal radii, and the response stays flat while the phase step slides in frequency. Breaking the pairing on purpose is worth doing once: it shows that “allpass” is a property of the arrangement, not of having a pole and a zero on the same ray. This is the mechanism behind allpass equalisers and fractional-delay filters.

Reading the coefficients

Watch the b and a arrays while you drag. A conjugate pair at radius \(r\) and angle \(\theta\) contributes the quadratic factor \(1 - 2r\cos\theta\, z^{-1} + r^2 z^{-2}\): this is the biquad section, seen from the z-plane. Placing two pairs and reading off the four coefficients is exactly what a filter design routine does for you.

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 shows the fundamental mechanism underneath all of them: 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. For the theory the explorer sits on, see The z-domain, and for what happens when those coefficients meet finite arithmetic, Filter structures.

Python equivalent

The explorer computes in the browser; here is the same thing in scipy.signal, which is where you would do reproducible work. Copy the b and a arrays from under the z-plane straight into this cell.

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

# 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)

z, p, k = tf2zpk(b, a)
w, h = freqz(b, a, worN=1024)

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

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)

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)

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_2893/4088053670.py:35: RuntimeWarning: divide by zero encountered in log10
  ax.plot(w / np.pi, 20 * np.log10(np.abs(h)), 'steelblue', linewidth=1.5)

tests/test_pole_zero_explorer.py keeps the two honest, with one caveat worth stating plainly: it reads the widget’s presets out of this page, reimplements its coefficient and response computation in Python, and checks that reimplementation against scipy.signal.freqz for every preset. What runs in your browser is the JavaScript, which no test executes. The port is short and deliberately line-for-line so the two can be diffed by eye, but the check is of the algorithm, not of the shipped code.

Prerequisites and next steps

This page assumes the z-domain (transfer functions, poles, zeros, the unit circle) and pairs with filter design, where specifications become pole and zero locations.