Popen.communicate(timeout=) Leaves Process Running (tested)

Catching subprocess.TimeoutExpired from Popen.communicate(timeout=...) feels like the timeout handled itself. We measured what's actually left behind: the child process, still running, completely unaffected by the exception.

1. communicate(timeout=) raises, but the process survives

import subprocess

proc = subprocess.Popen(["sleep", "5"])
try:
    proc.communicate(timeout=1)
except subprocess.TimeoutExpired as e:
    print(e)
Command '['sleep', '5']' timed out after 1 seconds

proc.poll()
None

Measured: poll() returning None means the child is still alive, a full second after the timeout fired and the exception was caught. Nothing about raising TimeoutExpired terminates the process — it only stops Python from waiting on it any longer.

2. It stays running until you kill it yourself

import time

time.sleep(0.5)
proc.poll()
None
proc.kill()
proc.wait()
proc.poll()
-9

Measured: the sleep 5 child kept running well past where the 1-second timeout fired, consuming a process slot and (for a real workload) whatever CPU/memory/file handles it holds, until proc.kill() plus proc.wait() actually ends it. Catch TimeoutExpired without that cleanup, and the child leaks for as long as it would have run on its own — a zombie-in-waiting if you never wait() on it, or a fully live orphan process otherwise.

3. Contrast: subprocess.run(timeout=) does kill it

try:
    subprocess.run(["sleep", "5"], timeout=1)
except subprocess.TimeoutExpired as e:
    print(e)
Command '['sleep', '5']' timed out after 1 seconds

Measured: checking for any surviving sleep process immediately after confirms subprocess.run() leaves nothing running — it kills the child for you before re-raising TimeoutExpired, exactly as the Python docs state. The asymmetry is real: the convenience wrapper run() cleans up on timeout; the lower-level Popen.communicate() it's built on does not.

4. What to actually do

If you're using Popen directly (not run()) and pass a timeout to communicate(), always catch TimeoutExpired and call proc.kill() (or proc.terminate() for a graceful shutdown first) followed by proc.wait() in the except block. Prefer subprocess.run(..., timeout=...) whenever you don't need Popen's lower-level control — it already does this cleanup for you, which is exactly why the two look interchangeable but aren't.

Frequently asked questions

Does Popen.communicate(timeout=...) kill the child process when it times out?

No — we measured the process still running (poll() returns None) immediately after catching TimeoutExpired from communicate(). You have to call proc.kill() and proc.wait() yourself.

Does subprocess.run(timeout=...) have the same problem?

No — we measured subprocess.run() leaving no surviving process after a timeout. It kills the child internally before re-raising TimeoutExpired, unlike the lower-level Popen.communicate().

What happens if I catch TimeoutExpired from communicate() and don't kill the process?

Based on what we measured, the child keeps running exactly as if the timeout never happened — it only stops Python from waiting on it, so it becomes a leaked/orphaned process for however long it would have run on its own.

Is this documented behavior for Popen.communicate()?

Yes — the Python docs for communicate() state the child isn't killed automatically on timeout, while the docs for subprocess.run() explicitly state it will kill and wait for the child. We confirmed both behaviors match in Python 3.13.5.

How do I safely time out a subprocess with Popen?

We'd wrap communicate(timeout=...) in a try/except for TimeoutExpired, and in the except block call proc.kill() (or proc.terminate() first) followed by proc.wait() to actually reap the process, or just use subprocess.run(timeout=...) if Popen's extra control isn't needed.

Written with AI assistance. Measured on 2026-10-03 with Python 3.13.5 on Linux: started a child process with subprocess.Popen, called communicate(timeout=1) against a process that runs longer than that, caught the TimeoutExpired exception, and checked with poll() whether the child was still alive afterward. Compared against subprocess.run(..., timeout=...) on the same kind of child. PlainHeaders is our extension; Python's subprocess module is built in.