2025, Nov 26 21:00

How to Terminate a Windows Batch Started by Python: Kill the Whole Process Tree with psutil

Learn how to reliably stop a Windows batch file launched by Python: handle KeyboardInterrupt, kill the entire process tree with psutil, and avoid leftovers.

Stopping a Windows batch file launched from Python sounds trivial until a Ctrl+C hits at the wrong moment. The subprocess keeps counting down, Python raises KeyboardInterrupt, and you discover that terminate() doesn’t always do what you expect. Here’s a focused walkthrough of the issue and a practical way to shut down what was actually started.

Reproducing the problem

The snippet below writes a simple .bat that runs a TIMEOUT, spawns it in a new console with a hidden window, and polls until it’s done. On KeyboardInterrupt it attempts to terminate the process, wait for it, clean up a file, and exit.

import os
import time
import subprocess

with open('test.bat', 'w', encoding='utf-8') as fh:
    fh.write('@echo off\nTIMEOUT /t 20')
with open('test.txt', 'w', encoding='utf-8') as fh:
    fh.write('test file')

worker = None
HIDE_FLAG = 1
start_cfg = subprocess.STARTUPINFO()
start_cfg.dwFlags = subprocess.STARTF_USESHOWWINDOW
start_cfg.wShowWindow = HIDE_FLAG
worker = subprocess.Popen('test.bat', startupinfo=start_cfg, creationflags=CREATE_NEW_CONSOLE)
print(f"Subprocess started with PID: {worker.pid}")
try:
    while worker.poll() is None:
        time.sleep(0.5)
except KeyboardInterrupt as exc:
    print('stopping')
    worker.terminate()
    worker.wait()
    os.remove('test.txt')
    quit()

print('continue code')
os.remove('test.txt')

What’s going on and why it’s tricky

The Python process receives KeyboardInterrupt, but the .bat you launched may still be running. Killing only the handle returned by Popen can miss the actual command interpreter evaluating the batch script. That aligns with the observation that solutions targeting a single PID or an image name often succeed only part of the time. For example, taskkill by image or PID can leave the .bat active in a significant fraction of runs:

subprocess.call(["taskkill", "/F", "/IM", 'test.bat'])
subprocess.check_call(['taskkill', '/F', '/PID', str(worker.pid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

Direct signals like os.kill(pid, signal.SIGTERM) also don’t help here. The consistent pattern behind these symptoms is that you need to stop the entire process tree, not just the initial handle.

The fix: terminate the process tree

A reliable way forward is to enumerate children of the started process and terminate them first, then kill the parent. Using psutil keeps this concise and explicit: grab the process by PID, kill all descendants recursively, and finally kill the root.

import os
import time
import subprocess
import psutil

with open('test.bat', 'w', encoding='utf-8') as outb:
    outb.write('@echo off\nTIMEOUT /t 20')
with open('test.txt', 'w', encoding='utf-8') as outf:
    outf.write('test file')

child = None
WIN_HIDE = 1
si = subprocess.STARTUPINFO()
si.dwFlags = subprocess.STARTF_USESHOWWINDOW
si.wShowWindow = WIN_HIDE
child = subprocess.Popen('test.bat', startupinfo=si, creationflags=CREATE_NEW_CONSOLE)
print(f"Subprocess started with PID: {child.pid}")
try:
    while child.poll() is None:
        time.sleep(0.5)
except KeyboardInterrupt as err:
    print('aborting')
    root = psutil.Process(child.pid)
    for sub in root.children(recursive=True):
        sub.kill()
    root.kill()
    os.remove('test.txt')
    quit()

print('continue code')
os.remove('test.txt')

This approach targets everything that was spawned on behalf of the batch, which is why it behaves more predictably than single-PID or image-name termination. It has been observed to work, with the caveat that more testing may still be required for edge cases.

Why this matters

Long-running scripts, CI jobs, test harnesses, and automation pipelines often rely on clean shutdown. A partial kill can leave a batch file ticking away in the background, consuming time and locking resources, and that’s a hard-to-diagnose flake. Ensuring you stop the entire process tree avoids these leftovers and keeps your control flow consistent when interruptions occur.

Takeaways

When a Python process launches a .bat on Windows, stopping only the PID returned by Popen may not halt the real work. Terminating the process tree—children first, then the root—addresses the cases where taskkill by image or PID is only partially effective. If you need predictable teardown on KeyboardInterrupt, explicitly kill all descendants before killing the parent and exit cleanly.