Filepath #
File paths are one of the things most often mishandled in code that needs to run on different operating systems. On Windows, the path separator is \, while on Linux and macOS it’s /. Joining paths with plain string concatenation (dir + "/" + file) will break on Windows. The path/filepath package solves this problem by providing path manipulation functions that automatically use the correct separator for the operating system being run. Besides portability, filepath also provides WalkDir for recursive directory traversal, Glob for wildcard file search, and functions for splitting and analyzing path components.
An Overview of the path/filepath Package #
flowchart TD
FP["package path/filepath"] --> Build["Building Paths"]
FP --> Split["Splitting Paths"]
FP --> Convert["Path Conversion"]
FP --> Search["File Search"]
FP --> Walk["Directory Traversal"]
Build --> B1["filepath.Join\njoin path components"]
Build --> B2["filepath.Abs\nrelative path → absolute"]
Build --> B3["filepath.FromSlash\n'/' → OS separator"]
Build --> B4["filepath.ToSlash\nOS separator → '/'"]
Split --> S1["filepath.Split\n→ dir, file"]
Split --> S2["filepath.Dir\n→ directory only"]
Split --> S3["filepath.Base\n→ file name only"]
Split --> S4["filepath.Ext\n→ extension only"]
Split --> S5["filepath.SplitList\n→ split the PATH env var"]
Convert --> C1["filepath.Abs\npath → absolute"]
Convert --> C2["filepath.Rel\nabsolute path → relative"]
Convert --> C3["filepath.Clean\nnormalize the path"]
Convert --> C4["filepath.EvalSymlinks\nresolve symlinks"]
Search --> G1["filepath.Glob\n'*.go', '**/*.txt'"]
Search --> G2["filepath.Match\nmatch a pattern"]
Walk --> W1["filepath.WalkDir\nrecursive with DirEntry"]
style FP fill:#4f86c6,color:#fff
style Build fill:#e8f5e9
style Split fill:#e3f2fd
style Convert fill:#fff3e0
style Search fill:#f3e5f5
style Walk fill:#fce4ecfilepath.Join — Building Paths Correctly #
filepath.Join is the most used function from this package. It joins path components with the correct separator for the current operating system and automatically cleans the resulting path:
package main
import (
"fmt"
"path/filepath"
)
func main() {
// ANTI-PATTERN: manual concatenation — breaks on Windows
path1 := "/home/user" + "/" + "documents" + "/" + "file.txt"
// On Windows, this produces a wrong path
// CORRECT: use filepath.Join
path2 := filepath.Join("/home/user", "documents", "file.txt")
fmt.Println(path2)
// Linux/macOS: /home/user/documents/file.txt
// Windows: \home\user\documents\file.txt
// Join cleans the path automatically
fmt.Println(filepath.Join("/home/user/", "/documents/../file.txt"))
// /home/user/file.txt — not /home/user//documents/../file.txt
// Join with zero or one argument
fmt.Println(filepath.Join()) // ""
fmt.Println(filepath.Join("file")) // "file"
fmt.Println(filepath.Join("a", "")) // "a"
// Building a path from variables
homeDir := "/home/budi"
appDir := filepath.Join(homeDir, ".config", "myapp")
configFile := filepath.Join(appDir, "config.yaml")
logFile := filepath.Join(appDir, "logs", "app.log")
fmt.Println(appDir) // /home/budi/.config/myapp
fmt.Println(configFile) // /home/budi/.config/myapp/config.yaml
fmt.Println(logFile) // /home/budi/.config/myapp/logs/app.log
// Join with an absolute path in the middle — previous paths are ignored!
fmt.Println(filepath.Join("/home", "/etc", "passwd"))
// NOTE: in some implementations, "/etc" can override "/home"
// Always use relative paths for middle components
}
Splitting Paths — Dir, Base, Ext, Split #
path := "/home/budi/documents/report-2024.pdf"
// Dir — the directory (without the file)
fmt.Println(filepath.Dir(path))
// /home/budi/documents
// Base — the file name (with the extension)
fmt.Println(filepath.Base(path))
// report-2024.pdf
// Ext — the file extension (including the dot)
fmt.Println(filepath.Ext(path))
// .pdf
// File name without the extension — no direct function, combine them
name := filepath.Base(path)
nameNoExt := name[:len(name)-len(filepath.Ext(name))]
fmt.Println(nameNoExt)
// report-2024
// Split — return (dir, file) at once
dir, file := filepath.Split(path)
fmt.Println(dir) // /home/budi/documents/
fmt.Println(file) // report-2024.pdf
// NOTE: dir includes the trailing separator, file does not
// Examples with various paths
examples := []string{
"/home/budi/file.txt",
"relative/path/file.go",
"file.tar.gz", // double extension
"/path/to/directory/", // directory with a trailing slash
".",
"..",
".hidden",
"",
}
for _, p := range examples {
fmt.Printf("%-30q dir=%-25q base=%-15q ext=%q\n",
p, filepath.Dir(p), filepath.Base(p), filepath.Ext(p))
}
flowchart LR
Path["'/home/budi/documents/report-2024.pdf'"] --> Dir["Dir()\n'/home/budi/documents'"]
Path --> Base["Base()\n'report-2024.pdf'"]
Path --> Ext["Ext()\n'.pdf'"]
Path --> Split["Split()\ndir='/home/budi/documents/'\nfile='report-2024.pdf'"]
Base --> NoExt["name without ext\n'report-2024'\n= Base - Ext"]
style Path fill:#4f86c6,color:#fff
style Dir fill:#e8f5e9
style Base fill:#e3f2fd
style Ext fill:#fff3e0
style NoExt fill:#f3e5f5filepath.Abs and filepath.Rel — Absolute and Relative Paths #
// Abs — convert a relative path to absolute based on the working directory
absPath, err := filepath.Abs("config.yaml")
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(absPath)
// /home/budi/myapp/config.yaml (if the CWD is /home/budi/myapp)
// Abs on an already absolute path — no change
absPath2, _ := filepath.Abs("/etc/hosts")
fmt.Println(absPath2) // /etc/hosts
// Abs also cleans the path
absPath3, _ := filepath.Abs("./config/../config.yaml")
fmt.Println(absPath3) // /home/budi/myapp/config.yaml
// Rel — a path relative from basepath to targetpath
rel, err := filepath.Rel("/home/budi", "/home/budi/documents/file.txt")
if err == nil {
fmt.Println(rel) // documents/file.txt
}
rel2, _ := filepath.Rel("/home/budi/apps", "/home/budi/documents/file.txt")
fmt.Println(rel2) // ../documents/file.txt
// Rel for paths on different drives in Windows — errors
// rel3, err := filepath.Rel("C:\\Users", "D:\\data")
// error: Rel: can't make D:\data relative to C:\Users
// Pattern: find the relative path from the executable to a resource
execPath, _ := os.Executable()
execDir := filepath.Dir(execPath)
resourcePath := filepath.Join(execDir, "assets", "template.html")
fmt.Println(resourcePath)
filepath.Clean — Path Normalization #
filepath.Clean normalizes a path by applying cleaning rules:
// Clean removes redundant elements
fmt.Println(filepath.Clean("/home//budi/./documents/../file.txt"))
// /home/budi/file.txt
fmt.Println(filepath.Clean("./config/./app/../main.go"))
// config/main.go
fmt.Println(filepath.Clean(""))
// . (empty string → the current directory)
fmt.Println(filepath.Clean("../../../etc/passwd"))
// ../../../etc/passwd — not safe! still allowed by Clean
// ANTI-PATTERN: assuming Clean prevents path traversal
func readFile(base, userInput string) ([]byte, error) {
path := filepath.Clean(filepath.Join(base, userInput))
return os.ReadFile(path) // still vulnerable to path traversal!
}
// CORRECT: validate that the path is inside the base directory
func readFileSafe(base, userInput string) ([]byte, error) {
// Make sure the base is an absolute path
absBase, err := filepath.Abs(base)
if err != nil {
return nil, err
}
// Join and clean
fullPath := filepath.Clean(filepath.Join(absBase, userInput))
// Validate that fullPath starts with absBase
if !strings.HasPrefix(fullPath, absBase+string(filepath.Separator)) {
return nil, fmt.Errorf("access denied: path outside the allowed directory")
}
return os.ReadFile(fullPath)
}
filepath.Glob — Finding Files with Wildcards #
filepath.Glob finds all files matching the given pattern. The supported patterns are similar to Unix shell globbing:
// Find all .go files in the current directory
matches, err := filepath.Glob("*.go")
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(".go files:", matches)
// [main.go handler.go service.go]
// Find all files in a subdirectory (one level)
matches2, _ := filepath.Glob("cmd/*")
fmt.Println(matches2)
// [cmd/main.go cmd/server.go]
// Supported patterns:
// * — matches anything except the separator
// ? — matches any single character except the separator
// [abc] — matches one character in the set
// [a-z] — matches one character in the range
// Pattern examples
matches3, _ := filepath.Glob("data/2024-0?.csv") // January-September 2024
matches4, _ := filepath.Glob("config.[yd]aml") // .yaml or .yml
matches5, _ := filepath.Glob("*.{go,mod}") // NOT supported! (no {})
// filepath.Glob doesn't support ** (recursive glob)
// For recursion, use filepath.WalkDir
// Alternative: WalkDir with an extension filter
func findAll(root, ext string) ([]string, error) {
var results []string
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && filepath.Ext(path) == ext {
results = append(results, path)
}
return nil
})
return results, err
}
// Usage
goFiles, _ := findAll(".", ".go")
fmt.Printf("Found %d .go files\n", len(goFiles))
filepath.WalkDir — Recursive Directory Traversal #
filepath.WalkDir traverses an entire directory structure recursively and calls a callback for every file and directory found:
flowchart TD
Root["/project"] --> WD["filepath.WalkDir(root, fn)"]
WD --> Visit1["fn('/project', dir, nil)"]
Visit1 --> Visit2["fn('/project/cmd', dir, nil)"]
Visit2 --> Visit3["fn('/project/cmd/main.go', file, nil)"]
Visit3 --> Visit4["fn('/project/internal', dir, nil)"]
Visit4 --> Visit5["fn('/project/internal/handler.go', file, nil)"]
Visit5 --> Visit6["fn('/project/go.mod', file, nil)"]
Visit6 --> Visit7["fn('/project/go.sum', file, nil)"]
subgraph Control["Traversal Control"]
C1["return nil\ncontinue"]
C2["return fs.SkipDir\nskip this directory"]
C3["return fs.SkipAll\nstop entirely (Go 1.20+)"]
C4["return error\nstop with an error"]
end
style Root fill:#4f86c6,color:#fff
style Control fill:#e8f5e9import (
"io/fs"
"path/filepath"
)
// Basic traversal
func listAll(root string) error {
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
// Handle the error on a specific path (e.g. permission denied)
fmt.Fprintf(os.Stderr, "error on %s: %v\n", path, err)
return nil // continue the traversal
}
indent := strings.Repeat(" ", strings.Count(path, string(filepath.Separator)))
kind := "📄"
if d.IsDir() {
kind = "📁"
}
fmt.Printf("%s%s %s\n", indent, kind, d.Name())
return nil
})
}
// Skip unnecessary directories
func findGoFiles(root string) ([]string, error) {
var files []string
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // skip the error, continue
}
// Skip unnecessary directories
if d.IsDir() {
name := d.Name()
if name == ".git" || name == "vendor" || name == "node_modules" ||
strings.HasPrefix(name, ".") {
return fs.SkipDir // skip the directory and its contents
}
return nil
}
// Only take .go files
if filepath.Ext(path) == ".go" {
files = append(files, path)
}
return nil
})
return files, err
}
// Calculate the total size of a directory
func directorySize(root string) (int64, error) {
var total int64
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
total += info.Size()
return nil
})
return total, err
}
// Find the newest file in a directory
func newestFile(root string) (string, time.Time, error) {
var newestPath string
var newestTime time.Time
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
if info.ModTime().After(newestTime) {
newestTime = info.ModTime()
newestPath = path
}
return nil
})
return newestPath, newestTime, err
}
filepath.Match — Matching Patterns #
// Match matches a name against a glob pattern
fmt.Println(filepath.Match("*.go", "main.go")) // true, nil
fmt.Println(filepath.Match("*.go", "main.txt")) // false, nil
fmt.Println(filepath.Match("*.go", "cmd/main.go")) // false, nil — * doesn't match /
fmt.Println(filepath.Match("?ello", "Hello")) // true, nil
fmt.Println(filepath.Match("?ello", "hello")) // true, nil
fmt.Println(filepath.Match("?ello", "ello")) // false, nil
fmt.Println(filepath.Match("[hH]ello", "Hello")) // true, nil
fmt.Println(filepath.Match("[hH]ello", "hello")) // true, nil
fmt.Println(filepath.Match("[hH]ello", "Aello")) // false, nil
// Match for filtering files in WalkDir
func filterFile(pattern, path string) bool {
// Only match the file name, not the full path
matched, err := filepath.Match(pattern, filepath.Base(path))
return err == nil && matched
}
filepath.SplitList — Parsing the PATH Environment Variable #
// SplitList splits a PATH environment variable by the OS separator
// Linux/macOS: : (colon)
// Windows: ; (semicolon)
pathEnv := os.Getenv("PATH")
dirs := filepath.SplitList(pathEnv)
fmt.Printf("There are %d directories in PATH:\n", len(dirs))
for i, dir := range dirs {
fmt.Printf(" %d. %s\n", i+1, dir)
}
// Find an executable in PATH
func findExecutable(name string) (string, error) {
pathEnv := os.Getenv("PATH")
dirs := filepath.SplitList(pathEnv)
for _, dir := range dirs {
path := filepath.Join(dir, name)
info, err := os.Stat(path)
if err == nil && !info.IsDir() && info.Mode()&0111 != 0 {
return path, nil
}
}
return "", fmt.Errorf("%s: not found in PATH", name)
}
goPath, err := findExecutable("go")
if err == nil {
fmt.Println("Go found at:", goPath)
}
filepath.EvalSymlinks — Resolving Symlinks #
// EvalSymlinks follows symlinks and returns the real path
realPath, err := filepath.EvalSymlinks("/usr/bin/python3")
if err == nil {
fmt.Println("Real path:", realPath)
// Might be: /usr/bin/python3.11
}
// Useful for making sure two different paths point to the same file
func samePath(path1, path2 string) (bool, error) {
real1, err := filepath.EvalSymlinks(path1)
if err != nil {
return false, err
}
real2, err := filepath.EvalSymlinks(path2)
if err != nil {
return false, err
}
return real1 == real2, nil
}
filepath.FromSlash and filepath.ToSlash #
// Converting between URL format (slash) and OS format
// Useful when receiving paths from config or APIs that use /
// FromSlash: '/' → OS separator
// On Linux/macOS: no change
// On Windows: '/' → '\'
winPath := filepath.FromSlash("home/user/documents/file.txt")
fmt.Println(winPath)
// Linux: home/user/documents/file.txt
// Windows: home\user\documents\file.txt
// ToSlash: OS separator → '/'
// On Linux/macOS: no change
// On Windows: '\' → '/'
unixPath := filepath.ToSlash(`home\user\documents\file.txt`)
fmt.Println(unixPath)
// home/user/documents/file.txt (on all OSes)
// Pattern: always store paths in config with /
// convert to the OS format when used
type Config struct {
DataDir string `yaml:"data_dir"` // stored with /
LogDir string `yaml:"log_dir"`
}
func (c *Config) DataDirOS() string {
return filepath.FromSlash(c.DataDir) // convert when used
}
Production Usage Patterns #
Application Directory Management #
type AppDirs struct {
Config string
Data string
Log string
Cache string
Temp string
}
func setupAppDirs(appName string) (*AppDirs, error) {
// Get the user's home directory
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get the home dir: %w", err)
}
// Get the OS-appropriate config directory
configDir, err := os.UserConfigDir()
if err != nil {
configDir = filepath.Join(homeDir, ".config")
}
// Get the cache directory
cacheDir, err := os.UserCacheDir()
if err != nil {
cacheDir = filepath.Join(homeDir, ".cache")
}
dirs := &AppDirs{
Config: filepath.Join(configDir, appName),
Data: filepath.Join(homeDir, ".local", "share", appName),
Log: filepath.Join(homeDir, ".local", "share", appName, "logs"),
Cache: filepath.Join(cacheDir, appName),
Temp: filepath.Join(os.TempDir(), appName),
}
// Create all missing directories
for _, dir := range []string{dirs.Config, dirs.Data, dirs.Log, dirs.Cache, dirs.Temp} {
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("create directory %s: %w", dir, err)
}
}
return dirs, nil
}
A Project Scanner — Analyzing Code Structure #
type ProjectInfo struct {
TotalFiles int
TotalLines int
ByExtension map[string]int
FilesIgnored int
}
func analyzeProject(root string) (*ProjectInfo, error) {
info := &ProjectInfo{
ByExtension: make(map[string]int),
}
// Directory patterns to ignore
ignoreDirs := map[string]bool{
".git": true, "vendor": true, "node_modules": true,
".idea": true, ".vscode": true, "dist": true, "build": true,
}
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
if ignoreDirs[d.Name()] {
info.FilesIgnored++
return fs.SkipDir
}
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if ext == "" {
ext = "(no extension)"
}
info.ByExtension[ext]++
info.TotalFiles++
// Count lines for text files
if ext == ".go" || ext == ".js" || ext == ".py" || ext == ".ts" {
n, _ := countLines(path)
info.TotalLines += n
}
return nil
})
return info, err
}
func countLines(path string) (int, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
n := 0
for scanner.Scan() {
n++
}
return n, scanner.Err()
}
Backup with Structure Preservation #
func backupDirectory(src, dst string) error {
// Normalize the paths
src = filepath.Clean(src)
dst = filepath.Clean(dst)
return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Calculate the path relative to src
relPath, err := filepath.Rel(src, path)
if err != nil {
return fmt.Errorf("rel path: %w", err)
}
// The destination path
dstPath := filepath.Join(dst, relPath)
if d.IsDir() {
// Create the directory at the destination
info, err := d.Info()
if err != nil {
return err
}
return os.MkdirAll(dstPath, info.Mode())
}
// Copy the file
return copyFile(path, dstPath)
})
}
func copyFile(src, dst string) error {
// Make sure the destination directory exists
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
source, err := os.Open(src)
if err != nil {
return err
}
defer source.Close()
dest, err := os.Create(dst)
if err != nil {
return err
}
defer dest.Close()
_, err = io.Copy(dest, source)
return err
}
filepath vs path — An Important Difference #
flowchart LR
subgraph FilePath["path/filepath\n(for filesystem paths)"]
F1["Uses the OS separator\nLinux/Mac: /\nWindows: \\"]
F2["For paths to files\nand directories on disk"]
F3["filepath.Join\nfilepath.Dir\nfilepath.WalkDir"]
end
subgraph Path["path\n(for URL paths)"]
P1["Always uses /\nregardless of the OS"]
P2["For URL paths, HTTP paths\nnot the filesystem"]
P3["path.Join\npath.Dir\npath.Base"]
end
subgraph When["Which one to use?"]
W1["File on disk → filepath"]
W2["URL, HTTP route → path"]
W3["go:embed path → path"]
W4["os.Open, os.ReadFile → filepath"]
end
style FilePath fill:#e8f5e9
style Path fill:#e3f2fd
style When fill:#fff3e0import (
"path"
"path/filepath"
)
// filepath — for the file system
configPath := filepath.Join(homeDir, ".config", "app.yaml")
os.ReadFile(configPath) // correct
// path — for URLs or HTTP routes
urlPath := path.Join("/api", "v1", "users")
// always: /api/v1/users (not \api\v1\users on Windows)
// ANTI-PATTERN: use filepath for URLs
badURL := filepath.Join("/api", "v1", "users")
// On Windows: \api\v1\users — wrong for HTTP!
// ANTI-PATTERN: use path for the filesystem on Windows
badPath := path.Join("C:", "Users", "file.txt")
// Result: C:/Users/file.txt — could be wrong on Windows
When to Switch to Alternatives #
Keep using path/filepath if:
✓ All filesystem path operations: Join, Dir, Base, Ext
✓ Directory traversal with WalkDir
✓ File search with Glob
✓ Relative-absolute path conversion with Abs and Rel
✓ Code that must run cross-platform (Windows, Linux, macOS)
Use the path package (not filepath) if:
✗ Working with URL paths or HTTP routes
✗ Paths in go:embed directives
✗ Path manipulation that always uses / regardless of the OS
Consider os.DirFS / fs.FS if:
✗ A filesystem abstraction that can be mocked in testing
✗ Working with embedded files (go:embed)
✗ Virtual or custom filesystems
Consider external libraries if:
✗ Watching file changes → github.com/fsnotify/fsnotify
✗ More advanced glob patterns (** for recursion)
→ github.com/bmatcuk/doublestar
✗ A more complete virtual filesystem → github.com/spf13/afero
Summary #
filepath.Joinis always better than string concatenation — it uses the correct separator for the current OS and cleans the resulting path automatically.filepath.Absconverts a relative path to absolute based on the current working directory — useful for validation and clear path logging.filepath.Dir,filepath.Base,filepath.Extfor splitting paths — there’s no direct function for a file name without its extension; combine them:Base[:len(Base)-len(Ext)].filepath.WalkDiris more efficient thanfilepath.Walk— it doesn’t make extraLstatcalls for every entry; useWalkDirfor traversal in Go 1.16+.- Return
fs.SkipDirfrom the WalkDir callback to skip a directory and everything in it — use it to skip.git,vendor,node_modules, and other unneeded directories.filepath.Globdoesn’t support**(recursive glob) — for recursive searches, useWalkDirwith an extension filter.filepath.Cleandoesn’t prevent path traversal — always validate that the resulting path starts with the allowed base directory after Clean.- Use
path(notfilepath) for URLs —filepath.Joinon Windows produces backslashes, which are wrong for URLs and HTTP routes.filepath.Relfor creating relative paths — useful when making backups, reports, or showing shorter paths to users.