DEV Community

Cover image for Validating Input at the Edge: Why Four Checks in Four Places Are Worse Than One Border
Vahid Aghajani
Vahid Aghajani

Posted on Originally published at software-engineer-blog.com

Validating Input at the Edge: Why Four Checks in Four Places Are Worse Than One Border

๐Ÿ“บ Prefer to watch? 90-second YouTube Short ยท ๐Ÿ’ฌ Telegram

Originally published on software-engineer-blog.com.

Somebody signs up. Their browser sends one JSON body to your endpoint. And here is the thing almost every tutorial skips over:

Your program does not receive an object. It receives bytes.

raw = await request.body()
# type(raw) -> <class 'bytes'>
# repr(raw) -> b'{"email":"ada@example.com","age":"42","role":"admn"}'

doc = json.loads(raw)
# type(doc) -> <class 'dict'>
# doc -> {'email': 'ada@example.com', 'age': '42', 'role': 'admn'}
Enter fullscreen mode Exit fullscreen mode

json.loads upgrades bytes to a dict. That is a real upgrade, and it is also the last one you get for free. A dict carries zero guarantees. age is still the string '42', not a number. role is still the typo 'admn'. Nothing between the socket and your handler has an opinion about whether any of this makes sense.

So the question is not whether something validates that data. Something always does. The question is how many things, and where.


The naive version: four places, four rules

Here is what happens by default, and it happens because every single step of it is reasonable in isolation. The request reaches your handler. The handler is careful, so it checks the email is not empty. It passes the dictionary on to a service function โ€” which does not trust its caller, so it checks for an at-sign. The service calls the database layer, which checks the length before the write. And the template that renders the confirmation trims the whitespace, because whitespace looks bad.

# handler
if not body.get("email"):
    return {"detail": "invalid input"}, 400

# service
if "@" not in email:
    raise ValueError

# db layer
if len(email) > 320:
    raise ValueError

# template
email = email.strip()
Enter fullscreen mode Exit fullscreen mode

Four places. Four slightly different rules for one field. Nobody wrote it that way on purpose โ€” it accreted, one careful commit at a time.

And nothing in the system knows the four rules disagree. The day somebody loosens one of them, the other three say nothing.

What that actually costs

I ran it. Not as a thought experiment โ€” as two real FastAPI apps with a SQLite database behind them, and a set of measurement scripts.

First result: a silent split-brain, on a request that succeeded. POST an email with spaces around it โ€” " ada@example.com ":

naive status  : 200
naive response: {"email":"ada@example.com", ...}
naive DB row  : {'email': '  ada@example.com  ', ...}
response email == stored email ? False
Enter fullscreen mode Exit fullscreen mode

HTTP 200. Nothing failed. Nothing was logged. And the row saved in the database is not the value returned to the user, because layer three and layer four disagree about what an email is. Two truths about the same user, created in the same request.

Second result: the crashes. Four bad bodies at the naive server:

{'emial': ..., 'age': '42'}          -> 400  {"detail":"invalid input"}
{'email': ..., 'age': '42'}          -> 500  TypeError: unsupported operand type(s) for //: 'str' and 'int'
{'email': ..., (no age)}             -> 500  KeyError: 'age'
{'email': ..., 'age': None}          -> 500  TypeError: ... 'NoneType' and 'int'
Enter fullscreen mode Exit fullscreen mode

Three of the four returned a 500. And the fourth โ€” the one with the typo'd field name โ€” returned a 400 that says invalid input and names nothing, so the client has no idea which of the three fields it got wrong.

Worth being precise here, because it is tempting to tell this story with the typo body crashing: it does not. The handler's truthiness check fires first and short-circuits into the 400. The 500 needs a valid email plus a bad age. That is the honest version, and it is the stronger one โ€” the naive app either falls over or answers uselessly, and which of the two you get depends on the order in which the checks happen to run.

Third result: the enum nobody enforced. LEGAL_ROLES is declared at the top of the naive app and never used. So:

POST {"email": "ada@example.com", "age": 42, "role": "admn"}
status: 200
rows now in the DB: [{'email': 'ada@example.com', 'role': 'admn', ...}]
Enter fullscreen mode Exit fullscreen mode

HTTP 200, and 'admn' is now a row in your database. Forever. That constant sitting unused at the top of the file is exactly how it happens for real.


The mechanism: a border that converts

The fix is one move, and the important part is that it is not a check.

Validate once, where the data arrives, and nowhere after that. One model, at the door:

class SignupIn(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")

    email: EmailStr = Field(max_length=254)
    age: int = Field(ge=13, le=120)
    role: Literal["admin", "editor", "viewer", "billing", "support"]


@app.post("/signup")
def signup(body: SignupIn):      # not Request. SignupIn.
    ...
Enter fullscreen mode Exit fullscreen mode

Here is the part people miss, and it is the whole idea:

The border does not hand you back True or False. It hands you back a typed value โ€” an object that could not have been constructed if the data were wrong. If the body cannot become a SignupIn, the function body never runs at all.

That is the difference between a check and a border. A check leaves you holding the same untrusted dictionary you had before, plus a boolean you now have to remember to respect. A border changes what you are holding. After it, body.age is an int because there is no reachable universe in which it is not.

And the consequence shows up as a measurement. Counting defensive lines in the two apps with grep -cE 'is None|if not ':

ย  Naive app Bordered app
Lines of code 73 37
Defensive is None / if not checks 13 0
Validation sites for email 6 1
5xx from the four bad bodies 3 of 4 0
Problems named per round trip 1 3

Thirteen defensive checks became zero. Not because anyone was braver about skipping them โ€” because after the border there is nothing left for them to catch.

That gives you the diagnostic that makes this idea worth internalising: every if x is None deep in your business logic is evidence that the border leaked. It is not defensive programming, it is a bug report about your boundary.


The four rules the border follows

1. Say what you accept, not what you reject

An allow-list, not a block-list. A block-list is a list of the attacks and mistakes you already thought of, which by construction excludes the ones that will actually reach you. Literal["admin", "editor", ...] enumerates the five legal roles; everything else is not a role, and you never have to enumerate the infinite set of things that are not roles.

2. Reject, do not coerce

This is the rule most codebases get backwards, because the default is convenient. Same input, one config flag apart:

input: {'age': '42'}   type(input['age']): <class 'str'>

LAX    -> .age = 42, type <class 'int'>
STRICT -> ValidationError:
          Input should be a valid integer [type=int_type, input_value='42', input_type=str]
Enter fullscreen mode Exit fullscreen mode

Lax mode silently turns the text "42" into the number 42. That feels helpful right up until the client that sent a string was sending a string because of a bug, and you have just laundered the bug into your database as a plausible-looking integer. Rejecting is what lets you find out.

One precision point the video states loosely and the code makes exact: strict=True controls type coercion, not value normalisation. A field type can still transform the value it accepts:

strict=True, EmailStr:
  '  ada@example.com  ' -> 'ada@example.com'
  'Ada@EXAMPLE.COM'     -> 'Ada@example.com'   # domain lowercased, local part not
Enter fullscreen mode Exit fullscreen mode

Strict means it will not accept a str where you declared an int. It is not a promise that the bytes pass through untouched. And that normalisation is a feature here, not a leak: it is why the bordered app returned and stored the same trimmed address, while the naive app stored the padded one. The border normalised once, in one place.

3. Name the field that failed

Compare the two answers to one bad body, {"email": "not-an-email", "age": 7, "role": "wizard"}:

NAIVE    -> 400 {"detail":"invalid input"}

BORDERED -> 422
{"detail": [
  {"type": "value_error",        "loc": ["body","email"], "msg": "value is not a valid email address: An email address must have an @-sign."},
  {"type": "greater_than_equal", "loc": ["body","age"],   "msg": "Input should be greater than or equal to 13"},
  {"type": "literal_error",      "loc": ["body","role"],  "msg": "Input should be 'admin', 'editor', 'viewer', 'billing' or 'support'"}
]}
Enter fullscreen mode Exit fullscreen mode

Three problems named in one round trip, instead of one. That is a client that fixes its request once instead of three times.

Be careful about how far you push this claim, though. The border reports every problem at that layer. Pydantic still short-circuits per field, and a model validator that runs after field validation does not run at all if a field already failed. "Three problems in one round trip instead of one" is measured and true. "All problems, always" is not.

4. HTTP is not the only edge

The most useful demonstration is the one where you cannot blame the internet. A producer in this same process puts a message on a queue.Queue; a consumer reads it back and revalidates with the same model:

producer drops a field   -> role    / Field required [type=missing]
producer changes a type  -> user_id / Input should be a valid integer [type=int_type]
producer adds a field    -> tier    / Extra inputs are not permitted [type=extra_forbidden]
Enter fullscreen mode Exit fullscreen mode

Our own code wrote that message and our own code serialised it, and the consumer still caught a schema change, a type change, and a field nobody agreed to. Anywhere data crosses from a system you do not control right now โ€” a queue, a webhook, a config file, a CSV, another team's service, last year's version of your own producer โ€” is an edge.


What it costs you, honestly

A border is a trade, not a free win. Four costs, all of them measured.

The shape is now written in two places, and it drifts immediately. Your model is one definition of a user; your table is another. In the test schema they disagreed in both directions within minutes:

model: email max_length=254     vs  DB: CHECK(length(email) <= 320)
  -> a 272-char email: MODEL rejects (string_too_long), DB stores it happily

model: age ge=13                vs  DB: CHECK(age >= 18)
  -> age=15: MODEL accepts, then
     sqlite3.IntegrityError: CHECK constraint failed: age >= 18

model: role Literal[...]        vs  DB: no CHECK on role at all
Enter fullscreen mode Exit fullscreen mode

So "one model at the edge means the shape is defined in one place" is not true. It is defined in fewer places, which is the actual win.

A strict border breaks callers โ€” loudly, which is the point, but it still breaks them. Add a required field and every existing client fails:

V2 with a REQUIRED field: country / Field required [type=missing]
V2 with a DEFAULT       : accepted, country='CH'
Enter fullscreen mode Exit fullscreen mode

One line of difference. And with extra="forbid", a purely additive producer change โ€” adding an optional tier field nobody has to use โ€” takes every consumer down. On a public API that is what you want. On an internal event bus it may not be, and you should choose deliberately rather than inherit the default.

Over-validating drags domain rules into the boundary. "Is this a well-formed email address" belongs at the border. "May this user publish" does not โ€” that needs the database, the session, and the current state of the world, none of which the border has.

It is not a security or integrity boundary on its own. A perfectly well-formed duplicate sails straight through:

model accepted: email='wiz@example.com' role='admin' age=30
sqlite3.IntegrityError: UNIQUE constraint failed: users.email
Enter fullscreen mode Exit fullscreen mode

The model has no idea what is already in the table. Uniqueness, authorization, balances, rate limits, quotas โ€” still the database's job, still the service's job. Edge validation stops bad shapes, not bad facts.


The performance folklore is backwards

The usual objection is that validating every request costs you. Measured with timeit, N = 100,000, pydantic 2.12.5 on Python 3.10:

Operation Per call
json.loads(bytes) โ€” baseline, no validation 2.75 ยตs
Small.model_validate(dict) 1.30 ยตs
Small.model_validate_json(bytes) 1.31 ยตs
WithEmailStr.model_validate(dict) 92.9 ยตs

Validating a three-field model straight from JSON bytes is 1.31 ยตs โ€” roughly twice as fast as the json.loads you were already paying for. pydantic-core parses the JSON itself in Rust, so you skip the stdlib parse entirely. On this model, validation is not a tax. It is a discount.

But do not then say validation is free, because one field blows that up: EmailStr costs 92.9 ยตs, about 68ร— the rest of the model put together. The email-validator library does real syntax and IDNA work in Python. If a hot endpoint validates an email on every call, that is your cost โ€” and it is one field, not the idea. Which is a much more useful thing to know than either slogan, because you can act on it: keep the border, and decide consciously whether that endpoint needs full RFC email validation or a cheaper constrained string.


The same border, one layer up: LLM tool calls and structured output

If you are building anything agentic, you already have this problem in its purest form โ€” and most codebases are currently solving it with the four-checks version.

When a model calls a tool, what your code receives is a JSON string generated by a probabilistic text generator. That is not a stricter edge than an HTTP request body; it is a looser one. An HTTP client is at worst buggy. A language model is sampling, and it will occasionally produce "42" where you documented an integer, omit a field it has filled correctly a thousand times, invent an enum value that reads perfectly plausibly, or hallucinate a parameter your function does not have. role: "admn" is exactly the failure mode of a model that is 99% right.

So the border is the same border:

class SearchArgs(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")
    query: str = Field(max_length=200)
    top_k: int = Field(ge=1, le=50)
    index: Literal["docs", "tickets", "code"]

args = SearchArgs.model_validate_json(tool_call.arguments)   # or it does not run
Enter fullscreen mode Exit fullscreen mode

Three things transfer directly, and one flips.

The allow-list becomes the schema you hand the model. The same declaration that rejects bad arguments is also the JSON Schema you put in the tool definition โ€” so the allow-list is doing double duty: it constrains generation and it verifies the result. With constrained decoding or strict structured-output modes, part of your border has moved into the decoder itself. That is genuinely better, and it is still not sufficient โ€” the shape can be guaranteed while the values remain nonsense, so you still validate what comes back.

The 422 body becomes a repair prompt. This is the part that is nicer in the LLM case than in the HTTP one. Pydantic's error list is not just diagnostics โ€” it is text a model can act on. Feed Input should be greater than or equal to 13 back to the model and it will usually fix the argument on the next turn. The "name the field that failed" rule stops being a courtesy to a human client and becomes the actual control loop.

Every edge still counts, and there are more of them. Model output into your tool, tool output back into the context window, retrieved documents into the prompt, another agent's message into this agent's inbox. Each of those is an untrusted dictionary crossing into code that assumes a shape.

And the flip: the cost argument disappears completely. That 92.9 ยตs EmailStr field, which was worth thinking about on a hot HTTP endpoint, sits next to an inference call measured in hundreds of milliseconds. It is four orders of magnitude cheaper than the thing it is guarding. There is no performance conversation to have.

One warning, and it is ยง8c again in a much more dangerous costume: validating tool arguments is not authorization. A model that asks to delete every row in a table will produce beautifully well-formed arguments. The border will hand you a perfectly valid DeleteArgs. Whether that call is allowed is a question about permissions and blast radius, and no schema in the world answers it.


The verdict

Validation is not a check you perform. It is a border you cross.

Do it once, at the place data enters your program, and let it convert rather than approve โ€” so what comes out the other side is a typed value that could not have been constructed if the input were wrong. Everything inside then takes that type as a precondition, instead of asking the same question again in slightly different words.

The practical test is the one you can run on your own codebase this afternoon: go find the if x is None checks buried in your business logic. Every one of them is either a border you never built, or a border that leaked. In the app measured here there were thirteen of them, and building one border took all thirteen to zero โ€” along with 36 lines of code and three of four 500s.

And then stay honest about the edges of the idea: the shape is still written in your database too, and it will drift. Strict borders break callers on schema change, which is a feature you must still opt into deliberately on an internal bus. And a well-formed duplicate is not the model's problem โ€” that is what the UNIQUE constraint is for, and it stays.


References and further reading

The mechanism โ€” validate once and convert

  • Pydantic documentation, Models โ€” model_validate / model_validate_json and the "parse, don't validate" behaviour this whole article rests on: the model returns a typed instance or raises, never a boolean.
  • Alexis King, Parse, Don't Validate (2019) โ€” the clearest statement of why a function that returns a type beats one that returns True, and where the "every if x is None is evidence the border leaked" diagnostic comes from.

Rule 1 โ€” say what you accept

  • OWASP, Input Validation Cheat Sheet โ€” the allow-list-over-block-list position, stated as a security control rather than a style preference.

Rule 2 โ€” reject, do not coerce

  • Pydantic documentation, Conversion Table โ€” exactly which inputs are accepted for each field type in lax versus strict mode; the '42' โ†’ 42 case is in here.
  • Pydantic documentation, Strict Mode โ€” what strict=True does and does not cover, which is the source of the coercion-versus-normalisation distinction above.

Rule 3 โ€” name the field that failed

  • FastAPI documentation, Handling Errors โ€” RequestValidationError and the shape of the 422 body, including the loc / msg / type entries quoted here.
  • RFC 9457, Problem Details for HTTP APIs (IETF, 2023) โ€” the standard way to return a machine-readable error that says which member of the request was wrong and why.

The costs โ€” drift, breakage, and what the border does not cover

  • Pydantic documentation, Model Config โ€” extra โ€” the extra="forbid" setting and therefore the additive-producer-change trade-off.
  • SQLite documentation, CHECK constraints โ€” the second definition of your data's shape, and why the UNIQUE violation in the duplicate case came from the database rather than the model.

The LLM edge

  • OpenAI, Structured Outputs โ€” schema-constrained generation: the allow-list pushed into the decoder, and an explicit statement of what it does and does not guarantee.
  • Anthropic, Tool use โ€” how tool arguments are returned and why they need validating on arrival like any other untrusted input.

If a reference you'd expect is missing, say so in the comments and I'll add it.


Watch the reel: the 2-minute version draws the four-stage pipeline and shows the drift; the full episode proves all of it on two apps that really run.

Top comments (0)