Skip to Content
Course content

334: Loading Settings from Multiple Sources Safely

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this exact bug pop up in countless production deployments. You've got a local setup that works perfectly, but the moment the code hits a staging or production server, it crashes with a TypeError or, worse, silently ignores a critical security setting. It usually happens when you try to merge settings from multiple places—like a default dictionary, a JSON config file, and environment variables.

Take a look at this snippet. It looks straightforward: load defaults, override them with a file, and then let the environment have the final say.

import os
import json

def load_settings():
    # 1. Default settings
    settings = {
        "port": 8000,
        "debug": False,
        "api_key": "dev_key"
    }

    # 2. Load from config file
    if os.path.exists("settings.json"):
        with open("settings.json") as f:
            settings.update(json.load(f))

    # 3. Override with environment variables
    settings["port"] = os.environ.get("APP_PORT")
    settings["debug"] = os.environ.get("APP_DEBUG")
    
    return settings

config = load_settings()
print(f"Server starting on port {config['port'] + 1}") 

The 'None' Overwrite and the Type Trap

If you run this on your machine without APP_PORT set in your environment, it's going to explode. Why? Because os.environ.get("APP_PORT") returns None if the key doesn't exist. That None doesn't just "not change" the setting; it actively overwrites your default port of 8000. When the code hits config['port'] + 1, Python throws a TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'.

Even if you fix the None issue, you've got a type problem. Environment variables are always strings. If you set APP_PORT=9000 in your shell, settings["port"] becomes the string "9000", not the integer 9000. This is a classic "silent killer" bug that only reveals itself when you try to perform math or pass the variable to a library that expects a specific type.

Establishing a Strict Precedence Hierarchy

To fix this, we need to change how we merge. We shouldn't blindly assign the result of an os.environ.get(). Instead, we only want to overwrite a value if the environment variable actually exists. I prefer using a loop over a mapping of environment keys to settings keys. This keeps the logic clean and prevents the "None overwrite" entirely.

import os
import json

def load_settings():
    # Start with hardcoded defaults
    settings = {
        "port": 8000,
        "debug": False,
        "api_key": "dev_key"
    }

    # Merge file settings (only if the file exists)
    if os.path.exists("settings.json"):
        with open("settings.json") as f:
            file_settings = json.load(f)
            settings.update(file_settings)

    # Map Env Var Name -> Settings Key
    env_map = {
        "APP_PORT": "port",
        "APP_DEBUG": "debug",
        "APP_API_KEY": "api_key"
    }

    for env_key, settings_key in env_map.items():
        value = os.environ.get(env_key)
        if value is not None:
            settings[settings_key] = value

    return settings

Enforcing Types with a Casting Layer

Now we've solved the None problem, but we're still dealing with strings from the environment. The safest way to handle this is to define what type each setting should be and cast it during the merge. I usually do this by creating a small helper or using the existing defaults as a type guide.

Here is the professional way to handle this. We use the type of the default value to determine how to cast the incoming environment string:

def cast_value(original_value, new_value):
    """Casts new_value to the type of original_value."""
    if original_value is None:
        return new_value
    
    # Handle Booleans separately because bool("False") is True!
    if isinstance(original_value, bool):
        return str(new_value).lower() in ("true", "1", "yes")
    
    return type(original_value)(new_value)

def load_settings_safely():
    settings = {
        "port": 8000,
        "debug": False,
        "api_key": "dev_key"
    }

    if os.path.exists("settings.json"):
        with open("settings.json") as f:
            settings.update(json.load(f))

    env_map = {
        "APP_PORT": "port",
        "APP_DEBUG": "debug",
        "APP_API_KEY": "api_key"
    }

    for env_key, settings_key in env_map.items():
        value = os.environ.get(env_key)
        if value is not None:
            # Cast the string from the environment to the correct type
            settings[settings_key] = cast_value(settings[settings_key], value)

    return settings

Notice the bool check in cast_value. This is a common trap: in Python, bool("False") is True because any non-empty string is truthy. You have to explicitly check for strings like "true" or "1".




📋 Practical Task

Build a Multi-Source Database Configuration Loader

Create a Python script that loads database configuration from three sources: a DEFAULTS dictionary, a db_config.json file, and environment variables. Your loader must satisfy these requirements:

  • Defaults: host ("localhost"), port (5432), timeout (30), and ssl_enabled (False).
  • Precedence: Environment variables override the JSON file, and the JSON file overrides the defaults.
  • Type Safety: port and timeout must be integers; ssl_enabled must be a boolean.
  • Environment Mapping: Map DB_HOST, DB_PORT, DB_TIMEOUT, and DB_SSL to the corresponding settings keys.

To test your solution, set an environment variable (e.g., export DB_PORT=9999) and verify that your final configuration object contains the integer 9999, even if db_config.json contains a different value or doesn't exist at all.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.