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:#fce4ec

filepath.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:#f3e5f5

filepath.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:#e8f5e9
import (
    "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)
}

// 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:#fff3e0
import (
    "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.Join is always better than string concatenation — it uses the correct separator for the current OS and cleans the resulting path automatically.
  • filepath.Abs converts a relative path to absolute based on the current working directory — useful for validation and clear path logging.
  • filepath.Dir, filepath.Base, filepath.Ext for splitting paths — there’s no direct function for a file name without its extension; combine them: Base[:len(Base)-len(Ext)].
  • filepath.WalkDir is more efficient than filepath.Walk — it doesn’t make extra Lstat calls for every entry; use WalkDir for traversal in Go 1.16+.
  • Return fs.SkipDir from the WalkDir callback to skip a directory and everything in it — use it to skip .git, vendor, node_modules, and other unneeded directories.
  • filepath.Glob doesn’t support ** (recursive glob) — for recursive searches, use WalkDir with an extension filter.
  • filepath.Clean doesn’t prevent path traversal — always validate that the resulting path starts with the allowed base directory after Clean.
  • Use path (not filepath) for URLsfilepath.Join on Windows produces backslashes, which are wrong for URLs and HTTP routes.
  • filepath.Rel for creating relative paths — useful when making backups, reports, or showing shorter paths to users.

← Previous: Bufio   Next: Regexp →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact