OS, Args, and Flags

Updated

September 13, 2026

OS, Args, and Flags

os is the thin layer between Go and the operating system. flag is the standard-library argument parser. The boring default is: flag for named flags, os.Args[1:] only when flags are too much, os.Getenv for configuration, and os.Exit(1) on a fatal error — after printing to os.Stderr, not os.Stdout.

Mental model

os.Args is the raw []string of the command line, os.Args[0] being the program name. flag wraps that slice with typed bindings: flag.String, flag.Int, flag.Bool. Call flag.Parse() once; after that the pointers hold the parsed values.

os.Getenv("KEY") returns a string; an absent key returns "". os.LookupEnv("KEY") returns (value, present) when you must distinguish absent from empty.

os.Exit(code) terminates the process immediately — deferred calls do not run. Reserve it for main and for top-level error paths where cleanup is not relevant. Libraries must never call it.

os.Stdin, os.Stdout, os.Stderr are open *os.File values. Pass them into functions as io.Reader or io.Writer so tests can substitute a bytes.Buffer.

Worked examples

Case 1: os.Args

Save as raw_args.go. Print every argument after the program name.

// raw_args.go
package main

import (
    "fmt"
    "os"
)

func main() {
    if len(os.Args) < 2 {
        fmt.Fprintln(os.Stderr, "usage: raw_args <name>")
        os.Exit(1)
    }
    for i, a := range os.Args[1:] {
        fmt.Printf("arg %d: %s\n", i, a)
    }
}

Run:

go run raw_args.go soup coffee cake

Output:

arg 0: soup
arg 1: coffee
arg 2: cake

os.Args[1:] skips the program name. When no arguments arrive, print a usage message on os.Stderr and exit 1. Exit 0 is success; any other code signals failure to the shell.

Case 2: flag — named flags

Save as open_desk.go. The desk operator names a window and a maximum table count.

// open_desk.go
package main

import (
    "flag"
    "fmt"
    "os"
)

func main() {
    window := flag.String("window", "A", "desk window label")
    maxTables := flag.Int("tables", 10, "maximum tables to seat")
    flag.Parse()

    if *window == "" {
        fmt.Fprintln(os.Stderr, "window must not be empty")
        os.Exit(1)
    }
    fmt.Printf("opening window %s with %d tables\n", *window, *maxTables)
}

Run:

go run open_desk.go -window B -tables 14

Output:

opening window B with 14 tables

flag.String returns a pointer. Dereference with *window. The flag package writes usage to os.Stderr and exits 2 on a parse error — you do not need to catch that yourself.

To see the generated help:

go run open_desk.go -help
Usage of /tmp/…:
  -tables int
        maximum tables to seat (default 10)
  -window string
        desk window label (default "A")

Case 3: flag.Args() — positional after flags

Save as print_tickets.go. Flags come first; ticket IDs follow as positional arguments.

// print_tickets.go
package main

import (
    "flag"
    "fmt"
    "os"
)

func main() {
    window := flag.String("window", "A", "desk window")
    flag.Parse()
    ids := flag.Args() // everything after the last flag
    if len(ids) == 0 {
        fmt.Fprintln(os.Stderr, "provide at least one ticket id")
        os.Exit(1)
    }
    for _, id := range ids {
        fmt.Printf("[%s] printing ticket %s\n", *window, id)
    }
}

Run:

go run print_tickets.go -window B 7 8 9

Output:

[B] printing ticket 7
[B] printing ticket 8
[B] printing ticket 9

flag.Args() returns the non-flag arguments after flag.Parse(). Flags must precede positional arguments or the parser stops at the first non-flag token.

Case 4: os.Getenv and os.LookupEnv

Save as env_config.go. The desk zone comes from the environment; an absent key is an error.

// env_config.go
package main

import (
    "fmt"
    "os"
)

func main() {
    zone, ok := os.LookupEnv("DESK_ZONE")
    if !ok {
        fmt.Fprintln(os.Stderr, "DESK_ZONE not set")
        os.Exit(1)
    }
    fmt.Println("zone:", zone)

    lang := os.Getenv("LANG")
    if lang == "" {
        lang = "en_US.UTF-8"
    }
    fmt.Println("lang:", lang)
}

Run:

DESK_ZONE=EU go run env_config.go

Output:

zone: EU
lang: en_US.UTF-8

LookupEnv is correct when an empty-string value is valid but an absent key is not. Getenv returning "" cannot tell you which case you are in. Use it only when the default for both absent and empty is the same.

Case 5: Stderr and structured errors

Save as desk_cmd.go. A command that writes its log to stderr and its product to stdout so they can be piped independently.

// desk_cmd.go
package main

import (
    "flag"
    "fmt"
    "os"
    "strconv"
)

func run(w *string, ids []string, out, errOut *os.File) int {
    for _, raw := range ids {
        n, err := strconv.Atoi(raw)
        if err != nil {
            fmt.Fprintf(errOut, "invalid id %q: %v\n", raw, err)
            return 1
        }
        fmt.Fprintf(out, "[%s] ticket %d ready\n", *w, n)
    }
    return 0
}

func main() {
    window := flag.String("window", "A", "desk window")
    flag.Parse()
    code := run(window, flag.Args(), os.Stdout, os.Stderr)
    os.Exit(code)
}

Run:

go run desk_cmd.go -window C 7 8 bad

Output:

[C] ticket 7 ready
[C] ticket 8 ready
invalid id "bad": strconv.Atoi: parsing "bad": invalid syntax

Exit code is 1. The caller’s shell can check $?. Separating stdout (data) and stderr (log/error) lets the caller pipe the data without the errors mixing in.

The trap

Save as exit_defer.go. os.Exit skips deferred calls.

// exit_defer.go
package main

import (
    "fmt"
    "os"
)

func main() {
    defer fmt.Println("this never runs")
    fmt.Println("before exit")
    os.Exit(1)
}

Run:

go run exit_defer.go

Output:

before exit

Exit code 1, and defer did not fire. This is by design. Do not rely on deferred cleanup in a path that calls os.Exit. Return error values up to main, do the cleanup, then exit.

The boring rule

  • flag for named options. flag.Args() for positional arguments after flags.
  • os.LookupEnv when absence and empty are different. os.Getenv when both map to the same default.
  • Errors go to os.Stderr. Program output goes to os.Stdout. They are different.
  • os.Exit(1) only in main (or the outermost run function). Libraries return errors.
  • Deferred calls do not run after os.Exit. Structure cleanup before you exit.

Try this

  1. In open_desk.go, add a -verbose bool flag. When true, print extra detail (any extra line you like).
  2. In print_tickets.go, add validation: reject any ticket id that is not a number (use strconv.Atoi).
  3. In env_config.go, run without DESK_ZONE=. Observe the exit message on stderr.
  4. In desk_cmd.go, move the os.Exit into a separate func main() that calls run and exits with its return value. Confirm that removing os.Exit from run lets you test run without killing the process.