Filesystem and path/filepath

Updated

September 13, 2026

Filesystem and path/filepath

Manipulating directories, reading files, and resolving paths are foundational for desk utilities and servers. Go provides cross-platform path handling in path/filepath and filesystem utilities in os. The boring default is: always use filepath (not string concatenation with "/"), use os.ReadFile and os.WriteFile for small files, and walk trees with filepath.WalkDir.

Mental model

Windows uses \ as a path separator, while Linux/macOS use /. If you join paths with dir + "/" + file, your code breaks on Windows. filepath.Join handles OS-specific path separators automatically.

filepath.Clean resolves . and .. components and strips redundant slashes. filepath.Rel computes relative paths between two targets. filepath.WalkDir iterates through a directory hierarchy efficiently, skipping stat calls when possible (superior to legacy filepath.Walk).

Use os.ReadFile(path) and os.WriteFile(path, data, perm) for self-contained file operations when streaming is unnecessary.

Worked examples

Case 1: Cross-platform filepath.Join and Clean

Save as desk_paths.go. Clean and join file paths safely.

// desk_paths.go
package main

import (
    "fmt"
    "path/filepath"
)

func main() {
    p := filepath.Join("reports", "2026", "september", "..", "august", "orders.csv")
    fmt.Println("joined & cleaned:", filepath.ToSlash(p))
    fmt.Println("base filename:", filepath.Base(p))
    fmt.Println("parent directory:", filepath.ToSlash(filepath.Dir(p)))
    fmt.Println("extension:", filepath.Ext(p))
}

Run:

go run desk_paths.go

Output:

joined & cleaned: reports/2026/august/orders.csv
base filename: orders.csv
parent directory: reports/2026/august
extension: .csv

filepath.ToSlash normalizes slashes to / (helpful for platform-neutral logs and output). filepath.Base extracts the last element; filepath.Dir extracts the directory path.

Case 2: Reading and writing files atomically with os

Save as file_ops.go. Write a file, verify its existence, read it back, and remove it cleanly.

// file_ops.go
package main

import (
    "fmt"
    "os"
    "path/filepath"
)

func main() {
    dir, err := os.MkdirTemp("", "desk-files-*")
    if err != nil {
        fmt.Println("err:", err)
        return
    }
    defer os.RemoveAll(dir)

    target := filepath.Join(dir, "shift_notes.txt")
    content := []byte("morning shift: all tables cleared\n")

    if err := os.WriteFile(target, content, 0644); err != nil {
        fmt.Println("write err:", err)
        return
    }

    data, err := os.ReadFile(target)
    if err != nil {
        fmt.Println("read err:", err)
        return
    }

    fmt.Print(string(data))
}

Run:

go run file_ops.go

Output:

morning shift: all tables cleared

os.WriteFile truncates and writes the file in one shot with requested permissions. os.MkdirTemp creates a dedicated sandbox for temporary files.

Case 3: Walking directory trees with filepath.WalkDir

Save as walk_desk.go. Traverse nested folders and inspect file entries.

// walk_desk.go
package main

import (
    "fmt"
    "io/fs"
    "os"
    "path/filepath"
)

func main() {
    tmp, err := os.MkdirTemp("", "desk-tree-*")
    if err != nil {
        fmt.Println(err)
        return
    }
    defer os.RemoveAll(tmp)

    // Create nested files
    os.MkdirAll(filepath.Join(tmp, "archive", "sub"), 0755)
    os.WriteFile(filepath.Join(tmp, "orders.txt"), []byte("ok"), 0644)
    os.WriteFile(filepath.Join(tmp, "archive", "receipt.csv"), []byte("ok"), 0644)

    var files []string
    err = filepath.WalkDir(tmp, func(path string, d fs.DirEntry, err error) error {
        if err != nil {
            return err
        }
        if !d.IsDir() {
            rel, _ := filepath.Rel(tmp, path)
            files = append(files, filepath.ToSlash(rel))
        }
        return nil
    })

    if err != nil {
        fmt.Println("walk err:", err)
        return
    }

    fmt.Println("found files:", len(files))
    for _, f := range files {
        fmt.Println("file:", f)
    }
}

Run:

go run walk_desk.go

Output:

found files: 2
file: archive/receipt.csv
file: orders.txt

fs.DirEntry supplies metadata without triggering unnecessary stat calls on every path, making WalkDir fast on large trees.

The trap

Save as slash_concat.go. Concatenating file paths with string additions or / creates broken paths on Windows and fails to handle boundary slashes properly.

// slash_concat.go
package main

import (
    "fmt"
    "path/filepath"
)

func main() {
    dir := "records/"
    file := "/ticket.json"

    // Wrong: manual string concat results in redundant slash
    badPath := dir + "/" + file

    // Right: filepath.Join sanitizes and handles separators correctly
    goodPath := filepath.Join(dir, file)

    fmt.Println("bad:", badPath)
    fmt.Println("good:", filepath.ToSlash(goodPath))
}

Run:

go run slash_concat.go

Output:

bad: records///ticket.json
good: records/ticket.json

Use filepath.Join to remove duplicate separators and resolve relative segments safely.

The boring rule

  • Always use path/filepath for filesystem paths; reserve path strictly for URL paths.
  • Use filepath.Join instead of string formatting or + "/" concatenation.
  • Use os.ReadFile and os.WriteFile for small files; use os.Open / bufio for large streams.
  • Use filepath.WalkDir instead of filepath.Walk.
  • Use os.MkdirTemp and os.RemoveAll to isolate tests and temporary file operations.

Try this

  1. In desk_paths.go, add filepath.Match("*.csv", "orders.csv") and check if it matches.
  2. In walk_desk.go, return filepath.SkipDir when d.Name() == "archive" and observe that files inside archive/ are skipped.
  3. Use os.Stat(target) in file_ops.go and print info.Size().