Lesson 2 - Field Validation and Constraints

Welcome to Field Validation and Constraints

In Lesson 1 your model checked types: a title had to be a string, a flag had to be a boolean. That’s a great first guarantee, but types alone let plenty of nonsense through. An empty string "" is still a valid string. The number -5 is still a valid float. The word "urgent" is still a valid string for priority, even if your app only understands low, medium, and high. Types answer “is this the right kind of value?” — this lesson answers “is this a sensible value?”

The tool for that is constraints: extra rules you attach to a field, like “at least 1 character” or “no greater than 100.” Pydantic checks them automatically and, when something fails, returns the same clean 422 you already know — now with an error that says exactly which rule was broken. For anything the built-in rules can’t express, you’ll write your own check with a validator.

By the end of this lesson, you will be able to:

  • Add string rules with Field(min_length=..., max_length=...)
  • Add numeric range rules with Field(ge=..., le=...) (and recognize gt/lt)
  • Write a custom rule with @field_validator that raises a ValueError
  • Read the precise 422 error each kind of constraint produces

We’ll keep building the Task Manager from Lesson 1. Let’s tighten it up.


Constraining Strings with Field

So far a field was just a type hint, like title: str. To attach rules to it, you give it a default value of Field(...) and pass your rules as arguments. Field is a helper from Pydantic whose whole job is to describe a field in more detail than a bare type can.

For text, the two most useful rules are min_length and max_length — the smallest and largest number of characters the string may have. Here’s a Task whose title must be between 1 and 100 characters:

from pydantic import BaseModel, Field

class Task(BaseModel):
    title: str = Field(min_length=1, max_length=100)

min_length=1 is what finally rules out the empty string "" — it has zero characters, so it fails. max_length=100 stops someone pasting an entire paragraph into a title. Notice the title still has no real default value; writing = Field(...) here doesn’t make it optional. A field stays required unless you give Field an actual default, which we’ll do later in the lesson. For now, read Field(min_length=1, max_length=100) as “required string, 1 to 100 characters.”


Constraining Numbers with ge and le

Numbers get range rules instead of length rules. The two you’ll reach for most are:

  • gegreater than or equal to (the value must be at least this much)
  • leless than or equal to (the value must be at most this much)

There are also gt (greater than, strictly) and lt (less than, strictly), which exclude the boundary. The difference matters at the edge: ge=0 allows 0, while gt=0 does not. We’ll use ge/le here because “an estimate of exactly 0 hours” and “exactly 100 hours” are both fine to allow.

Let’s give each task an estimate_hours field that must fall between 0 and 100:

from fastapi import FastAPI
from pydantic import BaseModel, Field, field_validator

app = FastAPI()

class Task(BaseModel):
    title: str = Field(min_length=1, max_length=100)
    priority: str = "medium"
    estimate_hours: float = Field(ge=0, le=100)

    @field_validator("priority")
    @classmethod
    def check_priority(cls, v):
        allowed = {"low", "medium", "high"}
        if v not in allowed:
            raise ValueError(f"priority must be one of {sorted(allowed)}")
        return v

@app.post("/tasks")
def create(task: Task):
    return task.model_dump()

(Ignore the @field_validator block for a moment — it’s the next section. The model above is the one we’ll use for every example from here on.) Now let’s send a valid task and see what comes back:

from fastapi.testclient import TestClient

client = TestClient(app)
r = client.post("/tasks", json={"title": "Plan", "priority": "high", "estimate_hours": 4})
print(r.status_code, r.json())
200 {'title': 'Plan', 'priority': 'high', 'estimate_hours': 4.0}

The request passed every rule, so you get a 200 and your data back. One small thing to notice: we sent 4, an integer, but it came back as 4.0. Because the field is typed float, Pydantic converted the whole number to a float for you. That’s type coercion working alongside your range rule — 4 is at least 0 and at most 100, so it’s accepted, and stored as a float.


Reading the 422 a Constraint Produces

The real payoff is what happens when a rule is broken. Each constraint failure becomes a 422 whose error has a specific type you can recognize. Let’s break the string rule first by sending an empty title:

r = client.post("/tasks", json={"title": "", "estimate_hours": 4})
print(r.status_code)
print(r.json())
422
{'detail': [{'type': 'string_too_short', 'loc': ['body', 'title'], 'msg': 'String should have at least 1 character', 'input': '', 'ctx': {'min_length': 1}}]}

That’s the full shape of a validation error. detail is a list (there could be several problems at once), and detail[0] describes the first one. Three parts are worth knowing by name:

  • type is "string_too_short" — a machine-readable label for which rule failed.
  • loc is ["body", "title"]where the problem is: the title field of the request body.
  • msg is a human-readable explanation: “String should have at least 1 character.”

Now break the numeric rule by sending an estimate of 999, well above our le=100 ceiling:

r = client.post("/tasks", json={"title": "X", "estimate_hours": 999})
print(r.status_code)
print(r.json()["detail"][0]["type"])
422
less_than_equal

A different broken rule gives a different type: "less_than_equal", telling you the value wasn’t less-than-or-equal to the limit. (Had you used lt instead of le, the type would read less_than.) You didn’t write any of this error-handling code — declaring the constraint was enough.


Custom Rules with field_validator

Field covers lengths and ranges, but some rules are too specific for it. Our priority should only ever be "low", "medium", or "high" — there’s no min_length for “one of these three words.” For rules like that, you write a validator: a small method on the model that inspects a value and rejects it if it’s wrong.

You declare one with the @field_validator("priority") decorator, naming the field it guards. It’s a classmethod (so it gets the class, not an instance), it receives the incoming value, and it must return that value if all is well. To reject a value, you raise ValueError(...) with a message. Here’s the validator from our model again on its own:

@field_validator("priority")
@classmethod
def check_priority(cls, v):
    allowed = {"low", "medium", "high"}
    if v not in allowed:
        raise ValueError(f"priority must be one of {sorted(allowed)}")
    return v

Read the flow: v is whatever the client sent for priority. If it isn’t in the allowed set, we raise a ValueError explaining the rule; otherwise we hand the value back unchanged. Returning the value is not optional — whatever you return becomes the field’s stored value. Now send a bad priority and watch Pydantic turn that ValueError into a 422:

r = client.post("/tasks", json={"title": "X", "priority": "urgent", "estimate_hours": 1})
print(r.status_code)
print(r.json()["detail"][0]["type"])
print(r.json()["detail"][0]["msg"])
422
value_error
Value error, priority must be one of ['high', 'low', 'medium']

Two things to notice. The type is the generic "value_error" (Pydantic uses this for every error your own code raises, rather than a specific label like string_too_short). And your message rides along inside msg, prefixed with "Value error, " — so the exact text you wrote in raise ValueError(...) reaches the client. The order ['high', 'low', 'medium'] is simply sorted() putting our three words in alphabetical order.

ge vs gt: mind the boundary

The only difference between ge/le and gt/lt is whether the boundary value itself is allowed. ge=0 accepts 0; gt=0 rejects it and demands something strictly larger. Reach for gt/lt when the edge is genuinely invalid — for example a price with gt=0 (“must be more than zero”), versus a quantity with ge=0 (“zero is fine”). Picking the right one is the difference between a sensible rule and an off-by-one bug.


A Default and a Description with Field

Field does more than enforce rules. It can also set a field’s default and attach metadata that shows up in your automatic /docs page. You give a default as the first argument (or as default=...), and a human-friendly note as description=...:

from pydantic import BaseModel, Field

class Task(BaseModel):
    title: str = Field(min_length=1, max_length=100, description="Short name of the task")
    priority: str = Field(default="medium", description="One of low, medium, or high")
    estimate_hours: float = Field(ge=0, le=100, description="Estimated effort in hours")

Here priority has a real default of "medium", so it’s now optionalField(default="medium", ...) behaves exactly like the plain = "medium" from Lesson 1, but with room for a description. The description text appears beside each field in the interactive docs FastAPI generates, so whoever calls your API can read what every field means without guessing. One helper, then, covers three jobs at once: validation rules, defaults, and documentation.


Practice Exercises

Exercise 1: Choose the right numeric rule

You’re adding a progress_percent: float field that should accept values from 0 through 100, including both ends. Should you use ge/le or gt/lt, and what would the Field(...) look like?

Hint

Both ends are allowed, so use the “or equal to” versions: progress_percent: float = Field(ge=0, le=100). With gt=0 you’d wrongly reject a brand-new task sitting at 0, and with lt=100 you’d reject a finished one at 100.

Exercise 2: Predict the error type

Using the model from this lesson, what status_code and detail[0]["type"] do you get from POST /tasks with body {"title": "X", "estimate_hours": -3}?

Hint

You get 422. The field has ge=0, and -3 is below the floor, so the value isn’t greater-than-or-equal to 0. The type is "greater_than_equal" (the mirror of the "less_than_equal" you saw for the 999 case).

Exercise 3: Write a custom validator

Suppose title must not only have at least 1 character but must not be only whitespace (so " " should be rejected). min_length won’t catch that, since spaces count as characters. Sketch a @field_validator("title") that handles it.

Hint

Strip the value and check it’s still non-empty, then return the value:

@field_validator("title")
@classmethod
def not_blank(cls, v):
    if not v.strip():
        raise ValueError("title cannot be blank")
    return v

" ".strip() becomes "", which is falsy, so the rule fires and Pydantic returns a 422 with your message.


Summary

Constraints turn a model from a type checker into a rule enforcer. With Field you add min_length/max_length to strings and ge/le (or the strict gt/lt) to numbers, and Pydantic checks them on every request — returning a 422 whose type names the broken rule, like string_too_short or less_than_equal. For rules the built-ins can’t express, you write a @field_validator classmethod that inspects the value, raises a ValueError to reject it, and returns the value to accept it; those failures arrive as a value_error carrying your own message. Field also doubles as the place to set a default and a description that surfaces in the automatic docs.

Key Concepts

  • Field(...) — attaches rules, a default, and metadata to a model field.
  • min_length / max_length — smallest and largest allowed length for a string.
  • ge / le — at least / at most for numbers; gt / lt exclude the boundary.
  • @field_validator — a classmethod for custom rules; raise ValueError to reject, return the value to accept.
  • Error type — a machine-readable label in the 422 body identifying which rule failed.

Why This Matters

Most bad input isn’t the wrong type — it’s the wrong value: empty titles, negative estimates, statuses your app doesn’t recognize. Constraints catch all of that at the edge of your API, before a single line of your logic runs, and they document the rules in your /docs for free. You write the rule once, declaratively, instead of scattering if not value: checks through every handler. That’s how a model becomes a single, trustworthy source of truth about what valid data looks like.


Next Steps

Continue to Lesson 3 - Nested and Complex Models

Model real-world data: fields that are themselves models, lists of items, and the validation that comes with them.

Back to Module Overview

Return to the Request Bodies and Pydantic module overview


Continue Building Your Skills

Your fields now enforce real rules, not just types — empty titles and out-of-range estimates never make it past the door. Next you’ll model data that has shape: tasks that contain a sub-object, lists of tags, and nested models that validate all the way down.