Hello FastAPI

Python types intro

This section is based on https://fastapi.tiangolo.com/python-types/. Read that instead of this.

Python has support for optional type hints (type annotations).

The motivation that’s given for using types seems to be largely that they help with IDE autocompletion. I turn autocompletion on from time to time…

E.g.:

def get_full_name(first_name, last_name):
    full_name = first_name.title() + " " + last_name.title()
    return full_name


print(get_full_name("john", "doe"))

def full_name(first_name: str, last_name: str):
    return first_name.title() + " " + last_name.title()

With the type annotations in the second function, Neovim was able to provide better autocomplete suggestions, because it knew that first_name and last_name were strings.

Type annotations also allows for better error checking:


def get_name_with_age(name: str, age: int):
    return name + " is " + age + " years old."  # operator "+" is not supported for types "str" and "int"

Python built in types

See https://docs.python.org/3/library/stdtypes.html

“The principal built-in types are numerics, sequences, mappings, classes, instances and exceptions.”

Numerics:

  • int
  • float
  • complex, i.e. 3+4i
  • bool (True/False, technically a subtype of int)

Sequences:

  • list (mutable [1, 2, 3])
  • tuple (immutable (1, 2, 3))
  • str ("hello")
  • bytes and bytearray (binary data)
  • range (for generating number sequences)

Mappings:

  • dict (dictionaries: {"foo": "bar"})

Classes:

Classes are the blueprints for creating objects. Every type in Python is actually a class. (So the methods that can be called on a string are the class methods for the type str.) When you define class MyClass:, you’re creating a new type.

Instances:

Instances are the actual objects created from classes. When you write x = MyClass(), x is an instance of MyClass.

Exceptions:

Exceptions are the types used for error handling:

  • Exception, ValueError, TypeError, KeyError, etc.

  • They are raised when something goes wrong and can be caught with try/except.

Generic types with type parameters

Data structures like dict, list, set, and tuple contain other values. These internal values have their own types. These are called container classes.

Generic types are types that can be parameterized, (like the types I listed above): https://docs.python.org/3/glossary.html#term-generic-type GenericAlias objects are generally created by subscripting a class. Note that GenericAlias objects were introduced in Python 3.9. They are created by parameterizing a generic type:

In [1]: type(list[int])
Out[1]: types.GenericAlias

The GenericAlias object was added in Python 3.9 to allow built-in types to be directly parameterized without importing from typing:

# before 3.9, you needed
from typing import List, Dict
def process(items: List[str]) -> Dict[str, int]:
    ...

Note: it’s generally only possible to subscript a class if the class implements the special method __class_getitem__(). I’m noting that because I see errors related to __get_item__ from time to time.

To define a GenericAlias for a dict, pass 2 type parameters:

def pricess_items(prices: dict[str, float]):
    for item_name, item_price in prices.items():
        ...

Type unions

A variable can be declared as having multiple acceptable types (in Python > 3.6) by using a Union type:

def process_item(item: int | str):
    ...
def say_hi(name: str | None):
    print(f"Hi {name}")

Pydantic models

Pydantic is a Python library to perform data validation: https://docs.pydantic.dev/latest/. You declare the shape of the data as classes with attributes:

from datetime import datetime
from pydantic import BaseModel


class User(BaseModel):
    id: int
    name: str = "Foo Bar"
    signup_ts: datetime | None = None
    friends: list[int] = []


user_data = {"id": "1", "signup_ts": "2025-11-27", "friends": [2, 3, 34]}

user = User(**user_data)

print(user)
# id=1 name='Foo Bar' signup_ts=datetime.datetime(2025, 11, 27, 0, 0) friends=[2, 3, 34]

Python type hint annotations

Python allows putting additional metadata in type hints:

def say_hello(name: Annotated[str, "The name to say hello to"]) -> str:
    return f"Hello {name}"

Annotations don’t seem to be doing anything with how I’ve got Neovim configured though.

Concurrency and async / await

Note: this section is (largely) based on https://fastapi.tiangolo.com/async/. Read that instead of this.

Modern versions of python support asynchronous code using “coroutines”, with async and await syntax.

Support for asynchronous code means that Python has a way to tell the computer that at some point in the code it will have to wait for something else to finish. During that time, the computer can do other work.

It’s called “asynchronous” because the computer / program doesn’t have to be “synchronized” with the slow task, waiting for the exact moment that the task finishes, while doing nothing, to be able to take the task result and continue the work.

Think of synchronous (the opposite of asynchronous) as sequential — the computer / program follows all steps in a sequence before switching to a new task.

Python coroutines

When you mark a function as async it becomes a coroutine that returns a coroutine object when it’s called.

The key mechanism is the event loop. The event loop manages and schedules coroutines, deciding when each one runs. When a coroutine executes an await expression, it yields control back to the event loop. The event loop cah run other coroutines while the first one waits for something, e.g., waits for some I/O operation.

Concurrency vs parallelism

The analogy from the FastAPI docs is good (https://fastapi.tiangolo.com/async/#concurrency-and-burgers).

Think of concurrency as a fast food restaurant with a single cashier where you’re given a number after placing an order. Think of parallelism as a fast food restaurant with multiple cashiers where you wait at the checkout after placing your order.

Concurrency makes sense for web APIs — handling the case of many users making requests. Concurrency makes sense for applications where there’s a lot of waiting, e.g., waiting for an external process to finish.

Parallelism makes sense for applications that are CPU bound — lots of work to be done, not much waiting. CPU bound applications might be: audio processing, machine learning, etc.

async and await

Modern versions of python support async and await. When there’s an operation that will require waiting:

burgers = await get_burgers(2)  # `await` can only be used in async functions

burgers is now a coroutine object.

await can only be used when calling a function that’s been defined with async def: functions that return a coroutine object:

async def get_burgers(number: int):
    # do some asynchronous things, e.g.:
    burgers = await make_burgers(number)
    return burgers

First FastAPI app

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def root():
    return {"message": "Hello world"}

Starting the FastAPI server

fastapi dev main.py

Or for my current case:

fastapi dev file_to_run.py

Create a FastAPI instance

from fastapi import FastAPI

app = FastAPI()  # type: <class 'fastapi.applications.FastAPI'>

Create a path operation and define a path operation function

While building an API, the “path” is the main way to separate “concerns” and “resources”.

“Resources” are the different things that an application deals with: users, posts, search responses, etc.

“Concerns” is about the idea of separation of concerns: use the URL structure to organize what the API does:

/users          # User resource
/users/{id}     # Specific user
/posts          # Post resource
/posts/{id}     # Specific post
/orders         # Order resource

FastAPI uses decorators to define the operation and path of a function. E.g.:

  • / (path)
  • get (operation)

Becomes the decorator: @app.get("/").

A decorator is a function that wraps another function to modify its behavior. When you call

@app.get("/")
async def root():
    return {"message": "Hello world"}

Python is essentially doing:

async def root():
    return {"message": "Hello world"}

root = app.get("/")(root)

So @app.get("/") is calling app.get("/") which returns a decorator function and that function wraps the root function.

Path parameters

@app.get("/")
async def root():
    return {"message": "Hello world"}


@app.get("/items/{item_id}")
async def read_item(item_id):
    return {"item_id": item_id}


@app.get("/numeric_items/{item_id}")
async def read_int_item(item_id: int):  # path parameter with type
    return {"item_id": item_id}

Note that the type set for item_id in read_int_item performs automatic type conversion (from string to int) and data validation:

❯ curl http://localhost:8000/numeric_items/foo
{"detail":[{"type":"int_parsing","loc":["path","item_id"],"msg":"Input should be a valid integer,
unable to parse string as an integer","input":"foo"}]}

Order of path definitions is significant

Path operations are evaluated in order, so this is correct, not the other way around:

@app.get("/users/me")
async def read_user_me():
    return {"user_id": "current_user_id"}


@app.get("/users/{user_id}")
async def read_user(user_id: str):
    return {"user_id": user_id}

Predefined parameter values

If you want possible valid path parameters to be predefined, use a Python Enum. E.g., create an Enum class that inherits from str and Enum. By inheriting from str the API docs will be able to know that the values must be of type string:

from fastapi import FastAPI
from enum import Enum


class ModelName(str, Enum):
    alexnet = "alexnet"
    resnet = "resnet"
    lenet = "lenet"


app = FastAPI()


@app.get("/models/{model_name}")
async def get_model(model_name: ModelName):
    if model_name is ModelName.alexnet:
        return {"model_name": model_name, "message": "should be alexnet"}
    # the `value` method also works
    if model_name.value == "lenet":
        return {"model_name": model_name, "message": "should be lenet"}

    return {"model_name": model_name, "message": "can only be resnet"}

Query parameters

When parameters are declared in an operation function that are not part of the path parameters, they are automatically interpreted as query parameters:

fake_items_db = [
    {"item_name": "Foo"},
    {"item_name": "Bar"},
    {"item_name": "Baz"},
]

@app.get("/items_from_db/")
async def read_items_from_db(skip: int = 0, limit: int = 10):
    return fake_items_db[skip : skip + limit]
❯ curl "http://localhost:8000/items_from_db/?skip=2&limit=2"
[{"item_name":"Baz"}]

Note that the query params are naturally strings, but when they’re declared with Python types, they are converted to the type and validated against it.

Declare optional query parameters by setting their default to None:

@app.get("/foo/{foo_id}")
async def get_foo(foo_id: str, q: str | None = None):
    if q:
        return {"foo_id": foo_id, "q": q}
    return {"foo_id": foo_id}
projects/python/hello_fastapi via  v3.11.13 (.ftutenv)
❯ curl http://localhost:8000/foo/3?q=four
{"foo_id":"3","q":"four"}
projects/python/hello_fastapi via  v3.11.13 (.ftutenv)
❯ curl http://localhost:8000/foo/3
{"foo_id":"3"}

boolean query parameters

Query parameters with a bool type will be converted from strings to booleans:

async def read_item(item_id: str, q: str | None = None, short: bool = False):
    ...

The above function would convert any (?) value of short to True (?). That doesn’t seem right. I’m guessing that some string values get converted to False, e.g., "False", "0", but I’ll test that later.

Required query parameters

To make a query parameter required, just don’t set an optional value for it:

@app.get("/needy_items/{item_id}")
async def read_user_item(item_id: str, needy: str):
    item = {"item_id": item_id, "needy": needy}
    return item

It will return an error if the param is missing:

❯ curl "http://localhost:8000/needy_items/foo"
{"detail":[{"type":"missing","loc":["query","needy"],"msg":"Field required","input":null}]}

Sending data to the API in the request body

A request body is sent by the client to the API. A response body is data sent by the API to the client.

To declare a request body, use Pydantic models:

from fastapi import FastAPI
from pydantic import BaseModel

# Declare the data model as a class that inherits from `BaseModel`
class Item(BaseModel):
    name: str
    description: str | None = None  # when an attribute has a default value, it's not required
    price: float
    tax: float | None = None


app = FastAPI()

@app.post("/items/")
# To add the parameter to the path operation, declare it in the same way as with query parameters:
async def create_item(item: Item):
    return item

FastAPI will:

  • read the body of the request as JSON
  • if needed, convert types
  • validate the data
  • populate the parameter with the received data
❯ curl -X POST "http://localhost:8000/items/" -H "Content-Type: application/json" -d '{"name": "Foo", "description": "Footastic item", "price": 17.99, "tax": 3.5}'
{"name":"Foo","description":"Footastic item","price":17.99,"tax":3.5}

The attributes of the model can be accessed directly in the operation function:

@app.post("/items/")
async def create_item(item: Item):
    # `model_dump()` (a Pydantic function):
    # Generate a dictionary representation of the model, optionally specifying which fields to include or exclude:
    item_dict = item.model_dump()
    if item.tax is not None:
        price_with_tax = item.price + item.tax
        item_dict.update({"price_with_tax": price_with_tax})
    return item_dict
❯ curl -X POST "http://localhost:8000/items/" -H "Content-Type: application/json" -d '{"name": "Foo", "description": "Footastic item", "price": 17.99, "tax": 3.5}'
{"name":"Foo","description":"Footastic item","price":17.99,"tax":3.5,"price_with_tax":21.49}

Request body with path parameters

FastAPI recognized that function parameters that match path parameters should be taken from the path. E.g.:

@app.put("/items/{item_id}")
async def update_item(item_id: int, item: Item):
    # item_id will be taken from the path
    # item will be an instance of Item, populated by the request body

Request body + path + query parameters also works as expected.

Query parameter validation

See https://fastapi.tiangolo.com/tutorial/query-params-str-validations/ The code before adding validations:

Note: Following the tutorial, I’m getting type warnings without explicitly annotating results as a dict. This approach works:

@app.get("/items/")
async def read_items(q: str | None = None):
    # I'd do almost anything to avoid this:
    results: dict[str, list[dict[str, str]] | str] = {
        "items": [{"item_id": "Foo"}, {"item_id": "Bar"}]
    }
    if q:
        results.update({"q": q})
    return results

# But that type of annotation is over-the-top. I prefer this approach:

@app.get("/bikes/")
async def read_bikes(q: str | None = None):
    results = {"bikes": [{"brand": "kona"}, {"brand": "norco"}]}
    if q:
        # This is a cleaner approach using dictionary unpacking
        results = {**results, "q": q}
    return results

Enforce a max_length on the q parameter

Annotated can be used to add metadata to parameters. With defining the q parameter as Annotated, Query can now be added inside of Annotated to set a max_length:

from fastapi import FastAPI, Query
from typing import Annotated

app = FastAPI()


@app.get("/items/")
async def read_items(q: Annotated[str | None, Query(max_length=10)] = None):
    results: dict[str, list[dict[str, str]] | str] = {
        "items": [{"item_id": "Foo"}, {"item_id": "Bar"}]
    }
    if q:
        results.update({"q": q})
    return results

From fastapi/param_functions.py:

def Query(  # noqa: N802
    # ...
    max_length: Annotated[
        Optional[int],
        Doc(
            """
            Maximum length for strings.
            """
        ),
    # ...

Query is being used here because it’s a query parameter. There are similar validations for Path(), Body(), Header(), and Cookie() that accept the same arguments as Query().

FastAPI will validate the data and return an error to the client if it’s not valid:

❯ curl "http://localhost:8000/items/?q=testingonetwo"
{"detail":[{"type":"string_too_long","loc":["query","q"],"msg":"String should have at most 10 characters","input":"testingonetwo","ctx":{"max_length":10}}]}

The parameter is also documented on the OpenAPI schema.

Available validations include:

  • min_length
  • max_length
  • pattern (a regular expression)

E.g.: Query(min_length=3, max_length=50, pattern="^fixedquery$")

Allow a query parameter to receive multiple values

I know what the docs are getting at, but the wording is hard to follow, see https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#query-parameter-list-multiple-values.

@app.get("/multi-q/")
async def read_multi_q(q: Annotated[list[str] | None, Query()] = None):
    query_items = {"q": q}
    return query_items
❯ curl "http://localhost:8000/multi-q/?q=foo&q=bar"
{"q":["foo","bar"]}

I don’t think it’s possible to use Query() to receive a list of query params and also validate the params with Query() (?), e.g. Query(max_length=3).

Note that this use case is often confusing, e.g, Discourse’s use of tags[]=foo&tags[]=bar&tags[]=baz.

Custom validation

Use the Pydantic AfterValidator method:

from fastapi import FastAPI, Query
from typing import Annotated
from pydantic import AfterValidator
import random

app = FastAPI()

data = {
    "isbn-9781529046137": "The Hitchhiker's Guide to the Galaxy",
    "imdb-tt0371724": "The Hitchhiker's Guide to the Galaxy",
    "isbn-9781439512982": "Isaac Asimov: The Complete Stories, Vol. 2",
}


def check_valid_id(id: str):
    if not id.startswith(("isbn-", "imdb-")):
        raise ValueError('Invalid ID format, it must start with "isbn-" or "imdb-"')
    return id

@app.get("/books/")
async def read_bookd(
    id: Annotated[str | None, AfterValidator(check_valid_id)] = None,
):
    if id:
        item = data.get(id)
    else:
        id, item = random.choice(list(data.items()))
    return {"id": id, "name": item}
❯ curl "http://localhost:8000/books/"
{"id":"isbn-9781529046137","name":"The Hitchhiker's Guide to the Galaxy"}
projects/python/hello_fastapi via  v3.11.13 (.ftutenv)
❯ curl "http://localhost:8000/books/?id=imdb-tt0371724"
{"id":"imdb-tt0371724","name":"The Hitchhiker's Guide to the Galaxy"}
projects/python/hello_fastapi via  v3.11.13 (.ftutenv)
❯ curl "http://localhost:8000/books/?id=imde-tt0371724"
{"detail":[{"type":"value_error","loc":["query","id"],"msg":"Value error, Invalid ID format, it must start with \"isbn-\" or \"imdb-\"","input":"imde-tt0371724","ctx":{"error":{}}}]}

Online API Documentation

Browse to http://localhost:8000/docs:

Auto generated documentation for my API

FastAPI generated API documentation

Tags: