Fail Fast: The Defensive Programming Principle You’re Applying Backwards
Returning a default value instead of raising is not defensive. It is silent corruption.
Fail Fast: The Defensive Programming Principle You’re Applying Backwards
Returning a default value instead of raising is not defensive. It is silent corruption.

The dashboard that was wrong for two days
The pipeline ran at midnight. No errors. No alerts. The job completed with status SUCCESS.
Two days later, a stakeholder noticed the daily revenue figure was zero. Not low, just plain zero. The pipeline had read from a data source that was temporarily unavailable, caught the connection error, logged a warning, and returned an empty DataFrame. Downstream joins on an empty DataFrame produce empty results. The aggregation summed to zero. The dashboard updated. Everything looked correct.
The code that caused it:
import pandas as pd
import logging
def load_transactions(date: str) -> pd.DataFrame:
try:
return pd.read_sql(
"SELECT * FROM transactions WHERE date = %s",
conn,
params=[date],
)
except Exception as e:
logging.warning(f"Failed to load transactions: {e}")
return pd.DataFrame() # "safe" fallback
The function returned a result. The result was wrong. The caller had no way to know.
By the end of this article, you will understand why returning a default on failure is almost always the wrong choice, what to do instead, and how to apply the fail-fast principle correctly at each layer of a system.
What fail-fast actually means

Fail-fast is the principle that a program should stop and report an error as soon as it detects an invalid state, rather than continuing with potentially corrupted data.
The key word is immediately. Not at the end of the pipeline. Not when a user notices. At the point where the invalid state first appears.
This is the opposite of what most engineers mean when they write “defensive” code. Returning 0 when a value is None, swallowing a connection error and returning an empty result, catching Exception and logging a warning…these feel safe until they are not. They keep the program running. But they move the failure from the origin to a distant consequence, where it will be much harder to diagnose.
The test: if your function returns a valid-looking result when something went wrong, that function has hidden a bug.
Anti-pattern 1: The sentinel return
import re
# BAD — None is indistinguishable from "not found" vs "error"
def extract_user_id(text: str):
match = re.search(r"user_id=(\d+)", text)
if match:
return int(match.group(1))
return None # was this "not found" or "pattern broke"?
# Caller has no way to distinguish the two failure modes
user_id = extract_user_id(log_line)
if user_id:
process(user_id)
# else: silently skipped
Returning None on failure forces every caller to write if result is not None and hope they remember to do so. Raising forces the caller to make an explicit decision.
import re
# BETTER — raise when the function's contract is violated
def extract_user_id(text: str) -> int:
match = re.search(r"user_id=(\d+)", text)
if not match:
raise ValueError(f"No user_id found in: {text!r}")
return int(match.group(1))
# Now the caller must make a conscious decision
try:
user_id = extract_user_id(log_line)
except ValueError as e:
logger.error("Malformed log line, skipping: %s", e)
raise # or handle explicitly — not silently
Raising makes the contract legible from the signature: -> int means this function always returns an integer on success. Callers cannot ignore the failure path, so they must write an explicit try/except or let the exception propagate to a layer that can handle it. The ambiguity between “not found” and “error” is gone.
Anti-pattern 2: Exception swallowing
import json
# BAD — the exception disappears
def parse_config(path: str) -> dict:
try:
with open(path) as f:
return json.load(f)
except Exception:
return {} # caller gets an empty dict, assumes config loaded
config = parse_config("config.json")
timeout = config.get("timeout", 30) # silently gets the default
# the application runs with wrong configuration and no one knows
except Exception: return {} converts every possible failure: file not found, permission denied, disk full, malformed UTF-8, truncated JSON, into the same silent empty dictionary. The caller receives a valid-looking result and falls back to defaults, with no indication that the config was never read. The application runs with wrong settings and nothing in the output explains why.
import json
from pathlib import Path
# BETTER — fail at the boundary, clearly
def parse_config(path: str) -> dict:
config_path = Path(path)
if not config_path.exists():
raise FileNotFoundError(f"Config file not found: {path}")
try:
return json.loads(config_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
raise ValueError(f"Config file is not valid JSON: {path}") from e
# Now the caller knows immediately, not three function calls later
Catching only the specific exceptions that can realistically occur and re-raising with context keeps each failure diagnosable: was the file missing, or was it corrupted? The from e clause chains the original exception so the traceback shows both the root cause and the higher-level error. The caller gets a clear signal, not a silent empty dict, and can decide whether to abort, retry, or alert.
Anti-pattern 3: Defensive defaults that corrupt results
import statistics
# BAD — returns 0 when it should signal "no data"
def mean_response_time(times: list) -> float:
if not times:
return 0.0 # "safe" but wrong: 0ms is a valid-looking result
# One bucket with no data pulls the aggregate down.
# The metric looks slightly low. No one investigates.
Returning 0.0 for an empty input makes absence of data indistinguishable from a genuine zero mean. If this value flows into an aggregate, the average of multiple time buckets, for example, one empty bucket silently pulls the total down. The metric is wrong, nothing signals that it is wrong, and the problem can persist for days before someone investigates a “slightly low” number.
import statistics
# BETTER — raise when the contract is violated
def mean_response_time(times: list) -> float:
if not times:
raise ValueError("Cannot compute mean of empty sequence")
return statistics.mean(times)
# Now empty buckets surface as errors instead of silent zeros
Raising on an empty sequence transfers the decision to the caller, where it belongs. The caller can skip the bucket, log a warning, substitute None, or halt the pipeline; but that choice is now explicit and visible in code. No data is more honest than wrong data that looks correct.
Where to actually catch exceptions

Fail-fast does not mean “never catch exceptions.” It means catch only what you can meaningfully handle, at the layer where meaningful handling is possible.
The correct layers:
System entry points: CLI main, API endpoint handlers, Celery task wrappers. These are the boundaries where you convert internal exceptions into user-facing messages or structured error responses. Catch broadly here, convert to appropriate output, and log.
# API endpoint — system boundary
@app.route("/train", methods=["POST"])
def train_endpoint():
try:
result = run_training_pipeline(request.json)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
logger.exception("Unexpected error in /train")
return jsonify({"error": "Internal error"}), 500
Internal functions: do not catch. Let exceptions propagate. The function that detects a problem is rarely the function that can fix it.
# Internal helper — raises; never catches
def load_user_profile(user_id: int) -> dict:
row = db.query("SELECT * FROM users WHERE id = %s", [user_id])
if row is None:
raise ValueError(f"User {user_id} not found")
return row
# Boundary — the only place that catches and converts to a response
@app.route("/profile/<int:user_id>")
def profile_endpoint(user_id: int):
try:
profile = load_user_profile(user_id)
return jsonify(profile)
except ValueError as e:
return jsonify({"error": str(e)}), 404
except Exception as e:
logger.exception("Unexpected error fetching profile %d", user_id)
return jsonify({"error": "Internal error"}), 500
Retry logic: catch specific, transient exceptions (network timeouts, rate limits) and retry. Do not catch Exception,that catches logic errors too.
import requests
import time
def fetch_with_retry(url: str, max_retries: int = 3) -> bytes:
for attempt in range(max_retries):
try:
return requests.get(url, timeout=10).content
except requests.Timeout:
# Catching Timeout only — specific, transient; NOT catching Exception
if attempt == max_retries - 1:
raise
time.sleep(2 ** attempt)
Validation at function boundaries
For functions that accept external data (user input, file contents, API responses), validate inputs at the top of the function before doing any work:
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class TrainingConfig:
learning_rate: float
epochs: int
model_path: str
def validate_training_config(config: TrainingConfig) -> None:
if config.learning_rate <= 0 or config.learning_rate > 1:
raise ValueError(f"learning_rate must be in (0, 1], got {config.learning_rate}")
if config.epochs < 1:
raise ValueError(f"epochs must be >= 1, got {config.epochs}")
if not config.model_path:
raise ValueError("model_path cannot be empty")
def run_training(config: TrainingConfig) -> None:
validate_training_config(config) # fail at the top, before any work
# ... training logic
This is preferable to discovering a bad value in the middle of a two-hour training job.
Takeaways
- Returning a default on failure hides bugs. A crash with a clear message is safer than a corrupted result that looks correct.
- Catch exceptions only at system boundaries (API handlers, CLI main, task runners) and in explicit retry logic for transient failures.
- Internal functions should raise, not return sentinels, and not swallow exceptions.
- Validate inputs at the top of a function before doing any work.
assertfor development-time invariants;raise ValueErrorfor production input validation.
The other half of this principle, what to do when you do need to catch, will be in Stop Catching Every Exception as a follow-up article.
If you found this post helpful, don’t forget to 👏 clap to show your support!
I’d also love to hear your thoughts and insights on the techniques covered in this article. 💡 Feel free to share your experiences in the comments 💬.

Connect with Me on LinkedIn!
You can also connect with me on LinkedIn for updates on my latest posts and projects. 🌐 Let’s keep the conversation going!
메타데이터
- post_id
- a0cb2d55fd75
- slug
- fail-fast-the-defensive-programming-principle-youre-applying-backwards-a0cb2d55fd75
- url
- https://python.plainenglish.io/fail-fast-the-defensive-programming-principle-youre-applying-backwards-a0cb2d55fd75
- canonical_url
- https://python.plainenglish.io/fail-fast-the-defensive-programming-principle-youre-applying-backwards-a0cb2d55fd75
- author_url
- https://medium.com/@ricardogr07
- status
- ok
- fetched_at
- 2026-07-26 02:44:51