Linux System Programming · intermediate · ~8 min
## 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.
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.
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.
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?
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
COLORis not set in the environment, what doesgetenv("COLOR")return, and what happens if you pass that straight toprintf("%s\n", ...)?
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)andsetenv("LOG", "debug", 1)whenLOGalready exists?
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 callssetenv("TZ", "PST", 1). After your program exits, what isTZin the shell?
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.
Every process has its own environment: a list of NAME=value strings. Programs read these strings for configuration.
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.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.
#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.
We trace the example from main downward.
extern char **environ; — declares the global array the C runtime set up at startup so we can iterate it later.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)".printf lines print the resolved USER and HOME. No copying was needed because we only read the values.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.getenv("APP_MODE") now returns "production", which we print.setenv("APP_MODE", "debug", 1) — the 1 forces an overwrite, so the value becomes "debug".unsetenv("APP_MODE") — removes the entry entirely. The following getenv returns NULL, so the guarded print shows (unset).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 |
/* 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.
/* 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.
/* 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); }
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.
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.
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.
getenv reports as a non-NULL "". Test the value, not just the pointer.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?
The environment is a shared, mutable global, so the usual C hazards apply with a few twists specific to it.
printf("%s"), strlen, strcpy, etc. without a NULL check is undefined behavior. Always guard it.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.setenv, unsetenv, and putenv may reallocate the environment, leaving previously returned pointers dangling. Copy values you need to keep across such calls.strcpy an environment value into a fixed buffer — use snprintf with sizeof the destination, or measure with strlen and allocate.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.strtol with range and error checks, not atoi, which silently returns 0 on garbage and cannot report failure.Where it shows up.
PORT, DATABASE_URL, and LOG_LEVEL from the environment so the same binary runs unchanged across dev, staging, and production (the twelve-factor pattern).PATH tells the system where to find executables; HOME, USER, LANG, TZ, and TERM shape how countless programs behave.-e KEY=value) and CI systems inject configuration and secrets through the environment because it is the lowest-common-denominator interface.CC, CFLAGS; debuggers and sanitizers read ASAN_OPTIONS, LD_LIBRARY_PATH.Professional best practices.
Beginner rules.
NULL) case, ideally through one small helper.getenv results const; never modify or free them.strtol, enums via explicit comparison).--help.Advanced habits.
getenv mid-run.PATH, IFS, LD_PRELOAD, LD_LIBRARY_PATH) or clearenv and set only what you need before spawning children.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.
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.
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.
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.
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.
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.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).const char *v = getenv("NAME"); then a NULL check; setenv("NAME", "value", 1); to overwrite.NULL result, writing to or freeing the borrowed string, keeping a pointer across a setenv, and expecting changes to reach the parent shell.NULL, never copy them into fixed buffers without bounds, validate them (use strtol, not atoi), and never log secrets.