6. FastAPI and Pydantic Tutorial
FastAPI is a modern, fast (high-performance), standards-first web framework for Python. It's designed around modern Python features such as type annotations (like you used in COMP110). FastAPI helps you both specify and build RESTful HTTP APIs quickly.
Pydantic is a library used by FastAPI for data modeling and validation. It is how we will specify the schemas for request and response body data. It enforces type hints at runtime and yields user-friendly errors.
Since you are now comfortable with HTTP methods, paths, query parameters, and so on, from the previous parts of this reading, you're in great shape to dive in!
1. Getting Started
In a terminal on your host machine, outside of any other git
repositories, follow the following steps:
-
Clone the tutorial repository: Start by cloning the repository at https://github.com/comp423-25s/fastapi-tutorial.git.
-
Open the repository in a VS Code Dev Container. The dev container is based on a modern Microsoft Dev Container image, which we have already used once in this course, so it should load quickly and install the necessary dependencies from
requirements.txt
. -
Read the
requirements.txt
file. Notice that we are taking a dependency onfastapi[standard]
package (PIP package repository page). The other two packages,black
andpylint
are tools used to automatically format your Python code using consistent style (black
) and lint check your code for common code smells or issues. We'll learn more about these kinds of tools soon, but they're configured in the dev container settings file if you are curious. -
Open main.py. This is the entrypoint of our API app and the tutorial starts meow
.
2. First Route: Hello World
There's only one way to venture into new territory in programming: hello, world! Let’s start with the simplest possible route. Update your main.py
:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root() -> str:
return "Hello, world!"
What Does @app.get("/")
Mean?
- Decorator: If you’re new to decorators, think of
@something
as a way to wrap or register the function that follows. In this case:@app.get("/")
tells FastAPI that this function (read_root
) handles GET requests to the root path ("/"
).- The function name
read_root
is arbitrary—choose a meaningful name for your own clarity.
- When you return a string (like
"Hello, world!"
), FastAPI automatically converts it into an HTTP response with the body containing that string.
3. Running the Development Server
To run your app in development, use the following command (from within the fastapi-tutorial
folder):
By default, FastAPI’s dev server:
- Runs at
http://127.0.0.1:8000
(port 8000). Note: If you have any other dev servers running on this same port (e.g. your MkDocs project's dev server) see the Ports tab in VSCode to learn what port this container's 8000 was mapped to on your host machine. - The
--reload
argument causes the server to watch your files. If you make changes, it auto-reloads so you don’t have to stop and restart the server on every change you make to your code.
Behind the scenes, FastAPI is using a Python package called Uvicorn to handle lower-level HTTP concerns. This is beyond your concern, but if you see anything about uvicorn
when reading about FastAPI just know it's a foundational HTTP layer that FastAPI sits above in the architecture.
Whenever a request hits GET /
, it calls our read_root()
function.
Take a look at your Python code and be sure you can identify where the following HTTP API dimensions are specified: the HTTP method (1), the path (2), and the response body schema (3). Click the annotation icon, the plus symbol, to expand the answers.
-
The HTTP method is specified in the
@app.get
annotation (GET
). If it makes it easier to remember, HTTP method specification in FastAPI is implemented as a method call on the FastAPIapp
object. -
The path is
/
, commonly called a root path since it has no parts beyond the slash, and it is specifed as the first parameter of the@app.get()
method call. -
The response body schema is specified as the return type of the route handler function. In this case it is
str
as the returned value is"Hello, world!"
.
4. Adding Another Static Route
Let’s add a second route, say, GET /about
which returns some simple text. Update your main.py
:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root() -> str:
return "Hello, world!"
@app.get("/about")
def read_about() -> str:
return "This is a simple HTTP API."
Try visiting http://localhost:8000/about
. You should see the alternate message!
5. Introducing a Pydantic Model and Listing Posts
Next, let’s introduce a Pydantic model to represent our data. These models serve a dual purpose: first they give us a Python class we can use throughout our server-side code. Second, in conjunction with FastAPI, they will automatically create a schema for our API specifications.
We’ll use a simple "Post" resource as an example throughout this tutorial. Let's start by returning a list of posts from a global dictionary that we’ll pre-populate with a couple sample posts.
- Define the
Post
model as a subclass ofpydantic.BaseModel
. Be sure to add theimport
statement forBaseModel
. Define it to have two attributes:id
andcontent
. - Create a global dictionary
posts_db
containing two posts keyed by their IDs. - Add a route
GET /posts
to list all posts.
Update main.py
with the following:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Post(BaseModel):
id: int
content: str
# Prepopulate dictionary of posts
posts_db = {
1: Post(id=1, content="Hello FastAPI!"),
2: Post(id=2, content="Writing my second post!")
}
@app.get("/")
def read_root() -> str:
return "Hello, world!"
@app.get("/about")
def read_about() -> str:
return "This is a simple HTTP API."
@app.get("/posts")
def list_posts() -> list[Post]:
return list(posts_db.values())
How This Works
- We store two example posts in a global dictionary,
posts_db
, keyed by their ID. - The route
GET /posts
returnslist(posts_db.values())
, which effectively returns all posts as a list. - Notice how each value in
posts_db
is already an instance ofPost
. When FastAPI sees these objects, it converts them to JSON automatically.
Notice the return type of the list_posts
function is a list
of Post
objects. This is specifying the response body schema. Try visiting this route in your browser to confirm it is working. If you do not see well formatted JSON that is easy to read, try going back to the previous part of this reading and installing a JSON Viewer plugin in your web browser.
6. Adding a Dynamic Route to Get a Single Post
Now let’s introduce our first dynamic route. For a URL like "/posts/1"
, we want to look up the post with id=1
in our dictionary and return the Post
object with this ID.
Add the following import and route definition to your main.py
file:
# ... Update FastAPI Imports ...
from fastapi import FastAPI, HTTPException
# ... Earlier App Stays Same ...
@app.get("/posts/{post_id}")
def get_post(post_id: int) -> Post:
if post_id in posts_db:
return posts_db[post_id]
raise HTTPException(status_code=404, detail="Post not found")
Try a Happy Path
Try navigating to /posts/1
and /posts/2
and convince yourself you can trace the flow of information. Specifically, look at how the path is specified with a dynamic part named post_id
and how that path part corresponds to the function parameter of the same name. The value is then used to lookup a post with a given ID in the dictionary.
Try an Unhappy Path
Try navigating to /posts/3
and seeing the 404 Response. Your browser won't show you the response code directly, but you can open up your browser's Developer Tools and look at your Network history (try reloading) to see the 404 is being sent. Notice this is achieved programatically in FastAPI by raising an HTTPException
with a status_code
keyword parameter.
Try an Invalid Path
Finally, navigate to /posts/abc
. Because we declared post_id: int
, FastAPI automatically checks if "abc"
can be converted to an integer. It cannot, so the framework responds with an HTTP 422 Unprocessable Entity error, including a helpful error message about the invalid type. This automatic validation is one of the many reasons FastAPI is a joy to work with compared to its predecessors! Edge case handling like this used to require more boilerplate code from engineers.
7. Understanding Routing in Modern API Frameworks
Now that you’ve seen both a static route ("/posts"
) and a dynamic route ("/posts/{post_id}"
), let’s briefly discuss how routing works in a modern framework like FastAPI. At a high-level, the routing algorithm works like this:
- Match the HTTP method (GET, POST, PUT, DELETE, etc.).
- Match the path pattern (
"/"
,"/about"
,"/posts"
,"/posts/{post_id}"
, etc.).- Routes are checked in the order they are defined which can be surprising. If you define a route like
/posts/{post_id}
and then a route like/posts/stats
follows it, the first route will always be matched (and error). To avoid this common issue, specify routes with static path parts before the dynamic path parts.
- Routes are checked in the order they are defined which can be surprising. If you define a route like
- Handle parameters (like
post_id
) including type conversion and validation. - Call the function associated with that route.
- Return a response which might be JSON, HTML, or something else.
Like everything, there is a bit more more machinery behind the scenes, but understanding routing at this level of details is sufficient for now.
8. Automatic Documentation with OpenAPI
One major benefit of FastAPI is its automatic generation of OpenAPI documentation. OpenAPI was previously known as Swagger, which was an objectively awful name, so this is a welcomed development in the community. By default, FastAPI sets up:
- An OpenAPI specification at
/openapi.json
. - An OpenAPI-based web interface at
/docs
.
With your FastAPI dev server is running, navivate to:
/docs
— a graphical user interface where you can see all endpoints, query them, and see sample requests and responses./openapi.json
— the raw JSON specification for your API.
Because we used pydantic.BaseModel
for Post
, the schema's model shape will be visible in /docs
, including field types and potential validation error states.
Why is an OpenAPI spec valuable?
- It standardizes your API contract, so other developers or tools (like code generators) know exactly how to consume your endpoints.
- The
/docs
interface provides a quick way to try out your endpoints. This will be valuable in the next section.
OpenAPI UI in the Wild: CSXL.unc.edu
To hopefully drive home the point that what you are learning is both real and used in the wild, try opening up this URL in a new tab: https://csxl.unc.edu/docs.
This is the API for the CSXL web application. Many routes require an authentication token. If you want to try those routes, in a separate tab open up the CSXL website, login, and go to your user profile. Under profile actions, click "Copy" on the Bearer Token (which is an authorization key for your user). Paste that in to the /docs
unlock screen. Then try running the GET /api/profile
API endpoint and you should see your data.
Some other fun routes include public ones like listing student organizations or classes in a given semester.
If you've used office hours via the CSXL, or applied to be a TA, or reserved a room or checked into the XL Coworking space for a desk to work at... you've already used this API without knowing it! If you scroll around you can see the API end points powering coworking, office hours, and more.
9. Adding a POST Route
Let’s make our API a bit more dynamic by allowing clients to create new posts. We’ll maintain our dictionary posts_db
but now add a route for POST. We can do something like this:
# Update FastAPI Imports
from fastapi import FastAPI, HTTPException, status
# ... Keep other routes the same ...
@app.post("/posts", status_code=status.HTTP_201_CREATED)
def create_post(post: Post):
if post.id in posts_db:
raise HTTPException(status_code=400, detail="Post with this ID already exists")
posts_db[post.id] = post
return post
Walkthrough of the POST Route
- Request Body: FastAPI automatically parses the incoming JSON body into a
Post
object (thanks to Pydantic). Notice how simple this is! We specified a parameter to the function of a Pydantic model type, there is no conflicting name in a dynamic path part, so FastAPI convention infers this must be the schema of the data in the request body. - We "store" that post in
posts_db
using the post’sid
as the key. - By specifying
status_code=status.HTTP_201_CREATED
, FastAPI will return a 201 Created status code upon success. - We also added a small check to ensure that an existing post with the same ID doesn’t get overwritten.
Open your browser to the API UI page /docs
, or reload it (this page will not automatically refresh upon saving your work in the editor). Scroll to the POST /posts
endpoint. You can:
- Click Try it out.
- Provide a sample JSON body, e.g.:
- Click Execute and see the response information.
You can then go to the GET /posts/{post_id}
endpoint in /docs
(or directly at /posts/3
) to verify the newly created post.
There are a few other activities for you to try here:
- Try posting the same JSON and seeing the response code.
- Try posting a JSON body that is just
{"id": 4}
and seeing the response FastAPI produces. (WOW!) - Look at the specific response status code of the happy path (201) in the
/docs
UI. Notice where this is coming from in the definition. Take a look at how this is specified in the decorator as an additional parameter. There are other ways of responding with a specific status code, but this is preferred in a case like this.
Your 'Database' of Posts Will Reset
We are not actually using a "database" in this tutorial; just a dictionary stored in our module's global memory a sa simplification. As such, every time your FastAPI server stops and restarts, this dictionary is reset to its initialized contents. That means each time you change your main.py
file below, and the server automatically reloads, you will lose any changes made via the API.
In a coming unit, we will learn how to connect our API to persistent databases that live in a layer outside of our code such that when we stop and restart our server the data is securely stored and accessible again as soon as our server starts back up.
10. Adding PUT and DELETE
Finally, let’s round out our basic CRUD functionality (Create, Retrieve, Update, Delete) with PUT (update) and DELETE HTTP method routes. Here’s a simple approach, using our dictionary to check for existence by key:
# ... previous code remains the same ...
@app.put("/posts/{post_id}")
def update_post(post_id: int, updated_post: Post) -> Post:
if post_id not in posts_db:
raise HTTPException(status_code=404, detail="Post not found")
posts_db[post_id] = updated_post
return updated_post
@app.delete("/posts/{post_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_post(post_id: int) -> None:
if post_id not in posts_db:
raise HTTPException(status_code=404, detail="Post not found")
del posts_db[post_id]
return None # 204 = No Content
PUT: Update a Resource
At the HTTP specification level, PUT is meant to replace the resource at the specified URL. Here, our resource is "/posts/{post_id}"
. When the client requests PUT /posts/5
, for example, we expect the request body to provide the new id
and content
fields for post 5
(or whichever post ID is specified). If that post doesn’t exist, we respond with a 404 Not Found.
DELETE: Remove a Resource
Similarly, DELETE aligns directly with the idea of removing the resource at the URL. When a client requests DELETE /posts/5
, we remove post 5
from our posts_db
. A successful removal returns a 204 No Content, which communicates that the request succeeded, but there’s no response body.
Testing PUT and DELETE in the OpenAPI UI
- Open the documentation: Navigate to
http://localhost:8000/docs
. You’ll see your newPUT
andDELETE
endpoints under the/posts/{post_id}
section. - Try PUT:
- Expand PUT /posts/{post_id}.
- Click Try it out.
- Enter a valid
post_id
(e.g.,1
) in the path parameter box. - Provide a JSON body with the
id
andcontent
fields. For instance: - Execute the request and verify that the response shows the updated post.
- Try using the
GET
routes (list or by ID) to confirm the update is reflected following the update.
- Try DELETE:
- Expand DELETE /posts/{post_id}.
- Click Try it out.
- Enter the
post_id
for the post you want to remove. - Execute, and you’ll see a 204 response indicating success (no body returned).
- If you try a
post_id
that doesn’t exist, you’ll get a 404 Not Found. - Try using the
GET
routes (list or by ID) to confirm thePOST
as deleted from your in-memory "database".
With PUT and DELETE, you now have the full set of HTTP operations to manage a simple resource.
Summary and Next Steps
Congratulations! You’ve:
- Declared routes with static paths (
"/"
,"/about"
). - Introduced Pydantic models (
Post
) and used a global dictionary to store and retrieve posts. - Created routes to list all posts, get a specific post by ID (dynamic route), and handled invalid IDs.
- Learned how FastAPI automatically validates path parameters.
- Explored how FastAPI auto-generates OpenAPI docs at
"/docs"
. - Implemented POST, PUT, and DELETE to complete the CRUD operations set.
Best Practices Beyond This Tutorial
- Organize your files: Real projects separate routers, models, and database logic into different modules.
- Use databases: Instead of an in-memory dictionary, integrate a real database system for persistence.
- Validation and error handling: Explore more Pydantic features to ensure robust data validation. One place where we did not fully specify our APIs above, for the sake of not getting bogged down in error cases, is when we responded with error status codes but did not specify this in the route decorator. In the next assignment, we will fully specify all expected response types in our route handler functions.
- Deployment to the Web: You have successfully deployed a static website to the web. Deploying a dynamic web application, like this API, requires some more machinery because our API doesn't just result in static files it results in a running server program. We will learn more about deploying an application like this to the cloud soon.
With these fundamentals, you have a solid handle on building a basic API with FastAPI. Enjoy experimenting, and happy coding!