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.
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.
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.
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.
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.
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.
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.
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.
APP_HOST=db.example.com APP_PORT=5432
The file defines two variables in the same KEY=value form used by
the shell.
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
List all Go tutorials.