skip to content

Paths and Filesystems

filepath joins and cleans OS-specific paths while path handles slash-separated ones, and io/fs plus embed.FS turn a directory into a value you can pass and fake. The traversal question lives here.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

explore

questions

13

Why does Go code build file paths with filepath.Join instead of concatenating strings with a slash?

level: juniorimportance: must knowfreq 70%

answer

  1. one call instead of string plus
  2. it knows the separator for here
  3. empty elements simply disappear
  4. the joined result is then cleaned

basics

~20 s

filepath.Join separates elements with the platform's own separator, drops empty elements, and cleans the result, so doubled separators and .. segments collapse. Hand-concatenating with a slash yields names like logs//app and emits the wrong separator on Windows.

solid answer

~40 s

`filepath.Join` joins its arguments with `filepath.Separator` — `/` on Linux and macOS, a backslash on Windows — ignores empty arguments, and passes the joined result through `filepath.Clean`. So `Join("logs/", "/app")` is `logs/app`, `Join("logs", "", "app")` is `logs/app`, and `Join("logs", "..", "app")` is `app`. Building the same string by hand with `+` and `"/"` gives a doubled separator whenever an element already ends in one, turns an empty variable into a stray separator, and on a Windows build emits forward slashes where every user-facing message and every comparison against a real Windows path expects backslashes. Join is also variadic, so `filepath.Join(parts...)` handles a slice without a loop. The habit to state: any string you are about to hand to `os.Open`, `os.Stat` or `os.MkdirAll` should have been produced by `filepath.Join`.

code

go · 6 lines
go
filepath.Join("logs", "app")        // "logs/app"
filepath.Join("logs/", "/app")      // "logs/app"
filepath.Join("logs", "", "app")    // "logs/app"
filepath.Join("logs", "..", "app")  // "app"
filepath.Join("logs", "sub/../app") // "logs/app"
filepath.Join()                     // "" - not "."

go deeper

for a junior

Be ready to say what filepath.Join buys you over string concatenation: the platform's separator, empty elements dropped, and a cleaned result. Naming os.Open as the consumer of that string shows you know why it matters.

for a middle

Explain that Join runs filepath.Clean over the joined result, so dot and dot-dot elements and doubled separators collapse, and that an all-empty argument list yields an empty string rather than a dot.

for a senior

Show where you enforce it: a review habit or a check that catches paths assembled with plus or Sprintf, plus the awareness that a cleaned name is a lexical result and can denote a different file when a symlink sits in the middle.

for a principal

Own the convention across a codebase: which layer produces operating-system paths, which layer produces slash-separated identifiers, and the fact that mixing them is a portability bug you only pay for on the platform you test least.

## What `filepath.Join` actually does `filepath.Join(elem ...string) string` takes any number of path elements and returns a single path. Three things happen, in order: 1. **Empty elements are ignored.** They contribute nothing — not even a separator. 2. **The remaining elements are concatenated with `filepath.Separator`**, the platform constant: `/` on Linux and macOS, `\` on Windows. 3. **The result is passed through `filepath.Clean`.** If the argument list is empty, or every element is empty, `Join` returns the empty string `""` — not `"."`. That is a small trap worth remembering, because `filepath.Clean("")` does return `"."`. ## What the cleaning step removes `filepath.Clean` rewrites a path **purely as a string**. It never opens, stats or resolves anything. Its rules: - runs of separators collapse to one (`logs//app` becomes `logs/app`); - `.` elements are removed (`./logs/./app` becomes `logs/app`); - an inner `..` is removed together with the preceding non-`..` element (`logs/sub/../app` becomes `logs/app`); - a `..` that would climb above the root of a rooted path is dropped (`/..` becomes `/`); - if the result would be empty, `Clean` returns `"."`; - on Windows, any forward slashes in the input are replaced with the platform separator. So `Join` is not merely "glue with a separator": it is glue plus normalisation, and the normalisation is what makes its output safe to compare, log and print. ## Why hand-concatenation goes wrong ```go p := dir + "/" + name ``` Four distinct failures live in that line. **Doubled separators.** If `dir` came from configuration and ends in a separator, you get `srv/data//report.csv`. It usually still opens — the kernel tolerates it — but it is now a *different string* from the one another part of the program built with `Join`, so map keys, deduplication and equality checks silently disagree. **Stray separators from empty values.** If `name` is empty, you produce `srv/data/`, which is a directory name; if `dir` is empty you produce `/report.csv`, which is suddenly **absolute** and points at the root of the machine. `Join` would have dropped the empty element and given you `report.csv`. **The wrong separator.** A Go binary compiled for Windows is expected to produce Windows paths. Many Windows APIs accept forward slashes, so the program may appear to work, but the names it prints in errors, writes into a manifest, or compares against a path obtained from the operating system are wrong, and mixed-separator strings (`C:\data/reports/x.csv`) are the ones that leak into bug reports. **No normalisation.** `dir + "/../" + name` stays literally in the string; `Join` collapses it. `fmt.Sprintf("%s/%s", dir, name)` is the same bug wearing a nicer hat. ## What `Join` deliberately does not do It does **not** touch the filesystem. Nothing is opened, created, stat-ed, or checked for existence; symbolic links are not resolved. `Join` is a string function whose output *may* name a file. It also has no idea about URLs, archive entry names, or any other slash-only namespace — for those, `path.Join` from the `path` package is the correct tool, because it uses `/` unconditionally on every platform. ## The variadic shape Because `Join` is variadic, a `[]string` of elements expands directly: ```go filepath.Join(parts...) ``` and a base plus a slice is usually written by building a fresh slice rather than `append`-ing onto the caller's, so you do not risk writing into the caller's backing array: ```go elems := make([]string, 0, len(parts)+1) elems = append(elems, root) elems = append(elems, parts...) name := filepath.Join(elems...) ``` ## The review rule In a code review the shortcut is: **grep for `+ "/"` and for `%s/%s`.** Every hit is either a path that should have used `filepath.Join`, or a slash-separated identifier that should have used `path.Join`; there is very rarely a third answer. Both replacements are one-line changes, and both remove a class of bug that only shows up on somebody else's machine.

  • What does filepath.Join return when every element it is given is empty?
    The empty string. Join ignores empty elements, so with nothing left to join it returns `""` rather than `"."`. That matters because `filepath.Clean("")` does return `"."`, so code that assumes Join always yields a usable name can hand `""` to `os.Open` and get a confusing error instead of a clear one.
  • Does filepath.Join strip a leading separator from its first element?
    No. Join preserves rootedness: `Join("/srv", "app")` is `/srv/app` and stays absolute, and a relative first element stays relative. What the cleaning step removes is noise — `.` elements, doubled separators, inner `..` — plus any `..` that would climb above the root of an already-rooted path, so `/..` becomes `/`.
  • How do you join a variable number of path elements held in a slice?
    Join is variadic, so `filepath.Join(parts...)` works on a `[]string` directly and no loop is needed. When you also need a prefix, build a fresh slice and expand that; `append(existing, parts...)` risks writing into the caller's backing array if it has spare capacity, which is a separate bug you do not want inside your path helper.

saying these in an interview costs you the question

  • Says a forward slash works everywhere, so Join is optional
  • Thinks Join only inserts a separator and never cleans
  • Reaches for filepath.Join to build a URL path
  • Expects an empty element to leave a doubled separator
  • Believes Join checks that the resulting path exists
open as a page

What does the //go:embed directive do, and what must be true of the declaration it sits on?

level: juniorimportance: must knowfreq 58%

basics

~20 s

The //go:embed directive copies the files matching its pattern into the compiled binary at build time. It must sit directly above one package-level variable of type string, []byte, or embed.FS, and the source file must import the embed package.

open as a page

Why does filepath.Join("/srv/uploads", name) not guarantee a path inside /srv/uploads?

level: juniorimportance: must knowfreq 55%

basics

~10 s

filepath.Join cleans its result, so ".." elements are resolved rather than rejected: Join("/srv/uploads", "../../etc/passwd") is "/etc/passwd". Join is path arithmetic, not a confinement check, and Clean is not one either.

open as a page

Do filepath.Clean and filepath.Rel touch the filesystem, and how is filepath.Abs different?

level: middleimportance: should knowfreq 36%

basics

~20 s

No. Clean, Rel, Join, Base, Dir and Ext are pure string operations that never open a file or resolve a symbolic link. filepath.Abs is the exception: for a relative input it prepends the process's working directory.

open as a page

When should you use Go's path package instead of path/filepath?

level: middleimportance: should knowfreq 50%

basics

~20 s

Use path for strings that are slash-separated on every platform: URL paths, archive entry names, slash-separated keys. Use path/filepath only for names you hand to the operating system, because it follows the local separator and understands Windows volume names.

open as a page

Why does embed.FS keep the directory prefix in file names, and what does fs.Sub do about it?

level: middleimportance: should knowfreq 48%

basics

~20 s

An embed.FS stores every file under the exact path the pattern matched, so embedding a templates directory gives names like templates/index.html, not index.html. fs.Sub returns a view rooted at a subdirectory, so callers can ask for index.html.

open as a page

What does the io/fs.FS interface require, and why are fs.ReadFile and fs.Stat package functions?

level: middleimportance: should knowfreq 50%

basics

~20 s

io/fs.FS requires exactly one method: Open(name string) (fs.File, error). Helpers such as fs.ReadFile and fs.Stat are package functions because they use an optional extension interface like fs.ReadFileFS when the value implements one, and otherwise fall back to Open.

open as a page

What does filepath.IsLocal report about a path name, and which names does it reject?

level: middleimportance: should knowfreq 35%

basics

~20 s

filepath.IsLocal reports, by lexical analysis only, whether a name stays inside the directory it is evaluated in. It is false for the empty string, absolute paths, anything escaping via "..", and on Windows reserved device names.

open as a page

Why do archives whose entry names came from filepath.Join on Windows extract as flat files on Linux?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Because the Windows build joined entry names with backslashes, and a backslash is an ordinary character in an archive name, so an extractor elsewhere creates one file literally called docs\index.html. Convert operating-system paths with filepath.ToSlash before storing them.

open as a page

A Go service's embed.FS is missing files that exist in the source tree. How do you diagnose it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Walk the embedded filesystem and diff it against the source tree; the file set was fixed at build time. The usual causes are a star glob, which never crosses a slash, and dot- or underscore-prefixed names, skipped unless the pattern says all:.

open as a page

What does os.OpenRoot give an archive unpacker that a check on the entry name cannot?

level: seniorimportance: should knowfreq 40%

basics

~20 s

os.OpenRoot returns an *os.Root holding an open directory handle. Root.Open, Root.Create and Root.OpenFile resolve each name component relative to that handle and fail on anything leaving the root, including through a symlink — enforcement at open time, not string analysis.

open as a page

When should a Go service embed its assets in the binary instead of shipping them beside it?

level: principalimportance: nice to knowfreq 32%

basics

~20 s

Embed when you want one artefact whose code and assets cannot drift apart and can accept that every asset change means a rebuild and redeploy. Keep files outside when a non-engineering owner must change them. Exported APIs should take an fs.FS.

open as a page

Would you mandate os.Root for every untrusted filename across your team's Go services, and how do you make that stick?

level: principalimportance: nice to knowfreq 22%

basics

~20 s

Yes for names crossing a trust boundary, but only if the safe call is the easy one: ship one handle-based storage package that hands out no base path string, accept the Go 1.24 floor, and make exceptions named and owned.

open as a page