Python subprocess: 7 Silent Failures Behind Exit Code 0
The hard part about launching child processes is not when they fail. It is when they do not.
I ran all seven scenarios below on my own machine. Not one of them raises anything to the caller. The exit code is 0 every time, or a plausible non-zero number. And either you get no output, or you get None, or the process is still running after you think you killed it.
TL;DR: subprocess fails quietly. Pipes that nobody drains deadlock. Decode errors are swallowed inside an internal thread. check_output() does not capture stderr by default. timeout only kills the process you started. None of the seven require changes to your business logic — they are all calling-convention problems, and every one has a fix you can copy.
No affiliate links in this article. Every number is either measured on this machine or quoted from the official documentation and the CPython source.
Environment and how to reproduce
| Item | Value |
|---|---|
| OS | Windows 11 (sys.platform = win32, os.name = nt) |
| Interpreter | CPython 3.13.14 (installed under a directory named 3.13.12) |
| Stdlib | Lib/subprocess.py, locally at C:\Users\xuany\.workbuddy\binaries\python\versions\3.13.12\Lib\subprocess.py |
| Official docs | docs.python.org/3/library/subprocess.html (accessed 2026-10-29, page labelled 3.15.2) |
| Windows API | Microsoft Learn, GenerateConsoleCtrlEvent (accessed 2026-10-29) |
Every claim below is labelled either measured (I ran it on the setup above) or documentation / source (quoted verbatim). Where the two disagree, I say so instead of papering over it.
One pair of values worth noting up front, because trap 2 depends on it:
>>> import locale
>>> locale.getencoding() # 'cp936'
>>> locale.getpreferredencoding(False) # 'utf-8'
Two functions, same machine, two different answers. That discrepancy is itself part of the problem.
Trap 1: `wait()` deadlocks, and `kill()` takes the unread output with it
The documentation warns about Popen.wait() in so many words:
> This will deadlock when using stdout=PIPE or stderr=PIPE and the child process generates enough output to a pipe such that it blocks waiting for the OS pipe buffer to accept more data. Use Popen.communicate() when using pipes to avoid that.
Measured, with a child writing 100000 bytes:
p = subprocess.Popen([sys.executable, "-c", "import sys;sys.stdout.write('x'*100000)"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
p.wait(timeout=6)
# subprocess.TimeoutExpired: Command '[...]' timed out after 6 seconds
What happens after the timeout is worse. I tried four ways of reading — read(), peek()+read(), read1() and communicate() — and all four returned 0 bytes:
no-wait -> read() after kill = 0 bytes
wait(timeout) -> read() after kill = 0 bytes
To rule out "there was never any data", I read once killing:
read1(4096) before kill = 4096 bytes <-- the data really was in the pipe
read() after kill = 0 bytes
So on this machine, whatever is still sitting in the anonymous pipe when TerminateProcess() runs does not come back. I could not find any statement about this in the official documentation, so I am reporting the measurement only and not claiming it as a general rule.
Two fixes, pick one.
Use communicate() instead of wait(). Same timeout path, all 100000 bytes come back:
p = subprocess.Popen([sys.executable, "-c", "import sys;sys.stdout.write('x'*100000)"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = p.communicate(timeout=20)
# measured: len(out) == 100000, returncode == 0
Or redirect to a file, and wait() becomes safe again:
with open("out.txt", "wb") as f:
p = subprocess.Popen([sys.executable, "-c", "import sys;sys.stdout.write('x'*300000)"],
stdout=f, stderr=subprocess.DEVNULL)
rc = p.wait(timeout=30)
# measured: rc == 0, file size 300000 bytes
The thing to remember: kill() is not just "end the process". It also destroys the diagnostic evidence you had not read yet. Before you use it to handle a timeout, make sure you do not need that output.
Trap 2: `text=True` does not use the encoding you think, and the decode error is swallowed
This is the quietest of the seven, because it raises nothing at all.
On Windows, cmd.exe writes in the console code page, not UTF-8. Measured on one command:
r = subprocess.run("cmd /c echo 中文测试", shell=True, capture_output=True)
r.stdout
# b'\xd6\xd0\xce\xc4\xb2\xe2\xca\xd4"\r\n' <- GBK bytes
r.stdout.decode("utf-8")
# UnicodeDecodeError: 'utf-8' codec can't decode byte 0xd6 in position 0: invalid continuation byte
So what happens with text=True? By rights it should raise that UnicodeDecodeError. Measured:
r = subprocess.run("cmd /c echo 中文测试", shell=True, capture_output=True, text=True)
r.returncode # 0
r.stdout # None
r.stderr # ''
Exit code 0, stdout is None, not a single exception. The real error went to stderr, from an internal thread:
Exception in thread Thread-1 (_readerthread):
File "...\Lib\subprocess.py", line 1615, in _readerthread
buffer.append(fh.read())
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xd6 in position 0: invalid continuation byte
The reason is in the source. Lines 1614–1616 of the local subprocess.py:
def _readerthread(self, fh, buffer):
buffer.append(fh.read())
fh.close()
An exception in that thread never propagates back into communicate(), so run() has no idea anything happened and just sees an empty buffer. Note which codec the error names — utf-8, which matches locale.getpreferredencoding(False) (measured utf-8 here) and not locale.getencoding() (measured cp936). The documentation agrees:
> If *encoding* or *errors* are specified, or *text* (also known as *universal_newlines*) is true, the file objects *stdin*, *stdout* and *stderr* will be opened in text mode using the *encoding* and *errors* specified in the call or the defaults for io.TextIOWrapper.
Fix: always pass encoding and errors explicitly, and guard against a None stdout.
r = subprocess.run("cmd /c echo 中文测试", shell=True, capture_output=True,
text=True, encoding="gbk")
r.stdout # '中文测试"\n'
# when you do not know what the child emits, take replacement chars over None
r = subprocess.run(cmd, shell=True, capture_output=True,
text=True, encoding="utf-8", errors="replace")
text = (r.stdout or "")
errors="replace" costs you '���IJ���', but at least you know something went wrong. None lets you believe everything is fine.
Trap 3: `check_output()` puts the error message nowhere you are looking
try:
subprocess.check_output([sys.executable, "-c",
"import sys;sys.stderr.write('FATAL: db down\\n');sys.exit(1)"])
except subprocess.CalledProcessError as e:
print(e.output, e.stderr, e.stdout)
# measured: b'' None b''
output is empty and stderr is None. Where did FATAL: db down go? Straight down file descriptor 2 into the parent's own stderr — which means contextlib.redirect_stderr() cannot catch it (measured: empty string) and neither can your logging setup.
The documentation offers one sentence: to get stderr too, merge it explicitly.
try:
subprocess.check_output(cmd, stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as e:
print(e.output)
# measured: b'FATAL: db down\r\n'
Trap 4: `CalledProcessError` does not tell you what the child said
Following on from trap 3: even when you catch the exception, printing it tells you nothing. Lines 145–155 of the local source:
def __str__(self):
if self.returncode and self.returncode < 0:
...
else:
return "Command '%s' returned non-zero exit status %d." % (
self.cmd, self.returncode)
Measured output:
Command '['C:\\...\\python.exe', '-c', "import sys;sys.stderr.write('real cause\\n');sys.exit(3)"]' returned non-zero exit status 3.
The full command line and a number. Not one word from the child. The reason only lives in e.stderr:
except subprocess.CalledProcessError as e:
raise RuntimeError(f"failed rc={e.returncode}: {(e.stderr or b'').decode('utf-8','replace')}")
Related: the died with %r branch at line 148 uses signal.Signals(-self.returncode) and only runs when returncode < 0 — which on Windows never happens. See trap 7.
Trap 5: `shell=True` swallows "program not found", and on Windows the shell is cmd.exe
Same command, two call styles, completely different outcomes:
subprocess.run(["definitely-not-a-real-command-xyz"], check=True, capture_output=True)
# FileNotFoundError: [WinError 2] The system cannot find the file specified.
r = subprocess.run("definitely-not-a-real-command-xyz", shell=True,
capture_output=True, text=True, encoding="gbk", errors="replace")
r.returncode # 1
r.stderr # "'definitely-not-a-real-command-xyz' is not recognized as an internal or external command..."
With shell=True, "program does not exist" turns from an exception into exit code 1. You lose the traceback and get a number, and if you did not pass check=True nobody looks at that number either.
Second problem: on Windows that shell is cmd.exe, not bash. The documentation, verbatim:
> On Windows with shell=True, the COMSPEC environment variable specifies the default shell. The only time you need to specify shell=True on Windows is when the command you wish to execute is built into the shell (e.g. dir or copy).
Measured:
echo $(echo hi) -> rc=0 stdout='$(echo hi)' <- no command substitution
echo `echo hi` -> rc=0 stdout='`echo hi`' <- no backtick substitution
echo a; echo b -> rc=0 stdout='a; echo b' <- semicolon is not a separator
echo a && echo b -> rc=0 stdout='a \nb' <- && is a separator
The third one is the dangerous one: it does not raise, it just hands echo b to echo as arguments. You think two commands ran.
Also note that with shell=True the exit code comes from the shell. From the docs:
> When shell=True, the return code reflects the exit status of the shell itself (e.g. /bin/sh), which may map signals to codes such as 128+N.
Fix: pass a list, not a string; leave shell=False. This doubles as the security advice, which the docs give its own section:
> If the shell is invoked explicitly, via shell=True, it is the application's responsibility to ensure that all whitespace and metacharacters are quoted appropriately to avoid shell injection vulnerabilities.
Trap 6: `timeout` kills the child you started, not its children
When subprocess.run(timeout=...) expires, the process that gets killed is the one you launched. Anything it spawned is not on the list.
Measured: the parent starts a grandchild and then sleeps; the parent is killed by timeout=3, and this is the grandchild afterwards:
grandchild PID = 12224 | marker written = True
before killing parent: parent alive = True | grandchild alive = True
after killing parent: parent alive = False | grandchild alive = True
The documentation takes the same line for Popen.communicate():
The child process is not killed if the timeout expires, so in order to cleanup properly a well-behaved application should kill the child process and finish communication:
The recommended shape is proc.kill() followed by another communicate(). Note that run() behaves differently here — from the docs:
> If the timeout expires, the child process will be killed and waited for. The TimeoutExpired exception will be re-raised after the child process has terminated.
Two fixes on Windows.
Kill the tree with an external command:
subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], capture_output=True)
# measured output: SUCCESS: The process with PID 12224 (child process of PID 25320) has been terminated.
Or create a process group at launch and send CTRL+BREAK:
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True, bufsize=1,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
...
p.send_signal(subprocess.signal.CTRL_BREAK_EVENT)
# measured: parent alive = False | grandchild alive = False
There is a detail here that is easy to get backwards. Microsoft Learn on GenerateConsoleCtrlEvent:
> CTRL_C_EVENT 0 ... This signal cannot be limited to a specific process group. If *dwProcessGroupId* is nonzero, this function will succeed, but the CTRL+C signal will not be received by processes within the specified process group.
In other words, sending CTRL_C_EVENT returns success while no process in the group receives anything. For a whole-group exit you have to use CTRL_BREAK_EVENT.
One more silent failure from the same family: start_new_session and process_group are both marked (POSIX only) in the docs (verbatim at lines 795 and 797 of the local source), yet on Windows they neither raise nor do anything:
p = subprocess.Popen([sys.executable, "-c", "import time;time.sleep(2)"],
start_new_session=True)
# measured: no error, returncode = 0
You think you isolated a process group. Nothing happened.
Trap 7: On Windows there is no "killed by signal", and `-1` is 4294967295
subprocess.run([sys.executable, "-c", "import sys;sys.exit(-1)"]).returncode
# measured: 4294967295
subprocess.run("cmd /c exit -1", shell=True).returncode
# measured: 4294967295
The documentation's definition of a negative value carries a qualifier:
> A negative value -N indicates that the child was terminated by signal N (POSIX only).
So on Windows, any if rc < 0: is dead code, and any if rc == -9: never matches. check=True only tests != 0, so this kind of code looks fine while you silently lose the whole "killed by signal" semantic.
Two more measured edge values: sys.exit(300) gives returncode = 300 (returned as-is, not truncated to the low 8 bits), and sys.exit(0) of course gives 0.
Troubleshooting: six real errors and fixes you can paste
All six reproduced locally. The commands are copy-pasteable.
Error 1: `subprocess.TimeoutExpired: Command '[...]' timed out after 6 seconds`
p = subprocess.Popen([sys.executable, "-c", "import sys;sys.stdout.write('x'*100000)"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
p.wait(timeout=6)
Cause: the child fills the pipe and blocks; the parent never drains it, so both sides wait for each other. The docs warn about this method verbatim (see trap 1).
Fix: replace wait() with communicate(), or redirect stdout to a file.
Error 2: `UnicodeDecodeError: 'utf-8' codec can't decode byte 0xd6 in position 0: invalid continuation byte`
r = subprocess.run("cmd /c echo 中文测试", shell=True, capture_output=True, text=True)
# returncode = 0, stdout = None, the exception only appears in the _readerthread traceback on stderr
Cause: with text=True and no encoding, decoding uses the io.TextIOWrapper default (here locale.getpreferredencoding(False) = utf-8) while cmd.exe emits GBK bytes. The decode happens inside the reader thread at subprocess.py:1615, and the exception does not propagate.
Fix: pass encoding= matching the child, add errors="replace", and use r.stdout or "" as a guard.
Error 3: `subprocess.CalledProcessError: Command '[...]' returned non-zero exit status 1.`
subprocess.check_output([sys.executable, "-c",
"import sys;sys.stderr.write('FATAL: db down\\n');sys.exit(1)"])
# e.output = b'', e.stderr = None
Cause: check_output() captures stdout only; the child's stderr travels down fd 2 into the parent's stderr.
Fix: add stderr=subprocess.STDOUT so the message lands in e.output.
Error 4: `FileNotFoundError: [WinError 2] The system cannot find the file specified.`
subprocess.run(["definitely-not-a-real-command-xyz"], check=True, capture_output=True)
Cause: with shell=False the first element is resolved as the path to an executable.
Fix: this is correct behaviour — do not silence it with shell=True. That only converts an exception into exit code 1 (see trap 5) and makes debugging more expensive.
Error 5: the process is still running after the timeout (visible in `tasklist`)
after killing parent: parent alive = False | grandchild alive = True
Cause: timeout only terminates the process you started directly.
Fix: taskkill /F /T /PID , or CREATE_NEW_PROCESS_GROUP plus send_signal(CTRL_BREAK_EVENT).
Error 6: `returncode = 4294967295`
subprocess.run([sys.executable, "-c", "import sys;sys.exit(-1)"]).returncode
# 4294967295
Cause: Windows exit codes are unsigned 32-bit; the docs mark the negative -N definition as (POSIX only).
Fix: do not write if rc < 0 to detect "killed by signal". In cross-platform code use rc != 0 plus explicit enumeration.
A safe wrapper you can copy
Folding the seven traps into one shape:
import subprocess, sys
def run_safe(cmd, *, timeout=30, encoding="utf-8"):
"""Returns (ok, text). Pass cmd as a list; if you need a shell, you own the escaping."""
try:
r = subprocess.run(
cmd,
stdin=subprocess.DEVNULL, # never let the child wait on you
capture_output=True, # goes through communicate(), not wait()
timeout=timeout, # on expiry run() kills and re-raises
text=True,
encoding=encoding,
errors="replace", # replacement chars beat None
)
except subprocess.TimeoutExpired as e:
# docs: run() kills the child and re-raises; grandchildren need their own cleanup
return False, f"timed out after {e.timeout}s, partial: {(e.output or b'')[:200]!r}"
except FileNotFoundError:
return False, f"program not found: {cmd[0] if isinstance(cmd, list) else cmd}"
out = (r.stdout or "").strip()
err = (r.stderr or "").strip()
if r.returncode != 0:
return False, f"rc={r.returncode} stderr={err or '(empty)'}"
return True, out
ok, info = run_safe([sys.executable, "-c", "print('ok')"])
print(ok, info)
Three invariants worth turning into review checklist items:
1. Pass a list, keep shell=False. If you really need a shell, the string and its escaping are your responsibility.
2. Always set encoding and errors="replace". Never use bare text=True.
3. Never combine wait() with PIPE — use communicate() or a file. If you need to clean up a process tree on timeout, write that as a separate layer.
FAQ
Q: Why can't I call wait() after communicate() times out?
The documentation says it outright: "After a call to communicate() raises TimeoutExpired, do not call wait(). Use an additional communicate() call to finish handling pipes and populate the returncode attribute." The pipes are not drained yet, so wait() puts you back in the mutual-wait state.
Q: Is TimeoutExpired.output a str or bytes when text=True?
Bytes. From the docs: "This is always bytes when any output was captured regardless of the text=True setting. It may remain None instead of b'' when no output was observed." Confirmed by measurement: with text=True, e.output was b'partial-1'.
Q: Does run() actually kill the child on timeout?
Yes. From the docs: "the child process will be killed and waited for. The TimeoutExpired exception will be re-raised after the child process has terminated." Its own children are not killed (trap 6).
Q: Why is stdout still None even though I passed capture_output=True?
Two candidates: text-mode decoding failed and the exception was swallowed by _readerthread (trap 2), or the pipe data was taken by kill() after a timeout (trap 1). In both cases the exit code can be 0.
Q: Can I use start_new_session on Windows to detach the child?
No. The docs mark it (POSIX only) and measurement shows it neither raises nor works on Windows (trap 6). For whole-group control use CREATE_NEW_PROCESS_GROUP with CTRL_BREAK_EVENT.
Related reading
A few other posts on this site sit in the same "quiet failure" layer:
- Python tarfile extraction: five ways files escape the destination — the same shape of failure: no exception, no log, exit code 0.
- Retries that never fire: urllib3 2.8.0 and tenacity — the other half of failure recovery, about retries that never happen.
- Python logging tested: 6 real errors and lost records — the sibling question of where output went, this time about log records.
Almost nothing in this layer is "it is broken". It is "it does what the documentation says, and the documentation does not say what you assumed". Bake the three invariants above into your code and these seven traps stop coming back.
👉 Join Xiaomi MiMo Platform: Leading AI model platform with cost-effective inference
👉 Join Aliyun AI: Top AI products with exclusive coupons for business innovation
📌 This article was AI-assisted generated and human-reviewed | TechPassive — An AI-driven content testing site focused on real tool reviews
🔗 Recommended Tools
These are carefully selected tools. Using our affiliate links supports us to keep producing quality content: