New: Try Voli The Bear, Fast package manager (and not only) for Windows
Updated Jul 10, 2026 Edit on GitHub

Environment Variables & .env Files

You've seen them in READMEs (export API_KEY=...), in error messages (DATABASE_URL is not set), and in that mysterious .env file everyone has but nobody explains. Environment variables are the single most common way config reaches a running program, and once you see what they actually are, they stop being magic. Let's make them ordinary.

What an environment variable actually is

What it actually is. An environment variable is a NAME=value pair that the operating system keeps for a running program and its children. When your program starts, it inherits a little bag of these pairs - its environment - and it can look any of them up by name. That's the whole concept: named values handed to a process from the outside.

   ┌──────────── the process's environment ────────────┐
   │  DATABASE_URL = postgres://localhost:5432/myapp    │
   │  LOG_LEVEL    = debug                              │
   │  API_KEY      = sk_test_abc123                     │
   │  PATH         = /usr/local/bin:/usr/bin:/bin       │
   └────────────────────────────────────────────────────┘
            │  your program reads these by name
            ▼
        "give me DATABASE_URL"  →  "postgres://localhost:5432/myapp"

📝 Terminology. The convention is ALL_CAPS_WITH_UNDERSCORES for the name - not an OS rule, but every project follows it. The value is always text (a string); even a "number" like a port is stored as the characters "5432".

Why people get this wrong. People assume environment variables are something Python or Node invented. They're not - they're an operating-system feature that predates all of them. Your shell has them, every program has them, and that PATH variable that tells your terminal where to find commands is the same mechanism. Frameworks just give you a convenient way to read them.

Reading a variable from the shell

The fastest way to see one is from your terminal. On macOS or Linux, echo prints a value and $NAME asks the shell for that variable:

$ echo $HOME
/home/ada

What just happened: The shell looked up the HOME variable - which the OS set for you at login - and substituted its value, then echo printed it. HOME is one your system always sets; it points at your user folder.

Ask for one that isn't set, and you get nothing - an empty line, not an error:

$ echo $DATABASE_URL

What just happened: DATABASE_URL isn't set in this shell, so $DATABASE_URL expanded to nothing, and echo printed a blank line. This is the root of those is not set errors: the program asked for a variable, the OS had nothing, and the program gave up.

⚠️ Gotcha - Windows is different. echo $VAR is Unix-shell syntax (bash, zsh). On PowerShell the same lookup is echo $env:HOME; in the old Command Prompt it's echo %HOME%. The concept is identical everywhere - only the reading syntax differs.

You can set one for the current shell session like this (Unix):

$ export LOG_LEVEL=debug
$ echo $LOG_LEVEL
debug

What just happened: export created LOG_LEVEL and marked it so programs launched from this shell inherit it. No spaces around the =, and it's temporary - it lives only until you close this terminal. That temporariness is exactly the problem .env files solve, coming up.

Reading a variable from your code

Every language has a built-in way to read the environment. You don't import a library for this part - the variables are just there for any program. A few of the common ones:

   Python      os.environ["DATABASE_URL"]        # or os.environ.get(...) for a safe default
   Node.js     process.env.DATABASE_URL
   Ruby        ENV["DATABASE_URL"]
   Go          os.Getenv("DATABASE_URL")
   Rust        std::env::var("DATABASE_URL")

Here's the shape it takes in practice (Python), and what it does when you run it:

$ export GREETING=hello
$ python3 -c "import os; print(os.environ.get('GREETING', 'default'))"
hello
$ python3 -c "import os; print(os.environ.get('MISSING', 'default'))"
default

What just happened: The first call read GREETING from the environment we just exported. The second asked for MISSING, which we never set - but because we used .get() with a fallback of 'default', it printed that instead of crashing. That fallback pattern is how you give a setting a sensible default while still letting the environment override it.

⚠️ Gotcha - everything is a string. A variable read from the environment is always text. Set PORT=8080 and need a number? Convert it (int(os.environ["PORT"]) in Python) - forgetting this gives confusing errors like trying to do math on the string "8080".

.env files - sane local development

Why people get this wrong. Setting variables by hand with export works, but it's miserable daily: the values vanish when you close the terminal, and a real app might need a dozen of them. Nobody wants to re-type twelve export lines every morning. There's a standard fix.

What it actually is. A .env file (pronounced "dot-env") is a plain text file in your project, one NAME=value per line, that a small library loads into the environment when your app starts. It's a convenience: instead of exporting variables by hand, you write them down once.

A typical .env looks like this:

DATABASE_URL=postgres://localhost:5432/myapp_dev
LOG_LEVEL=debug
API_KEY=sk_test_abc123
PORT=8080

To load it, you add a tiny library - python-dotenv for Python, dotenv for Node, and so on. In Python:

$ pip install python-dotenv
$ python3 -c "from dotenv import load_dotenv; import os; load_dotenv(); print(os.environ['LOG_LEVEL'])"
debug

What just happened: load_dotenv() found the .env file, read each line, and placed those pairs into the process environment - exactly as if you'd exported each by hand. os.environ['LOG_LEVEL'] then reads debug from the file. Your code doesn't change; it still just reads environment variables. The .env file only populates them.

💡 Key point. The .env file is a development convenience. In staging and production you usually don't ship one - the hosting platform (or container, or secrets manager) sets the real environment variables directly. Your code reads os.environ[...] the same way everywhere; only the source of those values changes. That's the payoff from Phase 1: one codebase, different surroundings.

⚠️ Never commit your .env

This is the rule that gets its own heading because getting it wrong is genuinely costly.

Your .env file holds the real values for your machine - and on real projects, that includes secrets: API keys, database passwords, tokens. Commit it to Git and those secrets go into the repository's history, visible to everyone with access and effectively impossible to erase (deleting the file in a later commit doesn't remove it from history).

So you tell Git to ignore it. Add this one line to a file named .gitignore in your project:

.env

Then confirm Git is actually ignoring it:

$ git status
On branch main
nothing to commit, working tree clean

What just happened: Even though .env exists on disk, it doesn't appear in git status - .gitignore told Git to pretend it isn't there, so you can't accidentally stage or commit it. If .env did show up, it isn't ignored yet - fix that before your next commit.

📝 The .env.example convention. Since .env itself is secret and uncommitted, projects commit a companion .env.example instead - it lists the names every variable needs, with fake or blank values, so a new teammate knows what to fill in:

DATABASE_URL=
LOG_LEVEL=debug
API_KEY=your_test_key_here
PORT=8080

When you clone a project, the ritual is: copy .env.example to .env, then fill in the real values. That's the is not set error from the start of this guide, solved.

This is only the first layer of keeping secrets safe. Storing and distributing production secrets properly (vaults, encrypted files, cloud secret managers) is its own topic: Secrets Management.

Why this saves you later. Reading config from the environment means the same code works on your laptop with a .env file and in production with platform-injected variables - and keeping .env out of Git means a leaked laptop or a public repo doesn't hand an attacker your production keys.

Recap

  1. An environment variable is a NAME=value pair the OS gives your running program; your code reads it by name (os.environ, process.env, ENV[...], etc.).
  2. Read one from the shell with echo $NAME (Unix), $env:NAME (PowerShell), or %NAME% (cmd). An unset variable reads as empty, which is what is not set errors come from.
  3. Everything is a string - convert ports and numbers yourself.
  4. A .env file loads many variables at once for local development; a small library reads it into the environment so your code doesn't change.
  5. Never commit .env - add it to .gitignore. Commit a .env.example listing the variable names instead.

Next: when a handful of variables isn't enough and you need structured, nested config - YAML and friends.


← Phase 1: Why Config Lives Outside Code · Guide overview · Phase 3: Config Files: YAML & Friends →

Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.

Check your understanding 2 questions

1. An environment variable is...

2. The rule about a .env file is: