Python Guard Clauses Practice Problems & Exercises
Practice: Guard Clauses and Defensive Logic
← Back to lessonEasy
Refactor the deeply nested function below so it uses guard clauses instead of nested if/else blocks. The behavior must remain identical — same inputs produce same outputs.
def process_order(item, quantity):
# Refactored with guard clauses (flat structure)
if item is None:
return "Error: item cannot be None"
if quantity <= 0:
return "Error: quantity must be positive"
return f"Processing {quantity} x {item}"
# --- Tests (do not modify) ---
print(f"process_order(None, 5): {process_order(None, 5)}")
print(f"process_order(\"Widget\", 0): {process_order('Widget', 0)}")
print(f"process_order(\"Widget\", -3): {process_order('Widget', -3)}")
print(f"process_order(\"Widget\", 5): {process_order('Widget', 5)}")Solution
def process_order(item, quantity):
if item is None:
return "Error: item cannot be None"
if quantity <= 0:
return "Error: quantity must be positive"
return f"Processing {quantity} x {item}"
Why guard clauses are better here:
The original deeply nested version looks like this — hard to read and hard to extend:
def process_order_nested(item, quantity):
if item is not None:
if quantity > 0:
return f"Processing {quantity} x {item}"
else:
return "Error: quantity must be positive"
else:
return "Error: item cannot be None"
With guard clauses, each invalid condition is checked and rejected at the top. Once you pass the guards, the remaining code is the happy path — flat, clean, and easy to follow. Adding a new validation (e.g., checking for maximum quantity) is just one more line at the top, not another level of nesting.
Expected Output
process_order(None, 5): Error: item cannot be None
process_order("Widget", 0): Error: quantity must be positive
process_order("Widget", -3): Error: quantity must be positive
process_order("Widget", 5): Processing 5 x WidgetHints
Hint 1: Guard clauses check for invalid conditions at the TOP of a function and return (or raise) immediately — eliminating the need for deeply nested if/else.
Hint 2: Convert each nested check into a flat "if bad_condition: return error" statement at the beginning of the function.
Write calculate_average that computes the mean of a list of numbers. Add guard clauses to handle these invalid inputs — each guard returns a string error message:
- Input is not a list →
"Error: expected a list" - List is empty →
"Error: empty list" - Any element is not a number →
"Error: all elements must be numbers"
def calculate_average(data):
if not isinstance(data, list):
return "Error: expected a list"
if len(data) == 0:
return "Error: empty list"
if not all(isinstance(e, (int, float)) for e in data):
return "Error: all elements must be numbers"
return sum(data) / len(data)
# --- Tests (do not modify) ---
print(f"calculate_average([]): {calculate_average([])}")
print(f'calculate_average("hello"): {calculate_average("hello")}')
print(f"calculate_average([1, \"a\", 3]): {calculate_average([1, 'a', 3])}")
print(f"calculate_average([10, 20, 30]): {calculate_average([10, 20, 30])}")
print(f"calculate_average([7]): {calculate_average([7])}")
print(f"calculate_average([1.5, 2.5, 3.0]): {calculate_average([1.5, 2.5, 3.0])}")Solution
def calculate_average(data):
if not isinstance(data, list):
return "Error: expected a list"
if len(data) == 0:
return "Error: empty list"
if not all(isinstance(e, (int, float)) for e in data):
return "Error: all elements must be numbers"
return sum(data) / len(data)
Guard clause ordering matters:
The guards are ordered from most fundamental to most specific:
- Type guard — Is it even a list? If we skip this and call
len()on an integer, Python would crash withTypeError. - Emptiness guard — Is it non-empty? Calling
sum([]) / len([])would giveZeroDivisionError. - Element guard — Are all elements numeric?
sum([1, "a", 3])would giveTypeError.
Each guard protects the code below it from a specific class of error. This is fail-fast design — you catch problems as early as possible, with clear error messages, instead of letting them cascade into cryptic tracebacks.
Expected Output
calculate_average([]): Error: empty list
calculate_average("hello"): Error: expected a list
calculate_average([1, "a", 3]): Error: all elements must be numbers
calculate_average([10, 20, 30]): 20.0
calculate_average([7]): 7.0
calculate_average([1.5, 2.5, 3.0]): 2.3333333333333335Hints
Hint 1: Check the most fundamental assumptions first: Is it the right type? Then: Is it non-empty? Then: Are all elements valid?
Hint 2: Use `isinstance(x, list)` for type checking and `all(isinstance(e, (int, float)) for e in data)` to validate every element.
Refactor all three functions to use early return. Each one currently has unnecessary nesting or unnecessary else clauses. Behavior must stay identical.
def get_discount(price, is_member):
# Guard: nothing to discount
if price <= 0:
return 0
# Early return for member path
if is_member:
return price * 0.20
return price * 0.10
def find_first_negative(numbers):
if not numbers:
return None
for n in numbers:
if n < 0:
return n
return None
def classify_temperature(temp):
if temp >= 100:
return "critical"
if temp >= 90:
return "warning"
return "normal"
# --- Tests (do not modify) ---
print(f"get_discount(120, True): {get_discount(120, True)}")
print(f"get_discount(120, False): {get_discount(120, False)}")
print(f"get_discount(0, True): {get_discount(0, True)}")
print(f"get_discount(-10, True): {get_discount(-10, True)}")
print(f"find_first_negative([1, 2, -3, 4]): {find_first_negative([1, 2, -3, 4])}")
print(f"find_first_negative([1, 2, 3]): {find_first_negative([1, 2, 3])}")
print(f"find_first_negative([]): {find_first_negative([])}")
print(f"classify_temperature(105): {classify_temperature(105)}")
print(f"classify_temperature(99): {classify_temperature(99)}")
print(f"classify_temperature(75): {classify_temperature(75)}")Solution
def get_discount(price, is_member):
if price <= 0:
return 0
if is_member:
return price * 0.20
return price * 0.10
def find_first_negative(numbers):
if not numbers:
return None
for n in numbers:
if n < 0:
return n
return None
def classify_temperature(temp):
if temp >= 100:
return "critical"
if temp >= 90:
return "warning"
return "normal"
Three patterns that signal "use early return":
-
get_discount— The original might haveif price > 0: ... else: return 0wrapping everything. With a guard clause forprice <= 0, the happy path is flat. -
find_first_negative— The original might use aresult = Nonevariable and a full loop. Early return inside the loop eliminates the variable entirely and stops iteration the moment we find our answer. -
classify_temperature— The original might useif/elif/elsechains with the else carrying the "normal" case. By returning from each threshold check, we eliminate the need forelifentirely. Each condition is independent and reads top-to-bottom like a priority list.
Rule of thumb: If you see an else clause whose body is the rest of the function, eliminate the else and dedent. The code reads better when the exceptional/simple cases exit early and the main logic flows without indentation.
Expected Output
get_discount(120, True): 24.0
get_discount(120, False): 12.0
get_discount(0, True): 0
get_discount(-10, True): 0
find_first_negative([1, 2, -3, 4]): -3
find_first_negative([1, 2, 3]): None
find_first_negative([]): None
classify_temperature(105): critical
classify_temperature(99): warning
classify_temperature(75): normalHints
Hint 1: Look for patterns where the else branch is the entire rest of the function — that is a classic candidate for early return.
Hint 2: For `find_first_negative`, return immediately when you find the first match instead of using a flag variable. For `classify_temperature`, check the most urgent thresholds first.
Medium
Write register_user(username, email, password, age) that validates all inputs using guard clauses. Each invalid condition returns an error string. Only if all guards pass does it return a success message.
Validation rules (in order):
- Username must be non-empty →
"Error: username must be non-empty" - Username must be at least 3 characters →
"Error: username must be at least 3 characters" - Email must contain
@→"Error: email must contain @" - Password must be at least 8 characters →
"Error: password must be at least 8 characters" - Password must contain an uppercase letter →
"Error: password must contain an uppercase letter" - Password must contain a lowercase letter →
"Error: password must contain a lowercase letter" - Password must contain a digit →
"Error: password must contain a digit" - Age must be between 0 and 150 (inclusive) →
"Error: age must be between 0 and 150" - If all pass →
"Success: user {username} registered"
def register_user(username, email, password, age):
if not username:
return "Error: username must be non-empty"
if len(username) < 3:
return "Error: username must be at least 3 characters"
if "@" not in email:
return "Error: email must contain @"
if len(password) < 8:
return "Error: password must be at least 8 characters"
if not any(c.isupper() for c in password):
return "Error: password must contain an uppercase letter"
if not any(c.islower() for c in password):
return "Error: password must contain a lowercase letter"
if not any(c.isdigit() for c in password):
return "Error: password must contain a digit"
if not (0 <= age <= 150):
return "Error: age must be between 0 and 150"
return f"Success: user {username} registered"
# --- Tests (do not modify) ---
tests = [
("", "[email protected]", "Pass123!", 25),
("ab", "[email protected]", "Pass123!", 25),
("alice", "invalid", "Pass123!", 25),
("alice", "[email protected]", "short", 25),
("alice", "[email protected]", "alllower1", 25),
("alice", "[email protected]", "ALLUPPER1", 25),
("alice", "[email protected]", "NoDigits!", 25),
("alice", "[email protected]", "Pass123!", -1),
("alice", "[email protected]", "Pass123!", 200),
("alice", "[email protected]", "Pass123!", 25),
]
for args in tests:
print(f"register_user{args}: {register_user(*args)}")Solution
def register_user(username, email, password, age):
# --- Username guards ---
if not username:
return "Error: username must be non-empty"
if len(username) < 3:
return "Error: username must be at least 3 characters"
# --- Email guard ---
if "@" not in email:
return "Error: email must contain @"
# --- Password guards ---
if len(password) < 8:
return "Error: password must be at least 8 characters"
if not any(c.isupper() for c in password):
return "Error: password must contain an uppercase letter"
if not any(c.islower() for c in password):
return "Error: password must contain a lowercase letter"
if not any(c.isdigit() for c in password):
return "Error: password must contain a digit"
# --- Age guard ---
if not (0 <= age <= 150):
return "Error: age must be between 0 and 150"
# --- Happy path (all guards passed) ---
return f"Success: user {username} registered"
Why this is superior to nested if/else:
Imagine writing this with nested conditionals:
# DON'T DO THIS — 8 levels of nesting
def register_user_nested(username, email, password, age):
if username:
if len(username) >= 3:
if "@" in email:
if len(password) >= 8:
if any(c.isupper() for c in password):
# ... 3 more levels deep
The guard clause version is flat — every validation is at the same indentation level. When you need to add a new rule (e.g., "username must not contain spaces"), you add one line. You never need to restructure the existing code.
Key principle: Guard clauses are ordered by cost — cheap checks (string length) before expensive checks (character iteration). This is fail-fast optimization: if the username is empty, why bother scanning the password character by character?
Expected Output
register_user("", "[email protected]", "Pass123!", 25): Error: username must be non-empty
register_user("ab", "[email protected]", "Pass123!", 25): Error: username must be at least 3 characters
register_user("alice", "invalid", "Pass123!", 25): Error: email must contain @
register_user("alice", "[email protected]", "short", 25): Error: password must be at least 8 characters
register_user("alice", "[email protected]", "alllower1", 25): Error: password must contain an uppercase letter
register_user("alice", "[email protected]", "ALLUPPER1", 25): Error: password must contain a lowercase letter
register_user("alice", "[email protected]", "NoDigits!", 25): Error: password must contain a digit
register_user("alice", "[email protected]", "Pass123!", -1): Error: age must be between 0 and 150
register_user("alice", "[email protected]", "Pass123!", 200): Error: age must be between 0 and 150
register_user("alice", "[email protected]", "Pass123!", 25): Success: user alice registeredHints
Hint 1: Order your guards from simplest to most expensive — check username length before running character-level password validation.
Hint 2: Each guard is a single if-return pair. By the time you reach the final return statement, ALL inputs are guaranteed valid.
Write validate_request(request) that validates an HTTP-like request dict using cascading guard clauses. Return a tuple of (status, message).
Validation rules (in order):
- Request cannot be None →
("invalid", "request cannot be None") - Must be a dict →
("invalid", "request must be a dict") - Must contain keys:
method,path,headers→("invalid", "missing required field: {field}") - Method must be one of: GET, POST, PUT, DELETE →
("invalid", "method must be one of: GET, POST, PUT, DELETE") - Path must start with
/→("invalid", "path must start with /") - POST and PUT require
Content-Typein headers →("invalid", "{method} requires Content-Type header") - If valid →
("valid", "{method} {path}")
def validate_request(request):
if request is None:
return ("invalid", "request cannot be None")
if not isinstance(request, dict):
return ("invalid", "request must be a dict")
for field in ["method", "path", "headers"]:
if field not in request:
return ("invalid", f"missing required field: {field}")
method = request["method"]
path = request["path"]
headers = request["headers"]
if method not in ("GET", "POST", "PUT", "DELETE"):
return ("invalid", "method must be one of: GET, POST, PUT, DELETE")
if not path.startswith("/"):
return ("invalid", "path must start with /")
if method in ("POST", "PUT") and "Content-Type" not in headers:
return ("invalid", f"{method} requires Content-Type header")
return ("valid", f"{method} {path}")
# --- Tests (do not modify) ---
tests = [
None,
"not a dict",
{},
{"method": "GET"},
{"method": "GET", "path": "/api"},
{"method": "PATCH", "path": "/api", "headers": {}},
{"method": "GET", "path": "", "headers": {}},
{"method": "GET", "path": "api", "headers": {}},
{"method": "POST", "path": "/api", "headers": {}},
{"method": "POST", "path": "/api", "headers": {"Content-Type": "application/json"}},
{"method": "GET", "path": "/users", "headers": {}},
]
for req in tests:
print(f"validate_request({req}): {validate_request(req)}")Solution
def validate_request(request):
# Structural guards (cheapest checks first)
if request is None:
return ("invalid", "request cannot be None")
if not isinstance(request, dict):
return ("invalid", "request must be a dict")
# Required field guards
for field in ["method", "path", "headers"]:
if field not in request:
return ("invalid", f"missing required field: {field}")
# Extract values (safe now — all fields are guaranteed to exist)
method = request["method"]
path = request["path"]
headers = request["headers"]
# Semantic guards
if method not in ("GET", "POST", "PUT", "DELETE"):
return ("invalid", "method must be one of: GET, POST, PUT, DELETE")
if not path.startswith("/"):
return ("invalid", "path must start with /")
if method in ("POST", "PUT") and "Content-Type" not in headers:
return ("invalid", f"{method} requires Content-Type header")
# Happy path
return ("valid", f"{method} {path}")
The cascading guard pattern:
Notice how each guard depends on the previous guards passing:
- You cannot check
request["method"]until you know it is a dict with a"method"key. - You cannot check
path.startswith("/")until you knowpathexists. - You cannot check for
Content-Typeuntil you know the method is valid.
This is cascading validation — each guard both rejects bad input AND establishes a precondition that later guards rely on. By the time you reach request["method"], you are guaranteed it will not raise KeyError because the field-existence guard already ran.
This pattern is extremely common in API validation, form processing, and any function that accepts complex structured input.
Expected Output
validate_request(None): ('invalid', 'request cannot be None')
validate_request("not a dict"): ('invalid', 'request must be a dict')
validate_request({}): ('invalid', 'missing required field: method')
validate_request({'method': 'GET'}): ('invalid', 'missing required field: path')
validate_request({'method': 'GET', 'path': '/api'}): ('invalid', 'missing required field: headers')
validate_request({'method': 'PATCH', 'path': '/api', 'headers': {}}): ('invalid', 'method must be one of: GET, POST, PUT, DELETE')
validate_request({'method': 'GET', 'path': '', 'headers': {}}): ('invalid', 'path must start with /')
validate_request({'method': 'GET', 'path': 'api', 'headers': {}}): ('invalid', 'path must start with /')
validate_request({'method': 'POST', 'path': '/api', 'headers': {}}): ('invalid', 'POST requires Content-Type header')
validate_request({'method': 'POST', 'path': '/api', 'headers': {'Content-Type': 'application/json'}}): ('valid', 'POST /api')
validate_request({'method': 'GET', 'path': '/users', 'headers': {}}): ('valid', 'GET /users')Hints
Hint 1: Check structural validity first (is it a dict? has required fields?) before checking semantic validity (is the method valid? does the path start with /?).
Hint 2: Use a loop to check required fields: `for field in ["method", "path", "headers"]: if field not in request: ...`
Write check_permission(user, action, resource) that determines whether a user can perform an action. User is a dict with keys: name, role, active, expired. Use fail-fast guards to reject unauthorized access as early as possible.
Rules (in order):
- No user (None) →
"DENIED: no user provided" - User not active →
"DENIED: account is suspended" - User expired →
"DENIED: account has expired" - Admin role → always granted:
"GRANTED: admin can {action} {resource}" - Check role-permission mapping →
"GRANTED: {role} can {action} {resource}"or"DENIED: role '{role}' cannot perform '{action}'"
ROLE_PERMISSIONS = {
"viewer": {"read"},
"editor": {"read", "write"},
"admin": None, # None means all permissions
}
def check_permission(user, action, resource):
if user is None:
return "DENIED: no user provided"
if not user.get("active", False):
return "DENIED: account is suspended"
if user.get("expired", False):
return "DENIED: account has expired"
role = user["role"]
# Admin early return — skip permission lookup
if role == "admin":
return f"GRANTED: admin can {action} {resource}"
allowed = ROLE_PERMISSIONS.get(role, set())
if allowed is not None and action in allowed:
return f"GRANTED: {role} can {action} {resource}"
return f"DENIED: role '{role}' cannot perform '{action}'"
# --- Tests (do not modify) ---
suspended_user = {"name": "bob", "role": "editor", "active": False, "expired": False}
expired_user = {"name": "carol", "role": "editor", "active": True, "expired": True}
viewer = {"name": "dave", "role": "viewer", "active": True, "expired": False}
editor = {"name": "eve", "role": "editor", "active": True, "expired": False}
admin = {"name": "alice", "role": "admin", "active": True, "expired": False}
tests = [
(None, "read", "doc1"),
(suspended_user, "read", "doc1"),
(expired_user, "read", "doc1"),
(viewer, "delete", "doc1"),
(viewer, "read", "doc1"),
(editor, "write", "doc1"),
(editor, "delete", "doc1"),
(admin, "delete", "doc1"),
(admin, "launch_missiles", "doc1"),
]
labels = [
"None", "suspended_user", "expired_user", "viewer", "viewer",
"editor", "editor", "admin", "admin",
]
for label, (user, action, resource) in zip(labels, tests):
print(f"check_permission({label}, \"{action}\", \"{resource}\"): {check_permission(user, action, resource)}")Solution
ROLE_PERMISSIONS = {
"viewer": {"read"},
"editor": {"read", "write"},
"admin": None, # None means all permissions
}
def check_permission(user, action, resource):
# Guard 1: Existence check (cheapest)
if user is None:
return "DENIED: no user provided"
# Guard 2: Account status (fail fast on suspended)
if not user.get("active", False):
return "DENIED: account is suspended"
# Guard 3: Expiration check
if user.get("expired", False):
return "DENIED: account has expired"
role = user["role"]
# Guard 4: Admin bypass (early return — skip permission lookup)
if role == "admin":
return f"GRANTED: admin can {action} {resource}"
# Guard 5: Role-permission check
allowed = ROLE_PERMISSIONS.get(role, set())
if allowed is not None and action in allowed:
return f"GRANTED: {role} can {action} {resource}"
return f"DENIED: role '{role}' cannot perform '{action}'"
Fail-fast in authorization:
This is a textbook example of fail-fast design in security code. The guards are ordered by:
- Cost —
is Noneis cheaper than a dict lookup which is cheaper than a set membership test. - Likelihood — Suspended/expired accounts are rejected before we even look at permissions.
- Severity — A missing user is a more fundamental problem than an insufficient role.
The admin early-return is particularly important. Without it, you would need to add admin to every permission set, or add or role == "admin" checks everywhere. The guard clause makes the admin rule explicit and impossible to miss.
Expected Output
check_permission(None, "read", "doc1"): DENIED: no user provided
check_permission(suspended_user, "read", "doc1"): DENIED: account is suspended
check_permission(expired_user, "read", "doc1"): DENIED: account has expired
check_permission(viewer, "delete", "doc1"): DENIED: role 'viewer' cannot perform 'delete'
check_permission(viewer, "read", "doc1"): GRANTED: viewer can read doc1
check_permission(editor, "write", "doc1"): GRANTED: editor can write doc1
check_permission(editor, "delete", "doc1"): DENIED: role 'editor' cannot perform 'delete'
check_permission(admin, "delete", "doc1"): GRANTED: admin can delete doc1
check_permission(admin, "launch_missiles", "doc1"): GRANTED: admin can launch_missiles doc1Hints
Hint 1: Check the most disqualifying conditions first: no user → suspended → expired → insufficient role. Each check is cheaper and more fundamental than the next.
Hint 2: Admin users can do anything — that is itself an early return that skips the role-permission lookup entirely.
Build a precondition decorator that enforces guard clauses declaratively. The decorator takes a check function and a message. If the check fails (returns falsy), return "Precondition failed: {message}" instead of calling the function.
Then use it to protect divide and withdraw with preconditions.
def precondition(check, message):
def decorator(func):
def wrapper(*args, **kwargs):
if not check(*args, **kwargs):
return f"Precondition failed: {message}"
return func(*args, **kwargs)
return wrapper
return decorator
@precondition(lambda a, b: b != 0, "divisor must not be zero")
def divide(a, b):
return a / b
@precondition(lambda balance, amount: amount > 0, "amount must be positive")
@precondition(lambda balance, amount: balance >= amount, "insufficient funds")
def withdraw(balance, amount):
return balance - amount
# --- Tests (do not modify) ---
print(f"divide(10, 2): {divide(10, 2)}")
print(f"divide(10, 0): {divide(10, 0)}")
print(f"withdraw(100, 30): {withdraw(100, 30)}")
print(f"withdraw(100, 150): {withdraw(100, 150)}")
print(f"withdraw(-5, 10): {withdraw(-5, 10)}")Solution
def precondition(check, message):
def decorator(func):
def wrapper(*args, **kwargs):
if not check(*args, **kwargs):
return f"Precondition failed: {message}"
return func(*args, **kwargs)
return wrapper
return decorator
@precondition(lambda a, b: b != 0, "divisor must not be zero")
def divide(a, b):
return a / b
@precondition(lambda balance, amount: amount > 0, "amount must be positive")
@precondition(lambda balance, amount: balance >= amount, "insufficient funds")
def withdraw(balance, amount):
return balance - amount
How stacked decorators work:
When you write:
@precondition(check_A, "message A") # outer — runs LAST
@precondition(check_B, "message B") # inner — runs FIRST
def withdraw(balance, amount): ...
Python applies them bottom-up: withdraw = precondition(check_A, "message A")(precondition(check_B, "message B")(withdraw)). But at call time, the outer decorator runs first. So check_A (amount > 0) is checked before check_B (balance >= amount).
This is powerful: you can compose guard clauses as reusable, declarative decorators. The function body contains only happy-path logic. The preconditions are visible at the function definition — anyone reading the code immediately sees what must be true before the function runs.
Real-world use: This pattern appears in Design by Contract (DbC), popularized by Bertrand Meyer's Eiffel language. Python libraries like icontract and deal use exactly this approach.
Expected Output
divide(10, 2): 5.0
divide(10, 0): Precondition failed: divisor must not be zero
withdraw(100, 30): 70
withdraw(100, 150): Precondition failed: insufficient funds
withdraw(-5, 10): Precondition failed: amount must be positiveHints
Hint 1: A precondition decorator takes a check function and an error message. It returns a wrapper that calls the check before the real function.
Hint 2: The check function receives the same arguments as the decorated function. If it returns False, the wrapper returns the error message instead of calling the real function.
Hard
Build a composable validation pipeline. Each guard is a function that takes a data dict and returns ("ok", data) (possibly modified) or ("error", message). The pipeline runs guards in sequence, short-circuiting on the first error.
Implement run_pipeline(data, guards) and build two pipelines to prove it works.
def run_pipeline(data, guards):
current = data
for guard in guards:
status, result = guard(current)
if status == "error":
return ("error", result)
current = result
return ("ok", current)
# --- Guard functions ---
def require_field(field):
def guard(data):
if field not in data or not data[field]:
return ("error", f"{field} must be non-empty")
return ("ok", data)
return guard
def validate_range(field, low, high):
def guard(data):
val = data.get(field)
if val is None or not (low <= val <= high):
return ("error", f"{field} must be between {low} and {high}")
return ("ok", data)
return guard
def validate_format(field, check, message):
def guard(data):
val = data.get(field, "")
if not check(val):
return ("error", message)
return ("ok", data)
return guard
def transform_field(field, func):
def guard(data):
new_data = dict(data)
if field in new_data:
new_data[field] = func(new_data[field])
return ("ok", new_data)
return guard
def add_computed_field(new_field, source_field, func):
def guard(data):
new_data = dict(data)
new_data[new_field] = func(data.get(source_field, ""))
return ("ok", new_data)
return guard
# --- Pipeline 1: strict validation ---
user_pipeline = [
require_field("name"),
require_field("age"),
validate_range("age", 0, 200),
require_field("email"),
validate_format("email", lambda e: "@" in e, "email must contain @"),
]
# --- Pipeline 2: validation + transformation ---
transform_pipeline = [
require_field("name"),
validate_range("age", 0, 200),
transform_field("name", str.upper),
add_computed_field("username", "name", lambda n: n.lower()),
]
# --- Tests (do not modify) ---
print("Pipeline 1 - valid data:")
print(f" {run_pipeline({'name': 'Alice', 'age': 30, 'email': '[email protected]'}, user_pipeline)}")
print("Pipeline 1 - empty name:")
print(f" {run_pipeline({'name': '', 'age': 30, 'email': '[email protected]'}, user_pipeline)}")
print("Pipeline 1 - bad age:")
print(f" {run_pipeline({'name': 'Alice', 'age': -5, 'email': '[email protected]'}, user_pipeline)}")
print("Pipeline 1 - bad email:")
print(f" {run_pipeline({'name': 'Alice', 'age': 30, 'email': 'invalid'}, user_pipeline)}")
print("Pipeline 2 - transform test:")
print(f" {run_pipeline({'name': 'Bob', 'age': 25, 'email': '[email protected]'}, transform_pipeline)}")Solution
def run_pipeline(data, guards):
current = data
for guard in guards:
status, result = guard(current)
if status == "error":
return ("error", result)
current = result
return ("ok", current)
The pipeline pattern explained:
Each guard is a function with signature dict -> ("ok", dict) | ("error", str). This uniform interface means guards are composable — you can mix validation guards, transformation guards, and computed-field guards in any order.
The key insight is that run_pipeline is itself a guard clause — it short-circuits on the first error. This gives you fail-fast behavior across an arbitrary number of validations without writing a single if/else chain.
Why this is powerful:
- Reusable —
require_field("name")works in any pipeline, for any data shape. - Composable — Combine validators and transformers in any order.
- Testable — Each guard is an independent function you can unit test in isolation.
- Extensible — Adding a new rule is just appending to the list. No existing code changes.
Real-world parallels:
- Express.js middleware chains
- Django REST Framework serializer validators
- Unix pipes (each stage transforms or filters data)
- Railway-oriented programming (from F# / functional programming)
The ("ok", data) / ("error", message) pattern is a simplified version of the Result type used in Rust, Go, and functional languages.
Expected Output
Pipeline 1 - valid data:
('ok', {'name': 'Alice', 'age': 30, 'email': '[email protected]'})
Pipeline 1 - empty name:
('error', 'name must be non-empty')
Pipeline 1 - bad age:
('error', 'age must be between 0 and 200')
Pipeline 1 - bad email:
('error', 'email must contain @')
Pipeline 2 - transform test:
('ok', {'name': 'BOB', 'age': 25, 'email': '[email protected]', 'username': 'bob'})Hints
Hint 1: Each guard in the pipeline is a function that takes data and returns either ("ok", data) to pass through or ("error", message) to short-circuit.
Hint 2: The pipeline runner iterates through guards in order. The moment any guard returns "error", stop and return that error. If all pass, return the final data.
Build a contract decorator that enforces both preconditions (checked before the call) and postconditions (checked after). If any check fails, return "ContractError: {message}".
- Precondition checks receive
(*args, **kwargs)— the function arguments. - Postcondition checks receive
(result, *args, **kwargs)— the return value plus original arguments.
def contract(pre=None, post=None):
pre = pre or []
post = post or []
def decorator(func):
def wrapper(*args, **kwargs):
# Check preconditions
for check, message in pre:
if not check(*args, **kwargs):
return f"ContractError: {message}"
# Call the function
result = func(*args, **kwargs)
# Check postconditions
for check, message in post:
if not check(result, *args, **kwargs):
return f"ContractError: {message}"
return result
return wrapper
return decorator
import math
@contract(
pre=[(lambda x: x >= 0, "input must be non-negative")],
post=[(lambda result, x: result >= 0, "result must be non-negative")],
)
def safe_sqrt(x):
return math.sqrt(x)
@contract(
pre=[
(lambda a, b: b != 0, "divisor must not be zero"),
],
post=[
(lambda result, a, b: result != a, "result must not equal the first argument"),
],
)
def safe_divide(a, b):
return a / b
@contract(
pre=[(lambda value, low, high: low <= high, "low must be <= high")],
post=[
(lambda result, value, low, high: low <= result <= high,
"result must be within [low, high]"),
],
)
def clamp(value, low, high):
if value < low:
return low
if value > high:
return high
return value
# --- Tests (do not modify) ---
print(f"safe_sqrt(25): {safe_sqrt(25)}")
print(f"safe_sqrt(-4): {safe_sqrt(-4)}")
print(f"safe_divide(10, 3): {safe_divide(10, 3)}")
print(f"safe_divide(10, 0): {safe_divide(10, 0)}")
print(f"safe_divide(10, 10): {safe_divide(10, 10)}")
print(f"clamp(5, 1, 10): {clamp(5, 1, 10)}")
print(f"clamp(15, 1, 10): {clamp(15, 1, 10)}")
print(f"clamp(-3, 1, 10): {clamp(-3, 1, 10)}")
print(f"clamp(5, 10, 1): {clamp(5, 10, 1)}")Solution
def contract(pre=None, post=None):
pre = pre or []
post = post or []
def decorator(func):
def wrapper(*args, **kwargs):
# Check all preconditions (guard the inputs)
for check, message in pre:
if not check(*args, **kwargs):
return f"ContractError: {message}"
# Call the actual function (happy path)
result = func(*args, **kwargs)
# Check all postconditions (guard the output)
for check, message in post:
if not check(result, *args, **kwargs):
return f"ContractError: {message}"
return result
return wrapper
return decorator
Design by Contract (DbC):
This pattern was formalized by Bertrand Meyer in 1986 for the Eiffel language. The idea: every function has a contract with its callers:
- Preconditions — "I promise to give you valid inputs" (caller's responsibility)
- Postconditions — "I promise to give you a valid result" (function's responsibility)
When a precondition fails, it means the caller has a bug. When a postcondition fails, it means the function has a bug. This distinction is powerful for debugging.
Why postconditions matter:
The safe_divide(10, 10) test is revealing. The function computes 10 / 10 = 1.0 correctly, but the postcondition catches that result == a — which our contract says should never happen. This is a deliberately unusual contract to demonstrate that postconditions can enforce arbitrary business logic, not just type safety.
In production Python, you would typically raise exceptions rather than return error strings, and use libraries like icontract or deal that also support class invariants (conditions that must hold before AND after every method call).
Expected Output
safe_sqrt(25): 5.0
safe_sqrt(-4): ContractError: input must be non-negative
safe_divide(10, 3): 3.3333333333333335
safe_divide(10, 0): ContractError: divisor must not be zero
safe_divide(10, 10): ContractError: result must not equal the first argument
clamp(5, 1, 10): 5
clamp(15, 1, 10): 10
clamp(-3, 1, 10): 1
clamp(5, 10, 1): ContractError: low must be <= highHints
Hint 1: The contract wrapper accepts lists of (check_function, message) tuples for both preconditions and postconditions.
Hint 2: Preconditions check the inputs BEFORE calling the function. Postconditions check the result AFTER — the check function receives (result, *args, **kwargs).
Build a FormValidator class with two modes:
- fail_fast=True — returns the first error found (classic guard clause behavior)
- fail_fast=False — collects ALL errors across all fields (useful for form UIs)
Both modes return (True, data) on success or (False, [error_messages]) on failure.
Rules are added as tuples of (field_name, check_function, error_message) where check_function receives the field value.
class FormValidator:
def __init__(self, fail_fast=True):
self.fail_fast = fail_fast
self.rules = []
def add_rule(self, field, check, message):
self.rules.append((field, check, message))
return self # allow chaining
def validate(self, data):
errors = []
for field, check, message in self.rules:
value = data.get(field, "")
if not check(value):
errors.append(f"{field}: {message}")
if self.fail_fast:
return (False, errors)
if errors:
return (False, errors)
return (True, data)
# --- Build validators ---
def build_validator(fail_fast):
v = FormValidator(fail_fast=fail_fast)
v.add_rule("username", lambda v: bool(v), "must be non-empty")
v.add_rule("username", lambda v: len(v) >= 3, "must be at least 3 chars")
v.add_rule("email", lambda v: "@" in v, "must contain @")
v.add_rule("age", lambda v: v.isdigit(), "must be a number")
return v
# --- Tests (do not modify) ---
print("=== Fail-Fast Mode ===")
ff = build_validator(fail_fast=True)
print(f"Valid form: {ff.validate({'username': 'alice', 'email': '[email protected]', 'age': '25'})}")
print(f"Empty username: {ff.validate({'username': '', 'email': '[email protected]', 'age': '25'})}")
print(f"Multiple errors (stops at first): {ff.validate({'username': 'ab', 'email': 'bad', 'age': 'old'})}")
print(f"\n=== Collect-All Mode ===")
ca = build_validator(fail_fast=False)
print(f"Valid form: {ca.validate({'username': 'alice', 'email': '[email protected]', 'age': '25'})}")
print(f"Empty username: {ca.validate({'username': '', 'email': '[email protected]', 'age': '25'})}")
print(f"Multiple errors (collects all): {ca.validate({'username': 'ab', 'email': 'bad', 'age': 'old'})}")Solution
class FormValidator:
def __init__(self, fail_fast=True):
self.fail_fast = fail_fast
self.rules = []
def add_rule(self, field, check, message):
self.rules.append((field, check, message))
return self # allow method chaining
def validate(self, data):
errors = []
for field, check, message in self.rules:
value = data.get(field, "")
if not check(value):
errors.append(f"{field}: {message}")
if self.fail_fast:
return (False, errors)
if errors:
return (False, errors)
return (True, data)
Fail-fast vs collect-all — when to use each:
This problem demonstrates that guard clauses are not always the right strategy. The correct approach depends on who consumes the errors:
Fail-fast (guard clauses) is better when:
- You are writing backend validation — reject bad requests immediately
- Error handling is expensive (database calls, API calls)
- You want to minimize wasted computation
- The caller fixes one thing at a time (CLI tools, APIs)
Collect-all is better when:
- You are validating a form in a UI — show all errors at once so the user can fix them in one pass
- You are running a batch validation report
- The cost of checking all rules is low compared to the cost of round-trips
The pattern in this solution:
The FormValidator class demonstrates the Strategy Pattern — the same rule set, two different execution strategies. The rules are data (a list of tuples), and the mode flag controls the iteration behavior. This separation of concerns means you can:
- Define rules once, reuse in both modes
- Switch modes without changing any rule definitions
- Add new rules without touching the validation engine
In production, libraries like Marshmallow, Pydantic, and Cerberus all support both modes. Pydantic calls them "first error" vs "all errors" (model_config = ConfigDict(validate_default=True)). Django forms always collect all errors by default.
Expected Output
=== Fail-Fast Mode ===
Valid form: (True, {'username': 'alice', 'email': '[email protected]', 'age': '25'})
Empty username: (False, ['username: must be non-empty'])
Multiple errors (stops at first): (False, ['username: must be at least 3 chars'])
=== Collect-All Mode ===
Valid form: (True, {'username': 'alice', 'email': '[email protected]', 'age': '25'})
Empty username: (False, ['username: must be non-empty', 'username: must be at least 3 chars'])
Multiple errors (collects all): (False, ['username: must be at least 3 chars', 'email: must contain @', 'age: must be a number'])Hints
Hint 1: Both modes use the same list of rule tuples: (field, check_function, message). The difference is whether you stop at the first failure or collect all failures.
Hint 2: In collect-all mode, you still group by field — if a field has a "required" check that fails, you might still want to skip further checks on that field. But across fields, you continue checking.
