scipy.signal.freqz computes the frequency response of a digital filter from its coefficients. It returns two arrays:
from scipy import signal
b, a = signal.butter(4, 0.2)
w, h = signal.freqz(b, a)
# w -> 512 frequencies, 0 to pi radians per sample
# h -> the COMPLEX response at each one
h is complex, which is the detail that trips most people up. You take abs(h) for magnitude and np.angle(h) for phase.
Every number and plot below came from running the code on Python 3.12.5, SciPy 1.18.1, NumPy 2.5.3, Matplotlib 3.11.2.
Plotting a frequency response with freqz
The standard plot is magnitude in decibels against frequency, and it’s three lines of work:
import numpy as np
from scipy import signal
import matplotlib.pyplot as plt
# a 4th-order Butterworth low-pass, cutoff at 0.2 of the Nyquist frequency
b, a = signal.butter(4, 0.2)
w, h = signal.freqz(b, a) # w: frequencies, h: complex response
plt.figure(figsize=(8, 4.2))
plt.plot(w, 20 * np.log10(abs(h)), color="#0b6bcb", lw=2)
plt.axhline(-3, color="#c0392b", ls="--", lw=1, label="-3 dB")
plt.title("Frequency response of a 4th-order Butterworth filter")
plt.xlabel("Frequency (radians / sample)")
plt.ylabel("Magnitude (dB)")
plt.ylim(-80, 5)
plt.grid(alpha=.3); plt.legend(); plt.tight_layout()
plt.show()
20 * np.log10(abs(h)) converts magnitude to decibels. Without that conversion the interesting part of the curve is squashed against zero.
The -3 dB line matters because that is the conventional definition of a filter’s cutoff, not the point where the response reaches zero.
What freqz returns: the w and h arrays
Print them before you plot anything. It saves a lot of guessing:
import numpy as np
from scipy import signal
b, a = signal.butter(4, 0.2)
w, h = signal.freqz(b, a)
print("w shape:", w.shape, "| h shape:", h.shape)
print("h dtype:", h.dtype) # complex, not float
print()
print("w starts at:", w[0])
print("w ends at :", round(float(w[-1]), 6))
print("pi is :", round(float(np.pi), 6), "<- the grid stops just short of it")
print()
print("magnitude at DC :", round(float(abs(h[0])), 4))
print("phase at DC (rad) :", round(float(np.angle(h[0])), 4))
Output:
w shape: (512,) | h shape: (512,)
h dtype: complex128
w starts at: 0.0
w ends at : 3.135457
pi is : 3.141593 <- the grid stops just short of it
magnitude at DC : 1.0
phase at DC (rad) : 0.0
h is complex128.| Returned | Contains | Use |
|---|---|---|
w | Frequencies, radians per sample by default | The x axis |
h | Complex response, one value per frequency | abs(h) and np.angle(h) |
Notice where the grid ends: 3.135457, not 3.141593. The frequency points are evenly spaced but the final endpoint is excluded, so you never quite reach π.
That rarely matters for a plot. It does matter if you’re indexing h[-1] expecting the response exactly at Nyquist.
The scipy freqz fs parameter: frequencies in hertz
Radians per sample is awkward to read. Pass fs and you’ll get the frequencies back in hertz instead:
import numpy as np
from scipy import signal
import matplotlib.pyplot as plt
b, a = signal.butter(4, 0.2)
w_rad, h1 = signal.freqz(b, a) # radians per sample, 0 to pi
w_hz, h2 = signal.freqz(b, a, fs=1000) # hertz, 0 to fs/2
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
ax1.plot(w_rad, 20 * np.log10(abs(h1)), color="#0b6bcb", lw=2)
ax1.set_title("Default: radians / sample")
ax1.set_xlabel("Frequency (rad/sample)")
ax2.plot(w_hz, 20 * np.log10(abs(h2)), color="#0a7d32", lw=2)
ax2.axvline(100, color="#c0392b", ls="--", lw=1, label="100 Hz cutoff")
ax2.set_title("fs=1000: hertz")
ax2.set_xlabel("Frequency (Hz)")
ax2.legend()
for ax in (ax1, ax2):
ax.set_ylabel("Magnitude (dB)"); ax.set_ylim(-80, 5); ax.grid(alpha=.3)
plt.tight_layout(); plt.show()
print("without fs, w ends at:", round(float(w_rad[-1]), 4), "rad/sample")
print("with fs=1000, w ends at:", round(float(w_hz[-1]), 4), "Hz")
Output:
without fs, w ends at: 3.1355 rad/sample
with fs=1000, w ends at: 499.0234 Hz
With fs=1000, the axis runs from 0 to just under 500 Hz. That upper limit is the Nyquist frequency, always half the sampling rate.
Design the filter with the same fs and you can specify the cutoff in hertz too, which is far easier to reason about than a fraction of Nyquist.
The scipy freqz worN parameter: an integer or an array
worN does two different jobs depending on what you hand it. An integer sets how many points to compute:
import numpy as np
from scipy import signal
b, a = signal.butter(4, 0.2)
# worN as an integer: how many points on the grid
for n in (8, 512, 2048):
w, h = signal.freqz(b, a, worN=n)
print(f"worN={n:<5} -> {len(w)} points")
print()
# worN as an ARRAY: evaluate at exactly the frequencies you name
wanted = [0, 50, 100, 200, 400]
w, h = signal.freqz(b, a, worN=wanted, fs=1000)
print(f"{'Hz':>6} {'|h|':>9} {'dB':>9}")
for f, mag in zip(w, abs(h)):
print(f"{f:>6.0f} {mag:>9.4f} {20 * np.log10(mag):>9.2f}")
print()
print("100 Hz gives |h| = 0.7071, which is exactly -3 dB: the cutoff.")
Output:
worN=8 -> 8 points
worN=512 -> 512 points
worN=2048 -> 2048 points
Hz |h| dB
0 1.0000 -0.00
50 0.9984 -0.01
100 0.7071 -3.01
200 0.0400 -27.97
400 0.0001 -78.12
100 Hz gives |h| = 0.7071, which is exactly -3 dB: the cutoff.
worN=2048— a finer grid, useful for a sharp filter whose notch falls between the default points.worN=[0, 50, 100]— evaluate at those frequencies and nothing else.worN=8— too coarse to plot, but handy when you just want a few numbers.
The array form is the one worth remembering. It answers questions like “how much does this filter attenuate 200 Hz” in one line, with no searching through a 512-point array.
The table above confirms the design: at 100 Hz the magnitude is 0.7071, which is -3.01 dB. That is the cutoff landing exactly where butter was told to put it.
Plotting the freqz phase response
The same h carries the phase. Take the angle, then unwrap it:
import numpy as np
from scipy import signal
import matplotlib.pyplot as plt
b, a = signal.butter(4, 0.2)
w, h = signal.freqz(b, a, fs=1000)
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 6), sharex=True)
ax1.plot(w, 20 * np.log10(abs(h)), color="#0b6bcb", lw=2)
ax1.set_ylabel("Magnitude (dB)")
ax1.set_title("Magnitude and phase response")
ax1.set_ylim(-80, 5); ax1.grid(alpha=.3)
phase = np.unwrap(np.angle(h)) # unwrap removes the jumps at +/- pi
ax2.plot(w, np.degrees(phase), color="#8e44ad", lw=2)
ax2.set_ylabel("Phase (degrees)")
ax2.set_xlabel("Frequency (Hz)")
ax2.grid(alpha=.3)
plt.tight_layout(); plt.show()
raw = np.angle(h)
print("raw phase jumps between", round(float(raw.min()), 2), "and", round(float(raw.max()), 2), "radians")
print("unwrapped runs smoothly from", round(float(phase[0]), 2), "to", round(float(phase[-1]), 2))
Output:
raw phase jumps between -3.13 and 3.12 radians
unwrapped runs smoothly from 0.0 to -6.28
np.angle(h) returns values confined to -π..π, so the real phase curve arrives chopped into pieces with vertical jumps.
np.unwrap adds the missing multiples of 2π back and gives you a continuous line. Always unwrap before plotting phase.
Phase tells you the delay the filter introduces at each frequency. If that delay matters for your application, look at Butterworth filter design or at filtfilt, which cancels phase distortion by filtering twice.
Why does scipy freqz fail on second-order sections?
Because it isn’t the right function for them. If you designed the filter with output="sos", you need sosfreqz:
import numpy as np
from scipy import signal
sos = signal.butter(4, 0.2, output="sos")
print("sos shape:", sos.shape, "(sections x 6 coefficients)")
try:
w, h = signal.freqz(sos)
except ValueError as err:
print("freqz(sos) raised ValueError:", err)
# sosfreqz is the function for second-order sections
w, h = signal.sosfreqz(sos, fs=1000)
print()
print("sosfreqz worked:", w.shape, h.shape)
b, a = signal.butter(4, 0.2)
_, h_ba = signal.freqz(b, a, fs=1000)
print("largest difference vs freqz(b, a):", float(np.max(np.abs(abs(h) - abs(h_ba)))))
Output:
sos shape: (2, 6) (sections x 6 coefficients)
freqz(sos) raised ValueError: Array shapes are incompatible for broadcasting.
sosfreqz worked: (512,) (512,)
largest difference vs freqz(b, a): 4.884981308350689e-15
freqz(sos) fails on shape. sosfreqz is the right call.The error is Array shapes are incompatible for broadcasting, which is not an obvious clue. It happens because freqz reads the first argument as numerator coefficients and an SOS array is two-dimensional.
sosfreqz takes the same keyword arguments, including fs and worN. The output matches freqz(b, a) to about 5e-15, so the two are interchangeable in accuracy.
Prefer SOS for filters above roughly order 8. Transfer-function coefficients become numerically unstable at high orders, and SOS avoids that.
Comparing filter orders with freqz
Because freqz just returns arrays, comparing designs is an ordinary loop:
import numpy as np
from scipy import signal
import matplotlib.pyplot as plt
plt.figure(figsize=(8, 4.5))
for order, colour in ((2, "#0b6bcb"), (4, "#0a7d32"), (8, "#c0392b")):
b, a = signal.butter(order, 100, fs=1000)
w, h = signal.freqz(b, a, fs=1000)
plt.plot(w, 20 * np.log10(abs(h)), color=colour, lw=2, label=f"order {order}")
plt.axvline(100, color="#555", ls=":", lw=1)
plt.axhline(-3, color="#555", ls="--", lw=1)
plt.title("Higher order means a steeper roll-off")
plt.xlabel("Frequency (Hz)"); plt.ylabel("Magnitude (dB)")
plt.ylim(-100, 5); plt.grid(alpha=.3); plt.legend(); plt.tight_layout()
plt.show()
for order in (2, 4, 8):
b, a = signal.butter(order, 100, fs=1000)
w, h = signal.freqz(b, a, worN=[200], fs=1000)
print(f"order {order}: attenuation at 200 Hz = {20 * np.log10(abs(h[0])):.1f} dB")
Output:
order 2: attenuation at 200 Hz = -14.1 dB
order 4: attenuation at 200 Hz = -28.0 dB
order 8: attenuation at 200 Hz = -55.9 dB
The printed attenuations quantify what the plot shows: at 200 Hz, order 2 gives -14.1 dB while order 8 reaches -55.9 dB.
This is the everyday use of freqz. You design a filter, plot its response, and check it does what you intended before running any real data through it. It pairs naturally with finding peaks in a signal.
Common freqz mistakes
| Symptom | Cause | Fix |
|---|---|---|
| Plot looks like a flat line at 1 | Plotting abs(h) instead of dB | Use 20 * np.log10(abs(h)) |
ComplexWarning when plotting | Passing h straight to plot | Take abs(h) first |
| Phase has vertical jumps | Raw np.angle | Wrap it in np.unwrap |
Array shapes are incompatible | Passing SOS to freqz | Use sosfreqz |
| X axis reads 0 to 3.14 | No fs given | Pass fs= your sample rate |
More SciPy signal processing and plotting guides:
- SciPy Butterworth filter design
- SciPy IIR filter
- Find peaks in a signal with SciPy
- SciPy convolve
- SciPy minimize
- The scipy.signal module
- SciPy in Python: what it is and how to use it
Frequently asked questions
What does scipy.signal.freqz do?
It computes the frequency response of a digital filter from its coefficients, returning the frequency points w and the complex response h. The full signature is in the scipy.signal.freqz reference.
What are w and h in freqz?
w holds the frequencies, in radians per sample unless you pass fs. h holds the complex response, so use abs(h) for magnitude and np.angle(h) for phase.
What does the fs parameter do in freqz?
It makes w come back in hertz, running from 0 to fs/2, instead of 0 to π radians per sample.
What is worN in scipy freqz?
An integer sets how many frequency points to compute, 512 by default. An array or list instead evaluates the response at exactly those frequencies.
Why does freqz fail with ‘Array shapes are incompatible for broadcasting’?
You passed second-order sections to freqz. SOS arrays are two-dimensional, so use scipy.signal.sosfreqz instead.
How do I convert the freqz output to decibels?
20 * np.log10(abs(h)). Plotting raw magnitude squashes the roll-off into the bottom of the chart.
Why does my phase plot have sudden jumps?
np.angle limits results to -π..π. Wrap it in np.unwrap(np.angle(h)) for a continuous curve.
Bijay Kumar is a 13-time Microsoft MVP with more than 18 years in software development, and the founder of Python Guides and TSinfo Technologies. He started out building .NET and SharePoint solutions at HP, TCS and KPIT before moving into Python, machine learning and AI, and he also builds web apps with TypeScript and React. He writes the tutorials here himself, and every example is run before publishing so you see the real output. More about Bijay · Microsoft MVP profile · LinkedIn