Default Values & Optional Parameters · learncode.live

Required vs Optional in FastAPI

FastAPI determines whether a parameter is required or optional based on its type annotation and default value:

ParameterTypeDefaultBehavior
item_id: intPath-Required (path param)
q: strQueryNo defaultRequired query param
q: str = NoneQueryNoneOptional
q: Optional[str]QueryNoneOptional (same as above)
q: str = "default"Query"default"Optional with default
skip: int = 0Query0Optional with default
from fastapi import FastAPI, Query, Path, Body
from typing import Optional, Union

app = FastAPI()


@app.get("/items")
def list_items(
    # Required query parameter - no default
    category: str,
    # Optional with default
    sort: str = "name",
    # Optional (explicit None)
    direction: Optional[str] = None,
    # Optional with default using Query()
    page: int = Query(1, ge=1),
    size: int = Query(10, ge=1, le=100),
):
    ...
/items?category=books&sort=price&page=2&size=20
                                    └─ Optional params omitted when absent
                                       (direction is None, page defaults to 1)

Optional Path Parameters

Path parameters can be made optional by giving them a default - but they must come after required path params:

@app.get("/users/{user_id}/posts/{post_id}")
def get_post(user_id: int, post_id: int):
    """Both path params required."""
    ...


@app.get("/items/{item_id}")
def get_item(
    item_id: int = Path(..., title="The item ID"),
    include_details: bool = False,
):
    """Path param required, query param optional."""
    ...

Path parameters cannot truly be optional - if the path segment doesn’t exist, there’s no URL to match. To simulate optional path segments, define multiple routes:

@app.get("/posts")
@app.get("/posts/{post_id}")
def get_posts(post_id: Optional[int] = None):
    if post_id is None:
        return {"posts": all_posts}
    return {"post": posts[post_id]}

Query Parameters with Query()

Use Query() for fine-grained control over query parameters:

@app.get("/products")
def list_products(
    # Required query with constraints
    category: str = Query(..., min_length=2, max_length=20),
    # Optional with constraints
    min_price: float = Query(None, ge=0),
    max_price: float = Query(None, ge=0, le=100000),
    # Optional with default
    in_stock: bool = Query(True),
    # List query param (e.g., ?tags=a&tags=b)
    tags: list[str] = Query(default_factory=list),
):
    ...

Query Parameter Behaviour

DeclarationURL ExampleValue
q: str = "default"(omitted)"default"
q: Optional[str] = None(omitted)None
q: str(omitted)Validation error (required)
tags: list[str] = []?tags=a&tags=b["a", "b"]
active: bool = True?active=0False
active: bool = True?active=1True

The Optional Type

Optional[X] is equivalent to Union[X, None]. FastAPI treats both identically:

from typing import Optional, Union


# These are equivalent - both mean "str or None"
def read_item(q: Optional[str] = None): ...
def read_item(q: Union[str, None] = None): ...
def read_item(q: str = None): ...

Important nuance:

# Optional WITHOUT a default IS required
def read_item(q: Optional[str]):   # ❌ Required (no default)
  ...

# Optional WITH a default = None IS optional
def read_item(q: Optional[str] = None):  # ✅ Optional
  ...

Union Types

Union lets a parameter accept multiple types:

from typing import Union


@app.get("/items")
def search_items(
    q: Union[str, int] = None,
    # Accepts: ?q=hello  or  ?q=42
):
    ...


@app.get("/config/{setting}")
def get_setting(setting: Union[int, str]):
    # Matches both /config/42 and /config/theme
    ...

FastAPI tries each type in order and uses the first one that validates. For query params, values are strings by default, so Union[str, int] with ?q=42 returns the string "42". For path params, type coercion works automatically.

Mixing Required and Optional

You can freely mix required and optional parameters - required first is convention but not enforced:

@app.post("/orders")
def create_order(
    # Required body params (from Pydantic model)
    payload: OrderCreate,
    # Optional query params
    coupon: Optional[str] = None,
    gift_wrap: bool = False,
    # Required header
    authorization: str = Header(...),
):
    ...

Body() Embed

When you have a single Pydantic model as a body parameter, FastAPI expects the model fields directly as JSON keys. Use Body(embed=True) to wrap them in a nested object:

from fastapi import Body


class ItemPrice(BaseModel):
    price: float = Field(..., gt=0)
    currency: str = "USD"


# Without embed - expects: {"price": 9.99, "currency": "USD"}
@app.put("/items/{item_id}/price")
def update_price(item_id: int, price: ItemPrice):
    ...


# With embed - expects: {"price": {"price": 9.99, "currency": "USD"}}
@app.put("/items/{item_id}/price-embed")
def update_price_embed(item_id: int, price: ItemPrice = Body(embed=True)):
    ...
embed=True?Expected JSON Body
No (default){"price": 9.99, "currency": "USD"}
Yes{"price": {"price": 9.99, "currency": "USD"}}

Multiple Body Parameters

FastAPI automatically wraps multiple body models into a JSON object:

class Item(BaseModel):
    name: str
    price: float

class User(BaseModel):
    username: str


@app.put("/items/{item_id}")
def update_item(
    item_id: int,
    item: Item,
    user: User,
    importance: int = Body(ge=1, le=5),
):
    """Expects: { "item": {...}, "user": {...}, "importance": 3 }"""
    ...

The single body param importance uses Body() directly and is treated as a top-level key.

Default Factory Functions

Use default_factory for mutable defaults (avoid the mutable default argument pitfall):

from datetime import datetime
from typing import Optional


@app.get("/logs")
def get_logs(
    level: str = "info",
    since: Optional[datetime] = None,
    tags: list[str] = Query(default_factory=list),  # ✅ New list each request
    metadata: dict = Query(default_factory=dict),    # ✅ New dict each request
):
    ...
# ❌ BAD - same list shared across requests
def get_logs(tags: list[str] = []): ...

# ✅ GOOD - fresh list per request
def get_logs(tags: list[str] = Query(default_factory=list)): ...

Summary: Declaration Cheat Sheet

ScenarioDeclaration
Required path paramitem_id: int
Required query paramq: str = Query(...)
Optional query paramq: Optional[str] = None
Optional with defaultq: str = "default"
Optional with constraintq: str = Query(None, min_length=3)
Required with constraintq: str = Query(..., max_length=50)
List query paramtags: list[str] = Query(default_factory=list)
Multiple typesq: Union[str, int] = None
Body embed single modelmodel: Model = Body(embed=True)
Multiple body modelsitem: Item, user: User

Key Takeaways

  • Parameters without defaults are required; those with defaults are optional
  • Optional[X] is sugar for Union[X, None] - you still need = None to make it optional
  • Use Query() and Path() to add constraints (ge, le, min_length) to optional parameters
  • Body(embed=True) wraps a single model field inside a named key in the JSON body
  • Use default_factory=list for mutable defaults to avoid shared-state bugs
  • Union types let parameters accept multiple input types - FastAPI attempts validation in order
  • Multiple body parameters are merged into a single JSON object automatically
Courses