skip to content

In Go, how do os.Stat and os.Lstat differ when the name is a symbolic link?

level: middleimportance: should knowfreq 50%

answer

  1. the l stands for link
  2. one of them resolves, the other reports
  3. a dangling link fails one call and not the other
  4. look for the ModeSymlink bit before deciding
  5. Readlink gives stored text, EvalSymlinks gives a real path

basics

~20 s

os.Stat follows a symbolic link and describes its target, failing if that target is gone. os.Lstat describes the link itself: the Mode it returns has the fs.ModeSymlink bit set, and os.Readlink then gives the raw target path stored in the link.

solid answer

~50 s

`os.Stat` resolves symbolic links, so on a link it returns metadata for the file at the end of the chain — and returns an error wrapping `fs.ErrNotExist` if the link dangles. `os.Lstat` takes the same argument but does not resolve: it describes the link entry itself, so `fi.Mode()&fs.ModeSymlink != 0` is true, `IsDir()` is false even when the target is a directory, and `Size()` on Unix is the byte length of the stored target path. Once you know it is a link, `os.Readlink(name)` returns that target text verbatim, which may be relative to the link's own directory; `filepath.EvalSymlinks` resolves the whole chain to a real path. The practical rule for anything that walks or copies files: use `Lstat` to decide what an entry *is*, and only call `Stat` when you have deliberately chosen to follow the link.

code

go · 16 lines
go
li, err := os.Lstat(name)
if err != nil {
	return err
}
if li.Mode()&fs.ModeSymlink != 0 {
	target, err := os.Readlink(name) // raw text, may be relative
	if err != nil {
		return err
	}
	return recordLink(name, target) // do not follow it
}
fi, err := os.Stat(name) // regular entry: same metadata either way
if err != nil {
	return err
}
return recordFile(name, fi.Size(), fi.Mode(), fi.ModTime())

go deeper

for a junior

Remember the one-line difference: os.Stat resolves a symbolic link and describes the target, os.Lstat describes the link entry. The l stands for link.

for a middle

Explain the observable consequences: the ModeSymlink bit only ever appears from Lstat, IsDir reflects the link not the target, a dangling link errors under Stat, and Readlink returns stored text that may be relative.

for a senior

Show the judgment in a scanner or copier: Lstat every entry, treat following a link as an explicit decision, and be able to say what breaks when a tool blindly Stats everything.

for a principal

Own the policy for tools your team ships: whether links are archived as links, followed, or refused, and how that choice is expressed once rather than rediscovered in each utility.

## Two calls, one difference `os.Stat` and `os.Lstat` have identical signatures — `func(name string) (fs.FileInfo, error)` — and behave identically on every kind of file except one. On a symbolic link: - **`os.Stat` follows it.** You get metadata about the file the link ultimately points at. If the chain ends nowhere (a dangling link, or a relative target that does not resolve), you get an error that satisfies `errors.Is(err, fs.ErrNotExist)`, even though the link entry itself is sitting right there in the directory. Deep or circular chains fail with the OS's loop error. - **`os.Lstat` does not follow it.** You get metadata about the link entry: `Mode()&fs.ModeSymlink != 0`, `IsDir()` is false regardless of what the target is, `ModTime()` is when the link was created or re-pointed, and on Unix `Size()` is the number of bytes in the stored target string. A dangling link stats fine here — the link exists, and that is what you asked about. The `l` is for `link`, and it mirrors the POSIX `stat`/`lstat` pair. Go exposes the same split elsewhere: `os.Chown` follows links while `os.Lchown` changes the link's own ownership. ## Reading the target `os.Readlink(name) (string, error)` returns the **raw text stored in the link**, not a resolved path. That matters more than it sounds: - The text may be relative, and it is interpreted relative to the *directory containing the link*, not the process working directory. `filepath.Join(filepath.Dir(name), target)` is how you turn it into something you can open, if the target is not already absolute. - It may point at something that does not exist. `Readlink` succeeds anyway; it is reading a string, not visiting a file. - It resolves exactly one hop. `filepath.EvalSymlinks(name)` walks the whole chain and returns a path with every link resolved, erroring if any hop is missing. ## What else follows links Most of the `os` mutators follow links, which surprises people who expect the call to act on the name they typed: - `os.Chmod` changes the **target's** permission bits. The `os` package offers no `Lchmod`; on Linux the kernel does not support changing a symlink's mode at all, and a link's own bits are ignored by permission checks. - `os.Truncate` resizes the **target**. - `os.Chtimes` sets the **target's** timestamps. - `os.Remove` and `os.Rename` are the exceptions you would hope for: they operate on the link entry itself, so removing a link never touches its target. - `os.Symlink(oldname, newname)` creates the link. The argument order trips people up: `oldname` is the target text to store, `newname` is the path of the link being created. `oldname` is never validated, so creating a link to a non-existent path succeeds. ## Why a scanner cares Consider a backup scanner that records every entry under a root, comparing sizes, mode bits and modification times against the previous run. If it calls `os.Stat` on every name, a symbolic link is invisible to it — the link reports itself as whatever it points at. Two consequences follow, and both are real defects a reviewer should catch: 1. **The target is archived twice** — once under its real name, once under the link's name — because both stat calls describe the same file and the scanner has no idea they are the same object. 2. **The link is never recorded**, so a restore rebuilds a real file where a symbolic link used to be, quietly changing the shape of the tree. And if the link dangles, `Stat` fails with a not-exist error on a name the scanner can plainly see in the directory, which usually turns into a spurious "file disappeared" log line. The fix is a rule, not a patch: `Lstat` first, branch on `Mode()&fs.ModeSymlink`, record links as links via `Readlink`, and call `Stat` only where following is a deliberate, documented choice. When you do follow, you need a second safeguard — a way to notice that you have already archived the file you just resolved to. ## Diagnosing it The fastest way to see the difference is to dump both for the same name side by side: print `Mode()`, `Size()` and `ModTime()` from `os.Stat` and from `os.Lstat`, and print `os.Readlink` when the Lstat mode has the symlink bit. A link shows a mode string beginning with `L`, a small size equal to the target path's length, and a completely different modification time from its target — three signals that make the situation obvious in a log.

  • What does os.Stat return for a symbolic link whose target has been deleted?
    An error satisfying `errors.Is(err, fs.ErrNotExist)`, because the call resolves the link and finds nothing at the end. `os.Lstat` on the same name succeeds and describes the link, and `os.Readlink` still returns the stored target text — the link entry exists even though the target does not.
  • If you call os.Chmod on a path that is a symbolic link, whose permission bits change?
    The target's. `os.Chmod`, `os.Truncate` and `os.Chtimes` all resolve links, and there is no `Lchmod` in the `os` package — on Linux a symlink's own mode bits are not meaningful and cannot be changed. `os.Remove` and `os.Rename` are the calls that act on the link entry itself, and `os.Lchown` sets the link's own ownership.
  • In os.Symlink(oldname, newname), which argument is the link being created?
    `newname` is the path of the new link; `oldname` is the target text stored inside it. Go does not validate `oldname`, so a link to a non-existent path is created happily. A relative `oldname` is later interpreted relative to the directory holding the link, not the process working directory.
  • When would you reach for filepath.EvalSymlinks instead of os.Readlink?
    When you need a real path rather than one hop of link text. `os.Readlink` returns exactly what is stored in that one link, possibly relative and possibly dangling; `filepath.EvalSymlinks` resolves every component of the path and every link in the chain, returning an error if any hop does not exist.

saying these in an interview costs you the question

  • Says os.Lstat is just a faster os.Stat
  • Assumes IsDir on an Lstat result reflects the target
  • Treats os.Readlink output as an openable absolute path
  • Thinks os.Chmod on a link changes the link's own bits
  • Reverses the arguments of os.Symlink
  • Logs a dangling link as a disappeared file