At a glance
Sat 0% CPU means waiting;Rnear 100% means computing.sudo py-spy dump --pid 12345prints every thread's stack.requestshas no default timeout, so set one on every call.- A heartbeat check or a stall alert catches the next hang.
Stuck or slow? Check the CPU first
Before you kill a silent job, ask the OS what it is doing. ps and top show the process state and CPU use without touching Python.
ps -o pid,stat,%cpu,etime,cmd -p 12345
top -p 12345Read the state and the CPU together:
S(interruptible sleep) near 0% CPU for minutes: waiting for an event, such as a network reply, a lock or a child process.D(uninterruptible sleep): waiting on I/O, usually disk or network storage.R(running) near 100% CPU: computing, which is either slow work making progress or an endless loop.
State and CPU tell you whether the script is waiting or working, but not which line it is on. The stack tells you that.
See the stack with py-spy
py-spy (0.4.2, released 2026-04-24) reads a running Python process from outside, so you can inspect a live job without restarting it or changing its code.
py-spy dump --pid prints the current call stack of every thread and exits. Add --locals to show each frame's local variables, or run py-spy top for a live view of where the time goes.
pip install py-spy
sudo py-spy dump --pid 12345
sudo py-spy dump --pid 12345 --locals
sudo py-spy top --pid 12345Take two dumps a minute apart. If the top frame is the same both times and CPU sits at 0%, the script is stuck waiting on that call.
Reading another process needs permission. Without it, py-spy stops with "Permission Denied: Try running again with elevated permissions".
- Linux: attaching to a process that isn't your own child usually needs
sudo. - macOS: always needs root.
- Docker: start the container with
--cap-add SYS_PTRACE.
Make Python dump its own traceback
Where py-spy can't attach, the standard library's faulthandler dumps every thread's traceback from inside the process. You have to set it up before the hang.
faulthandler.dump_traceback_later(timeout) starts a watchdog thread that dumps all threads after timeout seconds. Calling it again resets the timer, so one call per loop iteration only fires when a single iteration takes too long. On Unix, faulthandler.register(signal.SIGUSR1) waits until you send kill -USR1; it is not available on Windows.
import faulthandler
import signal
trace_log = open("/tmp/job.tracebacks.log", "w")
faulthandler.register(signal.SIGUSR1, file=trace_log, all_threads=True)
for item in items:
faulthandler.dump_traceback_later(600, file=trace_log)
process(item)
faulthandler.cancel_dump_traceback_later()kill -USR1 12345
cat /tmp/job.tracebacks.logKeep the log file open for the whole run, because faulthandler writes to it directly when the timer or the signal fires.
Put a ceiling on every wait
Most hangs are waits without an upper limit. requests has no default timeout, and its docs warn that leaving one out "can cause your program to hang indefinitely".
Pass a timeout to every requests call, subprocess.run and queue get(), so a stalled dependency raises an exception instead of blocking forever. For requests, (5, 30) means 5 seconds to connect and 30 seconds to read.
import subprocess
import requests
response = requests.get(url, timeout=(5, 30))
subprocess.run(["rsync", "-a", "src/", "dst/"], timeout=600, check=True)
item = work_queue.get(timeout=60)For a hard ceiling on the whole script, run it under GNU timeout. It stops the job at the deadline and exits with status 124, unless you pass --preserve-status. -k 30s sends SIGKILL if the job is still running 30 seconds after the first signal.
By default timeout sends SIGTERM, which ends Python without running finally blocks. -s INT raises KeyboardInterrupt instead, so your cleanup and notification code still run.
timeout -s INT -k 30s 2h python job.py
echo $?Get alerted when a script goes quiet
For unattended jobs, you want to hear about a stall when it starts, not the next morning. The simple version is a heartbeat file: the script touches it on every iteration, and cron sends an alert when the file gets too old.
from pathlib import Path
heartbeat = Path("/tmp/job.heartbeat")
heartbeat.touch()
try:
for item in items:
process(item)
heartbeat.touch()
finally:
heartbeat.unlink(missing_ok=True)*/5 * * * * [ -n "$(find /tmp/job.heartbeat -mmin +10 2>/dev/null)" ] && curl -s -d "job.py quiet for 10 minutes" ntfy.sh/your-topicCron checks every 5 minutes and sends an alert on each check while the file is more than 10 minutes old. Deleting the file at the end keeps a finished job quiet. The ntfy guide covers the phone side.
Tools compared
| Tool | What it tells you | Needs |
|---|---|---|
ps or top | Process state, CPU use, elapsed time | Shell on the host and the PID |
py-spy dump | Call stack of every thread, locals with --locals | Root on macOS, sudo on Linux unless ptrace allows it, SYS_PTRACE in Docker |
py-spy top | Live view of where CPU time goes | Same permissions as py-spy dump |
faulthandler.dump_traceback_later | Every thread's traceback when a timer runs out | Code added before the run |
faulthandler.register | Every thread's traceback on kill -USR1 | Code added before the run; Unix only |
GNU timeout | Whether the job passed its deadline (exit 124) | Wrapping the launch command |
| Heartbeat file and cron | Whether the loop stopped touching the file | Code in the loop and a cron job |
| Runpeek stall alert | Whether the progress bar went quiet, with a live link | tqdm-live and a webhook URL |
FAQ
How do I know if my Python script is stuck or just slow?
Check ps or top first: state S near 0% CPU for minutes means it is waiting, while 100% CPU means it is computing. Then take two py-spy dump snapshots a minute apart. The same stack both times at 0% CPU means it is stuck on that call.
Why does py-spy say permission denied?
py-spy reads another process's memory, which the OS restricts by default. Run it with sudo on Linux and macOS, or start your Docker container with --cap-add SYS_PTRACE.
Why does my script hang on a network request?
requests sets no timeout by default, so a server that accepts the connection and never replies leaves your script waiting forever. Pass timeout= on every request so a stalled connection raises an error instead.
Can I get a traceback from a hung script without py-spy?
Yes, if you set up faulthandler before the hang. dump_traceback_later dumps all threads when a timer runs out, and on Unix register(signal.SIGUSR1) dumps them whenever you run kill -USR1 on the process.
What exit code does the timeout command return?
GNU timeout exits with status 124 when it had to stop the command, unless you pass --preserve-status. Otherwise it returns the command's own exit status.
Check on long runs from your phone
Change one import and every tqdm bar prints a link you can open on any device. Get a Discord or Slack message when the run finishes, fails or stalls.
pip install tqdm-liveSources
- py-spy on GitHub github.com
- PyPI: py-spy pypi.org
- Python docs: faulthandler docs.python.org
- Requests quickstart: Timeouts requests.readthedocs.io
- Python docs: subprocess docs.python.org
- GNU coreutils: timeout invocation gnu.org
- ps(1) manual: process state codes man7.org