By “environment variables,” I mean more than just shell variables. I’m referring to the variables that each process possesses and can access through system APIs. Of course, shells typically provide ways to manipulate environment variables, and we often manage them through a shell. But shell variables and environment variables are not exactly the same thing, and the two concepts can easily be confused. Let’s try to untangle them here.

1. Environment Variables

From a program’s perspective, environment variables are simple. They are a collection of key-value pairs that each process owns. A process can read existing environment variables, modify them, or create new ones. When a process creates a child process, the child inherits the parent’s environment variables, but modifications made by the child do not affect the parent’s environment.

For convenience, let’s use Python as an example.

A process can read existing environment variables:

# inherit.py
import os
print(os.environ["HOME"])

It can also write or create environment variables:

os.environ["MY_VAR"] = "my_value"

Child processes inherit the parent’s environment variables:

# env.py
import os
import sys

assert len(sys.argv) == 2
father_proc = bool(int(sys.argv[1]))

print_my_var = lambda: print(
    f"process={'father' if father_proc else 'child'}, "
    f"MY_VAR={os.environ.get('MY_VAR', None)}"
)

print_my_var()

if father_proc:
    os.environ["MY_VAR"] = "1"
    print_my_var()
    os.system(f"{sys.executable} {sys.argv[0]} 0")
    print_my_var()
else:
    os.environ["MY_VAR"] = "2"
    print_my_var()
$ python inherit.py 1
process=father, MY_VAR=None
process=father, MY_VAR=1
process=child, MY_VAR=1
process=child, MY_VAR=2
process=father, MY_VAR=1

2. Environment Variables in the Shell

In the shell, things are more complex than in a programming language.

Taking bash as an example, you can set variables in the shell:

$ MY_VAR=1

But this variable’s value won’t be passed to child processes.

$ MY_VAR=1
$ bash -c 'echo $MY_VAR'

The output of the second command above is empty. Note that you must use single quotes here.

So this variable is not an environment variable — it’s merely a local variable within that shell process.

To set an environment variable, you need the export command.

$ export MY_VAR=1
$ bash -c 'echo $MY_VAR'
1

But what if an environment variable is already defined in the current environment? You’ll find that you no longer need export to change its value.

$ export MY_VAR=1
$ MY_VAR=2
$ bash -c 'echo $MY_VAR'
2

Because of this, we can achieve some surprising results. Let’s put the earlier code into a script:

# penetrate.sh
MY_VAR=1
bash -c 'echo $MY_VAR'

Based on what we saw on the command line, the output should be empty — and indeed it is.

$ bash penetrate.sh

But what if we define MY_VAR as an environment variable before running the script?

$ export MY_VAR=1234
$ bash penetrate.sh
1

As we can see, the MY_VAR=1 inside the script “penetrates” into the child bash process. Interesting.

Sometimes we have too many environment variables to set, so we want to put them in a script. We write the export commands into a script. But is this correct?

$ cat envs.sh
export MY_VAR=1
$ bash envs.sh
$ echo $MY_VAR

As mentioned earlier, a child’s modifications don’t affect the parent’s environment variables. bash envs.sh starts a new bash process, so it can’t modify the parent’s environment. What worked on the command line now fails.

bash provides a builtin command source that executes script commands in the current shell. Let’s replace bash with source.

$ source envs.sh
$ echo $MY_VAR
1
$ bash -c 'echo $MY_VAR'
1

Now we’ve successfully set MY_VAR as an environment variable. If we omit export in the script, MY_VAR becomes a local variable instead.

$ cat envs.sh
MY_VAR=1
$ source envs.sh
$ echo $MY_VAR
1
$ bash -c 'echo $MY_VAR'

Finally, there’s another way to define environment variables: prefix the command with the variable assignment. In this case, only the child process has the environment variable — the parent is unaffected.

$ MY_VAR=1 bash -c 'echo $MY_VAR'
1
$ echo $MY_VAR

This brings us back to the variable penetration problem. What happens if we use source in this context?

$ cat envs.sh
MY_VAR=1
$ MY_VAR=2 source envs.sh
$ echo $MY_VAR

MY_VAR is undefined? And what if we also define MY_VAR2?

$ cat envs.sh
MY_VAR=1
MY_VAR2=1
$ MY_VAR=2 source envs.sh
$ echo $MY_VAR

$ echo $MY_VAR2
1

Only MY_VAR is undefined. Very interesting.

3. Determining Whether a Variable Is an Environment Variable

Since shell variables are all accessed with the same syntax, you can’t directly tell which are local variables and which are environment variables. But environment variables are passed to child processes, so in some situations it’s important to know whether a variable is an environment variable.

The method here is to use the printenv command. Running printenv without arguments shows all environment variables in the current shell process. If you pass an argument, it prints the value of that environment variable, and you can check the exit code to determine if it exists.

$ MY_VAR=1
$ printenv MY_VAR
$ echo $?
1
$ export MY_VAR=1
$ printenv MY_VAR
1
$ echo $?
0