Linux System Programming · intermediate · ~8 min

Environment variables

## What you will learn - Explain what the process environment is and how a program receives it at startup. - Read configuration values with `getenv`, handling the `NULL` (unset) case correctly. - Add, change, and remove variables at runtime with `setenv`, `unsetenv`, and `clearenv`. - Iterate over the entire environment using the `environ` array (and the optional third `main` parameter). - Describe how children inherit a copy of the parent's environment and why that matters for configuration. - Treat environment values as untrusted input and validate them before use.

Overview

Every program you run on Linux is handed a small bundle of text configuration the moment it starts. That bundle is the environment: an unordered list of NAME=value strings such as HOME=/home/ana, PATH=/usr/bin:/bin, or LANG=en_US.UTF-8. The program can read any of these to decide how to behave — where to find files, what language to print messages in, whether a debug mode is on — without recompiling anything.

This builds directly on The main function. There you learned that main is the entry point the operating system calls and that it can receive argc and argv (the command-line arguments). The environment is the second channel of input into a program. Command-line arguments are typed explicitly for one run; environment variables are inherited from the shell or parent process and tend to persist across many runs, which makes them ideal for configuration that should not be baked into the source.

In this lesson the key terms are: environment (the whole list), environment variable (one NAME=value entry), inheritance (a child process receiving a copy of its parent's environment), and the C library functions that read and modify the list. By the end you will be able to write a program that reads its configuration from the environment, supplies sensible defaults when a variable is missing, and never trusts those values blindly.

Why it matters

Environment variables are the standard way real software is configured across deployments. The same compiled binary runs on a developer laptop, a test server, and production — the only thing that changes is the environment: a database URL here, an API endpoint there, a log level somewhere else. The widely cited twelve-factor app methodology explicitly recommends storing configuration in the environment precisely because it keeps secrets and machine-specific values out of the code and out of version control.

For security, the environment is a classic source of untrusted input. A user who can launch your program (or influence the shell that launches it) can set any variable to any string of any length, including empty strings and values crafted to confuse a parser. Historically this has caused real damage: the Shellshock family of bugs (2014) let attackers smuggle commands through environment variables that Bash mishandled. Programs that run with elevated privileges (setuid binaries) must be especially careful, because PATH, LD_PRELOAD, and IFS coming from the environment can change which code actually executes. Knowing how the environment works — and that its contents are attacker-controllable — is foundational to writing programs that are both configurable and safe.

Core concepts

1. The environment is a list of NAME=value strings

Definition. The environment is a NUL-terminated array of C strings, each of the form "NAME=value", ending with a NULL pointer that marks the end of the list.

Plain language. Think of it as a tiny key/value store that travels with the process. Names are conventionally uppercase with underscores (DATABASE_URL), but that is just convention — the only structural rule is that the first = separates the name from the value.

How it works internally. The kernel hands the new program three things at startup: argc, the argv array, and right after it the envp array. The C runtime stores the address of that array in a global variable named environ. All the standard functions (getenv, setenv, ...) operate on environ.

environ --> [ 0 ] --> "HOME=/home/ana\0"
            [ 1 ] --> "PATH=/usr/bin:/bin\0"
            [ 2 ] --> "LANG=en_US.UTF-8\0"
            [ 3 ] --> NULL          <- end marker

When to use / not use. Use the environment for configuration that varies by machine or deployment (paths, endpoints, feature flags, log levels). Do NOT use it as a general program-wide variable store within your own code — globals and function parameters are clearer for that, and the environment is shared with every child you spawn.

Pitfall. The order of entries is not defined and may change between runs. Never rely on a variable being at a particular index.

Knowledge check. What single character separates a variable's name from its value, and what marks the end of the whole environment array?

2. Reading values with getenv

Definition. char *getenv(const char *name) searches the environment for an entry whose name matches name and returns a pointer to its value, or NULL if no such entry exists.

Plain language. You ask for a name; you get back the text after the =, or nothing.

How it works internally. getenv walks environ comparing each entry up to its = against name. The pointer it returns points into the environment's own memory — you do not own it and must not free it.

When to use / not use. Use it whenever you want to read configuration. Do NOT assume the result is non-NULL, and do NOT modify the returned string in place — copy it first if you need to change it.

Pitfall. The returned pointer can be invalidated by a later setenv, unsetenv, or putenv call. If you need the value to survive such calls, copy it (for example with strdup) right away.

Knowledge check (predict the output). If COLOR is not set in the environment, what does getenv("COLOR") return, and what happens if you pass that straight to printf("%s\n", ...)?

3. Modifying the environment: setenv, unsetenv, clearenv

Definitions.

Function Effect
int setenv(const char *name, const char *value, int overwrite) Adds NAME=value; if name exists and overwrite is non-zero, replaces it; if overwrite is 0, leaves an existing value untouched. Returns 0 on success, -1 on error.
int unsetenv(const char *name) Removes name if present. Returns 0 on success.
int clearenv(void) Removes every variable, leaving an empty environment. Returns 0 on success.

Plain language. setenv writes, unsetenv deletes one, clearenv wipes all.

How it works internally. setenv allocates new storage and rewires environ to include the new entry. Because it manages the memory for you, prefer it over the older putenv, which inserts your own string pointer directly into the environment (so the string must stay alive and is shared, not copied).

When to use / not use. Use these to set up the environment for a child process before fork/exec, or to apply a default. Do NOT call them from multiple threads at once — the environment is global, shared, and these functions are not thread-safe.

Pitfall. Calling setenv does not change the parent shell's environment — only this process and any children it later starts. When your program exits, the change is gone.

Knowledge check (explain in your own words). What is the difference between setenv("LOG", "debug", 0) and setenv("LOG", "debug", 1) when LOG already exists?

4. Inheritance: children get a copy

Definition. When a process creates a child (via fork then exec, which the next lessons cover), the child starts with a copy of the parent's environment at that moment.

Plain language. Changes the parent makes before launching the child are visible to the child; changes either side makes afterward are private to that side.

shell (PATH=/usr/bin)
   |  setenv("MODE","prod")   <- happens before exec
   v
  exec ./worker            -> worker sees PATH and MODE
                              worker's own setenv/unsetenv
                              do NOT flow back to the shell

When to use / not use. This is exactly why environment variables configure a program "from the outside": the shell sets them, your program inherits them. Do NOT expect a child's edits to propagate upward — that is not how the copy works.

Pitfall. Forgetting the copy is a snapshot: setting a variable after the child already started has no effect on that child.

Knowledge check. A shell sets TZ=UTC, then runs your program, which calls setenv("TZ", "PST", 1). After your program exits, what is TZ in the shell?

Syntax notes

Include the right headers and remember each function's contract.

#include <stdlib.h>   /* getenv, setenv, unsetenv, clearenv */
#include <stdio.h>    /* printf */

/* environ is declared by POSIX; declare it yourself to iterate the list. */
extern char **environ;

const char *v = getenv("NAME");      /* NULL if unset; do not free; do not modify */

int rc = setenv("NAME", "value", 1); /* overwrite=1 replaces; 0 keeps existing  */
if (rc != 0) { /* errno is set; handle the failure */ }

unsetenv("NAME");                     /* safe even if NAME is absent             */

Key points the syntax encodes: getenv returns char * but you should treat it as read-only; setenv's third argument is the overwrite flag, not a length; and environ is the array you walk to see everything.

Lesson

The process environment

Every process has its own environment: a list of NAME=value strings. Programs read these strings for configuration.

Reading and changing the environment

The standard C library gives you these functions:

  • char *getenv(const char *name) — returns the value for a name, or NULL if the name is not set.
  • setenv / unsetenv — add, change, or remove a single variable.
  • clearenv — removes every variable.

Inheritance

When a process starts a child process, the child receives a copy of the parent's environment.

This makes environment variables a convenient way to configure a program per deployment, without changing any code.

Code examples

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

extern char **environ;   /* the live environment array, NULL-terminated */

/* Return the value of `name`, or `dflt` when the variable is unset. */
static const char *getenv_or(const char *name, const char *dflt) {
    const char *v = getenv(name);
    return v ? v : dflt;          /* getenv yields NULL when name is absent */
}

int main(void) {
    /* 1. Read configuration with safe defaults. */
    const char *user = getenv_or("USER", "(unknown)");
    const char *home = getenv_or("HOME", "(no home)");
    printf("USER = %s\n", user);
    printf("HOME = %s\n", home);

    /* 2. Set a variable only if it is not already present. */
    if (setenv("APP_MODE", "production", 0) != 0) {  /* overwrite=0 */
        perror("setenv");
        return EXIT_FAILURE;
    }
    printf("APP_MODE = %s\n", getenv("APP_MODE"));

    /* 3. Force-overwrite a variable, then remove it. */
    if (setenv("APP_MODE", "debug", 1) != 0) {       /* overwrite=1 */
        perror("setenv");
        return EXIT_FAILURE;
    }
    printf("APP_MODE = %s\n", getenv("APP_MODE"));
    unsetenv("APP_MODE");
    printf("APP_MODE after unset = %s\n",
           getenv("APP_MODE") ? getenv("APP_MODE") : "(unset)");

    /* 4. Walk the entire environment. */
    int count = 0;
    for (char **p = environ; *p != NULL; p++) {
        count++;
    }
    printf("environment has %d variables\n", count);

    return 0;
}

What it does. It reads USER and HOME (falling back to defaults if unset), demonstrates the two modes of setenv, removes a variable with unsetenv, and counts how many variables exist by walking environ to its terminating NULL.

Expected output (values depend on your machine).

USER = ana
HOME = /home/ana
APP_MODE = production
APP_MODE = debug
APP_MODE after unset = (unset)
environment has 37 variables

Edge cases. If USER is unset (common in some containers and cron jobs), the program prints (unknown) instead of crashing. A variable can legitimately be the empty string: getenv returns a pointer to "", which is not NULL, so getenv_or would return that empty string, not the default — distinguish "set to empty" from "unset" when that matters.

Line by line

We trace the example from main downward.

  1. extern char **environ; — declares the global array the C runtime set up at startup so we can iterate it later.
  2. getenv_or("USER", "(unknown)") — calls getenv("USER"). If the shell exported USER=ana, it returns a pointer to "ana"; the ternary keeps it. If USER is unset, getenv returns NULL, the ternary picks "(unknown)".
  3. The two printf lines print the resolved USER and HOME. No copying was needed because we only read the values.
  4. setenv("APP_MODE", "production", 0)APP_MODE is almost certainly unset, so it is created with value "production". The 0 would have prevented overwriting if it already existed. On success it returns 0; we check for -1 and bail out with perror if allocation failed.
  5. getenv("APP_MODE") now returns "production", which we print.
  6. setenv("APP_MODE", "debug", 1) — the 1 forces an overwrite, so the value becomes "debug".
  7. unsetenv("APP_MODE") — removes the entry entirely. The following getenv returns NULL, so the guarded print shows (unset).
  8. The for loop starts p at environ and advances until *p is NULL, incrementing count once per entry. That count is the number of variables.

State of APP_MODE across the run:

Step Action getenv("APP_MODE")
start (not set) NULL
4 setenv ..., 0 "production"
6 setenv ..., 1 "debug"
7 unsetenv NULL

Common mistakes

Mistake 1: Dereferencing a NULL from getenv

/* WRONG */
char *path = getenv("DATABASE_URL");
printf("%s\n", path);          /* crashes if DATABASE_URL is unset */
size_t n = strlen(path);       /* undefined behavior on NULL       */

Why it is wrong. getenv returns NULL when the name is absent, and passing NULL to printf("%s") or strlen is undefined behavior (often a segfault).

/* CORRECT */
const char *path = getenv("DATABASE_URL");
if (path == NULL) {
    fprintf(stderr, "DATABASE_URL is required\n");
    return EXIT_FAILURE;
}

How to prevent it. Always branch on the NULL case, or route every read through a helper like getenv_or.

Mistake 2: Modifying or freeing the returned string

/* WRONG */
char *home = getenv("HOME");
home[0] = 'X';     /* writing into environment-owned memory */
free(home);        /* you never allocated it               */

Why it is wrong. The pointer aims into the environment's own storage. Writing through it corrupts the environment; freeing it is an invalid free.

/* CORRECT */
const char *home = getenv("HOME");
char copy[PATH_MAX];
if (home) { snprintf(copy, sizeof copy, "%s", home); /* now edit copy */ }

How to recognize it. Compilers warn when you assign a const char * source to a non-const target; keep the result const to make accidental writes a compile error.

Mistake 3: Holding a getenv pointer across a setenv

/* WRONG */
const char *m = getenv("MODE");
setenv("OTHER", "x", 1);   /* may reallocate the environment */
printf("%s\n", m);          /* m might now dangle             */

Why it is wrong. Modifying functions can move the environment, invalidating earlier pointers.

/* CORRECT */
const char *src = getenv("MODE");
char *m = src ? strdup(src) : NULL;  /* own a copy */
setenv("OTHER", "x", 1);
if (m) { printf("%s\n", m); free(m); }

Mistake 4: Expecting changes to reach the parent shell

Running your program and then checking the variable in the shell shows it unchanged. That is correct behavior: children get a copy. To configure a child, set the variable before launching it, or set it in the shell with export.

Debugging tips

Compiler errors.

  • implicit declaration of function 'setenv' — you forgot #include <stdlib.h> (or are compiling in strict C89 mode; setenv is POSIX). Add the header and compile with -std=c11.
  • 'environ' undeclared — add extern char **environ; near the top, or #define _GNU_SOURCE before includes and #include <unistd.h>.

Runtime errors.

  • Segfault printing a value — almost always a NULL from getenv. Print a sentinel: printf("%s\n", v ? v : "(null)"); to confirm.
  • setenv returns -1 — check errno/perror; EINVAL means the name was empty or contained an =, ENOMEM means allocation failed.

Logic errors.

  • "My default never triggers." The variable is probably set to an empty string, which getenv reports as a non-NULL "". Test the value, not just the pointer.
  • "The child doesn't see my variable." You set it after the child started, or in a different process. Set it before exec.

Investigation tools. From the shell, run env to list everything, printenv NAME to read one, and NAME=value ./prog to set a variable for a single run. Inside a debugger, print (char*)getenv("NAME") shows the live value.

Questions to ask when it does not work. Is the variable actually exported (export NAME=..., not just NAME=...)? Am I reading the same name I set, including exact case? Did I check for NULL? Did a later setenv invalidate an earlier pointer?

Memory safety

The environment is a shared, mutable global, so the usual C hazards apply with a few twists specific to it.

  • NULL from getenv. The single most common bug. Passing the result to printf("%s"), strlen, strcpy, etc. without a NULL check is undefined behavior. Always guard it.
  • Borrowed, read-only memory. The pointer getenv returns is owned by the C runtime. Do not write through it and do not free it. Copy it if you need a mutable or long-lived version.
  • Pointer invalidation. setenv, unsetenv, and putenv may reallocate the environment, leaving previously returned pointers dangling. Copy values you need to keep across such calls.
  • Unbounded length. Values can be arbitrarily long and arbitrary bytes (no length limit you can rely on). Never strcpy an environment value into a fixed buffer — use snprintf with sizeof the destination, or measure with strlen and allocate.
  • Thread safety. Reading with getenv while another thread calls setenv/unsetenv is a data race. Modify the environment only before threads start (for example, right at the top of main), or serialize access yourself.
  • Validation = safety. Because values are user-controlled, parse them defensively: a variable expected to be a number should go through strtol with range and error checks, not atoi, which silently returns 0 on garbage and cannot report failure.

Real-world uses

Where it shows up.

  • Application configuration. Web services read PORT, DATABASE_URL, and LOG_LEVEL from the environment so the same binary runs unchanged across dev, staging, and production (the twelve-factor pattern).
  • The shell and OS. PATH tells the system where to find executables; HOME, USER, LANG, TZ, and TERM shape how countless programs behave.
  • Containers and CI. Docker (-e KEY=value) and CI systems inject configuration and secrets through the environment because it is the lowest-common-denominator interface.
  • Toolchains. Build tools read CC, CFLAGS; debuggers and sanitizers read ASAN_OPTIONS, LD_LIBRARY_PATH.

Professional best practices.

Beginner rules.

  • Always handle the unset (NULL) case, ideally through one small helper.
  • Keep getenv results const; never modify or free them.
  • Validate every value before trusting it (numbers via strtol, enums via explicit comparison).
  • Document which variables your program reads, in the README or --help.

Advanced habits.

  • Read configuration once at startup into a typed config struct, then pass that struct around — avoids repeated lookups and the thread-safety pitfalls of getenv mid-run.
  • Never log secret-bearing variables (tokens, passwords, keys); redact them.
  • For privileged programs, sanitize dangerous variables (PATH, IFS, LD_PRELOAD, LD_LIBRARY_PATH) or clearenv and set only what you need before spawning children.
  • Prefer explicit defaults and fail fast with a clear message when a required variable is missing, rather than limping along with surprising behavior.

Practice tasks

Beginner 1 — Print a variable with a default

Objective. Read one variable and never crash on unset.

Requirements. Read EDITOR; if unset, print EDITOR is not set, using vi; otherwise print EDITOR is <value>.

Example. With EDITOR=nano set, output is EDITOR is nano; with it unset, output is EDITOR is not set, using vi.

Hints. Use getenv and a NULL check. Concepts: getenv, NULL handling.

Beginner 2 — Count and list variables

Objective. Walk the whole environment.

Requirements. Print every NAME=value on its own line by iterating environ, then print the total count.

Constraints. Do not assume any particular ordering; stop at the terminating NULL.

Hints. Declare extern char **environ; and loop until *p == NULL. Concepts: environ array, iteration.

Intermediate 1 — getenv_or helper (mirrors exercise env-vars-sys-ex1)

Objective. Centralize the default logic.

Requirements. Implement const char *getenv_or(const char *name, const char *dflt) returning the value or dflt when unset. Use it to read HOST (default localhost) and PORT (default 8080) and print connecting to <HOST>:<PORT>.

Hints. A one-line ternary on the getenv result is enough. Concepts: getenv, defaults, helper functions.

Intermediate 2 — Boolean flag with validation (mirrors exercise env-vars-sys-ex2)

Objective. Parse a value, do not just check existence.

Requirements. Implement int env_is_true(const char *name) returning 1 only when the value is exactly "1" or "true", else 0 (including when unset). Print debug: on or debug: off based on DEBUG.

Constraints. Treat an empty string and unset both as false.

Hints. Guard NULL first, then use strcmp. Concepts: getenv, string comparison, validation.

Challenge — Safe integer config with range check

Objective. Turn an environment string into a validated integer.

Requirements. Read WORKERS. If unset, use 4. If set, parse it with strtol, rejecting non-numeric input and values outside 1..64; on bad input print an error to stderr and exit with a non-zero status. Otherwise print starting with N workers.

Input/output example. WORKERS=8 ./prog -> starting with 8 workers; WORKERS=abc ./prog -> error and non-zero exit; WORKERS=999 ./prog -> range error.

Hints. Use strtol with a non-NULL endptr, check that *endptr == '\0', and verify the range explicitly. Do NOT use atoi. Concepts: getenv, strtol, validation, error handling, exit codes.

Summary

  • The environment is a NULL-terminated array of NAME=value strings that every process receives at startup; the C runtime exposes it through the global environ. This is a second input channel alongside the argv you saw in The main function.
  • Read values with getenv, which returns NULL when the name is unset. Modify with setenv (the overwrite flag decides whether an existing value is replaced), unsetenv (remove one), and clearenv (wipe all).
  • Children inherit a copy of the parent's environment at the moment they are launched; changes do not flow back to the parent.
  • Most important syntax: const char *v = getenv("NAME"); then a NULL check; setenv("NAME", "value", 1); to overwrite.
  • Common mistakes: dereferencing a NULL result, writing to or freeing the borrowed string, keeping a pointer across a setenv, and expecting changes to reach the parent shell.
  • Remember: environment values are untrusted, user-controlled input of unbounded length. Always check for NULL, never copy them into fixed buffers without bounds, validate them (use strtol, not atoi), and never log secrets.

Practice with these exercises