The path separator trap
You write a tool on your Mac. It builds a path like /home/user/data/config.json. You push it to a Windows CI runner. The build fails because Windows expects backslashes. Or you hardcode C:\Users\... and it breaks on Linux. Path separators are the silent killer of portable Go programs. The filepath package exists to save you from this headache.
OS-aware path manipulation
Go splits path handling into two packages. path handles URL-style paths with forward slashes. filepath handles OS-specific file system paths. On Windows, that means backslashes. On Linux and macOS, forward slashes. filepath abstracts the separator so you write code once and it runs everywhere. It also handles edge cases like trailing slashes, empty segments, and volume letters.
The package provides functions to join segments, extract components, resolve symlinks, and walk directories. Every function respects the current operating system's conventions. You never need to check runtime.GOOS to decide which separator to use.
Joining and extracting
Here's the core pattern: join segments safely and extract the filename.
package main
import (
"fmt"
"path/filepath"
)
func main() {
// Join uses the OS separator automatically.
// On Windows this produces "home\user\docs\file.txt".
// On Linux/macOS it produces "home/user/docs/file.txt".
fullPath := filepath.Join("home", "user", "docs", "file.txt")
// Base extracts the final element, ignoring the separator.
// Returns "file.txt" regardless of the path format.
name := filepath.Base(fullPath)
fmt.Println(fullPath, name)
}
filepath.Join is your friend. Never concatenate paths with string concatenation.
How Join works under the hood
When you call filepath.Join, the function checks the OS separator at runtime. It iterates over your arguments, skips empty strings, and inserts the correct separator between non-empty segments. It also normalizes the result. If you pass Join("a", "", "b"), you get a/b, not a//b.
The function handles absolute paths specially. If any argument is absolute, Join discards all previous arguments and starts from that absolute path. Join("dir", "/absolute") returns /absolute. This behavior prevents accidental path traversal bugs where a relative segment gets appended to an absolute one unexpectedly.
On Windows, Join also handles volume letters. Join("C:", "dir") produces C:\dir. Join("C:", "") produces C:\. The function knows that a trailing backslash after a volume letter is significant.
Resolving relative paths
Here's a realistic pattern: resolve a config path relative to the current directory and check if it exists.
package main
import (
"fmt"
"os"
"path/filepath"
)
// findConfig locates the configuration file relative to the working directory.
// It handles OS-specific separators and normalizes the path.
func findConfig(baseDir, filename string) (string, error) {
// Join constructs the full path.
// If baseDir is empty, Join treats filename as the result.
configPath := filepath.Join(baseDir, filename)
// Abs converts the path to an absolute path.
// This resolves relative references like "." or "..".
// It returns an error if the OS cannot resolve the path.
absPath, err := filepath.Abs(configPath)
if err != nil {
return "", fmt.Errorf("resolve path: %w", err)
}
// Check existence by trying to stat the file.
// This avoids race conditions between checking and opening.
if _, err := os.Stat(absPath); err != nil {
return "", fmt.Errorf("config not found: %w", err)
}
return absPath, nil
}
Always resolve to an absolute path before doing file operations. Relative paths depend on the caller's working directory, which changes.
Symlinks and security
Symlinks break portability if you assume they resolve the same way. filepath.EvalSymlinks resolves a path to its canonical form, removing all symbolic links. This is essential for security checks. If you check if a file is inside a directory, you must evaluate symlinks first, or an attacker can create a symlink to escape the directory.
Here's how to resolve symlinks safely before accessing a file.
package main
import (
"fmt"
"path/filepath"
)
// resolvePath returns the canonical path with symlinks resolved.
// It fails if the path does not exist on the file system.
func resolvePath(p string) (string, error) {
// EvalSymlinks resolves the path.
// It returns an error if the path is missing or inaccessible.
// This prevents symlink-based directory traversal attacks.
canonical, err := filepath.EvalSymlinks(p)
if err != nil {
return "", fmt.Errorf("resolve symlinks: %w", err)
}
return canonical, nil
}
Symlinks can lie. Evaluate them before checking permissions or directory boundaries.
Walking directories efficiently
When you need to traverse a directory tree, use filepath.WalkDir. The older filepath.Walk is deprecated in spirit because it calls os.Stat on every entry, which is slow. WalkDir uses os.Lstat and provides a DirEntry interface that lets you check file types without extra system calls.
Here's the efficient way to walk a directory tree and filter files.
package main
import (
"fmt"
"io/fs"
"path/filepath"
)
// listGoFiles prints all Go source files in a directory tree.
// It uses WalkDir for performance and type checking without extra syscalls.
func listGoFiles(root string) error {
// WalkDir traverses the tree.
// The callback receives the path and a DirEntry.
// Returning filepath.SkipDir skips the current directory.
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
// Handle traversal errors immediately.
if err != nil {
return err
}
// IsDir checks the type efficiently.
// DirEntry caches the type info from the OS.
if d.IsDir() {
return nil
}
// Match checks the filename against a pattern.
// "*.go" matches Go source files.
if match, _ := filepath.Match("*.go", d.Name()); match {
fmt.Println(path)
}
return nil
})
}
WalkDir is fast. Walk is slow. Use WalkDir.
Pitfalls and compiler errors
A common mistake is importing path instead of filepath. If you use path.Join on Windows, you get forward slashes. The file system might accept them, but tools and libraries often expect backslashes. The compiler won't stop you; it's a logic error. The convention is clear: use filepath for disk paths, path for URL paths.
Another pitfall is filepath.Split. It splits the last element, not all elements. Split("a/b/c") returns ("a/b", "c"). If you need all parts, use filepath.SplitList on Windows or split manually on Linux. SplitList handles Windows drive letters correctly, which manual splitting often breaks.
Pattern matching has two functions. filepath.Match checks a single path against a pattern. filepath.Glob finds all paths matching a pattern. Glob returns a slice of strings. Use Match inside a loop or WalkDir. Use Glob when you need a list of files quickly.
If you try to use filepath functions on a non-string type, the compiler rejects it. You get cannot use x (type int) as string value in argument to filepath.Join. Also, filepath functions that interact with the file system return errors. If you forget to capture a return value, the compiler warns with assignment mismatch: 1 variable but filepath.Abs returns 2 values. Always handle the error from Abs and EvalSymlinks.
The community expects filepath for any disk I/O path manipulation. Using strings.Replace to swap slashes is a code smell. It breaks on edge cases and makes the code harder to read. Trust the standard library.
Trust filepath to handle the separator. Don't write your own path logic.
When to use filepath
Use filepath.Join when you need to combine path segments safely across operating systems.
Use filepath.Abs when you need to resolve a relative path to an absolute one based on the current working directory.
Use filepath.Base when you need to extract the filename from a path string.
Use filepath.Dir when you need the directory component of a path.
Use filepath.Clean when you have a messy path with .. or . segments and need to normalize it without touching the disk.
Use filepath.EvalSymlinks when you need the canonical path for security checks or to resolve symbolic links.
Use filepath.WalkDir when you need to traverse a directory tree and process files.
Use filepath.Match when you need to check if a filename matches a glob pattern.
Use filepath.Glob when you need a list of all paths matching a pattern.
Use path instead of filepath when you are manipulating URL paths or strings that must always use forward slashes.
Use os.PathSeparator only when you need the separator character itself for custom parsing; prefer filepath functions for structure.