Last Updated: June 01, 2026

Advanced

The python await and async is one of the more advanced features to help run your programs faster by making sure the CPU is spending as little time as possible waiting and instead as much time as possible working. If ever you see a capable chef, you’ll know what I mean. The chef is not just following a recipe step by step (i.e. working synchronously), the chef is boiling water to cook the pasta , measuring the amount of pasta, chopping tomatoes for the pasta sauce until the water boils etc (i.e. the chef is working asynchronously). The chef is minimizing the time they are waiting idle and always working on a task. That’s the same idea with async and await.

For this tutorial, we will focus on python 3.7 as it has some of the more modern features of await and async. We will call out some of the differences for python 3.4 – 3.6.

Pubs - Python How To Program

Written by Pubs

Python developer and educator with 15+ years building production systems across data engineering, web APIs, and AI tooling. Founder of Python How To Program — 270+ in-depth tutorials covering the modern Python stack.

View all tutorials by Pubs →

What is async await in Python?

The async await keywords help to define in your program which parts need to run sequentially, and which parts may take sometime but other parts of the program can execute while this step completes. A modern example of this is that if you’re downloading a web page it may take a few seconds, while the download is happening you can execute other parts of your program.

How does async await work in Python?

Sometimes the best way to explain something is to show how you would achieve the same thing without the feature.

Continuing with the restaurant theme, suppose you are running a hamburger stall (you’re the waiter and the chef) and it is almost instant to collect payment for a customer and serve the final hamburger, but the most time consuming task is to cooking the beef patty which takes 2 seconds (one could only wish!).

See the below diagram:

Figure 1: Sequentially serving customers at a hamburger stall

In the above diagram:

  • Step 1: you would first get the order and collect the money from Customer 1
  • Step 2: you would then put a beef patty on the cook top and then wait for 2 seconds for the beef patty to cook. At the same time, Customer 1 is also waiting for 2 seconds.
  • Step 3: when the beef patty is cooked, you can then plate this onto a hamburger bun
  • Step 4: pass the final hamburger to Customer 1
  • Step 5: You would then start to serve Customer 2 (who has already been waiting 2 seconds for you to serve Customer 1). You can then repeat steps 2-4

With the above approach, Customer 1 would have their burger in about 2 seconds, Customer 2 approx 4 seconds, and then Customer 3 approx 6 seconds.

The equivalent code would be as follows:

import time, datetime, timeit

customer_queue = [ "C1", "C2", "C3" ]

def get_next_customer():
    return customer_queue.pop(0)    #Get the first customer from list

def cook_hamburger(customer):
    start_customer_timer = timeit.default_timer()
    print( f"[{customer}]: Start cooking hamberger for customer")
    time.sleep(2)   # It takes 2 seconds to cook the hamburger
    end_customer_timer = timeit.default_timer()
    print( f"[{customer}]: Finish cooking hamberger for customer.  Total {end_customer_timer-start_customer_timer} seconds\n")

def run_shop():
    while customer_queue:
        curr_customer = get_next_customer()
        cook_hamburger(curr_customer)

def main():
    print('Hamburger Shop')
    start = timeit.default_timer()
    run_shop()
    stop = timeit.default_timer()
    print(f"** Total runtime: {stop-start} seconds ***")

if __name__ == '__main__':
    main()

The code above is fairly straightforward. We have a list of customers that are queuing in the list customer_queue which are being looped under the def run_shop(). For each customer (get_next_customer()), we call cook_hamburger() to cook the hamburger for 2 seconds and wait for it to complete.

Running this code you would get the following output:

As expected, the total runtime for 3 customers is 6 seconds since each customer is served sequentially.

Cooking Hamburgers Asynchronously and coding the event loop manually

Instead of serving the customer and cooking the hamburger for each customer, you can obviously do some of the tasks asynchronously, meaning you can start the task but you don’t have to sit and wait, you can do something else. See the following diagram where the chef/waiter is serving multiple customers and cooking at the same time. It’s not explicitly shown here, but the chef/waiter is constantly checking on the status of the next task and if a task doesn’t require his/her attention they’ll move on to the next task. This process of always looking for something to do is the equivalent of the “event loop”. The Event Loop is a programming construct where the logic is to always look for a task to execute and if there’s a task which will take some time it can release control to the next task in the loop.

Figure 2: Example of how the event loop works in a real life example – the chef/waiter is always busy!

In the above example, the following is happening:

  • Step 1: you would first get the order and collect the money from Customer 1
  • Step 2: you would then put a beef patty on the cook top and then let it cook, then immediately move on to the next customer while the patty is cooking.
  • Step 3: you would first get the order and collect the money from Customer 2. You would also check if the first beef patty has completed cooking yet.
  • Step 4: you would then put another beef patty on the cook top and then let it cook, then immediately move on to the next customer while the patty is cooking.
  • Step 5: When any of the beef patties are done, you would plate it
  • Step 6: Pass the plated hamburger to the respective customer. Note, in the above example we’ve assumed it to be Customer 1, but it could be any customer depending on which beef patty cooked fully first.
  • Step 7: When any of the beef patties are done, you would plate it, and server

This is the equivalent of the event loop. The chef/waiter is constantly checking if it needs to serve the customer or check on the hamburgers which are cooking. When there’s a hamburger is placed on the stove and we need to wait 2 seconds, the chef/waiter moves to the next task and does not wait for the 2 seconds to complete. When the hamburger is done, it is then served to the customer.

How can this be done programatically? Glad you asked:

import time ,datetime, timeit

customer_queue = [ "C1", "C2", "C3" ]
hamburger_queue = []

def get_next_customer():
    if customer_queue: return customer_queue.pop(0)    #Get the first customer from list
    return None 

def start_cooking_hamburger(customer):
    print( f"[{customer}]: Start cooking hamberger for customer")
    hamburger = { "customer":customer, "start_cooking_time": timeit.default_timer(), "cooked":False}
    hamburger_queue.append( hamburger )

def check_hamburger_status():
    curr_timer = timeit.default_timer()

    #Check if it's cooking, but release control
    for index, hamburger in enumerate(hamburger_queue):         
        elapsed_time = curr_timer-hamburger['start_cooking_time']
        if elapsed_time > 2: #2 second has passed for hamrburger to cook
            print( f"[{hamburger['customer']}]: Finish cooking hamberger for customer.  Total {elapsed_time} seconds\n")
            del hamburger_queue[ index].  #delete from list to mark as done

def run_shop():
    while customer_queue or hamburger_queue:        #Event loop
        curr_customer = get_next_customer()
        if curr_customer: start_cooking_hamburger(curr_customer)
        check_hamburger_status()

def main():
    print('Hamburger Shop')
    start = timeit.default_timer()
    run_shop()
    stop = timeit.default_timer()
    print(f"** Total runtime: {stop-start} seconds ***")

if __name__ == '__main__':
    main()

The output of the code is as follows:

Output running asynchronously – notice the runtime of 2 seconds compared to the 6 seconds in the synchronsous method.

So there’s a few things happening here:

  • There’s a new list called hamburger_queue[] which is keeping track of each hamburger that is being cooked
  • The event loop is the while customer_queue or hamburger_queue within the run_shop() function
  • We have a new function called start_cooking_hamburger() which helps to keep track of the task to cooking starting. Why is this needed? Well in the past we would simply wait for a given task. Now, since we are doing something else while we wait, we need to remember a few things to come back to the task
  • We also have a new function called check_hamburger_status() which checks the status of each hamburger being cooked (i.e. item in hamburger_queue[]), and if it is cooked (i.e. 2 seconds have passed), then it is considered complete

You may notice in the output that Customer 3 was in fact served before Customer 2. This is because that the execution order is not guarantee.

How To Use Python Decouple for Environment Variable Config

How To Use Python Decouple for Environment Variable Config

Intermediate

You write a FastAPI app that connects to a database and a third-party payment gateway. You hardcode the credentials during development and tell yourself you will fix it before deploying. A month later the repo is on GitHub and so is your production database password. This is not a hypothetical — it happens constantly, and it happens because os.environ.get() is clunky enough that developers avoid it until it is too late.

Python’s python-decouple library makes the right approach easier than the wrong one. It reads configuration from .env files or .ini files, applies type casting automatically, handles missing values with sensible defaults, and keeps your code clean. One pip install python-decouple is all it takes, and it works with any Python project — Flask, FastAPI, Django, or a plain script.

This article walks through everything you need to use python-decouple confidently: reading values, type casting, setting defaults, working with booleans, using .env vs .ini files, integrating with Django settings, and building a real-world config loader. By the end you will have a pattern you can drop into any project to keep secrets out of your source code for good.

Python Decouple: Quick Example

Here is a minimal working example that shows the core pattern — reading a string, an integer, and a boolean from a .env file — so you can see exactly what decouple does before we explore each feature in depth.

First, create a file named .env in your project root:

# .env
DATABASE_URL=postgresql://localhost:5432/myapp
PORT=8000
DEBUG=True
SECRET_KEY=my-local-dev-secret-key-change-in-prod

Now read those values in Python:

# quick_decouple.py
from decouple import config

# String (default type)
database_url = config('DATABASE_URL')

# Integer -- decouple casts automatically
port = config('PORT', cast=int)

# Boolean -- handles 'True', 'true', '1', 'yes', etc.
debug = config('DEBUG', cast=bool)

# String with a fallback default
secret_key = config('SECRET_KEY', default='fallback-dev-key')

print(f"DB:    {database_url}")
print(f"Port:  {port} (type: {type(port).__name__})")
print(f"Debug: {debug} (type: {type(debug).__name__})")
print(f"Key:   {secret_key[:10]}...")

Output:

DB:    postgresql://localhost:5432/myapp
Port:  8000 (type: int)
Debug: True (type: bool)
Key:   my-local-d...

Notice that port comes back as a real Python int and debug as a real bool — not strings. With os.environ you would need to write int(os.environ['PORT']) and handle the conversion yourself every time. Decouple does that work once, at the point of reading, so the rest of your code receives properly typed values.

Read on to see how decouple handles missing values, search paths, .ini files, and real-world project layouts.

Python developer managing environment variable secrets with decouple
Secrets stay in the safe. Your code gets a typed value through the slot.

What Is Python Decouple and Why Use It?

Python decouple is a library that implements the Twelve-Factor App principle of strict separation between configuration and code. Configuration here means anything that is likely to vary between deployment environments: database URLs, API keys, feature flags, port numbers, and debug settings. The idea is that these values live in the environment (a .env file locally, environment variables in production), not in the source code that gets committed to a repository.

Think of it like a restaurant kitchen. The recipes (your code) are written down and shared. The ingredients (your config values) change depending on what the supplier has that day — and the head chef does not write the supplier’s phone number into every recipe card. They keep it in a separate contact file. Decouple is that contact file system for your Python app.

Decouple vs os.environ

Here is how python-decouple compares to using os.environ directly:

Featureos.environpython-decouple
Read string valueos.environ['KEY'] — raises KeyError if missingconfig('KEY') — raises UndefinedValueError with clear message
Default valueos.environ.get('KEY', 'default')config('KEY', default='default')
Integer castingint(os.environ.get('PORT', '8000'))config('PORT', default=8000, cast=int)
Boolean castingManual: 'True' == os.environ.get('DEBUG')config('DEBUG', cast=bool) handles True/true/1/yes
Read from .env fileRequires python-dotenv or manual parsingBuilt in — searches parent directories automatically
Support .ini filesNoYes — useful for projects with existing .ini configs
Test overridesMust monkeypatch os.environCan pass values directly in code during tests

The bottom line: os.environ is built-in and requires no extra dependency, but every type conversion is manual boilerplate. Decouple pays for itself the moment you have more than two or three config values that need casting.

Installing python-decouple

Install it with pip in your virtual environment:

# install_decouple.py (run this in your terminal, not as a script)
pip install python-decouple

Output:

Successfully installed python-decouple-3.8

There is one important naming note: the library is called python-decouple on PyPI (what you install), but the import name is decouple (what you use in code). Do not confuse it with decouple on PyPI — that is a different package for Django-specific use. Always install python-decouple.

The .env File: Format and Best Practices

A .env file is a plain text file with one KEY=value pair per line. Decouple searches for it starting in the directory of the script being run, then walks up to parent directories. This means you can place it at the root of your project and it will be found regardless of which subdirectory you run from.

# .env  (place this in your project root)

# Database
DATABASE_URL=postgresql://user:password@localhost:5432/myapp_dev

# Server
PORT=8000
HOST=0.0.0.0

# Feature flags
DEBUG=True
ENABLE_CACHING=False

# Third-party APIs
STRIPE_SECRET_KEY=sk_test_abc123
SENDGRID_API_KEY=SG.xyz789

# Email
EMAIL_BACKEND=console
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587

There are a few formatting rules to know. Values do not need quotes — DEBUG=True works fine. If your value contains spaces or special characters, wrap it in single or double quotes: FULL_NAME='Ada Lovelace'. Lines starting with # are comments and are ignored. Empty lines are also ignored.

The most important rule: add .env to your .gitignore immediately. Create a .env.example file with the same keys but dummy values, and commit that instead. New developers clone the repo, copy .env.example to .env, fill in their local values, and they are ready to go.

Python developer pointing at .gitignore file to protect .env secrets
.env in your repo means your secrets are in everyone’s repo.

Type Casting with cast=

Every value in a .env file is stored as a string. Decouple’s cast parameter converts the string to the type you need before returning it, so the rest of your code never sees a string where it expects an integer or boolean.

Integers and Floats

Pass cast=int or cast=float to convert numeric config values. This is far cleaner than wrapping every read in a manual conversion.

# cast_examples.py
from decouple import config

# These values exist in .env:
# PORT=8000
# WORKERS=4
# TIMEOUT=30.5

port = config('PORT', default=8000, cast=int)
workers = config('WORKERS', default=2, cast=int)
timeout = config('TIMEOUT', default=30.0, cast=float)

print(f"Port:    {port}  -- {type(port).__name__}")
print(f"Workers: {workers}  -- {type(workers).__name__}")
print(f"Timeout: {timeout}  -- {type(timeout).__name__}")

Output:

Port:    8000  -- int
Workers: 4  -- int
Timeout: 30.5  -- float

If the .env value cannot be cast to the requested type — for example, PORT=eight_thousand — decouple raises a ValueError with a clear message pointing to the offending key. You get the error at startup when reading config, not somewhere deep in your app when the value is used.

Booleans

Boolean config values are tricky with os.environ because every string is truthy. "False" evaluates to True in Python because it is a non-empty string. Decouple’s boolean cast handles this correctly by recognizing a set of canonical true and false values.

# cast_bool.py
from decouple import config

# .env contains:
# DEBUG=True
# ENABLE_CACHING=False
# USE_SSL=yes
# MAINTENANCE_MODE=0

debug = config('DEBUG', cast=bool)
caching = config('ENABLE_CACHING', cast=bool)
ssl = config('USE_SSL', cast=bool)
maintenance = config('MAINTENANCE_MODE', cast=bool)

print(f"DEBUG:            {debug}")
print(f"ENABLE_CACHING:   {caching}")
print(f"USE_SSL:          {ssl}")
print(f"MAINTENANCE_MODE: {maintenance}")

Output:

DEBUG:            True
ENABLE_CACHING:   False
USE_SSL:          True
MAINTENANCE_MODE: False

The recognized truthy values are True, true, 1, yes, on. The recognized falsy values are False, false, 0, no, off. Anything else raises a ValueError. This strict set prevents the bug where DEBUG=False still evaluates to True because you forgot to cast.

Comma-Separated Lists

Decouple does not have a built-in list type, but you can pass any callable as the cast argument — including a lambda that splits a string into a list.

# cast_list.py
from decouple import config, Csv

# .env contains:
# ALLOWED_HOSTS=localhost,127.0.0.1,myapp.com
# CORS_ORIGINS=http://localhost:3000,https://app.example.com

# Option 1: built-in Csv helper (strips whitespace, handles quoting)
allowed_hosts = config('ALLOWED_HOSTS', cast=Csv())

# Option 2: lambda for simple cases
cors_origins = config('CORS_ORIGINS', default='', cast=lambda v: [s.strip() for s in v.split(',')])

print(f"Allowed hosts: {allowed_hosts}")
print(f"CORS origins:  {cors_origins}")

Output:

Allowed hosts: ['localhost', '127.0.0.1', 'myapp.com']
CORS origins:  ['http://localhost:3000', 'https://app.example.com']

The Csv() helper from decouple is the cleaner option for comma-separated values. It handles edge cases like extra whitespace and quoted values with commas inside them. The lambda approach works fine for simple cases where you control the format.

Python decouple type casting int bool float str config values
config(‘PORT’, cast=int) — your last line of defense before NoneType has no attribute ‘listen’.

Defaults and Missing Values

When a key is missing from both the .env file and the actual environment, decouple’s behavior depends on whether you provided a default.

# defaults_demo.py
from decouple import config, UndefinedValueError

# KEY_WITH_DEFAULT is not in .env -- returns the default
log_level = config('LOG_LEVEL', default='INFO')
print(f"Log level: {log_level}")

# KEY_WITH_NONE_DEFAULT is not in .env -- returns None
cache_url = config('CACHE_URL', default=None)
print(f"Cache URL: {cache_url}")

# KEY_REQUIRED is not in .env and has no default -- raises UndefinedValueError
try:
    api_key = config('REQUIRED_API_KEY')
except UndefinedValueError as e:
    print(f"Missing required config: {e}")

Output:

Log level: INFO
Cache URL: None
Missing required config: REQUIRED_API_KEY not found. Declare it as envvar or define a default value.

This behavior is intentional and useful. Required values — things your app absolutely cannot run without — should have no default. That way decouple raises a clear error at startup rather than letting the app start in a broken state and fail later with a cryptic message. Optional values should have a sensible default so the app can run in a minimal configuration without a full .env file in place.

.ini File Support

In addition to .env files, decouple can read from .ini files using the AutoConfig or explicit RepositoryIni approach. This is useful when your project already has a settings.ini or setup.cfg and you do not want to introduce a second config file.

# settings.ini
[settings]
DATABASE_URL=postgresql://localhost:5432/myapp
PORT=8000
DEBUG=True
# read_ini.py
from decouple import Config, RepositoryIni

# Explicitly read from a .ini file instead of .env
config = Config(RepositoryIni('settings.ini'))

database_url = config('DATABASE_URL')
port = config('PORT', cast=int)
debug = config('DEBUG', cast=bool)

print(f"DB:    {database_url}")
print(f"Port:  {port}")
print(f"Debug: {debug}")

Output:

DB:    postgresql://localhost:5432/myapp
Port:  8000
Debug: True

The default config object (imported directly from decouple) uses AutoConfig, which searches for .env first, then .ini, then falls back to actual environment variables. You only need to use RepositoryIni explicitly when you want to force a specific file rather than letting decouple search.

Python decouple AutoConfig reading from .env and .ini files
AutoConfig: checks .env, then .ini, then the actual environment. In that order. Every time.

Django Integration

Django’s settings.py is the most common place developers accidentally commit secrets. Decouple is designed to slot in cleanly as a drop-in replacement for hardcoded settings.

# settings.py (Django)
from decouple import config, Csv

# Core Django settings
SECRET_KEY = config('SECRET_KEY')
DEBUG = config('DEBUG', cast=bool, default=False)
ALLOWED_HOSTS = config('ALLOWED_HOSTS', cast=Csv(), default='localhost')

# Database -- dj-database-url makes this even cleaner
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': config('DB_NAME', default='myapp'),
        'USER': config('DB_USER', default='postgres'),
        'PASSWORD': config('DB_PASSWORD', default=''),
        'HOST': config('DB_HOST', default='localhost'),
        'PORT': config('DB_PORT', default=5432, cast=int),
    }
}

# Email
EMAIL_BACKEND = config('EMAIL_BACKEND', default='django.core.mail.backends.console.EmailBackend')
EMAIL_HOST = config('EMAIL_HOST', default='localhost')
EMAIL_PORT = config('EMAIL_PORT', default=25, cast=int)
EMAIL_USE_TLS = config('EMAIL_USE_TLS', cast=bool, default=False)

# Stripe
STRIPE_PUBLIC_KEY = config('STRIPE_PUBLIC_KEY', default='')
STRIPE_SECRET_KEY = config('STRIPE_SECRET_KEY', default='')

The pattern is consistent throughout: use config('KEY') for required values that must exist in production, and config('KEY', default=...) for optional values with safe development defaults. The entire settings.py file becomes safe to commit because it contains no actual secrets — just the names of the keys and their defaults.

Real-Life Example: Environment-Aware FastAPI App

Here is a realistic FastAPI application config module that uses decouple to manage all its settings. This pattern — a dedicated config.py module that gathers all config into a dataclass — scales cleanly as the project grows.

# config.py
from dataclasses import dataclass
from decouple import config, Csv, UndefinedValueError

@dataclass
class AppConfig:
    # Server
    host: str
    port: int
    debug: bool
    workers: int

    # Database
    database_url: str

    # Security
    secret_key: str
    allowed_origins: list

    # External APIs
    stripe_secret_key: str
    sendgrid_api_key: str
    slack_webhook_url: str

    # Feature flags
    enable_caching: bool
    enable_email: bool

def load_config() -> AppConfig:
    """Load and validate all application configuration at startup."""
    return AppConfig(
        # Server
        host=config('HOST', default='0.0.0.0'),
        port=config('PORT', default=8000, cast=int),
        debug=config('DEBUG', default=False, cast=bool),
        workers=config('WORKERS', default=1, cast=int),

        # Database -- required in production, no default
        database_url=config('DATABASE_URL'),

        # Security -- required always
        secret_key=config('SECRET_KEY'),
        allowed_origins=config('ALLOWED_ORIGINS', cast=Csv(), default='http://localhost:3000'),

        # External APIs -- optional with empty defaults (check before use)
        stripe_secret_key=config('STRIPE_SECRET_KEY', default=''),
        sendgrid_api_key=config('SENDGRID_API_KEY', default=''),
        slack_webhook_url=config('SLACK_WEBHOOK_URL', default=''),

        # Feature flags
        enable_caching=config('ENABLE_CACHING', default=False, cast=bool),
        enable_email=config('ENABLE_EMAIL', default=False, cast=bool),
    )

# main.py
from fastapi import FastAPI
from config import load_config, AppConfig

cfg: AppConfig = load_config()  # Fails fast at startup if required vars missing
app = FastAPI(debug=cfg.debug)

@app.get("/health")
def health():
    return {
        "status": "ok",
        "debug": cfg.debug,
        "caching": cfg.enable_caching,
        "port": cfg.port,
    }

if __name__ == "__main__":
    import uvicorn
    print(f"Starting on {cfg.host}:{cfg.port} (debug={cfg.debug})")
    uvicorn.run(app, host=cfg.host, port=cfg.port, workers=cfg.workers)

Output (with example .env values):

Starting on 0.0.0.0:8000 (debug=False)

The key design choice here is that load_config() is called once at module level, so any missing required variable raises UndefinedValueError the moment the app starts — not on the first request five minutes into a production deploy. The dataclass gives you IDE autocomplete throughout the rest of the codebase and makes it obvious what configuration the app expects without opening the .env file.

FastAPI application loading config at startup with python-decouple
load_config() at startup. Not at request time. Never at request time.

Testing with Decouple

Config management and testability often conflict — tests need predictable values, but decouple reads from files. There are two clean approaches: use a test-specific .env file, or monkeypatch os.environ.

# test_config.py
import os
import pytest
from unittest.mock import patch

# Approach 1: patch os.environ before importing config
def test_debug_defaults_to_false():
    with patch.dict(os.environ, {'DATABASE_URL': 'sqlite:///test.db', 'SECRET_KEY': 'test-key'}, clear=True):
        # Reimport or reload config within the patch context
        from decouple import config
        # config() reads os.environ after checking .env
        debug = config('DEBUG', default=False, cast=bool)
        assert debug is False

def test_port_casting():
    with patch.dict(os.environ, {'PORT': '9000'}):
        from decouple import config
        port = config('PORT', cast=int)
        assert port == 9000
        assert isinstance(port, int)

def test_missing_required_raises():
    from decouple import config, UndefinedValueError
    with patch.dict(os.environ, {}, clear=True):
        # Remove DATABASE_URL from the environment
        env_without_db = {k: v for k, v in os.environ.items() if k != 'DATABASE_URL'}
        with patch.dict(os.environ, env_without_db, clear=True):
            with pytest.raises(UndefinedValueError):
                config('DATABASE_URL')

Output (pytest):

...
3 passed in 0.12s

The recommended approach for larger projects is to create a .env.test file and use a fixture that temporarily swaps the decouple search path to point at it. This gives you a full, realistic config setup for tests without polluting your development .env. The patch.dict(os.environ, ...)` approach shown above works well for unit tests of individual config values.

Frequently Asked Questions

Does decouple replace os.environ entirely?

Not entirely -- decouple reads from .env files first, then falls back to actual environment variables set in the shell or by the deployment platform. In production you typically do not deploy a .env file; instead, environment variables are set by the platform (Heroku config vars, Docker environment, Kubernetes secrets). Decouple reads those just fine through the os.environ fallback. The .env file is a development convenience, not a production requirement.

Where should I put my .env file?

Place it in the root of your project -- the same directory as manage.py (Django), main.py (FastAPI/Flask), or your top-level package. Decouple uses AutoConfig which walks up from the running script's directory until it finds a .env or .ini file, so as long as it is somewhere in the directory tree above your code, it will be found. Do not commit it to version control -- add it to .gitignore and commit a .env.example instead.

How do I handle multiple environments (dev, staging, prod)?

The cleanest approach is one .env per environment, kept out of the repo. Your CI/CD pipeline injects the appropriate values as environment variables for staging and production. Locally you maintain a .env with development values. You can also use a tool like direnv to switch .env files automatically when you change directories. Never create .env.production files and commit them -- that defeats the entire purpose.

What is the difference between python-decouple and python-dotenv?

Both libraries load .env files, but they take different approaches. python-dotenv loads values into os.environ as a side effect, making them available to os.environ.get() and any other code that reads the environment. python-decouple does not modify os.environ -- instead it provides a config() function that reads directly from the file or the real environment. Decouple is preferable when you want typed values, sensible defaults, and a clean API. Dotenv is useful when you need the values to land in os.environ for libraries that read from there directly.

Should I use .env files in production?

Generally no. Deploying a .env file to a server creates a file on disk containing secrets, which is a security risk if the server is ever compromised. In production, use your platform's secret management: Heroku config vars, AWS Secrets Manager, Docker secrets, Kubernetes secrets, or environment variables set in your deployment config. Decouple reads all of these through its os.environ fallback, so no code changes are needed between development (using .env) and production (using platform secrets).

How do I manage a large number of config values?

Group related config into separate config objects or dataclasses, as shown in the FastAPI example above. A single config.py module that defines everything in one place makes it easy to see what the app needs at a glance. Avoid calling config() scattered throughout your codebase -- centralizing config reads means you only have one place to look when a value is wrong, and one place to update when a key changes.

Conclusion

Python decouple solves a problem that trips up almost every developer at some point: configuration that leaks into source code. The library gives you config('KEY') with type casting, defaults, and clear errors for missing required values -- all reading from a .env file that stays out of your repository. We covered reading strings, integers, booleans, and lists; comparing decouple to raw os.environ; using .ini files; integrating with Django settings; and building a real FastAPI config module with a dataclass pattern.

The next step is to take the config module pattern from the real-life example and adapt it to your own project. Start by identifying every hardcoded string in your settings that could change between environments -- database URLs, API keys, debug flags, port numbers -- and move them behind config() calls. Your future self, and anyone else who has to deploy your app, will thank you.

Full documentation for python-decouple is at github.com/HBNetwork/python-decouple. The Twelve-Factor App methodology that inspired it is documented at 12factor.net/config. For a complementary approach using type-validated settings objects, see our guide on Pydantic Settings for Configuration Management. If you prefer a more flexible multi-environment solution, dynaconf is worth exploring. To keep your secrets extra safe, pair decouple with the built-in Python secrets module for generating tokens and keys.

Async Await Code Example in Python

In the previous section we created an asynchronous version manually. Here’s the same outcome but written with the async await syntax. As you’ll notice it is very similar to the original synchronous version:

import time, datetime, time
import asyncio

import time, datetime, timeit

customer_queue = [ "C1", "C2", "C3"  ]

def get_next_customer():
    return customer_queue.pop(0)    #Get the first customer from list

async def cook_hamburger(customer):     
    start_customer_timer = timeit.default_timer()
    print( f"[{customer}]: Start cooking hamberger for customer")
    await asyncio.sleep(2)   # Sleep but release control
    end_customer_timer = timeit.default_timer()
    print( f"[{customer}]: Finish cooking hamberger for customer.  Total {end_customer_timer-start_customer_timer} seconds\n")

async def run_shop():
    cooking_queue = []

    while customer_queue:
        curr_customer = get_next_customer()
        cooking_queue.append(  cook_hamburger(curr_customer)  )   #this returns a task only

    #cooking_queue[] has all the async tasks
    await asyncio.gather( *cooking_queue )      #Run all in parallel

def main():
    print('Hamburger Shop')
    start = timeit.default_timer()

    asyncio.run( run_shop() )           #Start the event loop

    stop = timeit.default_timer()
    print(f"** Total runtime: {stop-start} seconds ***")

if __name__ == '__main__':
    main()

Output as follows:

Let’s walk through the code:

  • Firstly, the async await is available from the library asyncio hence the import asyncio
  • There’s funny set of async keywords which precede the def run_shop() and the def cook_hamburger(customer) functions. In addition the run_shop() is no longer called directly, instead it is called with a asyncio.run( run_shop() ) function call. So here’s what is happening:
    • The asyncio.run() function is the trigger for the so-called event loop. It continues to run forever until all the tasks given to it are completed. You must pass it a function with the async def... prefix hence why run_shop() has the async prefix
    • In the async def run_shop() function call, the code iterates while there are customers in the queue to process, and then there’s a call to cook_hamburger(curr_customer) for each customer. A direct call to the customer does not actually call the function but instead creates a task to execute this. That is what the async tells the compiler – that when called directly, return a task.
    • At the end of the function code in def run_shop() there’s a call to function await asyncio.gather( *cooking_queue). There’s a few things going on here:
      • The await keywords indicates that you need wait for the work to complete but python can do something else in the meantime
      • The call to gather() actually executes all the tasks given to it as a parameter collectively as a group and then returns the results sequentially (please note that the order of the tasks being executed may be random)
      • The *customer_queue simply expands the list into a list of parameter items. So for example if customer_queue[] == [ '1', '2', '3'] then the gather( *customer_queue) would be the same as gather( '1', '2', '3').
    • When the await asyncio.gather( *customer_queue ) is called, the await keyword releases control to any activities that are pending and one of them would be to the calls to function cook_hamburger() which was added to the customer_queue list. Hence calls to cook_hamburger() would be triggered.
    • Within cook_hamburger() there is also an await asyncio.sleep(2). This simply waits for 2 seconds, however, it does not force the program to wait for the 2 seconds to complete, instead the await keyword releases python to do something else in the meantime. This is similar to step 3 in Figure 2 where the chef/waiter puts the hamburger on the grill, but then doesn’t wait for the 2 second but instead does something else (i.e. serve the next customer)
  • The asyncio.run() are new keywords as part of python 3.7. In older versions of python you may see the following but it is the same as simply running asyncio.run( run_shop() ) :
    • loop = asyncio.get_event_loop()
    • loop.run_until_complete(run_shop())
    • loop.close()
  • As you will notice, this is very similar to the synchronous code that covers Figure 1 above. This is the beauty of async/await

So remember, whenever there’s an await then that means python pauses at that point for that task to complete but then also releases python to do something else. That’s how the performance improvement occurs. In this example, the runtime of this is 2 seconds instead of the sequential 6 seconds!

Async Asynchronous Calling Another Async Function Code Example

Suppose you want t also call another async function once your first async function is completed – how do you go about this? Remember the rule, if you want to run something asynchronously, you have to use the await keyword, and that the function you’re calling has to be defined with async def ...

To continue with the restaurant theme, suppose that after the hamburger is cooked you ask an assistant to put the hamburger into a takeaway bag which takes 1 second. This is also another task that you need not ‘block’ and wait for it to complete. Hence, this action can be put into a function which is defined as an async. Here’s what the code can look like:

import time, datetime, time
import asyncio

customer_queue = [ "C1", "C2", "C3" ]

def get_next_customer():
    return customer_queue.pop(0)    #Get the first customer from list

async def cook_hamburger(customer):     
    start_customer_timer = timeit.default_timer()
    print( f"[{customer}]: Start cooking hamberger for customer")
    await asyncio.sleep(2)   # Sleep but release control
    end_customer_timer = timeit.default_timer()
    print( f"[{customer}]: Finish cooking hamberger for customer.  Total {end_customer_timer-start_customer_timer} seconds")
    await put_hamburger_in_takeaway_bag( customer )

async def put_hamburger_in_takeaway_bag( customer):
    start_customer_timer = timeit.default_timer()
    print( f"[{customer}]: Start packing hamberger")
    await asyncio.sleep(1)   # It takes 2 seconds to cook the hamburger
    end_customer_timer = timeit.default_timer()
    print( f"[{customer}]: Finish packing hamberger.  Total {end_customer_timer-start_customer_timer} seconds\n")

async def run_shop():
    cooking_queue = []

    while customer_queue:
        curr_customer = get_next_customer()
        cooking_queue.append( cook_hamburger(curr_customer) )   #Get each of the event loops
    await asyncio.gather( *cooking_queue )      #Run all in parallel

def main():
    print('Hamburger Shop')
    start = timeit.default_timer()
    asyncio.run( run_shop() )           #Start the event loop 
    stop = timeit.default_timer()
    print(f"** Total runtime: {stop-start} seconds ***")

if __name__ == '__main__':
    main()

The output would be:

See how once the hamburger is cooked (e.g. [C1]: Finish cooking hamburger for customer. Total 2.000924572115764 seconds), then immediately afterwards you have the [C1]: Start packing hamburger step but also gets called asynchronously.

Async Await Real World Example With Web Crawler in Python

One difficulty in learning Async / Await is that many examples provided simply provide the asyncio.sleep() as an example which is helpful to understand the concept, but not very helpful when you want to make something more useful. Let’s try a more complex example where you want to get some stock data from finance.yahoo.com and then, for that same stock, you also get the first 3 newspaper articles from news.google.com in the last 24 hours.

Now one thing you will realise is that await only works with functions that are defined as async. So you cannot call any function with await. Why? Well recall that when you call await you are expecting a function to return a task and not actually call the function, hence that function needs to be defined as async in order to tell python that it returns a task to be executed at the next available time.

Let’s see the synchronous version of the code:

import asyncio, requests, timeit
from bs4 import BeautifulSoup
from pygooglenews import GoogleNews

stock_list = [ "TSLA", "AAPL"]

def get_stock_price_data(stock):
    print(f"-- getting stock data for {stock}")
    data = {"stock":stock, "price_open":0, "price_close":0 }
    stock_page = requests.get( 'https://finance.yahoo.com/quote/' + stock, headers={'Cache-Control': 'no-cache',  "Pragma": "no-cache"})

    soup = BeautifulSoup(stock_page.text, 'html.parser')
    #<fin-streamer active="" class="Fw(b) Fz(36px) Mb(-4px) D(ib)" data-field="regularMarketPrice" data-pricehint="2" data-symbol="TSLA" data-test="qsp-price" data-trend="none" value="759.63">759.63</fin-streamer>
    data['price_close'] = soup.find('fin-streamer', attrs={"data-symbol":stock, "data-field":"regularMarketPrice"} ).text

    #<td class="Ta(end) Fw(600) Lh(14px)" data-test="OPEN-value">723.25</td>
    data['price_open'] = soup.find( attrs={"data-test":"OPEN-value"}).text

    return data

def get_recent_news(stock):
    print(f"-- getting news data for {stock}")
    gn = GoogleNews()
    search = gn.search(f"stocks {stock}", when = '24h')
    news = search['entries'][0:3]
    return news

def print_stock_update(stock, data, news):
    print(f"Stock:{ stock }")
    price_change = 0
    if int(float(data['price_open'])) != 0: price_change = round( 100 * ( float( data['price_close'])/float(data['price_open'])-1), 2)
    print(f"Open Price:{data['price_open']} Close Price:{data['price_close']} Change:{price_change}% ")
    print("Latest News:")
    for news_item in news:        
        print( f"{news_item.published}:{news_item.source.title} - {news_item.title}" )
    print("\n")

def process_stocks():
    for stock in stock_list:
        data = get_stock_price_data( stock )
        news=[]
        news = get_recent_news( stock )
        print_stock_update(stock, data, news)

if __name__ == '__main__':
    start_timer = timeit.default_timer()
    process_stocks()
    end_timer = timeit.default_timer()

    print(f"** Total runtime: {end_timer-start_timer} seconds ***")

Output as follows:

So what’s happening here. Well, you are looping through two stocks TSLA and AAPL, and for each stock the following happens sequentially:

  • A call to data = get_stock_price_data( stock ) occurs in order to make a call to requests.get( 'https://finance.yahoo.com/quote/' + stock) to get the HTML page for the TSLA stock. Effectively, this page: https://finance.yahoo.com/quote/TSLA
  • Next we use BeautifulSoup() in order to find the HTML snippet that contains the stock price data for the opening price and the closing price:
  • After the call to yahoo is complete, then there’s a call to news = get_recent_news( stock ) which uses the module pygooglenews to get the latest google news. In fact we have used this function in our previous Twitter Bot article.
  • Once this is all done, that output is printed out with the call to print_stock_update(stock, data, news)

Clearly this could be called asynchronously as we are looping each time for each stock, and then also the call to get the stock data is independent to getting the news data. However, one thing has to happen sequentially is the print_stock_update(stock, data, news) which has to wait for both the async calls to complete.

One wait to try is to simply call the website download with:

stock_page = await requests.get( 'https://finance.yahoo.com/quote/' + stock, headers={'Cache-Control': 'no-cache',  "Pragma": "no-cache"})

However, you will get the following error:

The reason is, as you may have guessed, is that the requests.get() is not created with the async def... construct and hence cannot be called asynchronously.

What you can do however is to use another ‘get’ web page module called httpx. This function is defined with async def... and can be called similar to requests. That same line would be re-written as:

import httpx
#....

async def get_stock_price_data(stock):
    print(f"-- stock data:getting stock data for {stock}")
    data = {"stock":stock, "price_open":0, "price_close":0 }

    #*** instead of requests.get('https://finance.yahoo.com/quote/' + stock)) ****
    client = httpx.AsyncClient() 
    stock_page = await client.get( 'https://finance.yahoo.com/quote/' + stock)

    soup = BeautifulSoup(stock_page.text, 'html.parser')
    #<fin-streamer active="" class="Fw(b) Fz(36px) Mb(-4px) D(ib)" data-field="regularMarketPrice" data-pricehint="2" data-symbol="TSLA" data-test="qsp-price" data-trend="none" value="759.63">759.63</fin-streamer>
    data['price_close'] = soup.find('fin-streamer', attrs={"data-symbol":stock, "data-field":"regularMarketPrice"} ).text

    #<td class="Ta(end) Fw(600) Lh(14px)" data-test="OPEN-value">723.25</td>
    data['price_open'] = soup.find( attrs={"data-test":"OPEN-value"}).text
    print(f"-- stock data:done {stock}")
    return data

Ok, that works well. However, but what about the GoogleNews() code. There is no such async version of this function, so how can this be called asynchronously? Well for this, you can actually wrap it around a new thread. A ‘thread’ is way to run a piece of code under the same CPU process but in a parallel. It warrants a whole separate article but for now you can think of it as finding a separate space to execute this independent of the current execution path. However, to execute this in a separate thread, there’s a bit more involved.

The code looks like the following:

### Original Version
def get_recent_news(stock):
    print(f"-- stock news:getting stock data for {stock}")
    gn = GoogleNews()
    search = gn.search(f"stocks {stock}", '24h') #Slow code to run asynchronously
    news = search['entries'][0:3]
    print(f"-- stock news:done {stock}")
    return news

### Asynchronous Version
async def get_recent_news(stock):
    print(f"-- stock news:getting stock data for {stock}")
    gn = GoogleNews()
    search = await asyncio.get_event_loop().run_in_executor( None, gn.search, f"stocks {stock}", '24h')
    news = search['entries'][0:3]
    print(f"-- stock news:done {stock}")
    return news

Here what’s happening is that firstly we are using the await keyword to call the gn.search() function which is now being called through this asyncio.get_event_loop().run_in_executor( .. ) function call. What’s happening here is that we are asking the asyncio module to get access to the event loop (that piece of code that continuously checks for tasks to be done) and then to run in a separate thread. The way it is called is that the parameters must be passed in separate to the function call and hence why the parameters are to be passed in after the function name itself. You will also notice that the whole function can now be defined as async def get_recent_news(stock)

How To Mix Asynchronous And Synchronous Code With Await Async in Python

Now the final problem to be solved is how do we call the two functions of get_stock_price_data( stock ) and get_recent_news(stock) to be run asynchronously, but then wait for both to finish, and THEN run the print. This is where these steps should all be grouped under one function. This is the trick to mix asynchronous and synchronous code.

In order to run a group of tasks in parallel as a group you use asyncio.gather(). However, if you want to execute a synchronous function when ALL tasks that were given to asyncio.gather() is complete, then you should wrap it in another asyncio.gather()

async def process_stock_batch(stock):
    (data, news) = await asyncio.gather( get_stock_price_data( stock ), get_recent_news(stock)  )
    print('-- print:request printing')
    print_stock_update(stock, data, news) 
    print('-- print:done')

async def process_stocks():
    run_stock_list = []
    for stock in stock_list:
        run_stock_list.append(   process_stock_batch(stock) )
    await asyncio.gather( *run_stock_list )

Before we solve it for the real world examples, lets show a simpler example. Suppose we had the following example:

import asyncio, timeit

async def get_web_data_A(index):
    await asyncio.sleep(1)
    print(f"Get Web Data-A[{index}] - sleep 1 second")
        
async def get_web_data_B(index):
    await asyncio.sleep(1)
    print(f"Get Web Data-B[{index}] - sleep 1 second")

async def process(index, start_timer):
    await asyncio.gather( get_web_data_A(index), get_web_data_B(index) )
    print(f"Calculate [{index}] - Elapsed time:[{timeit.default_timer()-start_timer}]")

async def run_all():
    start_timer = timeit.default_timer()
    for index in range(0,2):
        await process(index, start_timer)

if __name__ == '__main__':
    asyncio.run( run_all() )

This has the following output:

What is encouraging with this code, is that even though the call to get_web_data_A() and get_web_data_B() both sleep for 1 second, since they were doing that asynchronously, then the total runtime is still just a little over 1 second. This can be shown by the Calculate [0]... output. However, the problem is that the code still iterates each index sequentially, meaning, that index 0 is processed completely first, and once that’s done, then index 1 is processed. What we want instead is to run all the slow get_web_data_A() and get_web_data_B() first, and then run the code to calculate afterwards. This is where you need to first create the tasks for ALL the iterations, and then call gather() on all the tasks. See the following code:

import asyncio, timeit

async def get_web_data_A(index):
    await asyncio.sleep(1)
    print(f"Get Web Data-A[{index}] - sleep 1 second")
        
async def get_web_data_B(index):
    await asyncio.sleep(1)
    print(f"Get Web Data-B[{index}] - sleep 1 second")

async def process(index, start_timer):
    await asyncio.gather( get_web_data_A(index), get_web_data_B(index) )
    print(f"Calculate [{index}] - Elapsed time:[{timeit.default_timer()-start_timer}]")

async def run_all_2():
    start_timer = timeit.default_timer()
    task_queue = []
    for index in range(0,2):
        task_queue.append( process(index, start_timer) )
    await asyncio.gather( *task_queue )

if __name__ == '__main__':
    asyncio.run( run_all_2() )

Here, in the function async def run_all_2() when we loop, we do not call the blocking code await asyncio.gather... inside the for loop. Instead, we are adding all the tasks to call process(..) into a list called task_queue[], and then at the end of the for loop we are calling await asyncio.gather( *task_queue ) on all tasks in one go. Hence, the output is as follows:

You’ll notice that ALL the get_web_data_A() and get_web_data_B() are being called asynchronously, and then the calculate function is called on all the available data. Hence, the elapsed time for all the iterations is only 1 second, compared to the previous 2 seconds.

So what does this mean for our real world example for getting stock data from Yahoo and then calling Google News asynchronously, and then only printing the data once both are done? Well, the same principle applies. The code is as follows:

import asyncio, httpx, timeit
from bs4 import BeautifulSoup
from pygooglenews import GoogleNews

stock_list = [ "TSLA", "AAPL"]

async def get_stock_price_data(stock):
    print(f"-- stock data:getting stock data for {stock}")
    data = {"stock":stock, "price_open":0, "price_close":0 }

    client = httpx.AsyncClient()
    stock_page = await client.get( 'https://finance.yahoo.com/quote/' + stock)

    soup = BeautifulSoup(stock_page.text, 'html.parser')
    #<fin-streamer active="" class="Fw(b) Fz(36px) Mb(-4px) D(ib)" data-field="regularMarketPrice" data-pricehint="2" data-symbol="TSLA" data-test="qsp-price" data-trend="none" value="759.63">759.63</fin-streamer>
    data['price_close'] = soup.find('fin-streamer', attrs={"data-symbol":stock, "data-field":"regularMarketPrice"} ).text

    #<td class="Ta(end) Fw(600) Lh(14px)" data-test="OPEN-value">723.25</td>
    data['price_open'] = soup.find( attrs={"data-test":"OPEN-value"}).text
    print(f"-- stock data:done {stock}")
    return data

async def get_recent_news(stock):
    print(f"-- stock news:getting stock data for {stock}")
    gn = GoogleNews()
    search = await asyncio.get_event_loop().run_in_executor( None, gn.search, f"stocks {stock}", '24h')
    news = search['entries'][0:3]
    print(f"-- stock news:done {stock}")
    return news

def print_stock_update(stock, data, news):
    print('-- print:starting print')
    print(f"Stock:{ stock }")
    price_change = 0
    if int(float(data['price_open'])) != 0: price_change = round( 100 * ( float( data['price_close'])/float(data['price_open'])-1), 2)
    print(f"Open Price:{data['price_open']} Close Price:{data['price_close']} Change:{price_change}% ")
    print("Latest News:")
    for news_item in news:        
        print( f"{news_item.published}:{news_item.source.title} - {news_item.title}" )

    print("\n")

async def process_stock_batch(stock):
    (data, news) = await asyncio.gather( get_stock_price_data( stock ), get_recent_news(stock)  )
    print('-- print:request printing')
    print_stock_update(stock, data, news) 
    print('-- print:done')

async def process_stocks():
    run_stock_list = []
    for stock in stock_list:
        run_stock_list.append(   process_stock_batch(stock) )
    await asyncio.gather( *run_stock_list )

if __name__ == '__main__':
    start_timer = timeit.default_timer()
    asyncio.run( process_stocks() )
    end_timer = timeit.default_timer()

    print(f"** Total runtime: {end_timer-start_timer} seconds ***")

The key bit of code is in the async def process_stocks() which now iterates over each of the stocks, creates tasks, and then calls await asyncio.gather( *run_stock_list ) on all the stocks in one go, and then in the function process_stock_batch(stock) we have the asynchronous call to (data, news) = await asyncio.gather( get_stock_price_data( stock ), and then the synchronous call to print_stock_update(stock, data, news) once both web data is complete.

Conclusion

The await and async function is an incredibly useful feature of python which takes a bit of getting used to in order to understand the concept, but once you’ve got the hang of it, it can be incredibly useful to get an improve of the performance of your code by leveraging idle time where you are waiting for a task to complete. Remember to be sure about the sequencing and being mindful of whether you care to have a follow-up activity once that task is completed, or you can simply continue to execute.

This not easy to grasp as a beginner, but follow the example code above, and if you get stuck feel free to reach out through our email list below.

How To Use Python Decouple for Environment Variable Config

How To Use Python Decouple for Environment Variable Config

Intermediate

You write a FastAPI app that connects to a database and a third-party payment gateway. You hardcode the credentials during development and tell yourself you will fix it before deploying. A month later the repo is on GitHub and so is your production database password. This is not a hypothetical — it happens constantly, and it happens because os.environ.get() is clunky enough that developers avoid it until it is too late.

Python’s python-decouple library makes the right approach easier than the wrong one. It reads configuration from .env files or .ini files, applies type casting automatically, handles missing values with sensible defaults, and keeps your code clean. One pip install python-decouple is all it takes, and it works with any Python project — Flask, FastAPI, Django, or a plain script.

This article walks through everything you need to use python-decouple confidently: reading values, type casting, setting defaults, working with booleans, using .env vs .ini files, integrating with Django settings, and building a real-world config loader. By the end you will have a pattern you can drop into any project to keep secrets out of your source code for good.

Python Decouple: Quick Example

Here is a minimal working example that shows the core pattern — reading a string, an integer, and a boolean from a .env file — so you can see exactly what decouple does before we explore each feature in depth.

First, create a file named .env in your project root:

# .env
DATABASE_URL=postgresql://localhost:5432/myapp
PORT=8000
DEBUG=True
SECRET_KEY=my-local-dev-secret-key-change-in-prod

Now read those values in Python:

# quick_decouple.py
from decouple import config

# String (default type)
database_url = config('DATABASE_URL')

# Integer -- decouple casts automatically
port = config('PORT', cast=int)

# Boolean -- handles 'True', 'true', '1', 'yes', etc.
debug = config('DEBUG', cast=bool)

# String with a fallback default
secret_key = config('SECRET_KEY', default='fallback-dev-key')

print(f"DB:    {database_url}")
print(f"Port:  {port} (type: {type(port).__name__})")
print(f"Debug: {debug} (type: {type(debug).__name__})")
print(f"Key:   {secret_key[:10]}...")

Output:

DB:    postgresql://localhost:5432/myapp
Port:  8000 (type: int)
Debug: True (type: bool)
Key:   my-local-d...

Notice that port comes back as a real Python int and debug as a real bool — not strings. With os.environ you would need to write int(os.environ['PORT']) and handle the conversion yourself every time. Decouple does that work once, at the point of reading, so the rest of your code receives properly typed values.

Read on to see how decouple handles missing values, search paths, .ini files, and real-world project layouts.

Python developer managing environment variable secrets with decouple
Secrets stay in the safe. Your code gets a typed value through the slot.

What Is Python Decouple and Why Use It?

Python decouple is a library that implements the Twelve-Factor App principle of strict separation between configuration and code. Configuration here means anything that is likely to vary between deployment environments: database URLs, API keys, feature flags, port numbers, and debug settings. The idea is that these values live in the environment (a .env file locally, environment variables in production), not in the source code that gets committed to a repository.

Think of it like a restaurant kitchen. The recipes (your code) are written down and shared. The ingredients (your config values) change depending on what the supplier has that day — and the head chef does not write the supplier’s phone number into every recipe card. They keep it in a separate contact file. Decouple is that contact file system for your Python app.

Decouple vs os.environ

Here is how python-decouple compares to using os.environ directly:

Featureos.environpython-decouple
Read string valueos.environ['KEY'] — raises KeyError if missingconfig('KEY') — raises UndefinedValueError with clear message
Default valueos.environ.get('KEY', 'default')config('KEY', default='default')
Integer castingint(os.environ.get('PORT', '8000'))config('PORT', default=8000, cast=int)
Boolean castingManual: 'True' == os.environ.get('DEBUG')config('DEBUG', cast=bool) handles True/true/1/yes
Read from .env fileRequires python-dotenv or manual parsingBuilt in — searches parent directories automatically
Support .ini filesNoYes — useful for projects with existing .ini configs
Test overridesMust monkeypatch os.environCan pass values directly in code during tests

The bottom line: os.environ is built-in and requires no extra dependency, but every type conversion is manual boilerplate. Decouple pays for itself the moment you have more than two or three config values that need casting.

Installing python-decouple

Install it with pip in your virtual environment:

# install_decouple.py (run this in your terminal, not as a script)
pip install python-decouple

Output:

Successfully installed python-decouple-3.8

There is one important naming note: the library is called python-decouple on PyPI (what you install), but the import name is decouple (what you use in code). Do not confuse it with decouple on PyPI — that is a different package for Django-specific use. Always install python-decouple.

The .env File: Format and Best Practices

A .env file is a plain text file with one KEY=value pair per line. Decouple searches for it starting in the directory of the script being run, then walks up to parent directories. This means you can place it at the root of your project and it will be found regardless of which subdirectory you run from.

# .env  (place this in your project root)

# Database
DATABASE_URL=postgresql://user:password@localhost:5432/myapp_dev

# Server
PORT=8000
HOST=0.0.0.0

# Feature flags
DEBUG=True
ENABLE_CACHING=False

# Third-party APIs
STRIPE_SECRET_KEY=sk_test_abc123
SENDGRID_API_KEY=SG.xyz789

# Email
EMAIL_BACKEND=console
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587

There are a few formatting rules to know. Values do not need quotes — DEBUG=True works fine. If your value contains spaces or special characters, wrap it in single or double quotes: FULL_NAME='Ada Lovelace'. Lines starting with # are comments and are ignored. Empty lines are also ignored.

The most important rule: add .env to your .gitignore immediately. Create a .env.example file with the same keys but dummy values, and commit that instead. New developers clone the repo, copy .env.example to .env, fill in their local values, and they are ready to go.

Python developer pointing at .gitignore file to protect .env secrets
.env in your repo means your secrets are in everyone’s repo.

Type Casting with cast=

Every value in a .env file is stored as a string. Decouple’s cast parameter converts the string to the type you need before returning it, so the rest of your code never sees a string where it expects an integer or boolean.

Integers and Floats

Pass cast=int or cast=float to convert numeric config values. This is far cleaner than wrapping every read in a manual conversion.

# cast_examples.py
from decouple import config

# These values exist in .env:
# PORT=8000
# WORKERS=4
# TIMEOUT=30.5

port = config('PORT', default=8000, cast=int)
workers = config('WORKERS', default=2, cast=int)
timeout = config('TIMEOUT', default=30.0, cast=float)

print(f"Port:    {port}  -- {type(port).__name__}")
print(f"Workers: {workers}  -- {type(workers).__name__}")
print(f"Timeout: {timeout}  -- {type(timeout).__name__}")

Output:

Port:    8000  -- int
Workers: 4  -- int
Timeout: 30.5  -- float

If the .env value cannot be cast to the requested type — for example, PORT=eight_thousand — decouple raises a ValueError with a clear message pointing to the offending key. You get the error at startup when reading config, not somewhere deep in your app when the value is used.

Booleans

Boolean config values are tricky with os.environ because every string is truthy. "False" evaluates to True in Python because it is a non-empty string. Decouple’s boolean cast handles this correctly by recognizing a set of canonical true and false values.

# cast_bool.py
from decouple import config

# .env contains:
# DEBUG=True
# ENABLE_CACHING=False
# USE_SSL=yes
# MAINTENANCE_MODE=0

debug = config('DEBUG', cast=bool)
caching = config('ENABLE_CACHING', cast=bool)
ssl = config('USE_SSL', cast=bool)
maintenance = config('MAINTENANCE_MODE', cast=bool)

print(f"DEBUG:            {debug}")
print(f"ENABLE_CACHING:   {caching}")
print(f"USE_SSL:          {ssl}")
print(f"MAINTENANCE_MODE: {maintenance}")

Output:

DEBUG:            True
ENABLE_CACHING:   False
USE_SSL:          True
MAINTENANCE_MODE: False

The recognized truthy values are True, true, 1, yes, on. The recognized falsy values are False, false, 0, no, off. Anything else raises a ValueError. This strict set prevents the bug where DEBUG=False still evaluates to True because you forgot to cast.

Comma-Separated Lists

Decouple does not have a built-in list type, but you can pass any callable as the cast argument — including a lambda that splits a string into a list.

# cast_list.py
from decouple import config, Csv

# .env contains:
# ALLOWED_HOSTS=localhost,127.0.0.1,myapp.com
# CORS_ORIGINS=http://localhost:3000,https://app.example.com

# Option 1: built-in Csv helper (strips whitespace, handles quoting)
allowed_hosts = config('ALLOWED_HOSTS', cast=Csv())

# Option 2: lambda for simple cases
cors_origins = config('CORS_ORIGINS', default='', cast=lambda v: [s.strip() for s in v.split(',')])

print(f"Allowed hosts: {allowed_hosts}")
print(f"CORS origins:  {cors_origins}")

Output:

Allowed hosts: ['localhost', '127.0.0.1', 'myapp.com']
CORS origins:  ['http://localhost:3000', 'https://app.example.com']

The Csv() helper from decouple is the cleaner option for comma-separated values. It handles edge cases like extra whitespace and quoted values with commas inside them. The lambda approach works fine for simple cases where you control the format.

Python decouple type casting int bool float str config values
config(‘PORT’, cast=int) — your last line of defense before NoneType has no attribute ‘listen’.

Defaults and Missing Values

When a key is missing from both the .env file and the actual environment, decouple’s behavior depends on whether you provided a default.

# defaults_demo.py
from decouple import config, UndefinedValueError

# KEY_WITH_DEFAULT is not in .env -- returns the default
log_level = config('LOG_LEVEL', default='INFO')
print(f"Log level: {log_level}")

# KEY_WITH_NONE_DEFAULT is not in .env -- returns None
cache_url = config('CACHE_URL', default=None)
print(f"Cache URL: {cache_url}")

# KEY_REQUIRED is not in .env and has no default -- raises UndefinedValueError
try:
    api_key = config('REQUIRED_API_KEY')
except UndefinedValueError as e:
    print(f"Missing required config: {e}")

Output:

Log level: INFO
Cache URL: None
Missing required config: REQUIRED_API_KEY not found. Declare it as envvar or define a default value.

This behavior is intentional and useful. Required values — things your app absolutely cannot run without — should have no default. That way decouple raises a clear error at startup rather than letting the app start in a broken state and fail later with a cryptic message. Optional values should have a sensible default so the app can run in a minimal configuration without a full .env file in place.

.ini File Support

In addition to .env files, decouple can read from .ini files using the AutoConfig or explicit RepositoryIni approach. This is useful when your project already has a settings.ini or setup.cfg and you do not want to introduce a second config file.

# settings.ini
[settings]
DATABASE_URL=postgresql://localhost:5432/myapp
PORT=8000
DEBUG=True
# read_ini.py
from decouple import Config, RepositoryIni

# Explicitly read from a .ini file instead of .env
config = Config(RepositoryIni('settings.ini'))

database_url = config('DATABASE_URL')
port = config('PORT', cast=int)
debug = config('DEBUG', cast=bool)

print(f"DB:    {database_url}")
print(f"Port:  {port}")
print(f"Debug: {debug}")

Output:

DB:    postgresql://localhost:5432/myapp
Port:  8000
Debug: True

The default config object (imported directly from decouple) uses AutoConfig, which searches for .env first, then .ini, then falls back to actual environment variables. You only need to use RepositoryIni explicitly when you want to force a specific file rather than letting decouple search.

Python decouple AutoConfig reading from .env and .ini files
AutoConfig: checks .env, then .ini, then the actual environment. In that order. Every time.

Django Integration

Django’s settings.py is the most common place developers accidentally commit secrets. Decouple is designed to slot in cleanly as a drop-in replacement for hardcoded settings.

# settings.py (Django)
from decouple import config, Csv

# Core Django settings
SECRET_KEY = config('SECRET_KEY')
DEBUG = config('DEBUG', cast=bool, default=False)
ALLOWED_HOSTS = config('ALLOWED_HOSTS', cast=Csv(), default='localhost')

# Database -- dj-database-url makes this even cleaner
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': config('DB_NAME', default='myapp'),
        'USER': config('DB_USER', default='postgres'),
        'PASSWORD': config('DB_PASSWORD', default=''),
        'HOST': config('DB_HOST', default='localhost'),
        'PORT': config('DB_PORT', default=5432, cast=int),
    }
}

# Email
EMAIL_BACKEND = config('EMAIL_BACKEND', default='django.core.mail.backends.console.EmailBackend')
EMAIL_HOST = config('EMAIL_HOST', default='localhost')
EMAIL_PORT = config('EMAIL_PORT', default=25, cast=int)
EMAIL_USE_TLS = config('EMAIL_USE_TLS', cast=bool, default=False)

# Stripe
STRIPE_PUBLIC_KEY = config('STRIPE_PUBLIC_KEY', default='')
STRIPE_SECRET_KEY = config('STRIPE_SECRET_KEY', default='')

The pattern is consistent throughout: use config('KEY') for required values that must exist in production, and config('KEY', default=...) for optional values with safe development defaults. The entire settings.py file becomes safe to commit because it contains no actual secrets — just the names of the keys and their defaults.

Real-Life Example: Environment-Aware FastAPI App

Here is a realistic FastAPI application config module that uses decouple to manage all its settings. This pattern — a dedicated config.py module that gathers all config into a dataclass — scales cleanly as the project grows.

# config.py
from dataclasses import dataclass
from decouple import config, Csv, UndefinedValueError

@dataclass
class AppConfig:
    # Server
    host: str
    port: int
    debug: bool
    workers: int

    # Database
    database_url: str

    # Security
    secret_key: str
    allowed_origins: list

    # External APIs
    stripe_secret_key: str
    sendgrid_api_key: str
    slack_webhook_url: str

    # Feature flags
    enable_caching: bool
    enable_email: bool

def load_config() -> AppConfig:
    """Load and validate all application configuration at startup."""
    return AppConfig(
        # Server
        host=config('HOST', default='0.0.0.0'),
        port=config('PORT', default=8000, cast=int),
        debug=config('DEBUG', default=False, cast=bool),
        workers=config('WORKERS', default=1, cast=int),

        # Database -- required in production, no default
        database_url=config('DATABASE_URL'),

        # Security -- required always
        secret_key=config('SECRET_KEY'),
        allowed_origins=config('ALLOWED_ORIGINS', cast=Csv(), default='http://localhost:3000'),

        # External APIs -- optional with empty defaults (check before use)
        stripe_secret_key=config('STRIPE_SECRET_KEY', default=''),
        sendgrid_api_key=config('SENDGRID_API_KEY', default=''),
        slack_webhook_url=config('SLACK_WEBHOOK_URL', default=''),

        # Feature flags
        enable_caching=config('ENABLE_CACHING', default=False, cast=bool),
        enable_email=config('ENABLE_EMAIL', default=False, cast=bool),
    )

# main.py
from fastapi import FastAPI
from config import load_config, AppConfig

cfg: AppConfig = load_config()  # Fails fast at startup if required vars missing
app = FastAPI(debug=cfg.debug)

@app.get("/health")
def health():
    return {
        "status": "ok",
        "debug": cfg.debug,
        "caching": cfg.enable_caching,
        "port": cfg.port,
    }

if __name__ == "__main__":
    import uvicorn
    print(f"Starting on {cfg.host}:{cfg.port} (debug={cfg.debug})")
    uvicorn.run(app, host=cfg.host, port=cfg.port, workers=cfg.workers)

Output (with example .env values):

Starting on 0.0.0.0:8000 (debug=False)

The key design choice here is that load_config() is called once at module level, so any missing required variable raises UndefinedValueError the moment the app starts — not on the first request five minutes into a production deploy. The dataclass gives you IDE autocomplete throughout the rest of the codebase and makes it obvious what configuration the app expects without opening the .env file.

FastAPI application loading config at startup with python-decouple
load_config() at startup. Not at request time. Never at request time.

Testing with Decouple

Config management and testability often conflict — tests need predictable values, but decouple reads from files. There are two clean approaches: use a test-specific .env file, or monkeypatch os.environ.

# test_config.py
import os
import pytest
from unittest.mock import patch

# Approach 1: patch os.environ before importing config
def test_debug_defaults_to_false():
    with patch.dict(os.environ, {'DATABASE_URL': 'sqlite:///test.db', 'SECRET_KEY': 'test-key'}, clear=True):
        # Reimport or reload config within the patch context
        from decouple import config
        # config() reads os.environ after checking .env
        debug = config('DEBUG', default=False, cast=bool)
        assert debug is False

def test_port_casting():
    with patch.dict(os.environ, {'PORT': '9000'}):
        from decouple import config
        port = config('PORT', cast=int)
        assert port == 9000
        assert isinstance(port, int)

def test_missing_required_raises():
    from decouple import config, UndefinedValueError
    with patch.dict(os.environ, {}, clear=True):
        # Remove DATABASE_URL from the environment
        env_without_db = {k: v for k, v in os.environ.items() if k != 'DATABASE_URL'}
        with patch.dict(os.environ, env_without_db, clear=True):
            with pytest.raises(UndefinedValueError):
                config('DATABASE_URL')

Output (pytest):

...
3 passed in 0.12s

The recommended approach for larger projects is to create a .env.test file and use a fixture that temporarily swaps the decouple search path to point at it. This gives you a full, realistic config setup for tests without polluting your development .env. The patch.dict(os.environ, ...)` approach shown above works well for unit tests of individual config values.

Frequently Asked Questions

Does decouple replace os.environ entirely?

Not entirely -- decouple reads from .env files first, then falls back to actual environment variables set in the shell or by the deployment platform. In production you typically do not deploy a .env file; instead, environment variables are set by the platform (Heroku config vars, Docker environment, Kubernetes secrets). Decouple reads those just fine through the os.environ fallback. The .env file is a development convenience, not a production requirement.

Where should I put my .env file?

Place it in the root of your project -- the same directory as manage.py (Django), main.py (FastAPI/Flask), or your top-level package. Decouple uses AutoConfig which walks up from the running script's directory until it finds a .env or .ini file, so as long as it is somewhere in the directory tree above your code, it will be found. Do not commit it to version control -- add it to .gitignore and commit a .env.example instead.

How do I handle multiple environments (dev, staging, prod)?

The cleanest approach is one .env per environment, kept out of the repo. Your CI/CD pipeline injects the appropriate values as environment variables for staging and production. Locally you maintain a .env with development values. You can also use a tool like direnv to switch .env files automatically when you change directories. Never create .env.production files and commit them -- that defeats the entire purpose.

What is the difference between python-decouple and python-dotenv?

Both libraries load .env files, but they take different approaches. python-dotenv loads values into os.environ as a side effect, making them available to os.environ.get() and any other code that reads the environment. python-decouple does not modify os.environ -- instead it provides a config() function that reads directly from the file or the real environment. Decouple is preferable when you want typed values, sensible defaults, and a clean API. Dotenv is useful when you need the values to land in os.environ for libraries that read from there directly.

Should I use .env files in production?

Generally no. Deploying a .env file to a server creates a file on disk containing secrets, which is a security risk if the server is ever compromised. In production, use your platform's secret management: Heroku config vars, AWS Secrets Manager, Docker secrets, Kubernetes secrets, or environment variables set in your deployment config. Decouple reads all of these through its os.environ fallback, so no code changes are needed between development (using .env) and production (using platform secrets).

How do I manage a large number of config values?

Group related config into separate config objects or dataclasses, as shown in the FastAPI example above. A single config.py module that defines everything in one place makes it easy to see what the app needs at a glance. Avoid calling config() scattered throughout your codebase -- centralizing config reads means you only have one place to look when a value is wrong, and one place to update when a key changes.

Conclusion

Python decouple solves a problem that trips up almost every developer at some point: configuration that leaks into source code. The library gives you config('KEY') with type casting, defaults, and clear errors for missing required values -- all reading from a .env file that stays out of your repository. We covered reading strings, integers, booleans, and lists; comparing decouple to raw os.environ; using .ini files; integrating with Django settings; and building a real FastAPI config module with a dataclass pattern.

The next step is to take the config module pattern from the real-life example and adapt it to your own project. Start by identifying every hardcoded string in your settings that could change between environments -- database URLs, API keys, debug flags, port numbers -- and move them behind config() calls. Your future self, and anyone else who has to deploy your app, will thank you.

Full documentation for python-decouple is at github.com/HBNetwork/python-decouple. The Twelve-Factor App methodology that inspired it is documented at 12factor.net/config. For a complementary approach using type-validated settings objects, see our guide on Pydantic Settings for Configuration Management. If you prefer a more flexible multi-environment solution, dynaconf is worth exploring. To keep your secrets extra safe, pair decouple with the built-in Python secrets module for generating tokens and keys.

Further Reading: For more details, see the Python asyncio documentation.

Frequently Asked Questions

What is async/await in Python?

async def defines a coroutine function and await pauses execution until an asynchronous operation completes. This enables concurrent I/O operations without threading, using the asyncio event loop.

When should I use async/await instead of threading?

Use async/await for I/O-bound tasks like network requests and database queries with many concurrent connections. Use threading for CPU-bound tasks or libraries that do not support async.

How do I run multiple async tasks concurrently?

Use asyncio.gather(task1(), task2()) to run multiple coroutines concurrently. Use asyncio.create_task() to schedule without immediately waiting.

What does ‘coroutine was never awaited’ mean?

You called an async function without await. Async functions return coroutine objects that must be awaited. Add await before the call or use asyncio.run() from synchronous code.

Can I mix synchronous and asynchronous code?

Yes. Use asyncio.run() to call async from sync. Use loop.run_in_executor() to run blocking functions inside async code without blocking the event loop.