skip to content

When does os.Rename fail with a cross-device link error, and where must the temp file be created?

level: middleimportance: should knowfreq 40%

answer

  1. a rename moves a name, not bytes
  2. names must share one filesystem
  3. the system temp directory is its own mount
  4. an empty first argument means os.TempDir()
  5. create it beside the file you replace

basics

~20 s

os.Rename only rearranges names inside one filesystem. If the temp file was created in the system temp directory and the target sits on another mount, the call fails with an *os.LinkError wrapping EXDEV. Create the temp file in the target's own directory.

solid answer

~50 s

A rename is a directory-entry operation, not a copy: both paths must live on the same filesystem. If the temp file is created with `os.CreateTemp("", ...)` it lands in `os.TempDir()`, which on a typical Linux host is a separate mount from `/var/lib`, so `os.Rename` returns an `*os.LinkError` whose wrapped error is `EXDEV` - printed as `invalid cross-device link`. Go does not fall back to copying, and you would not want it to, because a copy is exactly the non-atomic publish you were avoiding. The fix is always the same: create the temp file with `filepath.Dir(target)` as its directory so the swap is a local one. If you genuinely have to publish onto a different filesystem, copy the bytes into a temp file **on the destination filesystem** first, then do the local rename there - the cross-filesystem move itself can never be atomic.

code

go · 7 lines
go
f, err := os.CreateTemp("", "config-*.tmp") // lands in os.TempDir()
if err != nil {
	return err
}
// ... write, Sync, Close ...
err = os.Rename(f.Name(), "/var/lib/agent/config.yaml")
// rename /tmp/config-3145.tmp /var/lib/agent/config.yaml: invalid cross-device link

go deeper

for a junior

Remember the rule rather than the error code: the temporary file goes in the same directory as the file you are about to replace, never in the system temp directory.

for a middle

Explain why the boundary exists - a rename swaps a directory entry and cannot point at a file on another filesystem - and name the *os.LinkError wrapping EXDEV that Go returns.

for a senior

Talk about why this fails only in production, where the temp directory and the data volume are separate mounts, and how you would keep a cross-filesystem publish safe by copying onto the destination first.

for a principal

Own the guarantee a shared helper advertises. A move helper that silently degrades to copy-then-rename hands callers a weaker promise than rename, so decide whether that fallback exists at all and how it is named.

## What rename actually does `os.Rename(oldpath, newpath)` asks the kernel to change which directory entry names a file. The file's data does not move; only the name does. That is why the operation can be atomic in the first place - there are no bytes to shuffle, just one entry to swap. It is also why the operation has a hard boundary: a directory entry can only point at a file on the same filesystem as the directory. A rename whose two paths are on different mounts has no meaning at that level, so the kernel refuses it. On Unix the refusal is the `EXDEV` error. Go surfaces it as an `*os.LinkError`, a struct carrying `Op`, `Old`, `New` and the wrapped `Err`; printed, it reads roughly `rename /tmp/config-123.tmp /var/lib/agent/config.yaml: invalid cross-device link`. ## How the bug gets written The temp-then-rename idiom is usually learned as "write a temp file, then rename it", and `os.CreateTemp` makes it very easy to get the directory wrong. Passing an empty string as the first argument means "use `os.TempDir()`", which is the platform default temporary directory. On a developer laptop where everything is one filesystem, that works and the tests pass. On a host where the temp directory is a memory-backed filesystem and the target lives under a separate data volume, the very same code fails on the last line - after all the work of writing the file. This is why the failure so often shows up only in production, and why the fix belongs in a review checklist: the temp file's directory is not an implementation detail, it is part of the correctness argument. ## The fix Create the temp file in the directory of the file you intend to replace: ```go f, err := os.CreateTemp(filepath.Dir(path), ".config-*.tmp") ``` Now both names are entries in the same directory, so they are trivially on the same filesystem, and the rename is a local swap. As a bonus this also makes the temp file inherit the same storage characteristics, quota and mount options as the target, so a write that fits in the temp directory but not on the data volume fails at the write rather than after it. A subtlety worth naming: a rename *between two different directories* is fine, as long as both directories are on the same mount. The constraint is the filesystem boundary, not the directory boundary. What makes "same directory" the rule of thumb is that it is the only placement you can guarantee without inspecting the mount table. ## Detecting it If you need to react to this specific failure rather than just report it, unwrap the error. On Unix, `errors.Is(err, syscall.EXDEV)` matches, because `*os.LinkError` unwraps to the underlying syscall error. Note that `syscall.EXDEV` does not exist on every platform, so code that must build for Windows keeps that check in a Unix-only file rather than in shared code. In most services the honest answer is that you should not need the check at all: if the temp file is created beside the target, the condition cannot arise, and treating it as a runtime condition to handle usually means the placement bug is still there. ## When you really do have to cross a filesystem Sometimes the source genuinely lives elsewhere - an upload landing in a scratch area, or a file produced by another stage on a different volume. Then the move is two operations and you have to be explicit about it: 1. Create a temp file **on the destination filesystem**, in the destination directory. 2. Copy the bytes into it (`io.Copy` from the source file). 3. `Sync` it and close it. 4. `os.Rename` on the destination filesystem - which is now a local rename. 5. Remove the original source file if the move was meant to be destructive. Everything before step 4 is interruptible, and that is unavoidable: only the last step is atomic. The value of structuring it this way is that the *visible* target still only ever flips from old to new. A convenience helper that hides the copy behind a "move" name is a trap, because callers will assume they got rename's guarantee when they got a copy's. ## The one-line answer Rename is a name operation confined to a single filesystem; put the temp file next to the file you are replacing and the question never arises.

  • How would you match that specific failure in code rather than by its message text?
    os.Rename returns an *os.LinkError that unwraps to the underlying syscall error, so on Unix errors.Is(err, syscall.EXDEV) matches it. Keep that check in a Unix-only source file, since syscall.EXDEV is not defined on every platform. Better still, make it unreachable by creating the temp file beside the target.
  • Is a rename between two different directories still atomic?
    Yes, provided both directories are on the same filesystem - the constraint is the mount boundary, not the directory. The reason 'same directory' is the rule of thumb is that it is the only placement you can guarantee correct without inspecting the mount table at runtime.
  • What if you genuinely must publish a file that starts life on another filesystem?
    Split it: copy the bytes into a temp file on the destination filesystem, Sync and close it, then rename locally onto the target. The copy is interruptible and nothing can change that, but the target path still only ever flips from the whole old file to the whole new one.

saying these in an interview costs you the question

  • Thinks os.Rename copies bytes when the mounts differ
  • Creates temp files in the system temp directory by habit
  • Assumes the temp directory is always the same filesystem
  • Wraps the rename in a retry loop instead of moving the temp file
  • Reads the error message text instead of unwrapping the error