Async & Concurrency

Failing Fast with pytest-timeout

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.0 and pytest-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.

TOML
# 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
Python
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():
    ...
How each timeout method ends a hung test Two paths. The signal method delivers SIGALRM to the main thread, which raises at the exact line and produces an ordinary pytest failure so the session continues. The thread method runs a watchdog that dumps every thread's stack and then terminates the process, ending the session but capturing complete evidence. Two mechanisms, two kinds of evidence method = "signal" SIGALRM delivered to the main thread raises at the exact line · real traceback session continues · Unix main thread only method = "thread" watchdog thread fires dumps every thread, then kills the process session ends · works everywhere
Prefer 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 to thread.
  • 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 an xdist worker. Workers run tests on their main thread, so signal generally still works — but any suite that also spawns threads should use thread for 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.

Bash
# 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
Plain text
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:

Python
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)
Plain text
+++++++++++++++++++++++++++ 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.

Choosing a ceiling from measured durations A scale of test durations. Most tests sit under one second, a handful reach three to nine seconds, and one migration test takes forty-one. A global ceiling of sixty seconds covers everything except the outlier, which gets its own marker of three hundred seconds, keeping detection latency low for the bulk of the suite. One ceiling for the suite, one marker for the outlier most tests < 1 s 3–9 s integration 41 s migration timeout = 60 suite ceiling marker = 300 one test only Detection latency equals the ceiling, so keeping it low for the bulk of the suite matters.
Raising the global ceiling to 300 to accommodate one test would multiply every hang's cost by five for no benefit to the other tests.

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:

Python
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.

What the per-test timeout budget covers A single timeout budget spans fixture setup, the test body and teardown. A test with a thirty-second container fixture and a one-second body consumes thirty-one seconds of a sixty-second ceiling, leaving less headroom than the body alone suggests. One budget for the whole item timeout = 60 — the whole bar setup: container start, migrations — 30 s call 1 s teardown + headroom A one-second test body can still trip a sixty-second ceiling when its fixtures are slow. Widen the fixture's scope, or mark the test — do not raise the ceiling for everyone.
Session-scoped fixtures charge their setup to whichever test triggered it, which is why the first test in a module can time out while identical later ones pass.

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.

← Back to Timeouts, Cancellation & Deadlines