A suite without a per-test ceiling has one failure mode strictly worse than a red build: a job that runs until the platform kills it, with no traceback and no indication of which test was running. pytest-timeout removes that, and the whole art of configuring it is choosing values generous enough never to fire on a healthy test and a method that actually works on the platform in question.
Prerequisites
pytest >= 8.0andpytest-timeout >= 2.3.- Knowledge of whether the suite uses worker threads, since that decides the method.
- A measured picture of the suite's slowest tests under the configuration CI uses.
Solution
Set a ceiling in configuration, pick the method deliberately, and override the exceptions.
# pyproject.toml
[tool.pytest.ini_options]
# A ceiling, not an assertion: no healthy test should come near 60 s.
timeout = 60
# "thread" dumps every thread's stack before killing the process, which is the
# only useful output when the hang is a deadlock rather than a slow call.
timeout_method = "thread"
# Dump stacks 15 s earlier so the evidence lands before the process ends.
faulthandler_timeout = 45
import pytest
@pytest.mark.timeout(300) # a migration test against a real database
def test_full_migration_applies(engine_migrated):
assert engine_migrated.dialect.has_table(engine_migrated.connect(), "widget")
@pytest.mark.timeout(5, method="signal") # a tight bound, and a clean traceback
def test_parser_terminates_on_pathological_input(parser):
parser.parse("(" * 10_000)
@pytest.mark.timeout(0) # opt out entirely: an interactive helper
def test_manual_smoke_check():
...
signal where the blocking code runs on the main thread of a Unix process, because it is the only method that produces a normal failure and lets the rest of the suite run.Why this works
The signal method installs a SIGALRM handler and arms an alarm before each test. When it fires, the handler raises inside whatever frame the main thread is executing, which is why the traceback points at the exact blocking line. Signals in CPython are delivered only to the main thread, and only between bytecodes, so a main thread blocked inside a C call that does not release the GIL will not see it either.
The thread method sidesteps all of that with a watchdog that does not need cooperation from the test at all. It cannot raise into another thread — Python has no safe mechanism for that — so it dumps and terminates instead. That is a worse outcome for the session and a better one for the evidence, which is why it pairs naturally with faulthandler_timeout set slightly lower.
Edge cases and failure modes
- Timeout never fires under
signal. The blocking code is off the main thread, or the platform is Windows. Switch tothread. - A bound tuned on an idle machine. CI runners are shared, and a cold import can add a second. Set the ceiling an order of magnitude above the slowest observed test, not just above it.
- Raising the global ceiling for one slow test. Every other test loses its containment. Use the marker.
- Interaction with fixtures. The timeout covers setup, call and teardown together, so a test with a thirty-second container fixture needs a ceiling above that even if the body is instant.
method="signal"inside anxdistworker. Workers run tests on their main thread, so signal generally still works — but any suite that also spawns threads should usethreadfor consistency rather than relying on which code blocks.
Calibrating the numbers
The two mistakes are setting the ceiling too close to the observed duration and measuring under the wrong configuration. Both are avoidable in one pass.
# Measure under the configuration CI actually uses, not a serial local run.
pytest -n 8 --durations=0 -q > /tmp/durations.txt
sort -rn -k1 /tmp/durations.txt | head -20
41.02s call tests/integration/test_migration.py::test_full_migration_applies
8.71s setup tests/integration/test_orders.py::test_create_order
3.10s call tests/api/test_search.py::test_large_result_set
0.94s call tests/unit/test_parser.py::test_deeply_nested
With a slowest test of 41 seconds, a global ceiling of 60 is too tight: that test will occasionally take 55 on a loaded runner and fail for no reason. The correct arrangement is a global ceiling around 60 for the bulk of the suite plus a marker of 300 on the migration test, which keeps containment meaningful for the 99% while giving the genuine outlier room.
The reason to resist a single large ceiling is that the ceiling's value is the detection latency. At 600 seconds, a deadlocked test costs ten minutes of every affected pipeline run before anyone learns anything; at 60 it costs one. Marking the three genuine outliers is worth the ten minutes it takes.
Verifying that it fires
Configuration that has never been exercised is configuration that might not work. Prove it once:
import time
import pytest
@pytest.mark.skip(reason="run manually to verify the timeout configuration")
@pytest.mark.timeout(2)
def test_timeout_configuration_is_live():
time.sleep(30)
+++++++++++++++++++++++++++ Timeout ++++++++++++++++++++++++++++
~~~~~~~~~~~~~~~ Stack of MainThread (140234...) ~~~~~~~~~~~~~~~~
File "tests/test_meta.py", line 11, in test_timeout_configuration_is_live
time.sleep(30)
+++++++++++++++++++++++++++ Timeout ++++++++++++++++++++++++++++
Teams routinely discover at this point that timeout_method was left at its default in a threaded suite, where the signal never arrives and the ceiling has been doing nothing for months. Running this once after any change to the configuration takes ten seconds and is the only way to know.
What the ceiling does not tell you
It is worth being precise about the limits of a suite-wide timeout, because teams sometimes treat it as coverage of the code's own timeout behaviour and it is nothing of the kind.
A ceiling firing says only that a test did not finish. It does not say whether the code under test should have given up earlier, whether a retry loop is spinning, or whether a client's own deadline is configured correctly. Those are behavioural claims and need assertions inside the test:
import asyncio
import pytest
async def test_client_gives_up_on_a_stalled_server(stalling_server):
# This asserts the CLIENT's behaviour. Without it, the test would "pass"
# only in the sense that pytest-timeout eventually stopped it.
with pytest.raises(TimeoutError):
async with asyncio.timeout(0.5):
await stalling_server.client.fetch("/never-responds")
assert stalling_server.client.open_connections == 0
The distinction shows up sharply in a regression. If a change removes a client's internal deadline, the per-operation assertion above fails immediately and names the client; the suite ceiling fails sixty seconds later and names nothing in particular. Both are worth having, and only the first is a test.
A related confusion is treating the ceiling as a performance budget. It is not: a test that normally takes 200 ms and now takes 40 seconds is a serious regression that a 60-second ceiling happily allows. Performance claims belong in a benchmark with a distribution, as described in benchmarking with pytest-benchmark, and the ceiling remains what it is — containment.
Timeouts and fixtures
One behaviour surprises people often enough to state plainly: the timeout covers setup, call and teardown as a single budget.
That last point is the one that produces the confusing report: the first test to request a session-scoped container pays its thirty seconds, and every test after it pays nothing. If the ceiling is tight, the first test in a file fails and the rest pass, which looks like a problem with that test and is not. Widening the fixture's scope so the cost is paid once per session, or marking that one test, both fix it; raising the global ceiling hides it.
The same effect appears in reverse under pytest-xdist. Each worker is a separate process with its own session fixtures, so the expensive setup is paid once per worker rather than once per run — eight workers means eight container starts, and eight tests charged for them. A ceiling calibrated on a serial run, where only one test pays, will then fire on whichever test happened to be first in each worker. Measuring with the worker count CI uses is what makes this visible before it becomes an intermittent red build. It also usually argues for making the expensive fixture cheaper rather than the ceiling larger.
Frequently Asked Questions
Why does my timeout never fire?
Almost always because the signal method is in use and the blocked code is not on the main thread, or the platform is Windows where SIGALRM does not exist. Switch timeout_method to thread, which uses a watchdog rather than a signal and works everywhere, at the cost of ending the session when it fires.
Does pytest-timeout interfere with a debugger?
It detects an attached debugger and disables itself, so stepping through a test does not trip the ceiling. That detection covers pdb and debugpy; with an unusual debugger, set the timeout to zero for the session rather than removing the configuration.
How does the timeout interact with pytest-xdist? Each worker applies the timeout to its own tests independently, which is what you want. The complication is calibration: with eight workers on four cores every test is slower, so a bound tuned on a serial run will fire spuriously. Measure under the parallel configuration CI actually uses.
Related
- Timeouts, Cancellation & Deadlines — how this ceiling relates to the per-operation deadlines inside tests.
- Dumping Stacks on Deadlock with faulthandler — the layer that produces evidence before this one fires.
- Testing Cancellation and Cleanup Paths — asserting the code's own timeout behaviour rather than the runner's.
- Debugging a Test That Only Fails Under xdist — when the spurious timeout turns out to be contention.
← Back to Timeouts, Cancellation & Deadlines