Hard coding. You've seen it. You've probably done it. Maybe you're doing it right now.
That API key sitting in your config file? That's why hard coded. The file path that only works on your machine? Hard coded. Even so, the magic number 3600 instead of SECONDS_PER_HOUR? Yeah. That too.
It's one of those things every developer learns early, nods along to, and then quietly ignores when deadlines loom. But understanding what hard coding actually means — and more importantly, when it bites you — separates code that survives from code that becomes a nightmare to maintain.
Counterintuitive, but true.
Let's talk about what it really is, why it keeps happening, and how to stop it from wrecking your project.
What Is Hard Coding
At its simplest, hard coding means embedding values directly into your source code instead of pulling them from somewhere configurable — environment variables, config files, databases, command-line arguments, you name it.
# Hard coded
api_url = "https://api.stripe.com/v1"
timeout = 30
max_retries = 3
# Not hard coded
api_url = os.getenv("STRIPE_API_URL")
timeout = int(os.getenv("REQUEST_TIMEOUT", "30"))
max_retries = int(os.getenv("MAX_RETRIES", "3"))
The difference isn't syntax. It's where the value lives Small thing, real impact..
Hard coded values are baked into the binary, the container image, the deployed artifact. You rebuild. Because of that, you redeploy. Change them? You pray the CI pipeline doesn't flake.
It's Not Just Strings and Numbers
People think hard coding is about literal values. It's broader than that.
- File paths —
/home/user/data/input.csvworks great until it doesn't - URLs and endpoints — staging vs production vs that one weird legacy service
- Credentials — API keys, database passwords, JWT secrets (please don't)
- Business logic thresholds — "free tier gets 100 requests" written as
if count > 100 - Feature flags —
if user.id == 42: enable_new_dashboard()(yes, I've seen this) - Localization strings — "Welcome!" hardcoded in English everywhere
- Database schema names —
SELECT * FROM prod_users.ordersin your query builder
Any value that might change across environments, deployments, customers, or time — and lives in your code — is hard coded.
The Spectrum of "Hard"
Not all hard coding is equal.
| Level | Example | Pain When Changing |
|---|---|---|
| Literal | port = 8080 |
Rebuild + redeploy |
| Constant | DEFAULT_PORT = 8080 |
Rebuild + redeploy (but searchable) |
| Config file (committed) | config.yaml in repo |
Rebuild + redeploy, but readable |
| Environment variable | os.getenv("PORT") |
Restart only |
| External config service | Consul, etcd, Parameter Store | Instant, no restart |
The further right you go, the more flexible. The further left, the more rigid.
Why It Matters
You might think: It works on my machine. I'll fix it later.
Here's what "later" looks like.
Environment Drift
Your local machine runs PostgreSQL on port 5432. In real terms, production? Staging uses a managed instance with a different host. But cI runs it on 5433. Read replicas, connection pooling, TLS required.
Hard code the connection string once, and you've just signed up for maintaining four slightly different branches — or worse, one branch with commented-out blocks like:
# LOCAL
# db_url = "postgres://localhost:5432/myapp"
# CI
# db_url = "postgres://ci-db:5433/myapp"
# STAGING
db_url = "postgres://staging-db.internal:5432/myapp?sslmode=require"
# PROD - DON'T TOUCH
# db_url = "postgres://prod-primary:5432/myapp?sslmode=verify-full"
I've seen this in production codebases at Series B startups. It's not rare It's one of those things that adds up..
Security Incidents Waiting to Happen
Hard coded secrets are the number one way credentials leak And that's really what it comes down to..
- Developer commits
.envfile? Leaked. - Docker image builds with
ARG API_KEY=...? Leaked in layer history. - Code snippet shared in Slack? Leaked.
- Repository goes public by accident? Everything leaked.
Rotating a hard coded secret means finding every occurrence, updating, rebuilding, redeploying — across every service that uses it. Meanwhile, the old key is valid until you finish Small thing, real impact. Turns out it matters..
The "Just One More Deploy" Trap
Business wants to change the free trial from 14 days to 30. Marketing wants to swap the homepage hero text. Legal needs the privacy policy URL updated That's the part that actually makes a difference..
If any of those are hard coded, each change is a deploy. A deploy means:
- CI pipeline runs (10–30 minutes)
- Code review (if you're lucky)
- Staging verification
- Production rollout
- Rollback plan if something breaks
A text change becomes a half-day event. Day to day, multiply by ten changes a month. That's not engineering — that's bureaucracy Less friction, more output..
Testing Becomes a Lie
Unit tests that depend on hard coded values test those values, not your logic.
def test_discount_calculation():
# This test passes because 100 * 0.1 == 10
# But what if the discount rate changes?
assert calculate_discount(100) == 10
Now imagine that discount rate lives in a config. Consider this: your test should inject the rate. If it's hard coded, you're testing the constant, not the function Turns out it matters..
Integration tests suffer worse. That said, you hit real services. Hard coded endpoints mean you can't spin up a test double. Tests become flaky, slow, and expensive.
How It Works (And How to Stop Doing It)
The fix isn't "never write a literal value." It's knowing when a value belongs outside the code.
Step 1: Identify What Varies
Ask yourself: Could this value differ...
- Between my laptop and CI?
- Between staging and production?
- Between Customer A and Customer B?
- Next month when marketing changes their mind?
- When we rotate credentials (which we should do regularly)?
- When we migrate databases / cloud providers / APIs?
If the answer is yes to any of those, it doesn't belong in code Not complicated — just consistent..
Step 2: Choose the Right Abstraction
Not everything needs a distributed config system. Match the tool to the problem Most people skip this — try not to..
Environment Variables — The Baseline
# .env (local, never committed)
DATABASE_URL=postgres://localhost:5432/myapp
STRIPE_SECRET_KEY=sk_test_...
LOG_LEVEL=debug
# config.py
import os
from functools import lru_cache
@lru_cache
def get_settings():
return {
"database_url": os.getenv("DATABASE_URL"),
"stripe_secret_key": os.getenv("STRIPE_SECRET_KEY"),
"log_level": os.
Simple, works everywhere, supported by every platform (Docker, Kubernetes, Heroku, Vercel, Lambda, etc.).
**Caveat:** Environment variables are strings. You handle parsing, validation, defaults. They're process-global — changing them requires a restart. Fine for most apps.
#### Config Files — For Structure
When you have nested configuration, multiple sections, or non-string types:
```yaml
# config.yaml (committed, template only)
database:
host: "${DB_HOST
`.env.example` instead — a template with placeholder values that new developers copy and fill in.
```yaml
# config.yaml (committed, template only)
database:
host: "${DB_HOST}"
port: "${DB_PORT:-5432}"
name: "${DB_NAME}"
ssl: "${DB_SSL:-true}"
stripe:
secret_key: "${STRIPE_SECRET_KEY}"
webhook_secret: "${STRIPE_WEBHOOK_SECRET}"
features:
new_checkout: "${FEATURE_NEW_CHECKOUT:-false}"
maintenance_mode: "${MAINTENANCE_MODE:-false}"
Load it with a library like pydantic, dynaconf, or python-dotenv that handles type coercion, defaults, and nested structure for you.
Secrets Managers — For Sensitive Values
API keys, database passwords, certificates — these should never appear in config files, even in .env. Use a secrets manager:
- AWS Secrets Manager or Parameter Store
- HashiCorp Vault
- GCP Secret Manager
- Azure Key Vault
import boto3
import json
def get_secret(name: str) -> dict:
client = boto3.client("secretsmanager")
response = client.get_secret_value(SecretId=name)
return json.
db_creds = get_secret("prod/database/credentials")
This means your app has zero static secrets. Rotation, auditing, and access control are handled by the platform. If someone leaves the team, you rotate the secret in one place — no code changes, no deploys It's one of those things that adds up..
Feature Flags — For Runtime Behavior
Some values aren't infrastructure — they're business logic toggles:
def get_pricing(user: User) -> float:
if feature_flags.is_enabled("new_pricing_model", user.tenant_id):
return calculate_tiered_pricing(user)
return calculate_flat_pricing(user)
Tools like LaunchDarkly, Unleash, or even a simple database-backed flag table let you change behavior without deploying code. This is especially powerful for:
- Gradual rollouts (canary releases)
- A/B testing pricing or UX
- Killing a feature instantly when it breaks
Step 3: Validate at Startup
The worst thing that can happen is your app starts with missing or invalid configuration and fails silently mid-request. Fail fast:
from pydantic import BaseSettings, ValidationError
class Settings(BaseSettings):
database_url: str
stripe_secret_key: str
log_level: str = "info"
class Config:
env_file = ".env"
try:
settings = Settings()
except ValidationError as e:
print(f"Configuration error: {e}")
raise SystemExit(1)
If DATABASE_URL is missing or malformed, the app refuses to start with a clear error message. No cryptic connection timeouts at 3 AM Simple as that..
Step 4: Document What's External
A config file with no documentation is a guessing game. Add a CONFIGURATION.md or use inline comments in your schema:
# config.yaml
# --- Database ---
# Host for the primary PostgreSQL instance.
# Defaults to localhost in dev; set DB_HOST in production.
database:
host: "${DB_HOST}"
# --- Stripe ---
# Live secret key for production. Test key for staging.
# Never commit actual values — use your secrets manager.
stripe:
secret_key: "${STRIPE_SECRET_KEY}"
When onboarding a new engineer, they should be able to look at this doc, know what they need to set up locally, and understand why each value is externalized.
The Principle, Not the Pattern
Environment variables, config files, secrets managers, and feature flags are all **means
to an end** — the end being separation of concerns between code and configuration.
The principle isn't "use environment variables" or "put everything in AWS Secrets Manager." It's about making your application's behavior explicitly dependent on external inputs that can be changed without touching the code itself And that's really what it comes down to..
This gives you:
- Portability: The same code runs in dev, staging, and prod
- Security: Sensitive values never live in source control
- Flexibility: You can change behavior without deployments
- Observability: You know exactly what configuration your running app is using
Whether that's through os.getenv(), a YAML file, a secrets manager API call, or a feature flag service — the mechanism matters less than the discipline Practical, not theoretical..
Conclusion
Configuration management isn't a deployment detail — it's a foundational practice that determines how safely and efficiently your application evolves. By treating configuration as a first-class concern, you eliminate entire classes of bugs, reduce deployment risk, and make your system operable by humans who weren't there when it was built It's one of those things that adds up..
Start small: pick one source of truth for your environment-specific values, fail fast on missing config, and document what each setting does. The compound effect of these practices will pay dividends in every deployment, every incident response, and every new team member who joins your project That's the whole idea..
Not obvious, but once you see it — you'll see it everywhere.
Your future self — debugging at 3 AM — will thank you That's the whole idea..