Python changes the working directory with os.chdir(path), reads it back with os.getcwd(), and pathlib has no equivalent because changing directories is process state rather than a path operation.
That omission is deliberate, and it is a hint. os.chdir mutates something global to the entire process — every thread, every library, every relative path opened afterwards. Most code that reaches for it wants absolute paths instead and has not realised it yet.
Table of contents
- The basic operation
- Why pathlib does not have chdir
- The problem with process-global state
- The context manager, when you genuinely need it
- What to do instead
- Where this bites in deployment
- How this fits the rest of the stack
- FAQ
The basic operation
import os
print(os.getcwd()) # /home/app
os.chdir("/var/data")
print(os.getcwd()) # /var/data
os.chdir accepts a string, a Path, or anything implementing the filesystem path protocol, so pathlib objects work directly:
from pathlib import Path
import os
os.chdir(Path.home() / "projects" / "api")
It raises FileNotFoundError if the directory does not exist, NotADirectoryError if the path is a file, and PermissionError if you cannot enter it. All three are subclasses of OSError, so catching that covers the set — though catching the specific one usually produces a better error message.
Why pathlib does not have chdir
pathlib models paths as values. A Path is an immutable object describing a location; methods on it return new objects or touch the filesystem at that location. The current working directory is not a property of any path — it is a property of the process.
So pathlib gives you Path.cwd() to read it and stops there. To change it you go through os.chdir, passing a Path if you like.
from pathlib import Path
import os
here = Path.cwd()
os.chdir(here.parent)
print(Path.cwd())
This is not an oversight to work around. It is the library telling you that directory changes are a different category of operation from path manipulation, and the distinction is worth respecting.
The problem with process-global state
There is exactly one working directory per process. Not per thread, not per module, not per function. When you call os.chdir inside a helper, you have changed it for every other piece of code running in that process.
def load_config():
os.chdir("/etc/myapp") # helpful, briefly
with open("config.yaml") as fh:
return yaml.safe_load(fh)
config = load_config()
open("output.log", "w") # writes to /etc/myapp/output.log
The function did its job and left a landmine. Every relative path used afterwards resolves somewhere unexpected, and the failure surfaces far away from the cause — usually as a file appearing in a directory nobody expected, or a FileNotFoundError for a file that plainly exists.
In a threaded application it is worse, because there is no ordering guarantee. Thread A chdirs, thread B opens a relative path, and whether B gets the right file depends on timing. That is a bug that reproduces once a week and never under a debugger.
This is also why it breaks in async code: await hands control to another coroutine that may be mid-way through its own relative path work.
The context manager, when you genuinely need it
Sometimes you have no choice. A subprocess must run in a specific directory, or a library resolves relative paths internally and gives you no way to configure a base. In that case, contain it.
import os
from contextlib import contextmanager
from pathlib import Path
@contextmanager
def working_dir(path):
previous = Path.cwd()
os.chdir(path)
try:
yield Path(path)
finally:
os.chdir(previous)
with working_dir("/var/data"):
run_legacy_tool() # insists on relative paths
# back wherever we were, even if run_legacy_tool raised
The try/finally is the part that matters. Without it, an exception leaves the process in the wrong directory and the next unrelated operation fails for reasons that make no sense.
Python 3.11 ships contextlib.chdir, which is the same idea in the standard library:
from contextlib import chdir
with chdir("/var/data"):
run_legacy_tool()
Neither version is thread-safe, and neither can be. The state being restored is global; a second thread changing it concurrently will produce whatever it produces. If you need per-task directories, use processes, not threads.
What to do instead
Nearly every use of os.chdir is a workaround for building paths relative to a known location. Build the paths instead.
from pathlib import Path
BASE = Path(__file__).resolve().parent
config = BASE / "config" / "settings.yaml"
output = BASE / "out" / "report.csv"
with config.open() as fh:
...
output.parent.mkdir(parents=True, exist_ok=True)
Path(__file__).resolve().parent is the directory containing the module, resolved through any symlinks. It does not care what the working directory is, which means the code behaves identically whether it was launched from the project root, from /, or by a scheduler with no meaningful cwd at all.
For subprocesses, pass the directory instead of changing your own:
import subprocess
subprocess.run(["npm", "run", "build"], cwd="/srv/frontend", check=True)
The cwd argument sets the working directory for the child process only. Your process never moves. This is the correct answer roughly every time someone reaches for chdir before a subprocess call.
Where this bites in deployment
Code that depends on the working directory works on a laptop, where you cd into the project and run python app.py, and then behaves differently everywhere else. A container sets WORKDIR to whatever the image says. A systemd unit starts in /. A cron job starts in the user’s home directory. A process manager may start in the directory it was itself launched from.
The symptom is the classic one: works locally, FileNotFoundError: config.yaml in production, and the file is definitely in the image.
Services deployed on RunxBuild run from the repository root, so relative paths from there behave predictably — but relying on that is still relying on a convention. Anchoring to Path(__file__).parent and reading configuration from environment variables rather than files found relative to the cwd removes the class of problem entirely, and it makes the same code run identically on your laptop, in CI, and on the platform.
How this fits the rest of the stack
Path handling is a small correctness question sitting inside a larger cost question. The runtime, the storage the files live on, the database, and the bandwidth all carry their own numbers, and the total is what arrives monthly. The RunxBuild hosting calculator lays those line items out together so you can model them before committing.
Useful related references:
- Python Not Equal: != vs is not, and Why the Difference Bites
- Python Integer Division: Why // Floors and Why -7 // 2 Is -4
- Python for Websites: Where It Fits and Where It Does Not
- Python services on RunxBuild
FAQ
How do I change the current directory in Python?
Use os.chdir(path) and read it back with os.getcwd(). It accepts strings and pathlib Path objects. It raises FileNotFoundError, NotADirectoryError, or PermissionError depending on why the change failed, all subclasses of OSError.
Does pathlib have a chdir method?
No. Path.cwd() reads the working directory but there is no method to change it, because pathlib models paths as immutable values while the working directory is process state. Pass a Path to os.chdir when you need to change it.
Is os.chdir thread-safe?
No. There is one working directory per process, shared by every thread. If one thread changes it while another resolves a relative path, the result depends on timing. The same problem affects async code, since an await can hand control to a coroutine mid-way through its own path work.
What is the safest way to temporarily change directory?
A context manager that restores the previous directory in a finally block, so an exception cannot leave the process somewhere unexpected. Python 3.11 and later ship contextlib.chdir which does exactly this. Neither form is thread-safe.
How do I avoid needing chdir at all?
Build absolute paths from a known anchor: BASE = Path(file).resolve().parent, then BASE / config / settings.yaml. For subprocesses, pass cwd= to subprocess.run so the child gets the directory and your own process never moves.