• English
  • Go os in Production: Filesystem Semantics, Resource Ownership, and OS Boundaries

    The os package is where ordinary Go code meets operating-system state.

    Opening a file creates an operating-system resource. Renaming a file changes filesystem namespace state. Sync concerns durability. A process is identified by an operating-system primitive, not just an integer PID. A pathname is a reference into a namespace that other processes can change at any time.

    Most production problems with os are not caused by forgetting an API. They come from making an assumption about that state and treating it as if it were static.

    This article focuses on those assumptions: resource lifetime, atomicity, durability, concurrent filesystem changes, path traversal, symbolic links, process identity, and the boundary between os, io, io/fs, path/filepath, and lower-level system APIs.

    The examples target Go 1.26.


    1. os Is the Operating-System Boundary

    The os package provides a portable interface to operating-system facilities.

    It covers several distinct areas:

    AreaRepresentative APIs
    FilesOpen, OpenFile, Create, File
    File metadataStat, Lstat, FileInfo
    Root-confined filesystem accessOpenRoot, OpenInRoot, Root
    DirectoriesMkdir, MkdirAll, ReadDir
    File mutationRename, Remove, RemoveAll
    DurabilityFile.Sync
    Temporary resourcesCreateTemp, MkdirTemp
    EnvironmentGetenv, LookupEnv, Setenv
    Process informationGetpid, Getwd, Executable
    ProcessesProcess, StartProcess, Process.WithHandle
    Standard streamsStdin, Stdout, Stderr

    The package is deliberately lower-level than most application code needs.

    That is a feature.

    A parser should not need to know whether its input came from a file. A business service should not need to know how a temporary file is created. A configuration loader can accept an io.Reader while the application boundary uses os.Open.

    The usual direction is:

    application
        |
        | io.Reader / io.Writer / fs.FS
        v
    standard-library abstraction
        |
        | os.File / os.Root / os.Process
        v
    operating system

    Keep the os dependency near the edge unless the application is itself about operating-system resources.


    2. *os.File Is a Resource

    An *os.File is not merely a Go object containing a filename.

    It represents an open operating-system resource.

    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()

    After Open succeeds, the process owns a resource that has to be released.

    A file leak is not a small bookkeeping problem. File descriptors and OS handles are finite. Under load, leaked files eventually surface as failures elsewhere:

    open file
        |
        +-- file descriptor / OS handle
        |
        +-- network connection may fail
        +-- log file may fail
        +-- temporary file creation may fail
        +-- database connection may fail

    The eventual error may have nothing obvious to do with the code that leaked the file.

    That is why resource ownership should be visible at the point where the resource is acquired.


    3. Make File Lifetime Obvious

    For a file whose lifetime is local to a function:

    func processFile(path string) error {
        f, err := os.Open(path)
        if err != nil {
            return fmt.Errorf("open %q: %w", path, err)
        }
        defer f.Close()
    
        return process(f)
    }

    The function that acquires the resource owns its lifetime.

    This is generally better than returning an open *os.File from a helper without making ownership explicit.

    Be careful with loops, though.

    This pattern can keep every file open until the surrounding function returns:

    for _, path := range paths {
        f, err := os.Open(path)
        if err != nil {
            return err
        }
        defer f.Close()
    
        if err := process(f); err != nil {
            return err
        }
    }

    If paths is large, the number of simultaneously open files grows with the loop.

    Prefer a helper whose lifetime matches one iteration:

    func processFile(path string) error {
        f, err := os.Open(path)
        if err != nil {
            return err
        }
        defer f.Close()
    
        return process(f)
    }
    
    for _, path := range paths {
        if err := processFile(path); err != nil {
            return err
        }
    }

    Resource lifetime is now bounded by one file.


    4. Close Is Usually Cleanup, but Not Always Just Cleanup

    For read-only files, this is normally sufficient:

    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()

    Write paths deserve more thought.

    A critical file-generation operation may need to treat Close as part of the error path rather than something that can always be deferred and ignored.

    For example:

    f, err := os.Create(path)
    if err != nil {
        return err
    }
    
    if _, err := f.Write(data); err != nil {
        _ = f.Close()
        return err
    }
    
    if err := f.Close(); err != nil {
        return err
    }

    Whether a Close failure matters depends on what the file represents.

    A disposable cache file and a published configuration file do not have the same failure policy.

    The important question is not:

    "Did I call Close?"

    It is:

    "What does a successful return from this operation promise?"


    5. Open, Create, and OpenFile Express Different Intent

    Use os.Open when the operation is simply:

    Open an existing resource for reading.

    f, err := os.Open(path)

    Use os.Create when the intended semantics are essentially:

    Create or truncate this file for writing.

    f, err := os.Create(path)

    Use os.OpenFile when the creation and access semantics matter:

    f, err := os.OpenFile(
        path,
        os.O_WRONLY|os.O_CREATE|os.O_EXCL,
        0600,
    )

    The flags are not implementation details. They are part of the operation's concurrency and safety semantics.


    6. O_CREATE Does Not Mean Exclusive Creation

    This is a common mistake.

    os.OpenFile(
        path,
        os.O_CREATE|os.O_WRONLY,
        0644,
    )

    means:

    Create the file if it does not exist.

    It does not mean:

    Fail if the file already exists.

    For exclusive creation:

    f, err := os.OpenFile(
        path,
        os.O_WRONLY|os.O_CREATE|os.O_EXCL,
        0600,
    )

    Now the existence condition and the creation operation are handled together by the operating system.

    That is very different from:

    if _, err := os.Stat(path); err == nil {
        return errors.New("already exists")
    }
    
    f, err := os.Create(path)

    The second version contains a race.


    7. Avoid Check-Then-Act Filesystem Races

    Consider:

    info, err := os.Stat(path)
    if err != nil {
        return err
    }
    
    if info.Size() > maxSize {
        return errors.New("file too large")
    }
    
    f, err := os.Open(path)

    The file described by Stat is not necessarily the file opened by the next call.

    Another process can replace the directory entry between the two operations.

    The same problem appears in:

    check -> create
    check -> remove
    check -> chmod
    check -> open
    check -> rename

    The general rule is simple:

    If the operating system can express the condition and action as one operation, prefer that operation over a separate check followed by an assumption.

    O_EXCL is one example.

    os.Root is another, for a different class of path-security problems.


    8. O_TRUNC Can Destroy the Old Version Before the New Version Exists

    This is convenient:

    f, err := os.OpenFile(
        path,
        os.O_WRONLY|os.O_CREATE|os.O_TRUNC,
        0644,
    )

    But O_TRUNC changes the file immediately.

    The failure sequence is:

    open with O_TRUNC
            |
            v
    old content gone
            |
            v
    write new content
            |
            X
         process crash

    The result can be an empty or partially written file.

    For disposable output this may be acceptable.

    For configuration, manifests, metadata, checkpoints, and other state files, direct truncation is often the wrong publication strategy.


    9. Publish Complete Files Instead

    For state that other processes may read, a safer structure is:

    create temporary file
            |
            v
    write complete content
            |
            v
    validate
            |
            v
    Sync if durability requires it
            |
            v
    Close
            |
            v
    Rename into place

    The destination is not used as the workspace.

    Instead:

    target.json
        |
        | old published version
        v
    
    target.json.tmp
        |
        | generate new version
        v
    complete
        |
        v
    Rename
        |
        v
    target.json
        |
        | new published version

    This separates generation from publication.

    Readers do not have to understand whether the producer is 10%, 50%, or 90% finished.

    They see a published version.


    10. Create Temporary Files Beside the Destination

    If the final operation is:

    os.Rename(tmp, target)

    create the temporary file in the destination directory:

    dir := filepath.Dir(target)
    
    f, err := os.CreateTemp(dir, ".tmp-*")
    if err != nil {
        return err
    }

    Do not casually create the temporary file in the system temporary directory and then assume the rename will behave the same way.

    Keeping source and destination in the same directory avoids cross-filesystem rename problems and makes the intended publication operation explicit.

    A complete helper might look like:

    func writeFileAtomic(path string, data []byte, perm fs.FileMode) error {
        dir := filepath.Dir(path)
    
        f, err := os.CreateTemp(dir, ".tmp-*")
        if err != nil {
            return fmt.Errorf("create temp file: %w", err)
        }
    
        tmp := f.Name()
        committed := false
    
        defer func() {
            if !committed {
                _ = os.Remove(tmp)
            }
        }()
    
        if err := f.Chmod(perm); err != nil {
            _ = f.Close()
            return fmt.Errorf("chmod temp file: %w", err)
        }
    
        if _, err := f.Write(data); err != nil {
            _ = f.Close()
            return fmt.Errorf("write temp file: %w", err)
        }
    
        if err := f.Close(); err != nil {
            return fmt.Errorf("close temp file: %w", err)
        }
    
        if err := os.Rename(tmp, path); err != nil {
            return fmt.Errorf("rename temp file: %w", err)
        }
    
        committed = true
        return nil
    }

    If the application needs crash/power-loss durability, this is not the end of the discussion. Sync and directory durability have to be considered separately.


    11. Rename and Sync Solve Different Problems

    These operations are frequently conflated.

    Rename changes filesystem namespace state.

    Sync concerns flushing file data and metadata toward stable storage according to the operating system and filesystem semantics.

    A durable publication sequence may therefore look like:

    write
      |
      v
    Sync
      |
      v
    Close
      |
      v
    Rename
      |
      v
    directory durability, if required

    There are two different questions here:

    Visibility

    Can readers observe a partially written file?

    Durability

    After success, will the new state survive the failure model the application cares about?

    Temporary-file-plus-rename primarily addresses publication visibility.

    Sync addresses a different class of guarantee.

    Do not promise durability when the implementation only guarantees that a write returned successfully.


    12. A Successful Write Is Not a Power-Loss Guarantee

    This:

    if _, err := f.Write(data); err != nil {
        return err
    }

    means the write succeeded according to the I/O contract.

    It does not by itself mean:

    The bytes are guaranteed to survive a sudden power failure.

    For applications that require stronger durability:

    if _, err := f.Write(data); err != nil {
        return err
    }
    
    if err := f.Sync(); err != nil {
        return err
    }

    The actual guarantee still depends on the filesystem, operating system, storage device, and failure model.

    Production code should distinguish:

    write accepted
          |
          v
    filesystem-visible state
          |
          v
    durable state

    Those are different properties.


    13. Permissions Are Part of the Resource Contract

    When creating a file:

    f, err := os.OpenFile(
        path,
        os.O_WRONLY|os.O_CREATE,
        0600,
    )

    the mode expresses the requested permissions.

    On Unix-like systems, the final mode is also affected by the process's umask.

    So:

    0600

    should be understood as the requested creation mode, not as a universal promise that the final mode will be identical on every platform.

    For credentials, private keys, tokens, and other secrets, start with a restrictive mode rather than creating broadly accessible files and attempting to fix them later.

    For example:

    f, err := os.OpenFile(
        credentialsPath,
        os.O_WRONLY|os.O_CREATE|os.O_TRUNC,
        0600,
    )

    File permissions are one layer of security. They do not replace application authorization or path validation.


    14. Stat and Lstat Answer Different Questions

    Suppose:

    uploads/
        avatar.png -> /etc/passwd

    Then:

    info, err := os.Stat("uploads/avatar.png")

    follows the symbolic link and reports information about the target.

    By contrast:

    info, err := os.Lstat("uploads/avatar.png")

    reports information about the directory entry itself.

    That difference matters for:

    • deployment tools;
    • archive extraction;
    • file synchronization;
    • cleanup utilities;
    • upload systems;
    • security checks that prohibit symlinks.

    But Lstat is not a complete security mechanism.


    This looks safer:

    info, err := os.Lstat(path)
    if err != nil {
        return err
    }
    
    if info.Mode()&os.ModeSymlink != 0 {
        return errors.New("symlink not allowed")
    }
    
    f, err := os.Open(path)

    Yet there is still a gap:

    Lstat
      |
      v
    check
      |
      | attacker changes directory entry
      v
    Open

    The path can be changed between the check and the use.

    This is the same TOCTOU problem seen earlier, but now it has a security consequence.

    For untrusted filenames inside a trusted directory, manually combining Lstat, Clean, EvalSymlinks, and Open is not a robust general solution.

    Go now has a better abstraction.


    16. Path Traversal Is Not Just ..

    There are two different traversal problems.

    Lexical traversal

    For example:

    ../../etc/passwd

    This is a property of the path string.

    Filesystem traversal

    For example:

    uploads/
        avatar.png -> /etc/passwd

    This is a property of the filesystem namespace.

    A function such as:

    filepath.Clean(path)

    can normalize the first problem.

    It cannot make the second problem disappear.

    That distinction is critical in upload servers, archive extraction, file browsers, backup restoration, and any service that accepts a filename from outside the process.


    17. filepath.Join Is Not Authorization

    This is not a sufficient security boundary:

    path := filepath.Join(uploadDir, userName)
    
    f, err := os.Open(path)

    filepath.Join constructs a filesystem path.

    It does not authorize the resulting path.

    Likewise:

    filepath.Clean
    filepath.Join
    filepath.IsLocal
    filepath.EvalSymlinks

    each solves a different part of the path problem.

    None should be treated as a universal guarantee that an untrusted path remains inside a mutable directory tree.


    18. filepath.IsLocal Is Useful, but It Is Still Lexical

    For untrusted path components, Go provides:

    if !filepath.IsLocal(name) {
        return errors.New("invalid local path")
    }

    This is useful for rejecting paths that are not local according to filepath's lexical rules.

    It is a good first layer for APIs that accept filenames.

    But it does not establish that opening the resulting path cannot escape through a symbolic link.

    The distinction is:

    IsLocal
        |
        v
    path-string property
    
    os.Root
        |
        v
    filesystem access property

    Use the former when you need lexical validation.

    Use the latter when the security requirement is confinement to a filesystem root.


    19. Go 1.26: os.Root Is the Modern Answer to Root-Confined Access

    Go introduced os.Root and related APIs to address a problem that used to require fragile path-validation code.

    For a fixed trusted root and an externally supplied filename:

    f, err := os.OpenInRoot(uploadDir, name)
    if err != nil {
        return err
    }
    defer f.Close()

    Or when several operations share the same root:

    root, err := os.OpenRoot(uploadDir)
    if err != nil {
        return err
    }
    defer root.Close()
    
    f, err := root.Open(name)
    if err != nil {
        return err
    }
    defer f.Close()

    The important property is that the operation is defined relative to the root rather than first constructing an absolute host path and then hoping the path remains inside the intended directory.

    This is exactly the sort of guarantee that is difficult to reproduce correctly with:

    Clean
    +
    Stat
    +
    Lstat
    +
    EvalSymlinks
    +
    Open

    because the filesystem can change between operations.


    It is tempting to conclude that:

    os.Root makes the directory a complete sandbox.

    It does not.

    os.Root provides traversal-resistant filesystem access. It prevents path traversal through mechanisms such as .. and symbolic links from escaping the root.

    But it does not solve every filesystem isolation problem.

    In particular, filesystem boundaries such as mount points and bind mounts are a separate concern. Device files and special kernel-provided files are also outside the problem that os.Root is designed to solve.

    The right mental model is:

    os.Root
        =
    filesystem path confinement
    
    not
    
    os.Root
        =
    container / VM / security sandbox

    If the application needs isolation from the host filesystem itself, use an isolation mechanism designed for that job.


    21. io/fs Is Not a Host Filesystem Sandbox

    The io/fs package defines a portable filesystem abstraction.

    For example:

    type FS interface {
        Open(name string) (File, error)
    }

    That makes it possible for application code to work with:

    OS filesystem
    embedded files
    test filesystems
    virtual filesystems

    through one interface.

    But io/fs path rules are not the same thing as host filesystem security.

    For example:

    fs.ValidPath(name)

    validates an io/fs path according to the package's path rules.

    It does not guarantee:

    Opening this path on the host filesystem cannot cross a symbolic link.

    This is why fs.ValidPath, filepath.IsLocal, and os.Root should not be collapsed into one vague category called "path validation."

    They operate at different layers.


    22. Remove and RemoveAll Have Different Risk Profiles

    For one path:

    if err := os.Remove(path); err != nil {
        return err
    }

    For recursive deletion:

    if err := os.RemoveAll(path); err != nil {
        return err
    }

    RemoveAll is a recursive mutation of the filesystem namespace.

    Treat it as a destructive operation.

    The failure mode is not merely:

    delete one file

    It can be:

    wrong path
        |
        v
    recursive traversal
        |
        v
    many filesystem mutations

    That is why cleanup code should be designed around a clearly anchored root.


    23. Prefer Root-Confined Cleanup for Untrusted Names

    If the application has:

    /srv/jobs/
        job-123/
        job-124/

    and an external input identifies the job directory, Go 1.26 code can express the intended boundary directly:

    root, err := os.OpenRoot("/srv/jobs")
    if err != nil {
        return err
    }
    defer root.Close()
    
    if err := root.RemoveAll(jobID); err != nil {
        return fmt.Errorf("remove job %q: %w", jobID, err)
    }

    Now the operation is relative to /srv/jobs.

    This is preferable to taking an external string, constructing an absolute path, manually checking that it starts with the root prefix, and then calling RemoveAll.

    Prefix checks are especially error-prone because:

    /srv/jobs-evil

    is not:

    /srv/jobs

    even though a naive string-prefix test may say otherwise.


    24. RemoveAll and Filesystem Boundaries

    Do not explain RemoveAll as "following symlinks recursively."

    That is too imprecise.

    The important question is what filesystem namespace the recursive operation actually traverses.

    Symbolic links, mount points, bind mounts, and platform-specific deletion semantics are different things.

    Even os.Root does not promise to prevent traversal across every filesystem boundary.

    For destructive operations, the safe architecture is therefore:

    trusted root
         |
         v
    root-confined operation
         |
         v
    explicit target

    rather than:

    untrusted path
         |
         v
    string manipulation
         |
         v
    RemoveAll

    25. Temporary Resources Should Be Created by the Standard Library

    Do not construct temporary filenames manually:

    name := "/tmp/app-" + strconv.Itoa(rand.Int()) + ".tmp"

    Use:

    f, err := os.CreateTemp("", "app-*")

    or, when the temporary file will later be renamed into a target directory:

    f, err := os.CreateTemp(filepath.Dir(target), ".tmp-*")

    The standard library knows how to perform the resource-creation operation without making application code responsible for inventing a collision-avoidance protocol.

    The same applies to temporary directories:

    dir, err := os.MkdirTemp("", "app-*")
    if err != nil {
        return err
    }
    defer os.RemoveAll(dir)

    26. os.File Fits Naturally Into io

    The os package creates operating-system resources.

    The io package describes how data moves through those resources.

    That is why this is a useful boundary:

    func parseConfig(r io.Reader) (*Config, error) {
        // ...
    }

    The caller can provide:

    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer f.Close()
    
    return parseConfig(f)

    But the parser does not know that the input came from a file.

    It could just as easily receive:

    bytes.Reader
    network connection
    HTTP request body
    compressed reader
    test fixture

    This is one of the most useful consequences of keeping os at the edge.


    27. *os.File Provides More Than io.Reader

    Depending on how it is used, *os.File supports capabilities such as:

    io.Reader
    io.Writer
    io.ReaderAt
    io.WriterAt
    io.Seeker
    io.Closer

    Do not require *os.File when an interface expresses the actual dependency.

    For sequential parsing:

    func parse(r io.Reader) error

    For random-access reads:

    func parseIndex(r io.ReaderAt) error

    For output:

    func writeReport(w io.Writer) error

    This keeps filesystem concerns outside the logic that does not actually need them.


    28. Read and ReadAt Have Different Concurrency Semantics

    A normal read:

    n, err := f.Read(buf)

    uses the file's current offset.

    A positional read:

    n, err := f.ReadAt(buf, offset)

    specifies the offset explicitly.

    If multiple goroutines need independent random-access reads, ReadAt avoids making the shared current offset part of the protocol.

    The same reasoning applies to WriteAt.

    When the operation is conceptually:

    Read bytes at offset 1 MiB.

    make the offset an explicit parameter rather than coordinating through:

    Seek
    Read
    Seek
    Read

    29. Relative Paths Depend on Process State

    This:

    os.Open("config.json")

    means:

    Open config.json relative to the process's current working directory.

    It does not mean:

    Open the file next to the executable.

    These environments commonly have different working directories:

    go run
    IDE
    systemd
    Docker
    cron
    process supervisor

    If the application depends on a particular filesystem root, establish it explicitly during startup rather than assuming the caller's working directory.


    30. Getwd and Executable Answer Different Questions

    Use:

    dir, err := os.Getwd()

    for the current working directory.

    Use:

    path, err := os.Executable()

    for the executable path.

    Do not assume:

    working directory == executable directory

    That assumption frequently works during local development and then fails in production.

    A service launched by a process manager may have a completely different working directory from the directory containing its binary.


    31. Avoid os.Chdir in Concurrent Servers

    The working directory is process-wide.

    Calling:

    os.Chdir(path)

    changes the environment seen by the whole process.

    That means another goroutine can observe a different working directory than it expected.

    For servers and libraries, avoid changing the process working directory.

    Resolve paths explicitly instead.

    Chdir can be appropriate for a command-line program that deliberately changes its own process environment, but it is a poor coordination mechanism inside a concurrent service.


    32. Directory Creation: Mkdir vs MkdirAll

    For one directory:

    if err := os.Mkdir(path, 0755); err != nil {
        return err
    }

    For a directory tree:

    if err := os.MkdirAll(path, 0755); err != nil {
        return err
    }

    MkdirAll is convenient for application initialization because it creates missing parents.

    But it does not make an arbitrary external path safe.

    This is still dangerous:

    os.MkdirAll(userSuppliedPath, 0755)

    The question of whether a path is allowed is separate from whether the path can be created.

    If the path is relative to a trusted root and comes from outside the application, root-confined APIs are often the cleaner design.


    33. Directory Traversal: os.ReadDir and io/fs

    For simple directory listing:

    entries, err := os.ReadDir(path)
    if err != nil {
        return err
    }
    
    for _, entry := range entries {
        fmt.Println(entry.Name(), entry.IsDir())
    }

    For application code that should work with multiple filesystem implementations, io/fs is often the better abstraction.

    For example, code can accept:

    func loadTemplates(fsys fs.FS) error {
        // ...
    }

    and work with:

    os.DirFS(...)
    embed.FS
    testing filesystem
    custom filesystem

    This is a good example of where os should disappear behind an interface.


    34. os and path/filepath Have Different Jobs

    path/filepath manipulates host filesystem paths.

    os operates on filesystem resources.

    Prefer:

    path := filepath.Join(base, name)

    over:

    path := base + "/" + name

    But remember:

    filepath.Join
        |
        v
    path construction
    
    os.Open
        |
        v
    resource access

    The former does not authorize the latter.

    This separation becomes especially important when filenames are externally supplied.


    35. path/filepath Is About the Host OS

    path/filepath follows the path conventions of the target operating system.

    That makes it appropriate for:

    local filesystem paths
    Windows paths
    Unix paths
    filesystem-specific separators

    By contrast, io/fs uses slash-separated paths independent of the host OS.

    This difference matters when code crosses the boundary between:

    host filesystem

    and:

    portable filesystem abstraction

    Do not blindly pass a host path into an io/fs API or assume the two path grammars are identical.


    36. os vs io vs io/fs vs filepath vs x/sys

    The standard library becomes much easier to reason about if the packages are assigned clear responsibilities.

                             Application
                                  |
                     +------------+------------+
                     |                         |
                 io.Reader                 io.Writer
                     |                         |
                     +------------+------------+
                                  |
                             +----v----+
                             |   io    |
                             | streams |
                             +----+----+
                                  |
                  +---------------+----------------+
                  |                                |
              os.File                            fs.FS
                  |                                |
                  v                                v
                 os                              io/fs
                  |
           operating-system resources
                  |
          +-------+--------+----------+
          |                |          |
        files           process   environment

    Then there is a lower layer:

    application
        |
        v
    os / io / io/fs
        |
        v
    golang.org/x/sys
        |
        v
    kernel / native OS API

    io

    Answers:

    How does data move?

    os

    Answers:

    Which operating-system resource am I operating on?

    io/fs

    Answers:

    How can filesystem-like data be exposed through a portable filesystem interface?

    path/filepath

    Answers:

    How should a host filesystem path be constructed and manipulated?

    golang.org/x/sys

    Answers:

    I need an OS-specific primitive that the portable standard library does not expose.

    That is a much healthier dependency direction than letting application code fall directly into system calls.


    37. Use x/sys When the OS-Specific Detail Is the Requirement

    If a Linux-specific feature is genuinely part of the application's design, golang.org/x/sys/unix is usually preferable to manually reproducing system-call interfaces.

    The dependency direction should normally look like:

    portable application
           |
           v
    os / io / io/fs
           |
           +---- platform-specific component
                        |
                        v
                     x/sys

    Do not introduce platform-specific system calls simply because a standard-library API is unfamiliar.

    Use them when the operating-system primitive itself is part of the requirement.


    38. Environment Variables Are an Input Boundary

    This:

    port := os.Getenv("PORT")

    is easy.

    But Getenv returns an empty string both when:

    PORT is absent

    and when:

    PORT=""

    If the distinction matters:

    port, ok := os.LookupEnv("PORT")

    Now the two cases are separate.

    A good configuration architecture is:

    environment / arguments
            |
            v
    configuration parser
            |
            v
    typed Config
            |
            v
    application

    Do not spread os.Getenv calls throughout business logic.

    Read external configuration at the boundary and convert it into typed state.


    39. os.Args Is Another External Input

    Command-line arguments are just another process boundary.

    Instead of allowing:

    os.Args

    to appear throughout the program, parse them once into a configuration structure.

    For example:

    OS
     |
     +-- environment
     |
     +-- argv
     |
     v
    configuration
     |
     v
    application

    The same rule applies to working directories, file paths, and inherited environment variables:

    Normalize external process state at the boundary before passing it into application logic.


    40. File Removal Changes the Namespace, Not Necessarily the Open Resource

    On Unix-like systems, removing a directory entry does not necessarily invalidate an already-open file.

    Conceptually:

    path
      |
      v
    filesystem object
      ^
      |
    open file descriptor

    Removing the path can remove the namespace reference while the open descriptor continues to refer to the underlying object.

    Windows has different behavior around open files and deletion.

    This is one reason not to treat:

    pathname

    and:

    open file handle

    as interchangeable concepts.

    They are different pieces of state.


    41. File Locking Is Not Implicit

    Opening a file does not mean:

    This process now owns the file.

    If multiple processes can modify the same resource, the application needs an explicit concurrency design.

    Possible strategies include:

    atomic creation
    atomic rename
    advisory locking
    OS-specific locks
    database transactions
    external coordination

    Do not infer exclusive access from:

    f, err := os.OpenFile(...)

    The open operation and the application's consistency protocol are separate concerns.


    42. Atomicity, Durability, and Concurrency Are Three Different Properties

    Consider:

    state.json

    There are at least three independent questions.

    Atomicity

    Can a reader observe a partially published version?

    Durability

    Does a successful update survive the failure model we care about?

    Concurrency

    Can two writers update the state safely at the same time?

    No single os call answers all three.

    For example:

    temporary file + Rename

    is useful for atomic publication.

    Sync

    addresses durability.

    Neither one automatically provides application-level mutual exclusion between two writers.

    This separation makes filesystem designs much easier to reason about.


    43. os Errors Carry Structure

    Filesystem errors often contain useful context through *os.PathError.

    Instead of inspecting strings:

    if strings.Contains(err.Error(), "no such file") {
        // fragile
    }

    use semantic checks:

    if errors.Is(err, os.ErrNotExist) {
        // missing
    }

    Or inspect structured errors:

    var pathErr *os.PathError
    if errors.As(err, &pathErr) {
        fmt.Println(pathErr.Op)
        fmt.Println(pathErr.Path)
    }

    When adding application context, preserve the underlying error:

    return fmt.Errorf("open cache file %q: %w", path, err)

    Now callers can still use:

    errors.Is(err, os.ErrNotExist)

    The %w is important.

    Error strings are for humans. Error identity is for program logic.


    44. Standard Streams Are Files

    Go exposes:

    os.Stdin
    os.Stdout
    os.Stderr

    as *os.File.

    That means they naturally work with io:

    _, err := io.Copy(os.Stdout, os.Stdin)

    The same function can operate on:

    stdin
    file
    network connection
    HTTP body
    pipe
    buffer

    when it depends only on io.Reader and io.Writer.

    This is exactly the sort of composition the standard library is designed to encourage.


    45. Processes: A PID Is Not a Stable Identity

    A traditional process reference often starts with:

    pid := cmd.Process.Pid

    A PID is useful, but it is not a permanent identity.

    A typical sequence is:

    PID 1234
       |
    process exits
       |
    PID 1234 becomes available
       |
    another process gets PID 1234

    If a supervisor records only the integer PID and later performs a process-specific operation, the process associated with that integer may no longer be the one originally observed.

    This is a classic PID recycling problem.


    46. Go 1.26: Process.WithHandle

    Go 1.26 adds:

    p.WithHandle(func(handle uintptr) error {
        // OS-specific operation
        return nil
    })

    The important guarantee is about the lifetime of the handle during the callback.

    The handle refers to the process represented by p while the callback executes, even if that process has already exited.

    On supported systems, Go maps this to native process references such as:

    Linux 5.4+
        pidfd
    
    Windows
        process HANDLE

    The engineering value is not that WithHandle is a replacement for Process.Pid.

    It is that code that genuinely needs a stable OS-level process reference no longer has to re-identify a process from a recycled integer PID.

    That matters for:

    process supervisors
    container runtimes
    service managers
    debugging tools
    low-level process control

    Most application code should continue using the ordinary os.Process APIs.


    47. Process.WithHandle Is an Escape Hatch, Not a Default API

    A normal program can usually do:

    if err := cmd.Start(); err != nil {
        return err
    }
    
    if err := cmd.Wait(); err != nil {
        return err
    }

    There is no reason to introduce native handles merely because they exist.

    The useful boundary is:

    ordinary Go code
          |
          v
    os.Process
    
    system-level integration
          |
          v
    Process.WithHandle
          |
          v
    native OS handle

    This is consistent with the overall os package design: expose the portable abstraction first, then provide a controlled escape hatch when platform-specific code really needs it.


    48. Portable API Does Not Mean Identical OS Semantics

    Go's os package is portable.

    The operating systems are not identical.

    Examples include:

    file deletion while open
    permissions
    symbolic links
    directory semantics
    process handles
    filesystem durability
    path syntax

    The standard library provides a common API where practical, but it does not erase the underlying operating system.

    This distinction matters when writing code that claims to support:

    Linux
    Windows
    macOS
    containers
    network filesystems

    A portable API gives you a common vocabulary.

    It does not guarantee that every filesystem operation has identical semantics everywhere.


    49. A Production File Update Pattern

    A reasonable starting point for important generated state is:

    func replaceFile(path string, data []byte, perm fs.FileMode) error {
        dir := filepath.Dir(path)
    
        f, err := os.CreateTemp(dir, ".tmp-*")
        if err != nil {
            return fmt.Errorf("create temp file: %w", err)
        }
    
        tmp := f.Name()
        committed := false
    
        defer func() {
            if !committed {
                _ = os.Remove(tmp)
            }
        }()
    
        if err := f.Chmod(perm); err != nil {
            _ = f.Close()
            return fmt.Errorf("chmod temp file: %w", err)
        }
    
        if _, err := f.Write(data); err != nil {
            _ = f.Close()
            return fmt.Errorf("write temp file: %w", err)
        }
    
        if err := f.Sync(); err != nil {
            _ = f.Close()
            return fmt.Errorf("sync temp file: %w", err)
        }
    
        if err := f.Close(); err != nil {
            return fmt.Errorf("close temp file: %w", err)
        }
    
        if err := os.Rename(tmp, path); err != nil {
            return fmt.Errorf("rename temp file: %w", err)
        }
    
        committed = true
        return nil
    }

    This is not a universal transactional filesystem implementation.

    The exact durability protocol depends on the application's failure model.

    But it gets the important structure right:

    old version remains intact
            |
    new version generated separately
            |
    new version completed
            |
    new version published

    50. A Production Root-Confined File Access Pattern

    For an upload or artifact service:

    func openArtifact(rootDir, name string) (*os.File, error) {
        f, err := os.OpenInRoot(rootDir, name)
        if err != nil {
            return nil, fmt.Errorf("open artifact %q: %w", name, err)
        }
    
        return f, nil
    }

    If many operations share the same root:

    type ArtifactStore struct {
        root *os.Root
    }
    
    func NewArtifactStore(dir string) (*ArtifactStore, error) {
        root, err := os.OpenRoot(dir)
        if err != nil {
            return nil, err
        }
    
        return &ArtifactStore{root: root}, nil
    }
    
    func (s *ArtifactStore) Open(name string) (*os.File, error) {
        return s.root.Open(name)
    }
    
    func (s *ArtifactStore) Remove(name string) error {
        return s.root.Remove(name)
    }
    
    func (s *ArtifactStore) Close() error {
        return s.root.Close()
    }

    The root itself becomes a long-lived resource with an explicit lifetime.

    This is a much better abstraction for a service that repeatedly accesses files below one trusted directory.


    51. Do Not Spread *os.File Through the Application

    Suppose the application has:

    func LoadConfig(path string) (*Config, error)

    A cleaner internal design is often:

    func ParseConfig(r io.Reader) (*Config, error)

    Then the filesystem boundary is:

    func LoadConfig(path string) (*Config, error) {
        f, err := os.Open(path)
        if err != nil {
            return nil, fmt.Errorf("open config: %w", err)
        }
        defer f.Close()
    
        return ParseConfig(f)
    }

    Now:

    filesystem concern
            |
            v
    LoadConfig
            |
            v
    io.Reader
            |
            v
    ParseConfig

    The parser can be tested without creating files.

    The same approach works for writers, streams, and filesystem abstractions.


    52. Production Checklist

    Before shipping code that uses os, check the following.

    Resource lifetime

    • Who owns every *os.File?
    • Where is it closed?
    • Can an error path leak it?
    • Can a loop keep many files open?

    Creation

    • Should the file already exist?
    • Should creation be exclusive?
    • Do you need O_EXCL?
    • Is O_TRUNC actually safe?

    Updates

    • Can a crash leave a partial file?
    • Should the file be generated separately and renamed into place?
    • Does the temporary file live on the same filesystem?

    Durability

    • Does success mean "write accepted" or "durable"?
    • Do you need Sync?
    • Does the containing directory need durability for your failure model?

    Permissions

    • What mode should new files have?
    • Could the process umask affect the result?
    • Are secrets stored in the file?

    Concurrency

    • Are you doing Stat followed by Open?
    • Can another process replace the path?
    • Is application-level locking required?

    Path security

    • Is any path component externally supplied?
    • Is lexical traversal rejected where appropriate?
    • Can symbolic links escape the intended directory?
    • Should this operation use os.OpenInRoot or os.Root?
    • Are you accidentally treating filepath.Join or fs.ValidPath as authorization?

    Destructive operations

    • Is RemoveAll really required?
    • Is the target anchored to a trusted root?
    • Could a configuration error redirect the operation?

    Portability

    • Does the code assume Unix deletion semantics?
    • Does it depend on symlink behavior?
    • Does Windows require different handling?
    • Is a network filesystem involved?

    Abstraction

    • Does the code really need *os.File?
    • Could it accept io.Reader or io.Writer?
    • Could a filesystem dependency be represented as fs.FS?
    • Is a platform-specific feature actually necessary before introducing x/sys?

    53. API Selection

    RequirementAPI
    Open an existing file for readingos.Open
    Explicit open semanticsos.OpenFile
    Create or truncateos.Create
    Exclusive creationos.OpenFile(...O_CREATE|O_EXCL...)
    Temporary fileos.CreateTemp
    Temporary directoryos.MkdirTemp
    Create one directoryos.Mkdir
    Create directory treeos.MkdirAll
    Metadata following symlinksos.Stat
    Metadata of the link itselfos.Lstat
    Root-confined openos.OpenInRoot
    Root-confined filesystem operationsos.OpenRoot / os.Root
    Rename / publishos.Rename
    Delete one pathos.Remove
    Recursive deletionos.RemoveAll
    Force file synchronizationFile.Sync
    Current working directoryos.Getwd
    Executable pathos.Executable
    Environment valueos.Getenv
    Distinguish absent vs empty environment valueos.LookupEnv
    Process IDos.Getpid
    Stable native process referenceProcess.WithHandle
    Standard input/outputos.Stdin, os.Stdout, os.Stderr

    The API table is the easy part.

    The difficult part is selecting the semantics that match the failure model.


    54. The Design Behind os

    The os package does not try to hide the operating system completely.

    Instead, it gives ordinary Go programs a compact vocabulary for interacting with operating-system resources:

    file
    directory
    root
    process
    environment
    handle

    The rest of the standard library builds on those primitives.

    io describes data flow.

    io/fs describes filesystem-like interfaces.

    filepath manipulates host paths.

    os performs operating-system operations.

    x/sys is where genuinely platform-specific system primitives can live when the portable layer is not enough.

    The resulting architecture is deliberate:

                        Application
                             |
                   interfaces and policy
                             |
                 +-----------+-----------+
                 |                       |
                io                      io/fs
                 |                       |
                 +-----------+-----------+
                             |
                            os
                             |
                     operating system
                             |
                          x/sys
                             |
                      native kernel API

    A good Go program does not avoid the operating system.

    It uses the boundary deliberately.


    Conclusion

    The difficult part of using os is not opening a file.

    It is preserving the assumptions between a pathname, an operating-system resource, and the state of the filesystem while other things are changing.

    A pathname is mutable namespace state.

    A file handle is an acquired resource.

    Stat is an observation, not a lock.

    Lstat can inspect a symlink without following it, but it does not eliminate TOCTOU races.

    filepath.Clean and filepath.Join manipulate path strings; they do not create a security boundary.

    io/fs.ValidPath validates an io/fs path, not a host filesystem access policy.

    For root-confined access, modern Go provides os.Root and os.OpenInRoot.

    O_CREATE does not mean exclusive creation.

    O_TRUNC can destroy the old version before the new version exists.

    Rename and Sync solve different problems.

    RemoveAll is a recursive filesystem mutation and should be treated as a destructive operation.

    A PID is not a stable process identity. Go 1.26's Process.WithHandle provides a native process reference for code that actually needs one.

    The practical patterns are straightforward:

    acquire resources explicitly
            |
            v
    make ownership visible
            |
            v
    prefer atomic OS operations
            |
            v
    avoid check-then-act races
            |
            v
    treat external paths as untrusted
            |
            v
    use root-confined access when appropriate
            |
            v
    separate publication from durability
            |
            v
    keep os.File and os.Root near the system boundary
            |
            v
    pass io / io/fs interfaces into application logic

    The value of os is not that it makes the operating system disappear.

    It gives Go programs a small enough interface to use the operating system directly, while still leaving the important semantics visible to the engineer.

    That is exactly where production reliability begins.