Skip to content

Input validation and error handling

coreCommon atStripeAnthropicShopifyPlaidRead firstParsing and tokenizingAPI designPractise in the Sandbox →

Treat everything from outside your code as untrusted, and check it once, at the boundary, in layers: syntax, shape, types, field rules, cross-field rules, then rules that need stored state. Turn it into a typed value that is valid by construction, so the code inside never re-checks. Then decide two things on purpose: fail fast or collect all errors, and throw or return a result. Validation is O(size of input); the expensive mistake is doing it after you’ve already changed state.

Think of passport control. Every traveller is checked once, at the border. Inside the country, nobody asks for your passport at every shop. If border control were sloppy, every shop would need its own checks, and some would forget.

Code works the same way. The boundary is wherever data enters: an HTTP handler, a CLI argument, a file you read, a message from a queue, a function in a public library. Check there, then pass on a value whose type already promises it is valid: an Endpoint with a port between 1 and 65535, not a str that someone hopefully checked.

This idea has a name: parse, don’t validate (Alexis King, 2019). A validator answers yes or no and throws the knowledge away. A parser returns a new, more precise value, or an error. The type system then carries the proof for you.

Checks come in layers, and each layer only makes sense if the one before it passed:

Layer Example question Typical response
Syntax Is this JSON / CSV / a number at all? 400 Bad Request
Shape Is it an object? Are required fields present? Any unknown fields? 400 / 422
Types Is age an integer, not "36" or true? 422
Field rules Is age between 13 and 130? Is plan one of the allowed values? 422
Cross-field A team plan needs seats >= 2; end is after start 422
State Is this email already registered? Does the account exist? 404 / 409

Edit the request body or pick a preset, and switch strategies. Watch which checks run, which are skipped because their input is unusable, and whether the database is touched. Try wrong types: "36" is not an integer, and true is not a seat count.

Validate a signup request at the boundary
edit the JSON
0. Syntax: is it JSON?
✓$parses as JSON
1. Shape: object, known fields
✓$is an object
✓$no unknown fields
✓emailpresent
✓agepresent
✓planpresent
2. Types
✓emailis a string
✓ageis an integer
✓planis a string
✓seatsis an integer
3. Field rules
✗emailname@domain, ≤ 254 chars: must look like name@domain.tld
✗age13 ≤ age ≤ 130: must be between 13 and 130
✗planone of free / pro / team: must be one of free, pro, team
✗seats1 ≤ seats ≤ 500: must be between 1 and 500
4. Cross-field rules
·seatsteam plan ⇒ seats ≥ 2 (can't run: depends on a failed check)
5. State: needs the database
·emailnot already registered (can't run: depends on a failed check)
response
HTTP 422 Unprocessable Content
{
  "error": "validation_failed",
  "errors": [
    {
      "field": "email",
      "message": "must look like name@domain.tld"
    },
    {
      "field": "age",
      "message": "must be between 13 and 130"
    },
    {
      "field": "plan",
      "message": "must be one of free, pro, team"
    },
    {
      "field": "seats",
      "message": "must be between 1 and 500"
    }
  ]
}
checks run14/14
database queriedno
✓ passed✗ failed– not run (strategy stopped) · skipped (input not usable)
Collect per stage: report every problem in a stage, but don't start a stage whose inputs are broken. This is what most good APIs do: all field errors at once, and no database query for a request that is already invalid.
The problem says… What it’s really asking
“return an error for invalid input” / “malformed rows are skipped” A precise error contract. Which inputs are invalid, and what exactly is returned for each?
“report all problems” / “list every error per row” Collect-all validation with a stable order of messages.
“returns None / false and changes nothing” Validate everything first, then mutate. No partial updates.
“errors are returned as strings, not raised” The spec chose result values over exceptions. Follow it exactly.
an API endpoint, a file import, a command handler A boundary: parse into typed values here, trust them inside.

Collect errors per field, stop between layers

Section titled “Collect errors per field, stop between layers”
  1. Syntax first. If the bytes don’t parse, nothing else can run. Report where the parse failed.
  2. One rule chain per field. Type check, then range or format rules. A later rule only runs if the earlier ones passed: checking the range of a string is meaningless.
  3. Pick a strategy. Fail fast returns at the first error. Collect-all records the error and moves on to the next field.
  4. Cross-field and stateful rules last, and only when every field they read is valid. That also keeps the database untouched for requests that are already rejected.
validate_signup.py
import json
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
PLANS = ("free", "pro", "team")
Rule = tuple[Callable[[Any], bool], str]
# 2. one rule chain per field; a rule only runs if the ones before it passed
RULES: dict[str, list[Rule]] = {
"email": [(lambda v: isinstance(v, str), "required string"),
(lambda v: len(v) <= 254 and v.count("@") == 1 and "" not in v.split("@"),
"must look like name@domain")],
"age": [(lambda v: type(v) is int, "required integer"), # type() excludes bool, an int subclass
(lambda v: 13 <= v <= 130, "must be between 13 and 130")],
"plan": [(lambda v: v in PLANS, f"must be one of {', '.join(PLANS)}")],
}
@dataclass(frozen=True)
class FieldError:
field: str
message: str
def validate_signup(body: str, fail_fast: bool = False) -> list[FieldError]:
"""Check a JSON signup request. Returns every problem found ([] means valid)."""
try:
data = json.loads(body) # 1. syntax first: nothing else can run on broken JSON
except json.JSONDecodeError as e:
return [FieldError("$", f"invalid JSON at line {e.lineno}, column {e.colno}")]
if not isinstance(data, dict):
return [FieldError("$", "expected a JSON object")]
errors: list[FieldError] = []
for field, chain in RULES.items():
for ok, message in chain:
if not ok(data.get(field)):
errors.append(FieldError(field, message))
if fail_fast:
return errors # 3. fail fast: stop at the first problem
break # collect all: move on to the next field
# 4. cross-field rules run only once every field they read is known to be valid
seats = data.get("seats", 1)
if not errors and data["plan"] == "team" and not (type(seats) is int and seats >= 2):
errors.append(FieldError("seats", "team plan needs at least 2 seats"))
return errors
if __name__ == "__main__":
assert validate_signup('{"email": "ada@example.com", "age": 36, "plan": "pro"}') == []
bad = '{"email": "ada@", "age": true, "plan": "gold"}'
assert [e.field for e in validate_signup(bad)] == ["email", "age", "plan"] # collect all
assert [e.field for e in validate_signup(bad, fail_fast=True)] == ["email"] # first only
team = '{"email": "a@b.co", "age": 40, "plan": "team", "seats": 1}'
assert validate_signup(team) == [FieldError("seats", "team plan needs at least 2 seats")]
assert validate_signup('{"email": 1,}')[0].message.startswith("invalid JSON at line 1")
assert validate_signup("[1, 2]")[0].message == "expected a JSON object"
print("ok")

Notice the type check on age. In Python, True is an int (bool subclasses int), so isinstance(True, int) is True. In JavaScript, typeof NaN is "number". In C#, JsonElement.TryGetInt32 rejects 36.5 and true. Every language has a version of this trap.

Some failures are expected: users mistype, files contain bad rows. Making them part of the return type forces callers to handle them, and makes the happy path explicit.

  1. The signature says the function can fail: Result[Endpoint], not Endpoint.
  2. The caller must branch on success or failure to get at the value. TypeScript and C# check this at compile time.
  3. Errors from inner steps pass upward unchanged (an early return), so the first problem wins.
  4. A successfully parsed value has a precise type. Code that receives an Endpoint never re-checks the port.
parse_endpoint.py
from dataclasses import dataclass
from typing import Generic, TypeVar
T = TypeVar("T")
@dataclass(frozen=True)
class Ok(Generic[T]):
value: T
@dataclass(frozen=True)
class Err:
error: str
Result = Ok[T] | Err # 1. failure is a value in the signature, not a hidden exception
@dataclass(frozen=True)
class Endpoint: # 4. once parsed, the type itself says "already valid"
host: str
port: int
def parse_port(text: str) -> Result[int]:
if not (text.isascii() and text.isdigit()): # isdigit alone accepts "²" and "٣"
return Err(f"port {text!r} is not a number")
port = int(text)
return Ok(port) if 1 <= port <= 65535 else Err(f"port {port} out of range 1-65535")
def parse_endpoint(text: str) -> Result[Endpoint]:
host, sep, port_text = text.rpartition(":")
if not sep or not host:
return Err(f"expected host:port, got {text!r}")
match parse_port(port_text): # 2. the caller must look at both cases
case Err() as e:
return e # 3. pass the error up unchanged (early return)
case Ok(port):
return Ok(Endpoint(host, port))
raise AssertionError("unreachable")
if __name__ == "__main__":
assert parse_endpoint("db.internal:5432") == Ok(Endpoint("db.internal", 5432))
assert parse_endpoint("db:0") == Err("port 0 out of range 1-65535")
assert parse_endpoint("db:http") == Err("port 'http' is not a number")
assert parse_endpoint("db:²") == Err("port '²' is not a number")
assert parse_endpoint("5432") == Err("expected host:port, got '5432'")
print("ok")
Python TypeScript C#
Idiomatic default Exceptions (“easier to ask forgiveness”): ValueError, TypeError, custom subclasses Exceptions exist, but a discriminated-union result ({ ok: true, value } | { ok: false, error }) is common for expected failures Exceptions for exceptional cases; the TryParse pattern (bool TryX(..., out T value)) for expected ones
Several errors at once A list of error objects; or ExceptionGroup with except* (3.11+) An array of { path, message } A list of errors; ASP.NET Core returns ValidationProblemDetails
Guard clauses if ...: raise ValueError(...) throw new RangeError(...) ArgumentNullException.ThrowIfNull(x), ArgumentOutOfRangeException.ThrowIfNegative(n) (.NET 8)
Type of a caught error anything deriving BaseException unknown under strict (useUnknownInCatchVariables) Exception
Popular libraries pydantic (collects every error) zod (safeParse returns a result) FluentValidation, DataAnnotations

A rule of thumb that works in all three: return errors that a caller is expected to handle (bad user input, a missing record), and throw for bugs and broken invariants (a negative balance that “can’t happen”, a null where the type said no null). In an interview, if the spec says “return None” or “return error:...”, that is the contract: never let an exception escape instead.

  • Where: a field path (items[2].price), a row number, or a line and column.
  • What: a stable, machine-readable code (out_of_range) plus a human message.
  • What would be valid: “must be between 13 and 130”, not “invalid age”.
  • Never echo secrets (passwords, card numbers) back in the message.
  • For HTTP APIs, RFC 9457 “Problem Details” defines a standard JSON error shape (type, title, status, detail, plus extensions such as an errors array).
Time Notes
Field-level checks O(size of input) Each field is looked at a constant number of times.
Fail fast best case O(1) after parsing Cheapest, but costs the user a round trip per error.
Collect all O(size of input), always Every field is checked even when the first one failed.
Stateful checks one lookup each (DB, map) Run them last, and only for otherwise-valid input.

The real cost is not CPU. It’s ordering. Validating after you’ve started mutating state leaves half-applied changes when a later check fails. Check everything, then apply everything. If the change spans several records or services, use a transaction or design the operation to be idempotent.

  • Normalize, then validate. Trim whitespace, unify case where it’s meaningless (an email’s domain), apply Unicode NFC normalization. Do it before the checks, and store the normalized form, or duplicates slip through.
  • Allow-list over deny-list. Say what is accepted ([a-z0-9_]{3,20}) instead of listing what is forbidden. Deny-lists miss the case you didn’t think of.
  • Row-level validation for files. For CSV imports, validate each row independently, report row N: message lines in a deterministic order, and decide whether a bad row skips just itself or aborts the file.
  • Schema-driven validation. Describe the rules as data (a JSON Schema, a field→rules table like the samples) and write one generic engine. Adding a field becomes a one-line change.
  • Validation in depth. The boundary check is the main one, but database constraints (NOT NULL, UNIQUE, CHECK) are a second net that catches races, such as two signups with the same email at the same moment.
  • Partial mutation. Debiting the source account, then discovering the target doesn’t exist. Validate every precondition before the first write.
  • Missing vs null vs empty. {}, {"name": null} and {"name": ""} are three different inputs. Decide what each means.
  • Lenient built-ins. int(" 7 ") and int("1_000") succeed in Python; Number("") is 0 in JavaScript; int.Parse in C# accepts leading and trailing spaces. Validate the exact text if the format is strict.
  • Big numbers in JSON. JavaScript’s JSON.parse turns 12345678901234567890 into a nearby float silently. IDs and money amounts beyond 2^53 should travel as strings.
  • Length means what? "😀".length is 2 in JavaScript and C# (UTF-16 units) but len("😀") is 1 in Python (code points). Say which you limit.
  • Over-strict email checks. Real addresses can contain +, quotes and non-ASCII. Check the basics (one @, non-empty parts, a length cap of 254 in practice), then confirm by sending mail.
  • Swallowing errors. except Exception: pass or an empty catch {} turns bad input into wrong output. Catch the specific error you expect.
  • Ask which inputs can be invalid and what each should produce. The underspecified error path is often the real test.
  • Ask whether to stop at the first error or report all of them, and in what order messages should appear.
  • Say “I’ll validate everything before changing any state” out loud. Then do it.
  • Separate parsing from business logic in your code: one function turns text into typed values, the rest works on typed values.
  • Test the edge cases before you’re asked: empty input, missing field, wrong type, boundary values (12, 13, 130, 131), duplicates, very long strings.
  • Likely follow-ups: add a new rule without touching the others, report all errors instead of the first, make an import skip bad rows but count them, localize messages.
Check yourself
pick one
A transfer(from, to, amount) function first subtracts amount from the source balance, then looks up the target account, which doesn't exist, and returns an error. What's wrong?
Check yourself
pick one
In Python, a validator checks isinstance(value, int) for an 'age' field. Which JSON input wrongly passes?
Check yourself
pick one
A request has a malformed email and an out-of-range age, and the 'email already taken' check needs a database query. With collect-per-stage validation, what happens?

Classic exercises, not gym questions:

  • Validate a credit card number with the Luhn checksum, and report why it failed (length, non-digit, checksum).
  • Check an IBAN: country-specific length, move the first four characters to the end, convert letters to numbers, and verify mod 97 equals 1.
  • A password-policy checker that returns every failed rule (length, digit, symbol, not containing the username), in a fixed order.
  • Validate a nested configuration object and report errors with paths like servers[2].port.
  • Decide whether a partially filled 9×9 Sudoku board is valid, reporting the first conflicting row, column or box.