
Python Read Environment Variables: A Practical Guide 2026
Discover how to python read environment variables with os.environ, dotenv, Pydantic, and more. This 2026 guide covers configuration, defaults, and secrets.
You're probably here because you have a Python script, app, or API client that needs configuration right now. Maybe it's an API key, a database URL, a DEBUG flag, or a port number. Maybe you hardcoded one of those values just to “get it working,” then realized that committing secrets to Git is how small mistakes become production incidents.
That's the primary job of environment variables. They keep configuration outside your Python source code, so you can change values per machine, per deploy, or per environment without editing the program itself. In Python, that usually starts with the built-in os module, grows into .env files for local development, and then becomes more structured with typed settings libraries when the app gets serious.
I think of Python read environment variables as a maturity model, not just a syntax question. A simple script needs one approach. A team shipping multiple environments needs another. If you choose the method that matches the complexity of your project, configuration gets simpler and failures get easier to diagnose.
Table of Contents
- Why Environment Variables Are Critical in Python
- The Foundation Using Python's OS Module
- Local Development with Python Dotenv
- Robust Applications with Pydantic Settings
- Strategies for Docker and CI CD Pipelines
- Choosing Your Method and Securing Secrets
Why Environment Variables Are Critical in Python
You push a small Python app to staging, and it fails before the first request. The code worked locally because the API key, database URL, and debug flag were hardcoded for one machine.
API_KEY = "my-secret-key"
DATABASE_URL = "postgres://localhost/app"
DEBUG = True
That pattern is fine for a throwaway script you run once. It becomes expensive the moment the same code needs to run on your laptop, in CI, inside Docker, and in production. Secrets end up in Git history, config changes require code edits, and simple deployments turn into “what value is this app using right now?” debugging sessions.
Environment variables solve that by separating code from configuration. The application reads values at runtime, while the shell, container, platform, or deployment pipeline provides the right settings for that environment. The code stays the same. The configuration changes per machine or stage.
That separation is the core benefit. Security matters, but maintainability matters just as much. A database host, feature flag, log level, or third-party endpoint often changes by environment even when the Python code should not.
The practical way to choose a method is to treat configuration as a maturity model, not a one-size-fits-all rule:
- Use
os.environfor simple scripts and small services. - Use a
.envfile for local development when teammates need an easy way to run the app consistently. - Use typed settings with Pydantic when the app has enough moving parts that validation and clear defaults save time.
- Use a centralized secret manager when multiple environments, teams, and deployment systems need controlled access to shared secrets.
Practical rule: If a value changes between machines or environments, keep it out of source code.
That includes API keys, database credentials, service URLs, feature flags, and any setting you do not want to edit in multiple files before every deploy. The right approach depends on the size of the project and the cost of getting configuration wrong.
The Foundation Using Python's OS Module
For most projects, the first correct answer is still the built-in os module. No extra dependency. No magic. Just read from the process environment and fail in a predictable way when configuration is missing.

Read required and optional values
Python gives you two patterns that matter:
import os
api_key = os.environ["API_KEY"]
debug = os.getenv("DEBUG")
Use them intentionally.
os.environ["KEY"]is strict. If the variable is missing, Python raises an error.os.getenv("KEY")oros.environ.get("KEY")is softer. It returnsNonewhen the variable isn't there.os.environ.get("KEY", "default")lets you define a fallback.
That behavior is part of the standard interface: os.environ behaves like a dictionary, os.environ.get() returns None when a key is missing, and os.environ['KEY'] raises an error if the variable is absent (Alexandra Zaharia on Python environment variable use cases).
Here's the pattern I recommend:
import os
API_KEY = os.environ["API_KEY"] # required
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO") # optional with default
If your app cannot start without a setting, be strict. Let it fail immediately. Don't let a missing secret surface later during a user request.
Missing required config should break startup, not break production traffic an hour later.
Handle strings, integers, and booleans carefully
Every environment variable comes in as a string. That trips up a lot of junior developers because "8000" looks like a number and "False" looks like a boolean, but Python still reads both as strings.
You need to cast explicitly:
import os
PORT = int(os.environ.get("PORT", "8000"))
For booleans, don't do this:
DEBUG = bool(os.environ.get("DEBUG", False))
That's wrong because non-empty strings are truthy. "False" becomes True.
Do this instead:
DEBUG = os.environ.get("DEBUG", "false").lower() in {"1", "true", "yes", "on"}
A few practical habits help:
- Use strict access for secrets when the app must not run without them.
- Use defaults for safe optional settings like
LOG_LEVELor a localPORT. - Cast once at startup instead of converting values in random places later.
- Keep parsing logic near config loading so other modules don't need to know how values are shaped.
This is the lowest-friction way to do Python read environment variables. For scripts, CLIs, cron jobs, and small services, it's often enough. Where it starts to hurt is local development. Exporting a bunch of variables manually in every shell gets old fast.
Local Development with Python Dotenv
When you move beyond one-off scripts, the next pain point is obvious. You don't want to pollute your global shell profile for every project, and you don't want to re-export the same variables every time you open a new terminal.
That's where python-dotenv fits.

A clean local workflow
The common pattern is simple: create a .env file with plain KEY=VALUE entries, call load_dotenv(), then keep reading values through os.getenv() or os.environ.
from dotenv import load_dotenv
import os
load_dotenv()
API_KEY = os.getenv("API_KEY")
DATABASE_URL = os.getenv("DATABASE_URL")
A matching .env file might look like this:
API_KEY=dev-secret-key
DATABASE_URL=postgres://localhost/myapp
DEBUG=true
That workflow is widely used in Python local development. The important details are concrete: python-dotenv is the common loader, you call load_dotenv() first, the file format is plain KEY=VALUE, and the file should be excluded from version control with .gitignore to avoid secret leakage (GeeksforGeeks on accessing environment variables in Python).
If you want a deeper walkthrough on organizing project env files, this guide on Python env files is useful because it focuses on how teams structure local configuration rather than just showing one code snippet.
Here's the local setup I usually prefer:
- Create
.envfor local-only values - Add
.envto.gitignoreimmediately - Call
load_dotenv()at the entry point - Keep reading config through
os.getenv() - Commit a
.env.examplefile without secrets
That last step matters. A .env.example file helps teammates know which keys exist without sharing actual secrets.
Mistakes that cause confusion fast
The biggest mistake is assuming .env loads automatically. It doesn't. If you forget load_dotenv(), your variables won't appear, and you'll waste time debugging the wrong thing.
The second mistake is treating environment variables like native Python types. They're still strings after loading, so PORT, TIMEOUT, and DEBUG still need parsing.
A third mistake is using .env as if it were a production secret system. It isn't. It's a local convenience layer.
This short walkthrough is worth watching if you want another practical angle on local env handling:
My opinionated take is simple. python-dotenv is the right middle step for solo development and small projects. It keeps your local setup sane. It doesn't solve validation, typing, or team secret distribution. Once those become real problems, you're ready for a more structured settings layer.
Robust Applications with Pydantic Settings
A script can get away with a few os.getenv() calls. An application usually cannot.
Once config starts spanning database URLs, feature flags, ports, API credentials, and environment-specific defaults, ad hoc parsing becomes a source of bugs. Values get cast in different places. Required settings are easy to miss. A typo in one variable name can sit unnoticed until runtime.

Why typed settings are the next maturity step
Pydantic Settings fits the next stage in the config maturity model. Start with os for small scripts. Add .env for local development. Move to Pydantic once the app needs typed config, validation, and one clear source of truth. After that, teams usually pair this pattern with platform-injected secrets or a centralized manager across environments. If you also deploy through containers, the same settings model works well with standard Docker environment variable injection patterns.
Instead of spreading parsing logic around the codebase:
import os
DB_URL = os.environ["DB_URL"]
PORT = int(os.environ.get("PORT", "8000"))
DEBUG = os.environ.get("DEBUG", "false").lower() in {"1", "true", "yes"}
JWT_SECRET = os.environ["JWT_SECRET"]
Define the contract once:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
db_url: str
port: int = 8000
debug: bool = False
jwt_secret: str
settings = Settings()
That change matters for day-to-day maintenance. Required fields are obvious. Defaults are visible. Type conversion happens in one place. If PORT is invalid or JWT_SECRET is missing, the app fails early instead of drifting into a confusing runtime error later.
A practical Pydantic pattern
This is the shape I usually recommend for a service:
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "my-app"
environment: str = "development"
debug: bool = False
port: int = 8000
database_url: str
api_key: str
model_config = SettingsConfigDict(env_file=".env")
settings = Settings()
A few trade-offs are worth calling out.
- Types are enforced.
portbecomes anint, not a string that every module has to parse again. - Startup failures are clearer. Missing or invalid values show up when settings load.
- Config stays centralized. Other modules import
settingsinstead of reading from the environment directly. - There is added structure. That is a win for services and a burden for tiny scripts.
- It is still not a secret manager. Pydantic validates values. It does not solve secret distribution by itself.
That last point is the one people often miss.
Pydantic improves how the application reads configuration. It does not change where production secrets should live. In a team setting, values still need to come from the runtime environment, CI system, container platform, or a centralized secret store.
I draw the line like this:
| Project shape | Best fit |
|---|---|
| One script or tiny CLI | os.environ / os.getenv() |
| Small app with local setup needs | python-dotenv plus os.getenv() |
| Service with many settings and multiple environments | Pydantic Settings |
| Team-based systems with shared infrastructure | Pydantic plus platform env vars or a centralized secret manager |
Use Pydantic when config has become part of the application architecture, not just a few keys at startup. That is usually the point where validation and a clear schema save more time than the extra dependency costs.
Strategies for Docker and CI CD Pipelines
A common failure shows up the first time a Python app leaves a laptop. The code worked with a local .env file, then the Docker container starts in CI and fails because DATABASE_URL was never injected. The Python code usually is not the problem. The deployment path is.
At this stage, the maturity model matters. .env files are fine for local development. Containers, CI jobs, and production runtimes should supply configuration from the platform itself. Python should keep reading from the process environment the same way, whether you use os.getenv() directly or a Pydantic settings model on top.
What changes in Docker and CI
Docker and CI systems shift the question from "how do I read env vars?" to "where do these values enter the process?" That is the operational boundary that trips teams up.
For containers, the cleanest default is runtime injection. Pass values with docker run, Compose environment, or your orchestrator's secret mechanism. If you work with containers often, this guide on Docker environment variables is a useful reference because it focuses on injection patterns rather than Python syntax.
For CI, keep secrets in the CI platform, not in a checked-in file. GitHub Actions secrets, GitLab CI variables, and similar features exist for a reason. They give the pipeline access to credentials without teaching every developer to manage production values locally.
Examples for Docker and GitHub Actions
For Docker, you can pass variables directly:
docker run -e API_KEY=dev-secret -e DEBUG=false myapp
Or define them in docker-compose.yml:
services:
app:
build: .
environment:
API_KEY: ${API_KEY}
DEBUG: "false"
You can also use an env file:
services:
app:
build: .
env_file:
- .env
Each option has a trade-off. Inline values are explicit and easy to inspect in one place, but they get noisy fast. environment: works well when the host or CI system already exports the variables. env_file is convenient for local stacks, but I would treat it as a developer tool, not a production secret strategy.
For GitHub Actions, repository or environment secrets are the usual approach:
name: test-app
on: [push]
jobs:
test:
runs-on: ubuntu-latest
env:
API_KEY: ${{ secrets.API_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
steps:
- uses: actions/checkout@v4
- name: Run tests
run: pytest
A few rules keep this sane:
- Set values before the app starts. Configuration should be fixed at startup.
- Keep Python code environment-agnostic. Local dev, Docker, and CI should differ in injection, not in application logic.
- Avoid baking secrets into Docker images.
- Treat
.envas local setup, not as the default answer for shared environments.
That last rule is usually the line between a project that feels small and one that has entered team operations. Once multiple environments and multiple people are involved, secret handling becomes an infrastructure concern. Pydantic still helps inside the app because it validates what arrives, but the source of truth should be the runtime platform or a centralized secret manager. If you want a practical example of how environment-based config fits into an actual deployment flow, AppLighter environment setup is a useful reference.
Choosing Your Method and Securing Secrets
If you strip away the tooling debate, the choice is mostly about project maturity.
A simple maturity model
Here's the comparison I'd hand to a junior developer:
Comparison of Python Environment Variable Methods
| Method | Primary Use Case | Type Safety | Dependencies |
|---|---|---|---|
os.environ / os.getenv() |
Simple scripts, small services, required startup config | Manual | None |
python-dotenv |
Local development and project-specific env files | Manual | python-dotenv |
| Pydantic Settings | Larger apps with many settings and validation needs | Stronger through typed models | Pydantic settings package |
The practical recommendation is usually this:
- Start with
oswhen the app is small and the config surface is tiny. - Add
.envlocally when developers need project-specific setup without editing shell profiles. - Move to Pydantic when casting, defaults, and validation start spreading across the codebase.
- Use centralized secret management when multiple people, environments, and deployment systems need controlled access.
If you're integrating a Python app into a broader product workflow, a concise reference like AppLighter environment setup is helpful because it shows how environment-based configuration fits into an actual deployment path rather than treating env vars as an isolated coding trick.
When teams outgrow shared files
Shared .env files are workable for a while. Then the problems start.
Who has the latest version? Who changed a production secret? How do you revoke access when someone leaves? How do you avoid secrets getting copied into chat, email, or old laptops?
That's where centralized secret managers come in. Instead of passing files around, teams store values in a managed system with access control, environment separation, and auditability. If you want a grounded checklist for that shift, these secrets management best practices are a good reference.

One example is EnvManager, which stores environment variables and API secrets in a centralized vault, supports per-environment access controls, and syncs values to local machines or CI/CD through its CLI. That kind of tool doesn't replace Python's os.environ. It replaces the fragile human process around sharing and updating the values that os.environ reads.
The maturity model is the answer:
- Small script: use
os - Local project: add
.env - Real application: use typed settings
- Team workflow: move secrets into a managed system
That progression keeps your code simple for as long as possible, then adds structure exactly when the pain justifies it.
If your team has moved past passing .env files around, EnvManager is a practical next step. It gives you a centralized place to manage environment variables across local development, staging, production, and CI/CD without changing how your Python code reads them.