Validate function arguments¶
The question. Your function has real requirements on its inputs — a string must not be empty, a number must be within a range, a config dict must have certain keys — and you want the function to fail fast and loudly when those requirements aren't met, with an error message that tells the caller what's wrong.
The rule of thumb: TypeError when the argument is the wrong type, ValueError when it's the right type but the wrong content. Check type first, content after.
def register_user(username: str, age: int) -> dict:
"""Register a user. Fails fast on bad input."""
# Type checks first: the value has to be the right shape
# before it's worth asking whether it's the right content.
if not isinstance(username, str):
raise TypeError(
f'username must be a string, got {type(username).__name__}'
)
if not isinstance(age, int):
raise TypeError(
f'age must be an integer, got {type(age).__name__}'
)
# Content checks after: values of the right type but out of range.
if len(username) < 3:
raise ValueError(
f'username must be at least 3 characters, got {len(username)}'
)
if not 0 <= age <= 150:
raise ValueError(f'age must be between 0 and 150, got {age}')
return {'username': username, 'age': age}
print(register_user('alice', 30))
for bad in [('ab', 25), (123, 25), ('alice', 200)]:
try:
register_user(*bad)
except (TypeError, ValueError) as exc:
print(f'{type(exc).__name__}: {exc}')
Why it works¶
The type-then-content ordering matters. If you check 0 <= age <= 150 before confirming age is a number, passing a string raises TypeError: '<=' not supported between instances of 'str' and 'int' — technically right, but the message is about Python's operators, not about your function's contract. Raising your own TypeError first gives the caller a message in your function's vocabulary.
Python's exception hierarchy makes it easy for callers to do the right thing. TypeError and ValueError both inherit from Exception, so catching Exception catches both. But their distinct names let calling code react differently: a library usually wants to wrap ValueError as a domain-specific error, and let TypeError propagate as a programming bug.
Include the received value (or its type) in every error message. 'age must be between 0 and 150, got 200' tells the reader exactly what came in; 'age out of range' sends them back to re-read their own code. When in doubt, say more.
Trade-offs and variations¶
- Type hints are not runtime checks.
def f(age: int)does nothing at runtime if a caller passes a string. Reach forisinstance(or a library such aspydantic) when the check has to bite. - Don't repeat yourself with a decorator. If several functions share the same validation logic, extract a
@validate_types(name=str, age=int)or@validate_positive('width', 'height')decorator usinginspect.signatureto look up parameters by name. Use the Create decorators recipe as the starting skeleton. - Use
assertonly for internal invariants. Assertions are disabled by Python's-Oflag, so they must not be the only thing standing between your function and bad input. Reserve them for "this cannot be reached if the code is correct" rather than "the caller gave us rubbish". - Consider
pydanticorattrsfor data-heavy code. Once you have more than two or three of these checks, a dataclass with validators pays for itself. Keep manual checks for small numbers of arguments or hot-path functions where the overhead of a framework isn't justified.
Related¶
- Learn — Type hints for static annotations that complement runtime validation.
- Recipe — Create decorators for the validator-decorator pattern.
- Reference — Built-in functions for
isinstance,type, and the built-in exception hierarchy. - Guide — Error handling for when and how to raise, catch, and translate exceptions at module boundaries.