Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

os.environ in Python: Reading Configuration Without Shipping Secrets

Sean

Platform Writer

Aug 26, 2026
7 min read

os.environ is a mapping of the process’s environment variables. os.environ['KEY'] raises KeyError when the variable is absent; os.environ.get('KEY', default) returns a default instead. Every value is a string — there are no integers or booleans, and bool('False') is True, which is a bug people ship regularly.

os.environ in Python: Reading Configuration Without Shipping Secrets

Environment variables are the standard way to configure an application without putting the configuration in the code. Python’s interface to them is a dictionary-like object, and it behaves like a dictionary in most respects and unlike one in a few that matter.

Table of contents

Reading values

import os

os.environ['DATABASE_URL']              # KeyError if unset
os.environ.get('DATABASE_URL')          # None if unset
os.environ.get('PORT', '8000')          # a default
os.getenv('PORT', '8000')               # identical to .get()

'DATABASE_URL' in os.environ            # presence check
list(os.environ.keys())                 # everything

os.getenv and os.environ.get are the same function for practical purposes. Use whichever reads better.

The choice between bracket access and .get() is a design decision rather than a style one:

  • Required configuration — use brackets. A missing DATABASE_URL should stop the process at startup with a clear error, not produce None that fails a thousand lines later inside a connection call.
  • Optional configuration — use .get() with a sensible default.

Better still, fail loudly with an explanation:

def required(name):
    try:
        return os.environ[name]
    except KeyError:
        raise RuntimeError(f'{name} is required but not set') from None

DATABASE_URL = required('DATABASE_URL')

Validate every required variable at import time, not on first use. A service that starts successfully and then fails on the first request that touches a missing setting is much harder to diagnose than one that refuses to start.

Everything is a string

This is where the real bugs are.

os.environ['DEBUG'] = 'False'
bool(os.environ['DEBUG'])       # True -- any non-empty string is truthy

os.environ['PORT'] = '8000'
os.environ['PORT'] + 1          # TypeError

bool('False') being True is the classic one. It ships to production as debug mode left on, or a feature flag that cannot be turned off.

Convert explicitly, and be careful about what counts as true:

def env_bool(name, default=False):
    value = os.environ.get(name)
    if value is None:
        return default
    return value.strip().lower() in {'1', 'true', 'yes', 'on'}

def env_int(name, default=None):
    value = os.environ.get(name)
    if value is None:
        if default is None:
            raise RuntimeError(f'{name} is required')
        return default
    try:
        return int(value)
    except ValueError:
        raise RuntimeError(f'{name} must be an integer, got {value!r}') from None

For anything beyond a handful of settings, use a library rather than hand-rolling this. Pydantic Settings gives you typed configuration with validation at startup:

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    port: int = 8000
    debug: bool = False

settings = Settings()   # raises with a clear message if anything is wrong

That gives you the correct behaviour by default — required fields enforced, types coerced properly, and a useful error naming the field.

Writing, and why child processes matter

os.environ['MY_VAR'] = 'value'
del os.environ['MY_VAR']
os.environ.setdefault('LOG_LEVEL', 'INFO')

Two rules that are not obvious:

Changes affect this process and its future children only. You cannot modify the environment of the shell that started Python. This is why a script cannot set a variable for the terminal it was run from — the child cannot write to the parent’s environment. That is an operating system property, not a Python limitation.

import subprocess

os.environ['CHILD_VAR'] = 'visible'
subprocess.run(['printenv', 'CHILD_VAR'])   # visible

# or pass an explicit environment
subprocess.run(['printenv'], env={'ONLY': 'this'})
# or extend rather than replace
subprocess.run(['printenv'], env={**os.environ, 'EXTRA': 'value'})

Passing env= replaces the environment entirely. A child launched with env={'ONLY': 'this'} has no PATH, which produces a confusing FileNotFoundError on a binary that plainly exists. Extend with {**os.environ, ...} unless you specifically want isolation.

Values must be strings. Assigning an integer raises TypeError. Convert on the way in.

Local development with .env files

Environment variables are awkward to set by hand during development, which is what .env files solve:

# .env
DATABASE_URL=postgresql://localhost/myapp_dev
DEBUG=true
SECRET_KEY=dev-key-not-for-production
from dotenv import load_dotenv
load_dotenv()      # populates os.environ from .env

By default load_dotenv does not overwrite variables that are already set, which is the right behaviour — real environment variables in production should win over a file that happens to be present.

The rules that keep this safe:

  • .env goes in .gitignore. Always, before the first commit. A secret committed once is in the history permanently.
  • Commit a .env.example with the keys and placeholder values, so a new developer knows what to set.
  • Do not use .env files in production. The platform should supply the environment. A file on disk is one more thing to deploy, secure and rotate.
  • Add .env to .dockerignore as well, or it gets baked into an image layer.

Check nothing has leaked before pushing:

git log --all --full-history -- .env
git ls-files | grep -E '^\.env$'

If either returns anything, the secret is in history and rotating it is the only real fix — removing the file from the current commit does not remove it from the repository.

Reading the environment safely in logs and errors

A habit worth building early: never log the environment wholesale.

print(os.environ)                    # dumps every secret you have
print(dict(os.environ))              # same problem

This finds its way into debug output, exception handlers and error-reporting integrations, and the result is credentials sitting in a logging system that many more people can read than can read your production configuration.

If you need to log configuration, allowlist it and redact the rest:

SENSITIVE = ('SECRET', 'TOKEN', 'PASSWORD', 'KEY', 'CREDENTIAL', 'DSN')

def safe_environ():
    return {
        k: ('<redacted>' if any(s in k.upper() for s in SENSITIVE) else v)
        for k, v in os.environ.items()
    }

Substring matching is deliberately broad here. A false positive redacts something harmless; a false negative publishes a key.

The same applies to exception tracebacks — many error reporters capture local variables, and a DATABASE_URL containing a password in a stack frame ends up in the report. Most reporters support a denylist for exactly this reason, and configuring it is worth the ten minutes.

How this fits the rest of the stack

Environment variables are the standard answer to configuration because they keep secrets out of the code and let the same artefact run in several places. That only holds if the environment is actually supplied by something other than a file you committed.

RunxBuild sets environment variables per service in the dashboard, so the running process receives its configuration from the platform rather than from a .env in the image — and the deploy that reads them is built from your repository with logs you can check when a variable is missing. If you want to see what a Python service costs alongside a managed Postgres or MySQL, the RunxBuild hosting calculator itemises them.

Useful related references:

FAQ

What is the difference between os.environ and os.getenv?

os.environ['KEY'] raises KeyError when the variable is missing; os.getenv('KEY', default) and os.environ.get('KEY', default) return the default instead. Use brackets for required configuration so the process fails at startup, and .get() for genuinely optional settings.

Why is my boolean environment variable always True?

Because every environment value is a string and any non-empty string is truthy, so bool('False') is True. Convert explicitly by checking membership in a set such as {'1', 'true', 'yes', 'on'} after lowercasing, or use a settings library that handles the coercion.

Why does setting os.environ not affect my shell?

A child process cannot modify its parent’s environment — that is an operating system rule, not a Python one. Changes to os.environ apply to the current process and any children it starts afterwards, and nothing else.

Should I use .env files in production?

No. Use the platform’s environment configuration instead. A .env file in production is another artefact to deploy, secure and rotate, and it is easily baked into a container image by accident. Keep .env for local development, in .gitignore and .dockerignore.

How do I pass environment variables to a subprocess?

They are inherited automatically. To add to them, pass env={**os.environ, 'EXTRA': 'value'} — passing env= alone replaces the entire environment, which removes PATH and produces a confusing FileNotFoundError on binaries that exist.

#os environ#python#environment variables#configuration#twelve-factor