skip to content

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