Why does a correct IIR filter break at block boundaries?
Suppose a sensor or audio interface delivers 128 samples at a time. Calling signal.sosfilt(sos, block) on every block can produce repeated startup transients even when the filter coefficients are correct. The missing ingredient is the state left by previous samples.
This article focuses on continuously running a designed filter. For order and cutoff selection, see Butterworth filter design ; for passband selection, see bandpass filters .
SOS coefficients and filter state serve different purposes
Second-order sections (SOS) represent a higher-order filter as a cascade of biquads:
\[ H(z) = \prod_{k=1}^{K} \frac{b_{0,k}+b_{1,k}z^{-1}+b_{2,k}z^{-2}} {1+a_{1,k}z^{-1}+a_{2,k}z^{-2}}. \]We use SciPy-designed sections whose leading denominator coefficient is one. A sixth-order lowpass has three sections and a (3, 6) coefficient array.
from scipy import signal
sos = signal.butter(6, 40, fs=1000, output="sos")
print(sos.shape) # (3, 6)
Coefficients specify the frequency response. State retains the effect of earlier input. For a one-dimensional stream, each section has two state values, giving shape (K, 2). SOS also avoids concentrating a high-order filter into one polynomial. See SciPy’s
butter
and
sosfilt
documentation for the coefficient format and implementation.
Pass zi and keep the returned zf
The essential loop is short. Here blocks yields nonempty, consecutive arrays from the same stream in chronological order.
import numpy as np
from scipy import signal
sos = signal.butter(6, 40, fs=1000, output="sos")
state = np.zeros((len(sos), 2), dtype=np.float64)
for block in blocks:
block = np.asarray(block, dtype=np.float64)
filtered, state = signal.sosfilt(sos, block, zi=state)
consume(filtered)
Replace blocks and consume with acquisition and output code. Supplying zi makes the call return both the output and final state. Without it, the call assumes zero state and returns only the output.
Block lengths may vary. A final block shorter than 128 samples is fine as long as sample order and state are preserved. Skip empty blocks before calling the filter.
Verify that streaming matches one call
The following standalone example processes a deterministic two-second signal sampled at 1 kHz. A sixth-order 40 Hz lowpass filters a DC offset plus 8 Hz and 180 Hz sinusoids.
import numpy as np
from scipy import signal
fs = 1000.0
t = np.arange(2000) / fs
x = 1.5 + np.sin(2 * np.pi * 8 * t) + 0.3 * np.sin(2 * np.pi * 180 * t)
sos = signal.butter(6, 40, fs=fs, output="sos")
zi0 = signal.sosfilt_zi(sos) * x[0]
y_whole, z_whole = signal.sosfilt(sos, x, zi=zi0.copy())
block_size = 128
state = zi0.copy()
parts, reset_parts = [], []
for start in range(0, len(x), block_size):
block = x[start:start + block_size]
y_block, state = signal.sosfilt(sos, block, zi=state)
parts.append(y_block)
# Deliberate bug: use zero state for every block.
reset_parts.append(signal.sosfilt(sos, block))
y_stream = np.concatenate(parts)
y_reset = np.concatenate(reset_parts)
np.testing.assert_allclose(y_stream, y_whole, rtol=1e-13, atol=1e-13)
np.testing.assert_allclose(state, z_whole, rtol=1e-13, atol=1e-13)
print(f"max streaming error: {np.max(np.abs(y_stream - y_whole)):.3e}")
error = np.max(np.abs(y_reset[block_size:] - y_whole[block_size:]))
print(f"max reset error after first block: {error:.6f}")
The measured result with NumPy 2.5.4 and SciPy 1.18.1 is:
max streaming error: 0.000e+00
max reset error after first block: 2.499462
Even excluding the first block, resetting state produces a large error. In this run, both output and final state match exactly when state is carried forward. The assertions allow rounding differences that other implementations or evaluation orders might introduce. Comparing against a whole-signal call with default zero state would be an unfair test because the initial conditions differ.

Dashed lines mark boundaries. Carried-state output overlaps the one-call result; zero-state output starts up again at each boundary. Depending on the signal, this can appear as a discontinuity or an audible click. The complete verification and plotting script reproduces the figure.
What does sosfilt_zi assume about the past?
sosfilt_zi(sos) * x[0] initializes the filter as if the input had been constant at its first value before processing began. SciPy’s
sosfilt_zi documentation
defines the unit-step steady-state initialization.
For this unity-DC-gain lowpass, a constant input of 1.5 can start at its steady output instead of ramping up from zero. This does not reconstruct the hidden history of a sinusoid. A difference between the assumed constant past and the real past still causes a transient.
| Initialization | Assumed history | Typical use |
|---|---|---|
| Zero state | Zero past input and state | A signal actually starting from rest |
sosfilt_zi(sos) * x[0] | Constant past input at the first value | Starting a measurement with a DC offset |
Previous zf | The actual processed history | The next block of the same stream |
| Warm-up using preceding samples | History represented by those samples | Starting evaluation partway through a record |
A highpass or bandpass rejects DC, so its steady output generally differs from the constant input value. Do not generalize the lowpass example into a promise that initialization always makes the first output equal the first input.
Multichannel processing: choose the axis and state shape
For input shaped (samples, channels), time runs along axis=0. Replace that dimension by two state values and prepend the section dimension: state shape is (K, 2, channels).
# Continue after the standalone example.
X = np.column_stack([x, 2 * x]) # (2000, 2)
zi = signal.sosfilt_zi(sos)[:, :, None] * X[0][None, None, :]
Y, zf = signal.sosfilt(sos, X, axis=0, zi=zi)
print(zf.shape) # (3, 2, 2)
np.testing.assert_allclose(Y[:, 0], y_whole, rtol=1e-13, atol=1e-13)
np.testing.assert_allclose(Y[:, 1], 2 * y_whole, rtol=1e-13, atol=1e-13)
For (channels, samples) input, use axis=-1 and (K, channels, 2) state. Each channel needs its own history. With exactly two channels, an incorrect state layout can have the same shape, so also compare the result with independently filtered channels.
Begin with float64 coefficients, input, and state, and check finite output at the largest expected amplitude. If using float32 or fixed-point arithmetic, test high orders, narrow passbands, and long recordings separately. SOS is not immunity to arbitrary quantization or overflow.
sosfiltfilt changes the magnitude response too
Offline sosfiltfilt runs forward and backward. Ignoring finite-record edge effects, a real-coefficient filter has combined response
\[ H_{\mathrm{fb}}(e^{j\omega}) = H(e^{j\omega})H(e^{-j\omega}) = |H(e^{j\omega})|^2. \]Phase cancels, but the amplitude gain is squared. A one-pass Butterworth cutoff gain of approximately −3.01 dB becomes approximately −6.02 dB. This is a change in amplitude response, not a confusion between amplitude and power units.
# Continue after the standalone example.
_, h = signal.sosfreqz(sos, worN=[40], fs=fs)
print(f"one pass: {20 * np.log10(abs(h[0])):.4f} dB")
print(f"forward-backward: {20 * np.log10(abs(h[0]) ** 2):.4f} dB")
# one pass: -3.0103 dB
# forward-backward: -6.0206 dB
The response equation describes the interior behavior where edge handling is negligible. A finite record still has padding and initialization effects near its ends. Running sosfiltfilt separately on each 128-sample block does not reproduce whole-record zero-phase filtering. Nor is applying a sixth-order Butterworth forward and backward generally equivalent to a twelfth-order one-pass Butterworth. See also
filtfilt
.
Block waiting time is different from group delay
At 1 kHz, 128 samples span a 128 ms block period. If processing starts only after the whole block arrives, the first sample waits about 127 ms for the last sample. Acquisition buffers, scheduling, computation, and output buffering add further latency.
The IIR filter also has frequency-dependent group delay:
\[ \tau_g(\omega) = -\frac{d\arg H(e^{j\omega})}{d\omega}. \]Here \(\omega\) is in rad/sample and \(\tau_g\) is in samples; divide by the sampling frequency to obtain seconds. Smaller blocks reduce acquisition waiting time, but do not alter group delay if the coefficients remain unchanged. A fixed 128 ms buffering figure cannot describe all phase and transient behavior.
These SciPy examples verify numerical behavior, not operating-system callback deadlines. For audio callbacks, avoid allocations and file writes in the processing path and measure worst-case processing time on the target system.
Practical checks before running continuously
- Carry
zfinto the next block’szifor the same uninterrupted stream. - Maintain independent channel states and specify the time axis.
- Use identical initial conditions when comparing with whole-signal filtering.
- Track missing samples and their timestamps; silently concatenating them changes the assumed time grid.
- Decide how to initialize after reconnecting or changing coefficients. Reusing state associated with different coefficients can cause a transient.
Frequently asked questions
How do I use sosfilt on consecutive blocks?
Pass the final state zf returned for one block as the initial state zi of the next. With the same coefficients and initial condition, processing consecutive blocks matches processing the full signal in one call.
Can sosfiltfilt run as a causal real-time filter?
Ordinary forward-backward filtering requires future samples. Applying it separately to blocks also changes the edge handling and does not reproduce filtering the entire recording in one call.