The Problem: Cross-Platform Subprocess Failures

If you have ever developed a cross-platform Python script that runs CLI tools, you might have encountered code structured like this:

import subprocess, shlex

js_code = 'console.log(1 < 2 && 3 > 2);'
cmd = f'somecli eval {shlex.quote(js_code)}'
subprocess.run(cmd, shell=True, capture_output=True, text=True)

On macOS and Linux, this executes without a hitch. But the moment you run it on Windows, your command explodes with parsing errors, syntax errors, or unexpected token complaints. Why does perfectly escaped POSIX shell syntax fall apart under Windows, and what is the proper cross-platform solution?

Why shlex.quote() Fails Under Windows

The root cause comes down to the underlying shell that Python invokes when shell=True is passed:

  • POSIX (Linux/macOS): Python uses /bin/sh. Here, wrapping arguments in single quotes ('arg') safely prevents the shell from interpreting special characters like <, >, &&, or |. This is precisely what shlex.quote() produces.
  • Windows: Python invokes cmd.exe (via the %COMSPEC% environment variable). cmd.exe does not recognize single quotes for escaping. To cmd.exe, a single quote is just an ordinary character.

When cmd.exe encounters 'console.log(1 < 2 && 3 > 2);', it reads characters like < and > as file redirection operators and && as a command separator. The command is chopped up and misdirected before it ever reaches your CLI tool.

The Recommended Fix: Drop shell=True

The cleanest, most secure, and truly portable way to pass arbitrary strings to another process is to avoid the shell entirely. By setting shell=False and passing your command as an argument list (argv), you bypass both /bin/sh and cmd.exe.

import subprocess

js_code = 'console.log(1 < 2 && 3 > 2);'
cmd = ['somecli', 'eval', js_code]

result = subprocess.run(cmd, shell=False, capture_output=True, text=True)

How Does Python Handle Arguments Without a Shell?

  • On POSIX: Arguments are passed directly to the OS kernel via execve as separate strings in an array. No shell parsing occurs, meaning characters like && or * cannot be misinterpreted.
  • On Windows: The Windows API (CreateProcessW) expects a single command-line string rather than an array. When you pass a list with shell=False, Python automatically escapes and formats each argument using standard Windows C-runtime quoting rules (via subprocess.list2cmdline()). It wraps arguments in double quotes (") and escapes internal quotes and backslashes properly.

Handling CLI Tools Installed as Windows Batch Scripts (.cmd / .bat)

A common reason developers reach for shell=True on Windows is that tools installed via package managers (like Node's npm or Python's pip) often create wrapper batch files (e.g., somecli.cmd). In older Python versions, running a .cmd or .bat file without a shell was unreliable, and recent security updates (such as Python 3.12.3+ addressing CVE-2024-21503) make calling batch files with shell=False stricter.

If your executable is a wrapper script, resolve its absolute path using shutil.which():

import shutil
import subprocess

exe_path = shutil.which('somecli')
if not exe_path:
    raise FileNotFoundError("Target executable could not be found.")

cmd = [exe_path, 'eval', js_code]
result = subprocess.run(cmd, capture_output=True, text=True)

If the CLI is a Node.js package (like prettier or eslint), you can also invoke the Node runtime directly, passing the target JavaScript entry file as the first argument, avoiding batch wrappers altogether:

import subprocess

cmd = ['node', '/path/to/cli.js', 'eval', js_code]
subprocess.run(cmd, capture_output=True, text=True)

Can You Safely Keep shell=True Across Platforms?

In short: No. Attempting to create a universal string escaper for shell=True across POSIX and Windows shells is notoriously fragile. cmd.exe requires escaping characters with carets (^), but behaves differently depending on whether an argument is enclosed in double quotes, whether delayed environment variable expansion is enabled, or whether standard redirection tokens are involved.

Stick to passing an argument list with shell=False. It eliminates command injection risks, avoids shell-specific quoting idiosyncrasies, and guarantees that your arbitrary code strings arrive at your target CLI intact.