Changing the current working directory is one of those operations that looks local in code but is global in effect. I still see scripts that call os.chdir(), do some work, and then try to remember where they started.
Python 3.11 added contextlib.chdir(), which makes the restore step much cleaner. To be fair, though, a context manager does not make changing the working directory concurrency-safe. The important part is understanding what state is being changed and how long that state stays changed.
Here’s the idea: use contextlib.chdir() for short, linear sections of single-threaded script-like code. In application or library code, prefer explicit paths whenever possible.
The basic pattern
Suppose a build tool expects several relative paths under a project directory:
from contextlib import chdir
from pathlib import Path
project = Path('/srv/projects/demo')
with chdir(project):
config = Path('pyproject.toml').read_text()
output = Path('dist/app.tar.gz')On entry, chdir() changes the current working directory. On exit, it restores the previous directory, including when the body raises an exception.
That is a useful improvement over manual bookkeeping:
import os
old_cwd = os.getcwd()
try:
os.chdir(project)
# work with relative paths
finally:
os.chdir(old_cwd)The context manager expresses the lifetime of the change directly and is reentrant, so nested uses restore directories in the expected stack-like order.
The working directory is global state
The subtle problem is that the current working directory does not belong to the with block. Other code in the same process observes it too.
Consider two threads:
from contextlib import chdir
from pathlib import Path
from threading import Thread
def read_project(path: Path) -> None:
with chdir(path):
print(Path('config.json').read_text())
Thread(target=read_project, args=(Path('/srv/a'),)).start()
Thread(target=read_project, args=(Path('/srv/b'),)).start()Each function looks isolated, but it is not. One thread can change the process working directory while the other thread is resolving config.json. The result can depend on timing.
Wrapping os.chdir() in a context manager cannot fix that race. contextlib.chdir() is explicitly not parallel-safe.
The same warning applies to async code. An await can let another task run while the changed directory is still visible:
async def build(project):
with chdir(project):
await prepare_assets() # risky boundary
Path('manifest.json').write_text('{}')Even though asyncio usually runs tasks on one thread, tasks interleave. Process-wide state can therefore leak across logical task boundaries.
Avoid yielding while the directory is changed
Generators have a similar problem because yield suspends execution without exiting the context manager:
from contextlib import chdir
from pathlib import Path
def files_in(project: Path):
with chdir(project):
for path in Path('.').iterdir():
yield pathAfter the first item is yielded, the caller regains control while the process is still inside the changed directory. Unrelated code may now run with a surprising working directory.
A safer version finishes the directory-sensitive work before yielding:
from pathlib import Path
def files_in(project: Path):
paths = list(project.iterdir())
yield from pathsBetter yet, this version does not need to change the working directory at all.
Prefer explicit paths in reusable code
For most filesystem APIs, changing directories is unnecessary. Build paths from a known base instead:
from pathlib import Path
def load_config(project: Path) -> str:
return (project / 'config.json').read_text(encoding='utf-8')This style has several advantages. The dependency is visible in the function signature, concurrent calls can use different projects safely, and tests do not mutate process-wide state.
It also avoids a common hidden dependency:
def load_config() -> str:
return Path('config.json').read_text()That function only works when somebody has arranged the right working directory beforehand. Moving it into a service, test runner, worker, or scheduled job can expose the assumption later.
A good use case: adapting a synchronous tool
Sometimes relative paths are part of an API you do not control. A small synchronous adapter can be reasonable:
from contextlib import chdir
from pathlib import Path
def run_legacy_builder(project: Path) -> None:
project = project.resolve()
if not project.is_dir():
raise NotADirectoryError(project)
with chdir(project):
legacy_build()I would keep this boundary narrow. Validate the directory before changing global state, avoid starting threads or async work inside it, and return to the original directory as soon as the legacy call finishes.
For an external command, changing the parent Python process is often unnecessary. Give the child process its own working directory instead:
import subprocess
from pathlib import Path
def run_build(project: Path) -> None:
subprocess.run(
['make', 'release'],
cwd=project,
check=True,
)This is usually the cleaner boundary: the child starts in project, while the Python process keeps its own working directory unchanged.
Restoration can fail too
It is tempting to think of restoration as infallible cleanup. Filesystems can change while code is running.
For example, another actor could rename or remove a directory involved in the operation. chdir() itself can also fail because the target does not exist, is not a directory, or cannot be accessed.
So I avoid using directory changes as a substitute for validation or error handling. If the target comes from user input, validate the application-level policy separately. A context manager manages lifetime; it does not decide whether a path should be trusted.
Test the boundary, not just the happy path
For a helper that intentionally uses chdir(), I want at least one test proving that the original directory is restored after failure:
from contextlib import chdir
from pathlib import Path
def test_directory_is_restored(tmp_path):
before = Path.cwd()
try:
with chdir(tmp_path):
assert Path.cwd() == tmp_path
raise RuntimeError('boom')
except RuntimeError:
pass
assert Path.cwd() == beforeI also keep these tests away from parallel execution when possible. A test that changes the process working directory can interfere with another test for exactly the same reason production threads can interfere with each other.
If the code can be rewritten to accept a base path instead, that is usually the stronger testability improvement.
A small decision rule
My rule is simple:
- For a short synchronous script section,
contextlib.chdir()is a convenient way to restore the previous directory reliably. - For threads, async tasks, generators, or reusable library code, avoid changing the working directory across execution boundaries.
- For filesystem operations, prefer explicit
Pathobjects rooted at a known directory. - For subprocesses, prefer the subprocess
cwdargument instead of changing the parent process.
contextlib.chdir() solves cleanup, not isolation. That distinction is the part worth remembering. In the end, the safest working-directory change is often the one the process never has to make.