Have you ever started a new Python project and, within a week, everything already feels messy?
Your config.py file is slowly becoming a dumping ground. There are commented lines everywhere, database URLs hardcoded directly in the file, and if ENV == “prod” conditions scattered across the codebase. At first, it feels manageable. But very quickly, it becomes difficult to understand what is actually being used and what is not.
And somewhere in the back of your mind, there is always that small fear: What if I accidentally expose a production password or push the wrong configuration?
This kind of setup might work for a small script. But as the project grows, it becomes hard to maintain and almost impossible to scale properly. And yes, this still happens even in the modern world of AI-assisted coding, irrespective of which model we use.
Over time, I realized that the cleanest way to handle configuration is not through complex .ini files or deeply nested dictionaries. I prefer using Python class inheritance along with environment variables. In some projects, I also pair this with Pydantic for validation when things get more complex.
Here’s how I structure my configuration systems to keep them type-safe, secure, and, most importantly, easy to read.
The Foundation
First, we need to talk about secrets. Hardcoding a Telegram token inside your code is basically inviting trouble. The simplest solution is to move sensitive values into a .env file and load them from environment variables.
One important rule. Never commit your .env file to Git. Instead, keep a .env.example file with empty placeholders so your team knows what variables are required.
Example .env file for local development:
# .env file (Local only!)
TG_LIVE_TOKEN=55667788:AABBCC_Example
TG_LIVE_CHAT_ID=-100123456789
DATABASE_URL=sqlite:///app.db
Now, instead of scattering values everywhere, I keep a single configuration file which acts as the source of truth.
import os
class Config:
"""Common settings for all environments"""
SECRET_KEY = os.environ.get("SECRET_KEY", "change-this-in-production")
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///app.db")
SQLALCHEMY_TRACK_MODIFICATIONS = False
TELEGRAM_BOTS = {
"Live Notifications": {
"bot_token": os.environ.get("BOT_TOKEN_LIVE"),
"chat_id": "-1234455"
},
"Admin Bot": {
"bot_token": os.environ.get("BOT_TOKEN_ADMIN"),
"chat_id": "-45678"
}
}
This class holds the defaults. Everything common lives here. No duplication.
When I need environment-specific behavior, I simply inherit and override only what is required.
For example, in end-to-end testing, I might want notifications enabled but routed differently.
class E2EConfig(Config):
"""Overrides for E2E testing"""
TESTING = True
TELEGRAM_SEND_NOTIFICATIONS = True
E2E_NOTIFICATION_BOT = 'Admin Bot'
For unit or integration testing, I definitely do not want real Telegram messages going out. I also prefer an in-memory database for speed.
class TestConfig(Config):
"""Overrides for local unit tests"""
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' # Use in-memory DB for speed
TELEGRAM_SEND_NOTIFICATIONS = False
WTF_CSRF_ENABLED = False
Notice something important here. I am not copying the entire base class. I am only overriding what changes. That alone reduces many future mistakes.
To avoid magic strings floating around in the logic layer, I sometimes pair this with enums.
from enum import Enum
class LogType(Enum):
STREAM_PUBLISH = 'STREAM_PUBLISH'
NOTIFICATION = 'NOTIFICATION'
Now my IDE knows the valid options. Refactoring becomes safer. Typos become less likely.
Loading the configuration is also simple. In Flask, I usually use a factory pattern and switch based on one environment variable.
import os
from flask import Flask
from config import Config, E2EConfig, TestConfig
def create_app():
app = Flask(__name__)
# Select config based on APP_ENV environment variable
env = os.environ.get("APP_ENV", "production").lower()
configs = {
"production": Config,
"e2e": E2EConfig,
"test": TestConfig
}
# Load the selected class
app.config.from_object(configs.get(env, Config))
return app
That is it. One variable controls everything. No scattered if-else checks across the codebase.
Over time, this pattern has saved me from configuration-related surprises. All settings live in one place. Inheritance avoids copy-paste errors. Tests do not accidentally spam users because TELEGRAM_SEND_NOTIFICATIONS is explicitly set to False in TestConfig.
And if tomorrow I need a StagingConfig or DevConfig, I just add a small class that extends Config. Three or four lines, and I am done.
Configuration management may not be glamorous, but it decides how stable your application feels in the long run. A clean structure here reduces mental load everywhere else.



