ZetCode

Go filepath

last modified October 30, 2026

In this article we show how to work with file paths in Go. The path/filepath package manipulates paths in the way the target operating system expects, so the same program behaves correctly on Linux, macOS and Windows.

All examples were compiled and run with Go 1.27.0 on Linux. The examples that inspect files use the /tmp/gofp working directory and create their own test tree there.

Joining and cleaning paths

filepath.Join joins any number of elements with the separator of the operating system and cleans the result. Empty elements are ignored, and .. removes the preceding element.

main.go
package main

import (
    "fmt"
    "path/filepath"
)

func main() {

    fmt.Println(filepath.Join("a", "b", "..", "c"))
    fmt.Println(filepath.Join("a", "", "b"))
    fmt.Println(filepath.Clean("a//b/../c/./d"))
    fmt.Println(filepath.Clean(""))
}

Clean works purely on the text of the path; it does not check whether the path exists. The cleaned form of an empty path is ".", the current directory.

$ go run main.go
a/c
a/b
a/c/d
.

Base, Dir, Ext and Split

Base returns the last element of a path, Dir returns everything before it, and Ext returns the suffix starting at the last dot. Split returns both parts at once, keeping the trailing separator in the directory.

main.go
package main

import (
    "fmt"
    "path/filepath"
)

func main() {

    p := "/home/user/data/report.tar.gz"

    fmt.Println("Base:", filepath.Base(p))
    fmt.Println("Dir: ", filepath.Dir(p))
    fmt.Println("Ext: ", filepath.Ext(p))

    dir, file := filepath.Split(p)
    fmt.Printf("Split: %q %q\n", dir, file)

    fmt.Println("Base /:", filepath.Base("/"))
    fmt.Println("Dir file:", filepath.Dir("main.go"))
    fmt.Println("Ext Makefile:", filepath.Ext("Makefile"))
}

Only the last extension is returned, so report.tar.gz has the extension .gz. A path without a dot, such as Makefile, has an empty extension, and Dir of a bare file name is the current directory.

$ go run main.go
Base: report.tar.gz
Dir:  /home/user/data
Ext:  .gz
Split: "/home/user/data/" "report.tar.gz"
Base /: /
Dir file: .
Ext Makefile: 

Absolute and relative paths

Abs resolves a relative path against the current working directory, IsAbs reports whether a path starts at the root, and Rel computes a relative path between two absolute paths.

main.go
package main

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

func main() {

    wd, err := os.Getwd()
    if err != nil {
        fmt.Println(err)
        return
    }

    abs, err := filepath.Abs("data/report.txt")
    if err != nil {
        fmt.Println(err)
        return
    }

    fmt.Println("cwd:  ", wd)
    fmt.Println("abs:  ", abs)
    fmt.Println("isabs:", filepath.IsAbs(abs), filepath.IsAbs("data/report.txt"))

    rel, err := filepath.Rel(wd, abs)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println("rel:  ", rel)
}

The absolute path is built from the directory where the program runs, so the first two lines differ on another machine. Rel reverses the operation and returns the original relative path.

$ go run main.go
cwd:   /tmp/gofp
abs:   /tmp/gofp/data/report.txt
isabs: true false
rel:   data/report.txt

Matching a single path

filepath.Match checks one name against a shell-style pattern. A star matches any sequence of characters except the path separator, a question mark matches one character, and brackets match a character class.

main.go
package main

import (
    "fmt"
    "path/filepath"
)

func main() {

    cases := [][2]string{
        {"*.go", "main.go"},
        {"*.go", "main.txt"},
        {"data/*.txt", "data/a.txt"},
        {"data/*.txt", "data/sub/a.txt"},
        {"[a-c]*", "banana"},
        {"a[", "ab"},
    }

    for _, c := range cases {
        ok, err := filepath.Match(c[0], c[1])
        fmt.Printf("%-12s %-14s %v %v\n", c[0], c[1], ok, err)
    }
}

The fourth case is false because * does not cross a separator; matching several levels requires a different approach. The last pattern is malformed, so Match returns ErrBadPattern together with false.

$ go run main.go
*.go         main.go        true <nil>
*.go         main.txt       false <nil>
data/*.txt   data/a.txt     true <nil>
data/*.txt   data/sub/a.txt false <nil>
[a-c]*       banana         true <nil>
a[           ab             false syntax error in pattern

Glob patterns

filepath.Glob returns the existing paths that match a pattern. Unlike a shell, Go does not treat ** as a recursive wildcard; it is simply two stars, so it matches one path element.

main.go
package main

import (
    "fmt"
    "path/filepath"
)

func main() {

    patterns := []string{"data/*.txt", "data/*/*.txt", "data/**/*.txt"}

    for _, p := range patterns {
        matches, err := filepath.Glob(p)
        fmt.Printf("%-16s %v %v\n", p, matches, err)
    }
}

The tree contains data/a.txt, data/b.md and data/sub/c.txt. The double star finds the same file as a single star, because both match exactly one directory level. A recursive search uses WalkDir.

$ mkdir -p data/sub
$ touch data/a.txt data/b.md data/sub/c.txt
$ go run main.go
data/*.txt       [data/a.txt] <nil>
data/*/*.txt     [data/sub/c.txt] <nil>
data/**/*.txt    [data/sub/c.txt] <nil>

Walking a directory tree

filepath.WalkDir visits a directory and all its descendants in depth-first order. The callback receives the path, a directory entry and any error from the walk.

main.go
package main

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

func main() {

    err := filepath.WalkDir("data", func(path string, d fs.DirEntry, err error) error {
        if err != nil {
            return err
        }
        if d.IsDir() {
            fmt.Println("dir ", path)
        } else {
            fmt.Println("file", path)
        }
        return nil
    })

    if err != nil {
        fmt.Println(err)
    }
}

The root itself is the first entry. Returning fs.SkipDir for a directory would prune its subtree; returning any other error stops the walk and is returned by WalkDir.

$ go run main.go
dir  data
file data/a.txt
file data/b.md
dir  data/sub
file data/sub/c.txt

Separators, volumes and path lists

ToSlash and FromSlash convert the native separator to and from a forward slash. VolumeName reports a drive letter on Windows, and SplitList splits a list such as PATH using the operating system separator.

main.go
package main

import (
    "fmt"
    "path/filepath"
)

func main() {

    fmt.Println("ToSlash:  ", filepath.ToSlash(`a\b\c`))
    fmt.Println("FromSlash:", filepath.FromSlash("a/b/c"))
    fmt.Println("Volume:   ", filepath.VolumeName("/home/user"))
    fmt.Println("SplitList:", filepath.SplitList("/usr/bin:/bin:/usr/local/bin"))
    fmt.Printf("Separator: %q\n", filepath.ListSeparator)
}

On Linux the backslash is an ordinary character in a file name, so ToSlash leaves it alone. On Windows the same call would convert it to a forward slash. The volume name is empty on Unix, and the list separator is a colon.

$ go run main.go
ToSlash:   a\b\c
FromSlash: a/b/c
Volume:    
SplitList: [/usr/bin /bin /usr/local/bin]
Separator: ':'

Resolving symbolic links

filepath.EvalSymlinks follows symbolic links and returns the path of the final target. The result is cleaned but is not necessarily absolute.

main.go
package main

import (
    "fmt"
    "path/filepath"
)

func main() {

    target, err := filepath.EvalSymlinks("link.txt")
    if err != nil {
        fmt.Println(err)
        return
    }

    fmt.Println(target)

    abs, err := filepath.Abs(target)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(abs)
}

The link points to a file in the same directory, so the resolved path is relative. Abs turns it into a full path when one is needed.

$ printf 'hello\n' > target.txt
$ ln -s target.txt link.txt
$ go run main.go
target.txt
/tmp/gofp/target.txt

Source

Go path/filepath package - reference

In this article we have worked with file paths in Go. We joined and cleaned paths, split them into directory, base and extension, converted between absolute and relative forms, matched patterns, used globs and directory walks, handled separators and path lists, and resolved symbolic links.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Go tutorials.