Skip to main content

Building System Utilities in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

Go’s combination of fast startup, static binaries, cross-compilation, and strong standard library makes it ideal for system utilities. A Go utility compiles to a single binary with no runtime dependencies — scp it to a server and run it immediately.

This guide covers patterns for building practical system tools: file system analysis, log processing, process monitoring, and file watching. For CLI flag parsing see Go command-line parsing flags and for Cobra-based CLIs see Go building CLI with Cobra.

Disk Usage Analyzer

A practical example: a du-like tool that reports directory sizes sorted by size:

package main

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

func main() {
    depth := flag.Int("depth", 1, "max depth to display")
    human := flag.Bool("h", false, "human-readable sizes")
    flag.Parse()

    root := "."
    if flag.NArg() > 0 { root = flag.Arg(0) }

    sizes, err := directorySizes(root, *depth)
    if err != nil {
        fmt.Fprintf(os.Stderr, "error: %v\n", err)
        os.Exit(1)
    }

    // Sort largest first
    sort.Slice(sizes, func(i, j int) bool {
        return sizes[i].size > sizes[j].size
    })

    for _, entry := range sizes {
        if *human {
            fmt.Printf("%8s  %s\n", humanSize(entry.size), entry.path)
        } else {
            fmt.Printf("%12d  %s\n", entry.size, entry.path)
        }
    }
}

type dirSize struct { path string; size int64 }

func directorySizes(root string, maxDepth int) ([]dirSize, error) {
    rootDepth := strings.Count(root, string(os.PathSeparator))
    sizes := make(map[string]int64)

    err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
        if err != nil { return err }
        if d.IsDir() { return nil }

        info, err := d.Info()
        if err != nil { return err }

        // Attribute file size to each ancestor directory up to maxDepth
        rel, _ := filepath.Rel(root, path)
        parts := strings.Split(rel, string(os.PathSeparator))
        for i := 1; i <= len(parts) && i <= maxDepth; i++ {
            dir := filepath.Join(append([]string{root}, parts[:i]...)...)
            sizes[dir] += info.Size()
        }
        return nil
    })

    if err != nil { return nil, err }

    result := make([]dirSize, 0, len(sizes))
    for path, size := range sizes {
        result = append(result, dirSize{path, size})
    }
    return result, nil
}

func humanSize(n int64) string {
    units := []string{"B", "KB", "MB", "GB", "TB"}
    f := float64(n)
    for _, u := range units {
        if f < 1024 { return fmt.Sprintf("%.1f %s", f, u) }
        f /= 1024
    }
    return fmt.Sprintf("%.1f PB", f)
}

Log File Processor

Many system utilities parse logs. Key patterns: stream line-by-line (don’t load the whole file), parse fields, aggregate statistics:

package main

import (
    "bufio"
    "compress/gzip"
    "flag"
    "fmt"
    "os"
    "regexp"
    "sort"
    "strconv"
    "strings"
)

// Parse nginx access log format:
// 127.0.0.1 - - [01/Jan/2026:00:00:00 +0000] "GET /api/users HTTP/1.1" 200 1234
var logRE = regexp.MustCompile(`^(\S+) .+ "(\S+) (\S+) \S+" (\d+) (\d+)`)

type Stats struct {
    count    int
    total    int64
    status   map[int]int
}

func analyzeLog(path string) (map[string]*Stats, error) {
    f, err := os.Open(path)
    if err != nil { return nil, err }
    defer f.Close()

    var r io.Reader = f
    if strings.HasSuffix(path, ".gz") {
        gz, err := gzip.NewReader(f)
        if err != nil { return nil, err }
        defer gz.Close()
        r = gz
    }

    routes := make(map[string]*Stats)

    scanner := bufio.NewScanner(r)
    scanner.Buffer(make([]byte, 0, 64*1024), 1<<20)

    for scanner.Scan() {
        m := logRE.FindStringSubmatch(scanner.Text())
        if m == nil { continue }

        method, path, statusStr, bytesStr := m[2], m[3], m[4], m[5]
        status, _ := strconv.Atoi(statusStr)
        bytes, _ := strconv.ParseInt(bytesStr, 10, 64)
        key := method + " " + path

        s := routes[key]
        if s == nil {
            s = &Stats{status: make(map[int]int)}
            routes[key] = s
        }
        s.count++
        s.total += bytes
        s.status[status]++
    }
    return routes, scanner.Err()
}

Process Monitor

Watch running processes and alert on resource usage:

package main

import (
    "context"
    "fmt"
    "os"
    "runtime"
    "time"
)

type ResourceSnapshot struct {
    Timestamp  time.Time
    Goroutines int
    HeapAlloc  uint64
    HeapSys    uint64
    NumGC      uint32
}

func (r ResourceSnapshot) String() string {
    return fmt.Sprintf(
        "%s goroutines=%d heap=%.1fMB sys=%.1fMB gc=%d",
        r.Timestamp.Format("15:04:05"),
        r.Goroutines,
        float64(r.HeapAlloc)/1e6,
        float64(r.HeapSys)/1e6,
        r.NumGC,
    )
}

func snapshot() ResourceSnapshot {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    return ResourceSnapshot{
        Timestamp:  time.Now(),
        Goroutines: runtime.NumGoroutine(),
        HeapAlloc:  m.HeapAlloc,
        HeapSys:    m.HeapSys,
        NumGC:      m.NumGC,
    }
}

func monitor(ctx context.Context, interval time.Duration, alert func(ResourceSnapshot)) {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()

    var prev ResourceSnapshot
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            current := snapshot()

            // Alert on goroutine leak
            if prev.Goroutines > 0 && current.Goroutines > prev.Goroutines*2 {
                alert(current)
            }
            // Alert on rapid memory growth
            if prev.HeapAlloc > 0 && current.HeapAlloc > prev.HeapAlloc*3 {
                alert(current)
            }

            fmt.Println(current)
            prev = current
        }
    }
}

File Watcher with fsnotify

fsnotify watches for file system changes without polling:

go get github.com/fsnotify/fsnotify
import "github.com/fsnotify/fsnotify"

func watchConfig(ctx context.Context, path string, onReload func()) error {
    watcher, err := fsnotify.NewWatcher()
    if err != nil { return err }
    defer watcher.Close()

    if err := watcher.Add(filepath.Dir(path)); err != nil {
        return err
    }

    for {
        select {
        case <-ctx.Done():
            return nil
        case event, ok := <-watcher.Events:
            if !ok { return nil }
            // Watch for writes or creates to the specific file
            if event.Name == path && (event.Has(fsnotify.Write) || event.Has(fsnotify.Create)) {
                // Debounce: wait for write to complete
                time.Sleep(50 * time.Millisecond)
                onReload()
            }
        case err, ok := <-watcher.Errors:
            if !ok { return nil }
            fmt.Fprintf(os.Stderr, "watcher error: %v\n", err)
        }
    }
}

Tabular Output Formatting

System utilities need clean, aligned output. Use text/tabwriter for tab-aligned tables:

import "text/tabwriter"

func printTable(header []string, rows [][]string) {
    w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
    fmt.Fprintln(w, strings.Join(header, "\t"))
    fmt.Fprintln(w, strings.Repeat("-\t", len(header)))
    for _, row := range rows {
        fmt.Fprintln(w, strings.Join(row, "\t"))
    }
    w.Flush()
}

// Output:
// NAME      SIZE      MODIFIED
// -         -         -
// main.go   2.1 KB    2026-01-01
// util.go   845 B     2025-12-30

For JSON output, add a --json flag that changes output format without changing the logic:

if *jsonOutput {
    json.NewEncoder(os.Stdout).Encode(results)
} else {
    printTable(header, toRows(results))
}

Single Binary Distribution

Go utilities compile to static binaries. Cross-compile for any target:

# Linux AMD64 (for servers)
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o myutil-linux-amd64 .

# macOS Apple Silicon
GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o myutil-darwin-arm64 .

# Windows
GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o myutil.exe .

-ldflags="-s -w" strips debug symbols, reducing binary size by ~30%.

For automatic multi-platform releases, see Go packaging and distribution.

Summary

  • filepath.WalkDir + attribute sizes to ancestor directories for recursive disk usage calculation
  • Stream log files with bufio.Scanner — use scanner.Buffer to handle long lines; handle .gz transparently
  • runtime.ReadMemStats for in-process resource snapshots; alert on sudden jumps, not absolute values
  • fsnotify for event-based file watching — watch the directory, filter for the specific file
  • text/tabwriter for aligned table output; add --json flag for machine-readable alternative
  • Single-binary distribution: GOOS=linux GOARCH=amd64 go build -ldflags="-s -w"

Resources

Comments

👍 Was this article helpful?