subprocess
Python
Popen
environment-variables
programming

Python subprocess/Popen with a modified environment

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

When launching a subprocess in Python, you sometimes need to modify environment variables without affecting the parent process. The subprocess.Popen and subprocess.run functions accept an env parameter that lets you pass a custom environment dictionary. The standard pattern is to copy the current environment with os.environ.copy(), modify the copy, and pass it to the subprocess. This keeps the parent process's environment unchanged while giving the child process exactly the variables it needs.

Basic Pattern

python
1import subprocess
2import os
3
4# Copy current environment and add/modify variables
5env = os.environ.copy()
6env['MY_VAR'] = 'my_value'
7env['PATH'] = '/custom/bin:' + env['PATH']
8
9result = subprocess.run(
10    ['echo', '$MY_VAR'],
11    env=env,
12    shell=True,
13    capture_output=True,
14    text=True,
15)
16print(result.stdout)  # my_value

os.environ.copy() creates a shallow copy of the current environment as a regular dictionary. Modifying this copy does not affect os.environ in the parent process.

Using subprocess.run (Python 3.5+)

python
1import subprocess
2import os
3
4env = os.environ.copy()
5env['DATABASE_URL'] = 'postgresql://localhost/mydb'
6env['DEBUG'] = 'true'
7
8result = subprocess.run(
9    ['python', 'migrate.py'],
10    env=env,
11    capture_output=True,
12    text=True,
13    check=True,
14)
15
16print(result.stdout)

subprocess.run is the recommended high-level API. The check=True parameter raises CalledProcessError if the subprocess exits with a non-zero code.

Using subprocess.Popen

python
1import subprocess
2import os
3
4env = os.environ.copy()
5env['JAVA_HOME'] = '/usr/lib/jvm/java-17'
6env['MAVEN_OPTS'] = '-Xmx2g'
7
8proc = subprocess.Popen(
9    ['mvn', 'clean', 'install'],
10    env=env,
11    stdout=subprocess.PIPE,
12    stderr=subprocess.PIPE,
13    text=True,
14)
15
16stdout, stderr = proc.communicate()
17print(f"Exit code: {proc.returncode}")

Popen gives lower-level control — you can stream output, send input, and manage the process lifecycle. Use it when subprocess.run is not flexible enough.

Removing Environment Variables

python
1import subprocess
2import os
3
4env = os.environ.copy()
5
6# Remove a variable
7env.pop('HTTPS_PROXY', None)  # None prevents KeyError if missing
8
9# Or delete it
10if 'HTTP_PROXY' in env:
11    del env['HTTP_PROXY']
12
13subprocess.run(['curl', 'http://internal-api'], env=env)

Removing proxy variables is a common use case when a subprocess should connect directly without going through a proxy that the parent process uses.

Minimal Environment

python
1import subprocess
2
3# Start with an empty environment — only set what you need
4minimal_env = {
5    'PATH': '/usr/bin:/bin',
6    'HOME': '/tmp',
7    'LANG': 'en_US.UTF-8',
8}
9
10result = subprocess.run(
11    ['env'],
12    env=minimal_env,
13    capture_output=True,
14    text=True,
15)
16print(result.stdout)
17# Only shows PATH, HOME, LANG

Passing a dict without copying os.environ creates a minimal environment. The subprocess only sees the variables you explicitly set. This is useful for security-sensitive operations where you want to prevent leaking variables.

Using os.environ with Dictionary Unpacking

python
1import subprocess
2import os
3
4# One-liner to merge current env with overrides
5result = subprocess.run(
6    ['python', 'app.py'],
7    env={**os.environ, 'DEBUG': 'true', 'PORT': '8080'},
8    capture_output=True,
9    text=True,
10)

Dictionary unpacking ({**os.environ, 'KEY': 'val'}) creates a new dict with the current environment plus overrides. This is concise but creates a new dict each time — fine for most use cases.

Platform-Specific Considerations

python
1import subprocess
2import os
3import sys
4
5env = os.environ.copy()
6
7if sys.platform == 'win32':
8    # Windows environment variables are case-insensitive
9    # but os.environ preserves case
10    env['Path'] = r'C:\custom\bin;' + env.get('Path', '')
11else:
12    # Linux/macOS — case-sensitive
13    env['PATH'] = '/custom/bin:' + env.get('PATH', '')
14    env['LD_LIBRARY_PATH'] = '/custom/lib:' + env.get('LD_LIBRARY_PATH', '')
15
16subprocess.run(['my_tool'], env=env)

On Windows, environment variable names are case-insensitive. On Linux and macOS, they are case-sensitive. PATH and Path are different on Unix but the same on Windows.

Passing Secrets Securely

python
1import subprocess
2import os
3
4env = os.environ.copy()
5env['DB_PASSWORD'] = 'secret123'
6
7# The password is in the subprocess environment but NOT
8# visible in the command line (unlike passing as an argument)
9result = subprocess.run(
10    ['python', '-c', 'import os; print(os.environ["DB_PASSWORD"])'],
11    env=env,
12    capture_output=True,
13    text=True,
14)
15print(result.stdout.strip())  # secret123
16
17# Command-line args are visible in `ps` — environment variables are not
18# (on most systems)

Environment variables are more secure than command-line arguments for passing secrets because command-line arguments are visible in ps output on most systems, while environment variables are not.

Common Pitfalls

  • Modifying os.environ directly: Calling os.environ['KEY'] = 'value' modifies the parent process's environment permanently. Always use os.environ.copy() to get an isolated copy for the subprocess.
  • Passing env={} (empty dict): An empty environment dict removes all variables including PATH, HOME, and LANG. The subprocess may fail to find executables or behave unexpectedly. Always start from os.environ.copy() unless you intentionally want a minimal environment.
  • Non-string values in env dict: All keys and values in the env dictionary must be strings. Passing env={'PORT': 8080} raises TypeError. Convert to string first with str(8080) or use '8080'.
  • shell=True with env on Windows: When using shell=True on Windows, the shell is cmd.exe which may not expand variables the same way as on Unix. Test platform-specific behavior carefully.
  • Environment variable size limits: Most operating systems limit total environment size (typically 128KB-2MB). Setting many large variables can cause OSError: [Errno 7] Argument list too long when the combined size of arguments and environment exceeds the limit.

Summary

  • Use os.environ.copy() to create an isolated copy of the current environment
  • Pass the modified dict as the env parameter to subprocess.run() or Popen()
  • Use {**os.environ, 'KEY': 'val'} for concise one-liner overrides
  • Remove variables with env.pop('KEY', None) to prevent leaking proxy settings or secrets
  • All env dict keys and values must be strings
  • Prefer environment variables over command-line arguments for passing secrets to subprocesses

Course illustration
Course illustration

All Rights Reserved.