Intermediate
You write a Python script on your Mac, push it to GitHub, and your colleague pulls it on Windows — and it immediately crashes. The culprit is almost always file paths using /, line endings, or platform-specific system calls that silently break when moved to a different OS. Cross-platform compatibility is one of those problems that seems trivial until it bites you in production, and fixing it after the fact means tracking down subtle bugs across three operating systems at once.
The good news: Python was designed with portability in mind. The standard library includes pathlib, os, platform, sys, and shutil — tools that abstract over OS differences so your code runs identically on Windows, macOS, and Linux. You don’t need third-party libraries to write portable Python. You just need to know which patterns to avoid and which built-in tools to use instead.
In this guide, you’ll learn the biggest cross-platform pitfalls and how to avoid them. We’ll cover file path handling with pathlib, detecting the operating system, environment variable access, line ending normalization, executable detection, and common gotchas with file permissions and temporary files. By the end, your scripts will run cleanly on any platform your team or users might have.
Cross-Platform Python: Quick Example
Here is a minimal script that works correctly on Windows, macOS, and Linux. It reads a config file from the user’s home directory without any hardcoded path separators:
# cross_platform_quick.py
from pathlib import Path
import os
import sys
# Never use hardcoded slashes -- pathlib handles separators automatically
config_dir = Path.home() / ".config" / "myapp"
config_file = config_dir / "settings.txt"
# Create the directory if it doesn't exist (works on all platforms)
config_dir.mkdir(parents=True, exist_ok=True)
# Write a config file
config_file.write_text("debug=True\nversion=1.0\n")
# Read it back
content = config_file.read_text()
print(f"Config path: {config_file}")
print(f"Content:\n{content}")
print(f"Running on: {sys.platform}")
Output (macOS):
Config path: /Users/alice/.config/myapp/settings.txt
Content:
debug=True
version=1.0
Running on: darwin
Output (Windows):
Config path: C:\Users\alice\.config\myapp\settings.txt
Content:
debug=True
version=1.0
Running on: win32
The key insight: Path.home() / ".config" / "myapp" uses Python’s / operator overload to build paths — no hardcoded separators, no os.path.join() string juggling. The resulting path string uses the correct separator for whatever OS the code is running on. Keep reading to learn the patterns that make everything else portable too.
Why Cross-Platform Python Matters
Python runs on Windows, macOS, and Linux, but each OS has different conventions for several things that developers touch constantly: file path separators (\ on Windows, / on Unix), line endings (CRLF on Windows, LF on Unix), executable file extensions (.exe on Windows, none on Unix), and environment variable availability. Scripts written without thinking about these differences produce code that works perfectly on one machine and fails mysteriously on another.
The most common offenders are hardcoded file paths like "C:\\Users\\alice\\data.csv" or "/home/alice/data.csv". Neither works on the other platform. The fix is always to use pathlib.Path with relative references or home-directory-based anchors.
| Feature | Windows | macOS | Linux |
|---|---|---|---|
| Path separator | \ | / | / |
| Line ending | CRLF (\r\n) | LF (\n) | LF (\n) |
| Home directory | C:\Users\name | /Users/name | /home/name |
| Temp directory | C:\Temp | /tmp | /tmp |
| Executables | Need .exe | No extension | No extension |
| Case sensitivity | Case-insensitive | Usually insensitive | Case-sensitive |
Understanding this table is the foundation. Every pattern in this article addresses one or more of these differences.
File Paths with pathlib
The single most impactful change you can make to write portable Python is replacing all string-based path operations with pathlib.Path. It was added in Python 3.4 and makes path manipulation safe, readable, and OS-independent.
Building Paths
Use the / operator to join path components. Python overloads this operator on Path objects to build correct paths regardless of platform — it uses os.sep internally.
# pathlib_basics.py
from pathlib import Path
# Never do this -- breaks on other platforms:
# bad_path = "data/reports/2024/q1.csv" # fails on Windows
# bad_path = "data\\reports\\2024\\q1.csv" # fails on Unix
# Do this instead:
data_dir = Path("data") / "reports" / "2024"
report = data_dir / "q1.csv"
print(f"Path: {report}")
print(f"Parent: {report.parent}")
print(f"Name: {report.name}")
print(f"Stem: {report.stem}")
print(f"Suffix: {report.suffix}")
print(f"Parts: {report.parts}")
Output (Linux/macOS):
Path: data/reports/2024/q1.csv
Parent: data/reports/2024
Name: q1.csv
Stem: q1
Suffix: .csv
Parts: ('data', 'reports', '2024', 'q1.csv')
Output (Windows):
Path: data\reports\2024\q1.csv
Parent: data\reports\2024
Name: q1.csv
Stem: q1
Suffix: .csv
Parts: ('data', 'reports', '2024', 'q1.csv')
The path display differs, but the programmatic interface is identical. Code that calls report.name or report.suffix works the same on both platforms.
Absolute and Home-Relative Paths
Use Path.home() to anchor paths to the user’s home directory, and Path.cwd() for the current working directory. These return the platform-correct path automatically.
# path_anchors.py
from pathlib import Path
# Home directory -- fully cross-platform
home = Path.home()
documents = home / "Documents"
downloads = home / "Downloads"
# Current working directory
here = Path.cwd()
output = here / "output" / "results.txt"
# Resolve relative paths to absolute
relative = Path("../data/config.json")
absolute = relative.resolve()
print(f"Home: {home}")
print(f"Documents: {documents}")
print(f"Output: {output}")
print(f"Resolved: {absolute}")
Output (macOS):
Home: /Users/alice
Documents: /Users/alice/Documents
Output: /Users/alice/projects/output/results.txt
Resolved: /Users/alice/data/config.json
Path.resolve() handles .. traversal and symlinks, giving you a clean absolute path regardless of how the relative path was constructed.
Detecting the Operating System
Sometimes you genuinely need to do something different per platform — for example, opening a file in the default app, clearing the terminal, or locating a system-level config. Use sys.platform for lightweight checks and platform module for detailed information.
# detect_os.py
import sys
import platform
# Quick OS detection
if sys.platform == "win32":
os_name = "Windows"
elif sys.platform == "darwin":
os_name = "macOS"
else:
os_name = "Linux/Unix"
print(f"OS: {os_name}")
print(f"sys.platform: {sys.platform}")
# Detailed info via platform module
print(f"System: {platform.system()}") # 'Windows', 'Darwin', 'Linux'
print(f"Release: {platform.release()}") # e.g., '10', '22.3.0', '5.15.0'
print(f"Machine: {platform.machine()}") # 'AMD64', 'arm64', 'x86_64'
print(f"Python: {platform.python_version()}") # e.g., '3.12.1'
print(f"Node: {platform.node()}") # hostname
Output (macOS on Apple Silicon):
OS: macOS
sys.platform: darwin
System: Darwin
Release: 22.3.0
Machine: arm64
Python: 3.12.1
Node: alices-macbook.local
Use sys.platform for conditional code paths and platform.system() when you need human-readable output or when reporting system info in logs. Avoid using environment variables like OS or OSTYPE — they’re not reliably set on all systems.
Environment Variables
Environment variables are the standard way to pass configuration (API keys, database URLs, feature flags) to Python programs. But reading them correctly across platforms requires a few patterns to avoid crashes on missing variables.
# env_vars.py
import os
from pathlib import Path
# Safe read -- returns None if not set (never raises KeyError)
api_key = os.environ.get("API_KEY")
# Read with a default value
debug = os.environ.get("DEBUG", "false").lower() == "true"
port = int(os.environ.get("PORT", "8080"))
# Read required variable -- raise early with a clear message
def require_env(name):
value = os.environ.get(name)
if value is None:
raise EnvironmentError(
f"Required environment variable '{name}' is not set. "
f"Set it before running this script."
)
return value
# Example usage
try:
db_url = require_env("DATABASE_URL")
except EnvironmentError as e:
print(f"Configuration error: {e}")
db_url = "sqlite:///local.db"
print(f"API key set: {api_key is not None}")
print(f"Debug mode: {debug}")
print(f"Port: {port}")
print(f"DB: {db_url}")
# List all environment variables matching a prefix
python_vars = {k: v for k, v in os.environ.items() if k.startswith("PYTHON")}
print(f"PYTHON* vars: {python_vars}")
Output:
Configuration error: Required environment variable 'DATABASE_URL' is not set. Set it before running this script.
API key set: False
Debug mode: False
Port: 8080
DB: sqlite:///local.db
PYTHON* vars: {'PYTHONPATH': '/usr/local/lib/python3.12', 'PYTHONDONTWRITEBYTECODE': '1'}
Always use os.environ.get() instead of os.environ["KEY"]. The bracket notation raises a KeyError when the variable is missing — which means your program crashes with a confusing error instead of a helpful message. The require_env() pattern above gives you the crash-on-missing behavior with a clear error message that tells users exactly what they need to set.
Temp Files and Directories
Never hardcode temporary file paths like /tmp/myapp_cache.tmp — that path doesn’t exist on Windows. Use Python’s tempfile module, which always points to the correct temp directory for the current platform.
# temp_files.py
import tempfile
from pathlib import Path
# Create a named temporary file (auto-deleted when closed)
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=True) as tmp:
tmp.write("temporary data\n")
tmp.flush()
print(f"Temp file: {tmp.name}")
# File exists here
print("File deleted after context exit")
# Create a persistent temp file (you manage deletion)
fd, path = tempfile.mkstemp(suffix='.csv', prefix='report_')
import os
os.close(fd) # Close the file descriptor immediately
tmp_path = Path(path)
tmp_path.write_text("col1,col2\n1,2\n")
print(f"Persistent temp: {tmp_path}")
print(f"Exists: {tmp_path.exists()}")
tmp_path.unlink() # Delete it when done
# Create a temp directory
with tempfile.TemporaryDirectory() as tmp_dir:
work_dir = Path(tmp_dir)
output = work_dir / "results.json"
output.write_text('{"status": "ok"}')
print(f"Temp dir: {work_dir}")
print(f"Contains: {list(work_dir.iterdir())}")
# Directory and all contents deleted here
Output (Linux):
Temp file: /tmp/tmp8x2kf9ab.txt
File deleted after context exit
Persistent temp: /tmp/report_abc12345.csv
Exists: True
Temp dir: /tmp/tmpwqpfgh3j
Contains: [PosixPath('/tmp/tmpwqpfgh3j/results.json')]
Output (Windows):
Temp file: C:\Users\alice\AppData\Local\Temp\tmp8x2kf9ab.txt
File deleted after context exit
Persistent temp: C:\Users\alice\AppData\Local\Temp\report_abc12345.csv
Exists: True
Temp dir: C:\Users\alice\AppData\Local\Temp\tmpwqpfgh3j
Contains: [WindowsPath('C:\\Users\\alice\\AppData\\Local\\Temp\\tmpwqpfgh3j\\results.json')]
The same code. Two completely different paths. Zero manual path handling. This is exactly how cross-platform code should work.
Handling Line Endings
Windows uses \r\n (CRLF) for line endings while macOS and Linux use \n (LF). If you read a file in binary mode and write it back, or pass it through a process that doesn’t normalize line endings, you’ll end up with mixed or double-CR content. Python handles this for you in text mode — but only if you use text mode.
# line_endings.py
from pathlib import Path
# Python normalizes line endings when reading in text mode (default)
# This works correctly on all platforms
path = Path("data.txt")
path.write_text("line one\nline two\nline three\n")
# Text mode read -- line endings normalized to \n regardless of OS
lines = path.read_text().splitlines()
print(f"Lines: {lines}")
# Explicit newline control when writing
# newline='' disables translation (use for CSV files to avoid double-CR)
import csv, io
output = io.StringIO(newline='')
writer = csv.writer(output)
writer.writerow(["name", "score"])
writer.writerow(["Alice", 95])
csv_content = output.getvalue()
print(f"CSV content repr: {repr(csv_content)}") # Shows \r\n (CSV standard)
# Binary mode when you need exact bytes
raw = path.read_bytes()
print(f"Raw bytes (first 30): {raw[:30]}")
Output:
Lines: ['line one', 'line two', 'line three']
CSV content repr: 'name,score\r\nalice,95\r\n'
Raw bytes (first 30): b'line one\nline two\nline three\n'
Key rule: always open files in text mode (the default) unless you specifically need raw bytes. Text mode handles line ending translation automatically. For CSV files, use newline='' when creating csv.writer objects — this prevents Python from adding an extra \r before the CSV module’s own \r\n, which would create \r\r\n on Windows.
Running Platform-Specific Subprocesses
When you need to run shell commands from Python, subprocess is the right tool — but the command syntax differs by platform. The safest approach is to pass commands as lists of strings and avoid shell=True where possible.
# subprocess_cross_platform.py
import subprocess
import sys
def clear_screen():
"""Clear the terminal -- different command per OS."""
if sys.platform == "win32":
subprocess.run(["cmd", "/c", "cls"], check=True)
else:
subprocess.run(["clear"], check=True)
def get_disk_usage(path="."):
"""Get disk usage -- different tool per OS."""
if sys.platform == "win32":
result = subprocess.run(
["dir", "/s", str(path)],
capture_output=True, text=True, shell=True # dir needs shell=True on Windows
)
else:
result = subprocess.run(
["du", "-sh", str(path)],
capture_output=True, text=True
)
return result.stdout.strip()
def list_processes():
"""List running processes -- different per OS."""
if sys.platform == "win32":
result = subprocess.run(["tasklist"], capture_output=True, text=True)
else:
result = subprocess.run(["ps", "aux"], capture_output=True, text=True)
lines = result.stdout.splitlines()
return lines[:5] # First 5 lines
usage = get_disk_usage(".")
print(f"Disk usage: {usage}")
procs = list_processes()
print("Processes (first 5):")
for line in procs:
print(f" {line[:80]}")
Output (Linux):
Disk usage: 4.0K .
Processes (first 5):
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.1 167584 11244 ? Ss 08:00 0:01 /sbin/init
root 2 0.0 0.0 0 0 ? S 08:00 0:00 [kthreadd]
When shell=True is required (like for Windows built-in commands such as dir), be extra careful about command injection — never pass user-provided strings directly into shell commands. Build the command as a list whenever you can.
Real-Life Example: Cross-Platform Config Manager
Here is a practical config manager that stores application settings in the correct platform-specific location on every OS — ~/Library/Application Support on macOS, %APPDATA% on Windows, and ~/.config on Linux.
# config_manager.py
import json
import os
import sys
from pathlib import Path
def get_app_config_dir(app_name: str) -> Path:
"""Return the platform-correct config directory for this app."""
if sys.platform == "win32":
# Windows: %APPDATA%\AppName
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
elif sys.platform == "darwin":
# macOS: ~/Library/Application Support/AppName
base = Path.home() / "Library" / "Application Support"
else:
# Linux/Unix: ~/.config/AppName (XDG spec)
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
config_dir = base / app_name
config_dir.mkdir(parents=True, exist_ok=True)
return config_dir
class ConfigManager:
def __init__(self, app_name: str):
self.config_dir = get_app_config_dir(app_name)
self.config_file = self.config_dir / "config.json"
self._data = self._load()
def _load(self) -> dict:
if self.config_file.exists():
try:
return json.loads(self.config_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
return {}
def get(self, key: str, default=None):
return self._data.get(key, default)
def set(self, key: str, value):
self._data[key] = value
self._save()
def _save(self):
self.config_file.write_text(
json.dumps(self._data, indent=2),
encoding="utf-8"
)
def clear(self):
self._data = {}
if self.config_file.exists():
self.config_file.unlink()
# Usage
config = ConfigManager("MyPythonApp")
config.set("theme", "dark")
config.set("font_size", 14)
config.set("recent_files", ["/home/user/doc.txt"])
print(f"Config stored at: {config.config_dir}")
print(f"Theme: {config.get('theme')}")
print(f"Font size: {config.get('font_size')}")
print(f"Recent: {config.get('recent_files')}")
print(f"Missing key: {config.get('language', 'en')}")
Output (Linux):
Config stored at: /home/alice/.config/MyPythonApp
Theme: dark
Font size: 14
Recent: ['/home/user/doc.txt']
Missing key: en
Output (Windows):
Config stored at: C:\Users\alice\AppData\Roaming\MyPythonApp
Theme: dark
Font size: 14
Recent: ['/home/user/doc.txt']
Missing key: en
This pattern follows platform conventions that users expect — on macOS, apps store their config in ~/Library, not in a hidden dot-folder. The same code does the right thing on every platform without any conditional logic in the main program, just in the get_app_config_dir factory function.
Frequently Asked Questions
Should I use os.path or pathlib?
Use pathlib for all new code. It is the modern, object-oriented path API introduced in Python 3.4 and is consistently more readable and safer than os.path string functions. os.path.join("a", "b", "c") becomes Path("a") / "b" / "c". The only time to use os.path is when working with very old code that already uses it and you are making minimal changes.
Why does my script work on Mac but break on Linux?
Most likely a case sensitivity issue. macOS filesystems are case-insensitive by default (like Windows), so Path("Data/File.txt") and Path("data/file.txt") point to the same file. Linux filesystems are case-sensitive, so they are completely different paths. Always match filenames exactly, and use path.lower() comparisons only when searching, never when constructing paths.
What is the safest way to reference the home directory?
Always use Path.home(). Never hardcode /home/user, /Users/user, or C:\Users\user. Even on Linux, home directories are not always in /home — root’s home is /root, and system accounts often live in /var or /opt. Path.home() reads from the OS correctly every time.
Why does file deletion fail on Windows but not Linux?
Windows locks open files — you cannot delete or rename a file that is currently open by any process, including your own. On Linux, you can delete a file that is open (the inode is removed, but the data persists until all handles close). Always close files before deleting them, use context managers (with open(...)), and catch PermissionError when deleting files on Windows.
How do I copy or move files cross-platform?
Use shutil from the standard library. shutil.copy2(src, dst) copies a file with metadata, shutil.copytree(src, dst) copies a whole directory tree, and shutil.move(src, dst) moves files or directories — all platform-correctly. Never use raw OS commands like cp or xcopy from subprocess when shutil gives you a portable Python solution.
How do I find an executable on the system PATH?
Use shutil.which("program_name"). It searches the PATH correctly on all platforms and returns None if the executable is not found. On Windows it automatically checks for .exe, .cmd, and other extensions. Never manually construct paths like /usr/bin/python — use shutil.which("python") or sys.executable for the current interpreter.
Conclusion
Writing cross-platform Python is mostly about choosing the right standard library tools. The biggest wins come from four habits: using pathlib.Path for all file path operations, using os.environ.get() for environment variables, using tempfile for temporary files, and using sys.platform for the rare cases where you need platform-specific behavior. With these four patterns, the vast majority of portability bugs simply cannot happen.
The config manager example in this article is a good template for any tool that needs to store data in the user’s filesystem. Extend it by adding schema validation with pydantic, or by supporting environment variable overrides that take precedence over the config file. Both patterns follow naturally from what you’ve learned here.
For deeper reading, the official Python documentation on pathlib, os, and tempfile covers every method and parameter in detail. The shutil documentation is also worth reading for file operations beyond basic read/write.