Python Error Guide: 'subprocess.CalledProcessError' — Cause, Fix, and Troubleshooting Guide
Fix Python 'subprocess.CalledProcessError: returned non-zero exit status': capture stderr, check the command and PATH, and handle expected failures cleanly.
- #python
- #automation
- #troubleshooting
- #errors
Stuck on this Bash & Python Automation error? Get the free incident triage checklist
A one-page PDF — the exact steps to isolate, fix, and verify a production error like this one. No spam, unsubscribe anytime.
Overview
Python raises this when a command run with check=True (or check_call/check_output) exits non-zero:
subprocess.CalledProcessError: Command '['kubectl', 'get', 'pods']' returned non-zero exit status 1.
CalledProcessError is not a Python-level bug — it means the external program you launched failed and you asked subprocess to raise on failure. The exception carries the useful details: .returncode, .cmd, and (if you captured them) .stdout and .stderr. The mistake most people make is not capturing stderr, so the actual reason the command failed is thrown away and only the generic exit-status message remains.
Symptoms
- The traceback ends in
CalledProcessErrornaming the command list and an exit status. - You can see that the command failed but not why — stderr wasn’t captured.
- Running the same command by hand in a shell works, but fails from Python (usually PATH, cwd, or environment differences).
- It fires on the happy path for commands whose non-zero exit is actually expected (e.g.
grepno-match,diffdifferences).
Common Root Causes
- The command genuinely failed — bad arguments, missing file, permission denied, or the tool reported an error to stderr.
- stderr not captured, so the diagnostic message is lost and only the exit code remains.
- PATH/environment differs from your shell — the subprocess doesn’t inherit your interactive shell’s PATH, aliases, or
~/.bashrc. - Wrong working directory — a relative path that exists in your terminal’s cwd but not the script’s.
- Expected non-zero treated as fatal —
grep/diff/testreturn non-zero as normal signaling, butcheck=Trueraises anyway. shell=False(the default) with a string command — passing"ls -l"as a single argument instead of a list, so the program name includes the flags.
Diagnostic Workflow
Capture both streams and print the real error when it fails — this is the key fix:
import subprocess
try:
r = subprocess.run(
["kubectl", "get", "pods"],
check=True, capture_output=True, text=True,
)
except subprocess.CalledProcessError as e:
print("exit:", e.returncode)
print("stdout:", e.stdout)
print("stderr:", e.stderr) # the actual reason
raise
Verify the binary is found from the script’s environment:
import shutil
print(shutil.which("kubectl")) # None means PATH doesn't include it
Reproduce with the exact argv and environment the script uses:
print(r.args) # confirm the argument list is what you intended
Example Root Cause Analysis
A backup wrapper failed in cron but worked when run by hand:
subprocess.CalledProcessError: Command '['aws', 's3', 'sync', ...]'
returned non-zero exit status 127.
Exit status 127 is the shell/OS code for “command not found.” Adding stderr capture and shutil.which confirmed it:
>>> shutil.which("aws")
None
Interactively, aws was on PATH via the user’s ~/.local/bin (added in .bashrc). Cron runs with a minimal PATH that didn’t include it, so the subprocess couldn’t find the binary. The fix is to not depend on an interactive PATH — use an absolute path or set the environment explicitly:
import subprocess
AWS = "/usr/local/bin/aws" # or resolve once via shutil.which at startup
subprocess.run([AWS, "s3", "sync", src, dst], check=True,
capture_output=True, text=True)
For cron specifically, also set a known PATH in the crontab or pass env= to subprocess.run. Capturing stderr turned an opaque exit-127 into an obvious “binary not on PATH” diagnosis.
Prevention Best Practices
- Always capture output —
capture_output=True, text=Truesoe.stderrtells you why the command failed. - Use absolute paths or
shutil.whichfor binaries in scripts that run under cron/systemd with a minimal PATH. - Pass arguments as a list with
shell=False(the default) — safer and avoids quoting/command not foundconfusion. - Handle expected non-zero deliberately — set
check=Falseand inspectreturncode, or catchCalledProcessErrorfor commands likegrep/diff. - Set
env=explicitly for reproducibility instead of relying on inherited shell config. - Add
timeout=so a hung child raisesTimeoutExpiredinstead of blocking forever.
Quick Command Reference
import subprocess, shutil
r = subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=60)
# on failure, CalledProcessError has:
# e.returncode, e.cmd, e.stdout, e.stderr
shutil.which("tool") # None -> not on PATH
subprocess.run(cmd, check=False) # inspect .returncode yourself
subprocess.run(cmd, env={"PATH": "/usr/bin:/bin", **extra}) # explicit env
Related Guides
- Bash & Python Error Guide: ‘command not found’ — the exit-127 PATH problem behind many CalledProcessErrors.
- Python Error Guide: ‘FileNotFoundError: [Errno 2] No such file or directory’ — raised when the binary itself doesn’t exist.
- Python Error Guide: ‘BrokenPipeError: [Errno 32] Broken pipe’ — another subprocess/pipeline failure mode.
Conclusion
subprocess.CalledProcessError means the external command you launched exited non-zero while check=True was set — the failure is in the child process, not Python. Capture stdout/stderr so the real reason isn’t discarded, check the exit code (127 = not found, 126 = not executable), and resolve binaries with absolute paths or shutil.which for cron/systemd jobs. Handle expected non-zero exits with check=False, and your subprocess calls become debuggable instead of opaque.
Fixed it? Get 500 Bash & Python Automation & DevOps AI prompts — free
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.
Did this fix your issue?
Get 500 Battle-Tested DevOps AI Prompts — Free
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.