Validating Types, Defaults, Constraints

Learn how to validate types, defaults, and constraints in FastAPI. This step-by-step tutorial shows you how to enforce data integrity, handle edge cases, and apply these concepts in a hands-on exercise.

Focus: validating types, defaults, and constraints

Sponsored

Ever shipped an API endpoint that silently accepted a negative age, a string where a number was expected, or a missing field that crashed your database query? Without explicit validation, your FastAPI app is a ticking time bomb — data corruption, cryptic 500 errors, and frustrated frontend developers. In this lesson, you’ll learn how to use FastAPI’s built-in validation powered by Pydantic to enforce types, set defaults, and apply constraints, so your API rejects bad data before it ever touches your business logic.

The problem this lesson solves

When you build an API, every field in a request body, query parameter, or path parameter is a potential point of failure. Consider this naive endpoint:

from fastapi import FastAPI

app = FastAPI()

@app.post("/items/")
async def create_item(name: str, price: float):
    # Imagine saving this to a database...
    return {"name": name, "price": price}

If a client sends {"name": "Laptop", "price": "not-a-number"}, FastAPI will raise a validation error by default — that’s good. But what if they send {"name": 123, "price": -10}? FastAPI will coerce 123 to "123" and accept a negative price. Your database may reject it, or worse, your application logic might not expect it. The problem is that type hints alone are not enough to enforce domain rules. You need constraints — minimums, maximums, regex patterns, length limits — and defaults for optional fields. This lesson solves exactly that: how to declare validation rules so your API is predictable, self-documenting, and resilient.

By the end, you’ll be able to write endpoints that reject invalid payloads with clear, automatic 422 error responses, while still allowing flexible input with smart defaults.

Core concept / mental model

Think of FastAPI as a secretary at the door of your application. The secretary reads the type hints you’ve written on your function parameters and checks every incoming request against them. But the secretary also checks a list of extra rules — constraints — that you’ve attached to those parameters. If something doesn’t match, the secretary returns a polite rejection slip (a 422 Unprocessable Entity response) listing exactly what went wrong.

This secretary is built on Pydantic, a data validation library that turns your Python type hints into a full validation engine. When you write name: str, Pydantic creates a Field that expects a string. When you write price: float = Field(gt=0), Pydantic knows the value must be a float greater than zero. The same mechanism applies to query and path parameters through FastAPI’s Query and Path classes.

Key definitions:

  • Type validation – enforcing that a value is of a specific Python type (e.g., int, str, float, bool, list).
  • Defaults – a value used when the client doesn’t supply the field. In Python, you set = value in the function signature.
  • Constraints – additional rules like gt=0 (greater than), le=100 (less than or equal), min_length=3, max_length=50, or pattern="^[A-Z]$".

Mental model in a diagram:

Client request
    ↓
FastAPI reads your path/query/body parameter declarations
    ↓
Pydantic validates type, applies constraints, fills defaults
    ↓
Pass or reject (422 with detailed error JSON)
    ↓
Your endpoint function runs with clean, validated data

Think of your endpoint signature as the contract with the client. Every parameter you declare is a promise: “If you send this, I’ll handle it correctly.” Validation is how you enforce that promise.

How it works step by step

When you declare a parameter in FastAPI, the framework follows a predictable sequence:

  1. Extract the value – From the path, query string, request body, headers, or cookies, depending on how you declare the parameter.
  2. Check the type – Pydantic coerces the raw input to the declared Python type (e.g., a string "42" becomes integer 42).
  3. Apply constraints – If you’ve added Field constraints like gt=0 or max_length=10, Pydantic checks whether the value satisfies them.
  4. Apply defaults – If the parameter is optional and the client didn’t send a value, FastAPI substitutes the default you’ve assigned.
  5. Return the validated value – Your endpoint function receives a clean, typed value. If anything fails, FastAPI stops and returns a 422 response with a structured error list.

Let’s trace through a concrete example. Declare an endpoint that creates a product with a required name, optional description, and a price that must be positive:

from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI()

class Product(BaseModel):
    name: str = Field(min_length=1, max_length=50)
    description: str = Field(default="", max_length=500)
    price: float = Field(gt=0, le=10000)
    in_stock: bool = True

@app.post("/products/")
async def create_product(product: Product):
    return {"product": product.model_dump()}

When a client sends {"name": "Coffee Maker", "price": 39.99}:

  • description and in_stock are missing, so defaults are used ("" and True).
  • name passes min/max length checks.
  • price is a positive float within the allowed range.
  • The endpoint receives a Product instance with all fields populated.

If the client sends {"name": "X", "price": -5}, FastAPI returns a 422 with errors like:

{
  "detail": [
    {
      "loc": ["body", "price"],
      "msg": "ensure this value is greater than 0",
      "type": "greater_than"
    }
  ]
}

## Hands-on walkthrough

Let’s build a complete example you can run right now.

### Step 1: Set up your environment

Create a new directory and install FastAPI with `uvicorn`:

```bash
pip install fastapi uvicorn

Step 2: Define a model with type validation, defaults, and constraints

Create a file main.py:

from typing import Optional
from fastapi import FastAPI, Query, Path
from pydantic import BaseModel, Field

app = FastAPI()

class User(BaseModel):
    username: str = Field(..., min_length=3, max_length=20, pattern="^[a-zA-Z0-9_]+$")
    age: int = Field(..., ge=0, le=130)
    email: str = Field(..., max_length=100)
    is_active: bool = True
    tags: list[str] = Field(default=[], max_length=5)

class Product(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    price: float = Field(..., gt=0, le=10000)
    stock: int = Field(default=0, ge=0)

Notice the ellipsis ... — this means the field is required and has no default. For optional fields, you provide a default value.

Step 3: Create endpoints that use these models

@app.post("/users/")
async def create_user(user: User):
    return {"username": user.username, "age": user.age, "is_active": user.is_active}

@app.get("/products/{product_id}")
async def get_product(
    product_id: int = Path(..., ge=1),
    include_details: bool = Query(default=False),
):
    return {"product_id": product_id, "include_details": include_details}

Step 4: Run and test

Start the server:

uvicorn main:app --reload

Now try different requests in your browser or with curl:

curl -X POST http://localhost:8000/users/ -H "Content-Type: application/json" -d '{"username": "johndoe", "age": 30, "email": "john@example.com"}'

Expected response:

{"username": "johndoe", "age": 30, "is_active": true}

Now send invalid data:

curl -X POST http://localhost:8000/users/ -H "Content-Type: application/json" -d '{"username": "jd", "age": 200, "email": "j@"}'

You’ll get a 422 with three detailed errors — one for username too short, one for age too high, one for invalid email format (actually, email is not validated as an email by default, but it’s still accepted; we’ll fix that later).

Step 5: Add email validation with a Pydantic type

Pydantic has built-in types like EmailStr that enforce email format. Install the email-validator package:

pip install email-validator

Update your User model:

from pydantic import BaseModel, Field, EmailStr

class User(BaseModel):
    username: str = Field(..., min_length=3, max_length=20, pattern="^[a-zA-Z0-9_]+$")
    age: int = Field(..., ge=0, le=130)
    email: EmailStr
    is_active: bool = True
    tags: list[str] = Field(default=[], max_length=5)

Now a bad email like "j@" will fail with a clear message.

Expected output for valid data

After restarting, POST a valid user and you’ll see the same success response. Try an invalid email and you’ll get a 422 with a message like “value is not a valid email address”.

Compare options / when to choose what

When you need to validate request data, you have several options. Here’s a comparison:

Feature Pydantic Field/BaseModel FastAPI Query/Path/Body Custom validators
Use case Request bodies, complex models Query/Path params, simple checks Cross-field or complex logic
Declarative Yes Yes Yes, with @validator or field_validator
Type coercion Yes Yes No
Defaults Yes Yes No
Constraints (gt, ge, etc.) Yes Yes Yes
Reusability High Low High
Complexity Low Low Medium

When to use what?

  • For query and path parameters, use Query and Path — they give you the same constraints (ge, le, min_length, etc.) but are less reusable across endpoints.
  • For request bodies, define a Pydantic BaseModel with Field — this separates concerns and allows you to reuse the model in multiple endpoints and even in your own code.
  • If you need to validate start < end or ensure a list has at least one item with a certain property, use custom validators (covered in a later lesson).

Here’s an example of using Query:

@app.get("/items/")
async def list_items(
    page: int = Query(1, ge=1),
    per_page: int = Query(10, ge=1, le=100),
):
    return {"page": page, "per_page": per_page}

And here’s a Path example:

@app.get("/users/{user_id}")
async def get_user(user_id: int = Path(..., ge=1)):
    return {"user_id": user_id}

Both approaches return the same error structure, but the BaseModel pattern is preferred for anything larger than a single parameter because it’s modular and testable.

Pro tip: Use Query(..., ge=1) to make a query parameter required. The ellipsis is your 🚨 'no default' marker — without it, the parameter is optional.

Troubleshooting & edge cases

Here are the most common issues you’ll run into, with concrete fixes:

1. “Missing required parameter” errors

If you see a 422 saying field required for a field you thought you provided, check that:

  • You spelled the JSON key exactly as the field name (case-sensitive).
  • For query parameters, the parameter is actually required (no default) or you’ve passed ....
  • The client is sending a body when you declared it as a query parameter.

2. Type coercion surprises

FastAPI is lenient by design. A string "42" becomes integer 42, a string "true" becomes boolean True. If you want to disallow coercion, set strict=True in Field:

age: int = Field(..., strict=True)

Now a string "42" will fail, even though it’s convertible.

3. Negative numbers sneaking through

Without ge=0, your endpoint might accept a negative stock. Always add ge=0 for quantities, and gt=0 for prices or IDs.

4. Default for list — beware mutable defaults

You cannot use a mutable default like tags: list = [] directly in Python (it’s a common bug). Pydantic handles it for you if you write Field(default=[]), but be careful if you ever manually assign a default in a regular function.

5. Email validation not working

If you try EmailStr and get a ModuleNotFoundError, you must install email-validator. If you forget, you’ll get an ImportError when starting the app.

6. Regex pattern errors

If you use pattern with a raw string, make sure it’s valid — you could get a 500 instead of a 422 if the pattern is malformed. Test your regex separately.

7. Field vs Query namespace clash

If you import both Field from Pydantic and Query from FastAPI, remember: Field is for Pydantic models, Query is for function parameters. Mixing them up leads to confusing errors.

What you learned & what's next

You now understand how to validate types, set defaults, and apply constraints in FastAPI. You’ve seen how Pydantic models with Field enforce rules on request bodies, how Query and Path do the same for query and path parameters, and how to troubleshoot common validation failures. You also learned the mental model of FastAPI as a validating gatekeeper.

Key skills acquired:

  • Declaring required vs. optional fields with ... vs. defaults.
  • Applying gt, ge, le, min_length, max_length, and pattern constraints.
  • Using Pydantic types like EmailStr for richer validation.
  • Understanding when to use BaseModel, Query, or Path.

What’s next: In the next lesson, you’ll dive into custom validation and model validators — you’ll learn how to enforce complex rules that span multiple fields (e.g., start < end) and how to validate data with side effects. That’s where real-world business logic comes to life.

Pro tip: Always document your constraints in your OpenAPI schema — FastAPI does it automatically. This gives your frontend team a live contract to follow.

Now, go ahead and add validation to your existing endpoints. Happy coding!

Practice recap

Create a new endpoint called /reviews/ that accepts a Review model with a required product_id (int, ge=1), a rating (float, ge=0, le=5), and an optional comment (str, default , max_length=1000). Test it with invalid data (e.g., rating 6) and observe the 422 response. Then change the comment to be required and see the difference. Great practice for applying constraints!**

Common mistakes

  • Forgetting to install email-validator when using EmailStr – you’ll get an ImportError at app startup.
  • Using [] as a mutable default in Pydantic – while Pydantic handles it, it’s a bad habit; always use Field(default_factory=list) in complex cases.
  • Confusing Field (for Pydantic models) with Query/Path (for function parameters) – leads to import errors or silent misbehavior.
  • Omitting constraints like ge=0 for quantities and gt=0 for prices, only to realize negative values break your DB layer.

Variations

  1. Use typing.Optional or default=None for optional fields instead of a fill-in default like empty string.
  2. Use Pydantic’s StrictBool, StrictInt, or conint, confloat, constr for stricter or constrained types.
  3. Leverage model_validator (new in Pydantic v2) for cross-field validation instead of the deprecated @validator.

Real-world use cases

  • E-commerce API: validate product prices (must be > 0) and stock quantities (>= 0) to prevent invalid DB records.
  • User registration endpoint: enforce username pattern and email format to avoid spam and bad user data.
  • Analytics API: ensure page and per_page query parameters are within sane limits (1–100) to prevent DoS via huge offsets.

Key takeaways

  • Type hints are not enough – you need constraints and defaults to enforce domain rules.
  • Use Field with ... for required fields and a default for optional ones.
  • gt, ge, lt, le, min_length, max_length, and pattern are your bread and butter.
  • Query and Path give you the same power for URL parameters.
  • Pydantic automatically returns 422 responses with detailed error lists – leverage that for frontend debugging.
  • Document your constraints – FastAPI’s OpenAPI schema will expose them for free.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.