Why is calling os.Chdir inside a Go library or long-running server risky?
answer
- one value per process, not per goroutine
- who else opens relative paths meanwhile?
- save and restore leaves a window open
- carry a base directory instead
basics
~10 sThe working directory is one attribute of the whole process, not something each goroutine owns. os.Chdir changes how every relative path in the program resolves, including paths other goroutines are opening at that instant.
solid answer
~40 sThere is exactly one current working directory per process, and `os.Chdir` moves it for everybody: every goroutine, every package, every relative path opened while it is changed. The tempting idiom — `prev, _ := os.Getwd()`, `os.Chdir(dir)`, `defer os.Chdir(prev)` — looks scoped but is not: during that window another goroutine handling a request resolves `os.Open("data.json")` against your temporary directory, and two concurrent callers can restore each other's directory. Restoring is not even exact, since `os.Getwd` may hand back a different path through symlinks than the one you started with. The fix is to never depend on the process directory: keep a base directory in the component and `filepath.Join` names onto it. In tests, `t.Chdir` does the save and restore for you.
code
go · 11 linesfunc withDir(dir string, fn func() error) error {
prev, err := os.Getwd()
if err != nil {
return err
}
if err := os.Chdir(dir); err != nil {
return err
}
defer os.Chdir(prev) // everyone else saw dir in the meantime
return fn()
}go deeper
Recall that os.Getwd reports the process working directory and os.Chdir changes it, and that relative filenames are resolved against it rather than against your source file.
Explain the mechanics: one directory per process, so the change is visible to every goroutine, two concurrent save-restore helpers can interleave, and symlinks make the restore inexact.
Demonstrate the design fix under load — inject a base directory, join names onto it, and keep process-global mutation out of request paths so behaviour does not depend on what another goroutine is doing.
Own the boundary rule: libraries other teams import must not mutate process-global state, and the review standard that catches a Chdir in a dependency before it ships is yours to set.
The current working directory is a property the operating system keeps for the **process**. Go does not layer anything on top of it: there is no per-goroutine directory, no per-package directory, and no way to scope a change to one call stack. `os.Getwd() (string, error)` asks the OS what it currently is, and `os.Chdir(dir string) error` changes it for the entire program. ## Why that is a problem in a server Every relative path anywhere in the process is resolved against that one value at the moment the syscall happens. In a program with a single goroutine doing one thing at a time, `os.Chdir` is merely surprising. In a server, a batch worker with a pool of goroutines, or a test binary running many tests in one process, it is a correctness hazard: // Racy: the working directory is one process-wide value. func withDir(dir string, fn func() error) error { prev, err := os.Getwd() if err != nil { return err } if err := os.Chdir(dir); err != nil { return err } defer os.Chdir(prev) return fn() } This reads like a scoped, well-behaved helper. It is not: 1. **Other goroutines are affected for the whole window.** A request handler that happens to call `os.Open("templates/index.html")` while `fn` is running looks for that file under `dir`. The bug is timing-dependent and will not reproduce under a single-threaded test. 2. **Two concurrent callers interleave.** Goroutine A saves `/srv`, chdirs to `/a`. Goroutine B saves `/a` (not `/srv`), chdirs to `/b`. A finishes and restores `/srv`; B finishes and restores `/a` — the process is now sitting in a directory nobody asked for. 3. **The restore is not exact.** `os.Getwd` returns *a* rooted path to the current directory; when symlinks are involved several paths can name the same directory, and the one you get back is not guaranteed to be the string you would have chosen. Restoring by that string can leave later relative paths resolving through a different link. 4. **A mutex does not save you.** Guarding `os.Chdir` with a package-level mutex only serialises the callers who agreed to take that mutex. Every other package in the binary — including the standard library and any dependency — still resolves relative paths without asking permission. ## What to do instead Treat the process working directory as read-only input, decided once by whoever launched the program, and make the code independent of it: type Store struct{ base string } func (s *Store) Open(name string) (*os.File, error) { return os.Open(filepath.Join(s.base, name)) } The base directory becomes an ordinary field: explicit, injectable, and testable. Two instances can point at two different roots in the same process at the same time, which is impossible with `os.Chdir`. Library code in particular should never call `os.Chdir` at all — it is a global side effect the importing program did not ask for, and a library cannot know what else is running in the process. ## When os.Chdir is legitimate - **Early in `main`,** before any goroutines are started, for a command-line tool that is documented to operate from a particular root. This is a one-time decision, not a scoped change. - **In tests,** through `t.Chdir(dir)`, which calls into the same mechanism but registers a cleanup that puts the old directory back when the test finishes, pass or fail. That is strictly better than hand-rolling save-and-restore, and it also refuses to run in a test that has been marked parallel, precisely because the directory is shared. ## The interview signal The answer an interviewer is listening for is the words "process-wide". Everything else — the interleaving, the symlink caveat, the uselessness of a mutex — follows from that one fact. A candidate who says "I would give each goroutine its own working directory" has the model wrong; the operating system does not offer Go one.
- Does each goroutine, or each OS thread, get its own working directory in Go?No. The working directory belongs to the process, and Go exposes no per-goroutine or per-thread variant — `runtime.LockOSThread` does not give you one either. Every relative path resolved anywhere in the binary, including inside the standard library and dependencies, uses that single value at the moment of the syscall.
- Would guarding os.Chdir with a package-level mutex make the helper safe?No. A mutex only serialises callers that take it. Any other package in the same binary still resolves relative paths without acquiring your lock, so it can open a file against the temporarily changed directory. The mutex reduces interleaving between your own callers and hides the bug rather than removing it.
- Where is os.Chdir a reasonable call to make?Early in `main`, before any goroutines exist, for a tool documented to run from a given root — a one-off decision rather than a scoped change. And inside tests via `t.Chdir`, which restores the previous directory through the test's cleanup whether the test passes or fails.
- Why is restoring the directory from os.Getwd not always exact?`os.Getwd` returns a rooted path to the current directory, but when symlinks are involved several paths name the same place and you are not promised the one you started from. Restoring by that string can leave later relative paths resolving through a different link — another reason not to lean on the process directory.
saying these in an interview costs you the question
- Thinks each goroutine has its own working directory
- Believes defer os.Chdir(prev) makes the swap concurrency-safe
- Assumes os.Chdir only affects the calling package
- Says a mutex around os.Chdir removes the hazard
- Calls os.Chdir from library code the caller imports