A virtual environment looks the same in your pyproject.toml on every platform, but on
disk it is not the same shape. The directory that holds your interpreter and console
scripts is bin/ on Linux and macOS and Scripts/ on Windows; the activation script
comes in four flavours; and the shebang line that makes a wrapper executable on POSIX
means nothing on Windows. If your CLI's setup docs assume one platform, half your
contributors hit a wall on the first command. This guide shows how to manage venvs so
the same instructions work everywhere — and how to write code that discovers its own
environment without guessing.
TL;DR
- The interpreter lives in
bin/on POSIX,Scripts/on Windows. Never hardcode either — asksysconfig.get_path("scripts"). - Activation scripts differ per shell:
activate(bash/zsh),activate.fish,Activate.ps1(PowerShell). In scripts and CI, skip activation entirely and call.venv/bin/python/.venv\Scripts\python.exedirectly. - Prefer
python -m yourtoolover relying on a shebang — shebangs are POSIX-only. - Use uv or pyenv to pin one interpreter version across all three OSes so "3.12" means the same build everywhere.
The directory layout differs by platform
Create a venv and the structure depends on the OS:
# Linux / macOS
python -m venv .venv
.venv/
├── bin/
│ ├── python -> symlink to the interpreter
│ ├── pip
│ ├── activate # bash / zsh
│ ├── activate.fish # fish
│ └── yourtool # your console_scripts entry point
├── lib/
│ └── python3.12/site-packages/
└── pyvenv.cfg
# Windows (PowerShell)
py -m venv .venv
.venv\
├── Scripts\
│ ├── python.exe
│ ├── pip.exe
│ ├── activate.bat # cmd.exe
│ ├── Activate.ps1 # PowerShell
│ └── yourtool.exe # your console_scripts entry point
├── Lib\
│ └── site-packages\
└── pyvenv.cfg
Two consequences for cross-platform tooling: the executable directory name changes
(bin vs Scripts), and Windows wrappers carry a .exe suffix while POSIX ones don't.
Any code or Makefile that joins ".venv/bin/python" is already broken on Windows.
Activation across shells
Activation just prepends the venv's script directory to PATH and sets a few
environment variables for the current shell session. The script you source depends on
your shell:
# bash / zsh (Linux, macOS)
source .venv/bin/activate
# fish
source .venv/bin/activate.fish
# PowerShell (Windows, and PowerShell Core on macOS/Linux)
.venv\Scripts\Activate.ps1
# cmd.exe
.venv\Scripts\activate.bat
If Activate.ps1 fails with an execution-policy error, allow signed local scripts for
the current user once:
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
Activation is a developer convenience, not a deployment mechanism. It mutates this
shell's PATH, and that change does not propagate into subprocesses, Makefile recipes,
or CI steps that spawn a new shell. The robust alternative is to never activate.
Skip activation: call the interpreter directly
The most portable way to run a tool from its environment is to invoke the environment's
own Python and let the -m flag find your package:
# POSIX — no activation, works in scripts and CI
.venv/bin/python -m yourtool --help
# Windows — same idea, different path
.venv\Scripts\python.exe -m yourtool --help
Better still, let uv resolve the environment for you so you don't branch on the path at
all. uv run finds (or creates) the project's venv and runs the command inside it on
every platform identically:
uv run yourtool --help # identical command on Linux, macOS, Windows
uv run python -m yourtool # or invoke the module directly
Shebang vs python -m
On POSIX, a console-script wrapper starts with a shebang pointing at the venv's
interpreter, e.g. #!/path/to/.venv/bin/python. That is what lets you type yourtool
after activation. But shebangs are a kernel feature that Windows ignores entirely —
there, the .exe launcher does the equivalent job. So a script that shells out with a
hardcoded #!/usr/bin/env python3 will behave differently across platforms, and one
that assumes the shebang is honoured won't run on Windows at all.
The portable rule: don't depend on the shebang for cross-platform execution. Invoke
modules with python -m yourtool. The -m form uses the current interpreter, so it
runs against the right environment whether you activated it or called its Python
directly. Reserve the console-script entry point for the end-user convenience case where
pipx/uv tool install put a launcher on PATH for them.
Interpreter discovery from inside your CLI
Sometimes your CLI needs to find its own environment — to locate a sibling console
script, write a config next to the interpreter, or spawn a subprocess with the same
Python. Hardcoding bin/ breaks on Windows; the standard library tells you the truth
instead. sysconfig.get_path("scripts") returns bin on POSIX and Scripts on
Windows, and sys.prefix != sys.base_prefix reliably detects whether you're inside a
venv:
r"""Locate a virtual environment's executable directory cross-platform.
Works on Linux, macOS (bin/) and Windows (Scripts/) without hardcoding
the directory name. Run with the interpreter you want to inspect:
path/to/.venv/bin/python introspect.py # POSIX
path\to\.venv\Scripts\python.exe introspect.py # Windows
"""
from __future__ import annotations
import sys
import sysconfig
from pathlib import Path
def in_virtualenv() -> bool:
# base_prefix differs from prefix only inside a venv/virtualenv.
return sys.prefix != sys.base_prefix
def scripts_dir() -> Path:
# sysconfig knows the right layout for this interpreter and platform:
# 'Scripts' on Windows, 'bin' on POSIX. Never hardcode either name.
return Path(sysconfig.get_path("scripts"))
def console_script(name: str) -> Path:
exe = scripts_dir() / name
# Console-script wrappers gain a .exe suffix on Windows.
if sys.platform == "win32":
exe = exe.with_suffix(".exe")
return exe
def main() -> None:
print(f"interpreter : {sys.executable}")
print(f"version : {sys.version.split()[0]}")
print(f"platform : {sys.platform}")
print(f"in virtualenv : {in_virtualenv()}")
print(f"sys.prefix : {sys.prefix}")
print(f"sys.base_prefix : {sys.base_prefix}")
print(f"scripts dir : {scripts_dir()}")
print(f"this CLI wrapper : {console_script('mytool')}")
if __name__ == "__main__":
main()
Run against an isolated interpreter, this prints something like:
interpreter : /tmp/cli-validate/bin/python
version : 3.14.4
platform : linux
in virtualenv : True
sys.prefix : /tmp/cli-validate
sys.base_prefix : /usr
scripts dir : /tmp/cli-validate/bin
this CLI wrapper : /tmp/cli-validate/bin/mytool
On Windows the same code prints scripts dir : ...\Scripts and the wrapper as
mytool.exe — without a single platform branch in the discovery logic, because
sysconfig already encodes the platform's layout. When you need to spawn a subprocess
with the same interpreter, use sys.executable rather than the bare name python,
which may resolve to a different install on PATH.
Consistent interpreters with uv and pyenv
Layout differences are mechanical; version drift is insidious. If a contributor on macOS runs 3.11 and CI runs 3.12, you get bugs that reproduce on exactly one machine. Pin the interpreter so every platform agrees:
# uv: download and pin a specific CPython, then build the env from it
uv python install 3.12
uv venv --python 3.12 # creates .venv using that exact interpreter
uv sync # install dependencies from the lockfile
# pyenv (Linux/macOS; use pyenv-win on Windows): pin per-project
pyenv install 3.12.4
pyenv local 3.12.4 # writes .python-version
python -m venv .venv # the venv inherits 3.12.4
uv is the lower-friction option on CI and Windows because it ships a single static
binary and manages interpreters itself; pyenv is well established on developer machines
but needs pyenv-win (a separate project) for Windows. Either way, commit the version
pin (requires-python plus the lockfile for uv, or .python-version for pyenv) so the
environment is reproducible rather than ambient. See
uv for Python CLI dependency management
for the full lockfile workflow.
Production notes
- Don't commit the venv. Add
.venv/to.gitignore. The environment is derived from the lockfile and is platform-specific; checking it in guarantees breakage on the next OS. Commit the lockfile instead. PATHordering wins ties. After activation the venv directory is first, soyourtoolresolves there. Outside activation, a globally installed shim can shadow it — another reason to invokesys.executableoruv runexplicitly in automation.- CI caching can fight fresh environments. Caching
.venvacross runs can serve a stale or wrong-platform environment; cache the download/wheel directory or uv's cache, not the activated env, and recreate.venvfrom the lockfile each run. - Line endings break POSIX wrappers. If your repo ships shell wrapper scripts,
CRLF endings make them unrunnable on Linux/macOS. Add
* text=auto eol=lfto.gitattributesso wrappers stay LF regardless of who clones on Windows.
Related
- Python CLI env isolation best practices — why isolation matters and how venv, uv, pyenv, and pipx fit together.
- uv for Python CLI dependency management
— lockfiles, interpreter pinning, and
uv runfor activation-free execution. - Project Setup & Dependency Management — the full project-foundation track.