ZetCode

Go environment variables

last modified October 30, 2026

In this article we show how to work with environment variables in Go. They are key-value pairs inherited from the parent process and used to configure applications without recompiling them.

The os package provides the functions for reading and changing the environment. The examples were compiled and run with Go 1.27.0; they use APP_ prefixed names, so they do not depend on the variables of the machine they run on.

Reading a variable

os.Getenv returns the value of a variable, or an empty string when the variable is not set. An empty value is therefore indistinguishable from a missing variable, so a default is applied by checking for the empty string.

main.go
package main

import (
    "fmt"
    "os"
)

func main() {

    os.Setenv("APP_MODE", "production")

    fmt.Println("mode:", os.Getenv("APP_MODE"))
    fmt.Printf("missing: %q\n", os.Getenv("APP_MISSING"))

    mode := os.Getenv("APP_MODE")
    if mode == "" {
        mode = "development"
    }
    fmt.Println("effective:", mode)
}

The program sets the variable itself so that the output is reproducible. In a real program the value comes from the shell, from a container definition or from a service manager.

$ go run main.go
mode: production
missing: ""
effective: production

Distinguishing unset from empty

os.LookupEnv returns the value and a boolean that reports whether the variable exists. A variable can be present with an empty value.

main.go
package main

import (
    "fmt"
    "os"
)

func show(key string) {

    val, ok := os.LookupEnv(key)
    fmt.Printf("%-12s ok=%-5v value=%q\n", key, ok, val)
}

func main() {

    os.Setenv("APP_NAME", "falcon")
    os.Setenv("APP_EMPTY", "")

    show("APP_NAME")
    show("APP_EMPTY")
    show("APP_UNSET")
}

The boolean is true for both APP_NAME and APP_EMPTY, even though the second value is empty. Only APP_UNSET is reported as absent.

$ go run main.go
APP_NAME     ok=true  value="falcon"
APP_EMPTY    ok=true  value=""
APP_UNSET    ok=false value=""

Setting and unsetting

os.Setenv adds or replaces a variable and os.Unsetenv removes it. Both affect only the current process and the processes it starts; the parent shell is not changed.

main.go
package main

import (
    "fmt"
    "os"
    "strings"
)

func main() {

    os.Setenv("APP_REGION", "eu-west-1")
    fmt.Println(os.Getenv("APP_REGION"))

    err := os.Unsetenv("APP_REGION")
    if err != nil {
        fmt.Println(err)
        return
    }

    _, ok := os.LookupEnv("APP_REGION")
    fmt.Println("present:", ok)

    os.Setenv("APP_TEAM", "core")
    for _, kv := range os.Environ() {
        if strings.HasPrefix(kv, "APP_") {
            fmt.Println(kv)
        }
    }
}

After Unsetenv the variable is gone. The last loop filters the environment down to the application's own variables, which is handy for debugging without printing secrets from the whole environment.

$ go run main.go
eu-west-1
present: false
APP_TEAM=core

Listing the environment

os.Environ returns a copy of the environment as a slice of key=value strings. Splitting on the first equals sign handles values that themselves contain an equals sign.

main.go
package main

import (
    "fmt"
    "os"
    "strings"
)

func main() {

    os.Setenv("APP_URL", "https://example.com/?a=b")

    for _, kv := range os.Environ() {
        if !strings.HasPrefix(kv, "APP_") {
            continue
        }
        key, value, found := strings.Cut(kv, "=")
        fmt.Printf("%s -> %s (found=%v)\n", key, value, found)
    }
}

strings.Cut separates the name from the value at the first =, so the query string in the value stays intact.

$ go run main.go
APP_URL -> https://example.com/?a=b (found=true)

Expanding variables in strings

os.ExpandEnv replaces $name and ${name} references in a string with their values. Undefined variables become empty strings. os.Expand applies a custom mapping function instead.

main.go
package main

import (
    "fmt"
    "os"
)

func main() {

    os.Setenv("APP_HOME", "/srv/app")
    os.Setenv("APP_USER", "deploy")

    fmt.Println(os.ExpandEnv("$APP_USER starts $APP_HOME/bin"))
    fmt.Println(os.ExpandEnv("missing: [$APP_NOPE]"))

    expanded := os.Expand("${name}@${domain}", func(key string) string {
        return map[string]string{
            "name":   "admin",
            "domain": "example.com",
        }[key]
    })
    fmt.Println(expanded)
}

The last call does not read the environment at all: the function maps each name to a value, and an unknown name would become an empty string as well.

$ go run main.go
deploy starts /srv/app/bin
missing: []
admin@example.com

Loading a configuration struct

A common pattern reads several variables into a struct, applies defaults and converts the strings to numbers, booleans and durations. Conversion errors are returned with the name of the variable that caused them.

main.go
package main

import (
    "fmt"
    "os"
    "strconv"
    "time"
)

type Config struct {
    Host    string
    Port    int
    Debug   bool
    Timeout time.Duration
}

func LoadConfig() (Config, error) {

    cfg := Config{
        Host:    "localhost",
        Port:    8080,
        Timeout: 5 * time.Second,
    }

    if v := os.Getenv("APP_HOST"); v != "" {
        cfg.Host = v
    }

    if v := os.Getenv("APP_PORT"); v != "" {
        port, err := strconv.Atoi(v)
        if err != nil {
            return Config{}, fmt.Errorf("invalid APP_PORT: %w", err)
        }
        cfg.Port = port
    }

    if v := os.Getenv("APP_DEBUG"); v != "" {
        debug, err := strconv.ParseBool(v)
        if err != nil {
            return Config{}, fmt.Errorf("invalid APP_DEBUG: %w", err)
        }
        cfg.Debug = debug
    }

    if v := os.Getenv("APP_TIMEOUT"); v != "" {
        d, err := time.ParseDuration(v)
        if err != nil {
            return Config{}, fmt.Errorf("invalid APP_TIMEOUT: %w", err)
        }
        cfg.Timeout = d
    }

    return cfg, nil
}

func main() {

    os.Setenv("APP_PORT", "9090")
    os.Setenv("APP_DEBUG", "true")
    os.Setenv("APP_TIMEOUT", "1500ms")

    cfg, err := LoadConfig()
    if err != nil {
        fmt.Println(err)
        return
    }

    fmt.Printf("%+v\n", cfg)

    os.Setenv("APP_PORT", "eighty")
    _, err = LoadConfig()
    fmt.Println(err)
}

The first load uses the environment set by the program and produces a valid configuration. The second one replaces the port with a non-numeric value, and the error explains which variable failed and why.

$ go run main.go
{Host:localhost Port:9090 Debug:true Timeout:1.5s}
invalid APP_PORT: strconv.Atoi: parsing "eighty": invalid syntax

Environment of a child process

A command started with os/exec inherits the environment by default. The Cmd.Env field replaces it, and cmd.Environ returns a clean copy of the inherited environment that can be extended.

main.go
package main

import (
    "fmt"
    "os/exec"
)

func main() {

    cmd := exec.Command("sh", "-c", `echo "$APP_NAME runs on $APP_REGION"`)
    cmd.Env = append(cmd.Environ(), "APP_NAME=falcon", "APP_REGION=eu")

    out, err := cmd.Output()
    if err != nil {
        fmt.Println(err)
        return
    }

    fmt.Print(string(out))
}

The child shell reads the two variables from its environment. On Windows the command would have to be a program that exists there, such as cmd with /C.

$ go run main.go
falcon runs on eu

Loading a .env file

Development machines often keep variables in a .env file. The third-party godotenv package reads that file, and Load does not overwrite variables that are already set.

.env
APP_HOST=db.example.com
APP_PORT=5432

The file defines two variables in the same KEY=value form used by the shell.

main.go
package main

import (
    "fmt"
    "log"
    "os"

    "github.com/joho/godotenv"
)

func main() {

    os.Setenv("APP_HOST", "from-process")

    if err := godotenv.Load(".env"); err != nil {
        log.Fatal(err)
    }
    fmt.Println("load:    ", os.Getenv("APP_HOST"))

    envs, err := godotenv.Read(".env")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("read:    ", envs["APP_HOST"], len(envs))

    if err := godotenv.Overload(".env"); err != nil {
        log.Fatal(err)
    }
    fmt.Println("overload:", os.Getenv("APP_HOST"))
}

Load keeps the value that was already in the process, Read returns the file contents as a map without touching the environment, and Overload replaces existing values with the file ones. The file is not added to source control when it contains secrets.

$ go get github.com/joho/godotenv@v1.5.1
go: added github.com/joho/godotenv v1.5.1
$ go run main.go
load:     from-process
read:     db.example.com 2
overload: db.example.com

Source

Go os package - reference
godotenv - Github page

In this article we have worked with environment variables in Go. We read values and applied defaults, distinguished unset from empty variables, set and removed variables, listed and expanded the environment, loaded a typed configuration, passed variables to a child process and read a .env file.

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.