# Environment variables in Go: os.Getenv and .env

> Go reads environment variables with os.Getenv and os.LookupEnv and has no .env loader. Empty versus unset, godotenv's order, and running Go with Crunu.

URL: https://crunu.com/environment-variables/go
Language: en
Updated: 2026-10-30

Go reads environment variables only from the process environment, through the `os` package: `os.Getenv("NAME")` returns a value, `os.LookupEnv("NAME")` also tells you whether the variable exists, and `os.Environ()` lists everything. The standard library has no `.env` support and no file precedence. Whatever started the process decides what the program sees, and the values don't change while it runs. For `.env` files, Go projects add a library such as godotenv, which by default never overwrites variables that are already set. Function behavior below is quoted from the [os package documentation](https://pkg.go.dev/os) for Go 1.27.3, checked October 30, 2026.

## The os functions and what they return

| Function                                  | Returns                                   | Watch out for                                      |
| ----------------------------------------- | ----------------------------------------- | -------------------------------------------------- |
| `os.Getenv(key)`                          | the value, or `""`                        | an empty value and an unset variable look the same |
| `os.LookupEnv(key)`                       | the value and `true`, or `""` and `false` | nothing; prefer it for required settings           |
| `os.Environ()`                            | a copy of all `key=value` strings         | logging it prints every secret                     |
| `os.ExpandEnv(s)`                         | `s` with `$var` and `${var}` replaced     | undefined names become `""` silently               |
| `os.Setenv`, `os.Unsetenv`, `os.Clearenv` | change this process's environment         | children started later inherit the change          |

*Figure: Diagram of how Go loads environment variables. No env files are read by default; values come from the environment of the process. Values are read from the environment when the app starts. There is no public prefix: nothing reaches the browser unless your code sends it.*

The first row is the classic Go configuration bug. `port := os.Getenv("PORT")` gives `""` when someone forgot to set `PORT` and also when they set it to nothing on purpose. Code then starts with an address that has no port, or connects with an empty password, and the failure appears far from its cause. Write a small helper on top of `os.LookupEnv` that fails at startup when a required variable is missing, and read every setting through it.

`os.ExpandEnv` has the same blind spot. A config template with `${DATABASE_URL}` expands to an empty string when the variable is missing, and the error shows up later as a confusing connection failure. Check for missing names first if you rely on expansion.

## Reading configuration once

Go programs usually read their environment into a typed struct in `main` and pass it down, rather than calling `os.Getenv` deep inside packages. This has practical benefits for secrets:

- **One list of variables.** The struct is the documentation of what the service needs, and it's easy to compare with your `.env.example`.
- **Validation in one place.** Parse durations, ports and booleans once, with clear errors, instead of at first use.
- **No accidental logging.** A type for secret fields with its own `String()` method that prints a placeholder keeps values out of `%v` output.
- **Easier tests.** Tests construct the struct directly instead of mutating the process environment, which other tests running in parallel would see.

## godotenv and its order

[godotenv](https://github.com/joho/godotenv) describes itself as a Go port of Ruby's dotenv. Its README states the key rule plainly: existing environment variables take precedence over variables loaded later. In practice that means:

- `godotenv.Load()` reads `.env` and skips every name that's already set. A value exported in your shell, set by systemd or passed by `crunu run` wins.
- `godotenv.Load(".env.local", ".env")` keeps the first value it finds. Pass the file that should win first. Writing them in "override order", base file first, gives you the opposite of what you intended.
- `godotenv.Overload()` does overwrite existing variables. The README asks you to use it with caution, because it lets a file on disk silently replace values set by the platform.

The library also has a command mode that loads a file and runs another program, which by default doesn't override existing variables either.

## Child processes

`exec.Command` starts a child with the parent's whole environment unless you set the command's `Env` field: the [os/exec documentation](https://pkg.go.dev/os/exec) says that if `Env` is nil, the new process uses the current process's environment. A Go service that shells out to an image tool or a backup script therefore hands it every secret it has. Set `Env` to the few variables the child needs, keeping `PATH`, so a compromised or chatty helper can't leak the rest.

## Run Go with values from Crunu

`crunu run` decrypts a Crunu environment in memory and starts your program with those variables in its process environment, which is the only place Go looks. No library is needed, and if you keep godotenv for local defaults, the Crunu values still win because godotenv doesn't overwrite them.

1. Sign in once with `crunu login`, then run the service during development with `crunu run --env api/development -- go run ./cmd/api`.
2. Run integration tests against their own environment with `crunu run --env api/test -- go test ./...`.
3. Build the binary without any secrets, since Go doesn't need them at compile time: `go build -o bin/api ./cmd/api`.
4. On a Linux server with the agent, start it with `crunu run --env api/production --restart on-change -- /opt/api/bin/api`.

Step 3 is a quiet advantage of Go. The compiler doesn't read your environment, so CI can build and test without production values, and only the server that runs the binary ever receives them. With `--restart on-change`, `crunu` sends `SIGTERM` when a value changes, waits for the program to exit, and starts it again. Handle `SIGTERM` with `signal.NotifyContext` and shut the HTTP server down gracefully so requests in flight complete.

The Go toolchain itself reads environment variables. `crunu run` passes only a short allowlist from your shell, including `PATH` and `HOME`, so `go` still finds its cache under your home directory. `GOPRIVATE`, `GOFLAGS`, `GOPROXY` and `SSH_AUTH_SOCK` are not passed. If `go run` needs to download a private module, add those to the Crunu environment or use `--inherit-env` for development.

## Go in containers

Go services often ship in minimal images. `crunu` is a static binary for Linux amd64 and arm64, so it can be copied into the final stage of a multi-stage build next to your own binary. Two things are easy to forget: the final image needs CA certificates, or `crunu` can't verify the server's certificate and tells you which package is missing, and the install script needs `sh` plus `curl` or `wget`, so run it in an earlier stage. Inside the container, `crunu` works with a service token in `CRUNU_TOKEN`, signing in and decrypting inside its own process with no state files. Whoever stores that token can decrypt the environment it reads, so give it one environment only. See [Docker Compose](https://crunu.com/environment-variables/docker-compose) and [Fly.io](https://crunu.com/environment-variables/fly-io) for platform specifics.

## Related reading

- [Node.js](https://crunu.com/environment-variables/nodejs), the other runtime that reads only what it's given.
- [Spring Boot](https://crunu.com/environment-variables/spring-boot) for a typed, layered configuration system.
- [Secrets on servers without files on disk](https://crunu.com/blog/secrets-on-servers-without-files-on-disk) and the [systemd integration](https://crunu.com/integrations/systemd).
- [How to share .env files with your team](https://crunu.com/guides/share-env-files-with-a-team).

## FAQ

### Is there a standard .env package in Go?

No. The standard library only reads the process environment. godotenv, a port of Ruby's dotenv, is a common third-party choice, and many Go services skip files and expect real environment variables.

### How do I tell an unset variable from an empty one?

Use os.LookupEnv. It returns the value and a boolean that is false when the variable isn't present, while os.Getenv returns an empty string in both cases.

### Can I run crunu in a scratch or distroless image?

crunu is a static binary, so it can be copied into a minimal image, but the image also needs CA certificates so crunu can verify the server's TLS certificate. The install script itself needs sh and curl or wget, so run it in a build stage.

### Does a running Go service see a changed variable?

No. The environment is set when the process starts. Restart it, which crunu run does automatically with --restart on-change.

## Sources

- [Go standard library: package os](https://pkg.go.dev/os) (accessed 2026-10-30)
- [Go standard library: package os/exec](https://pkg.go.dev/os/exec) (accessed 2026-10-30)
- [godotenv README](https://github.com/joho/godotenv) (accessed 2026-10-30)
