Now that we understand the core concepts and application anatomy, it's time to get hands-on. We'll build task-tracker-api — a simple REST API for tracking tasks — from scratch, starting with in-memory storage (data is lost on every restart). This is deliberately kept simple, because this article's focus is purely on the basic mechanics of FastAPI: defining endpoints, reading parameters, and running the server. Stricter validation and a real database follow in the next two articles.
The goal for this article: a FastAPI app running locally, with endpoints to view and create tasks, explorable through the built-in interactive documentation.
Project Setup
The steps:
mkdir task-tracker-api
cd task-tracker-api
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install fastapi "uvicorn[standard]"
uvicorn[standard] includes a few optional dependencies (like watchfiles for auto-reload) that we'll use often during development.
First Endpoint: Health Check
Create a main.py file:
from fastapi import FastAPI
app = FastAPI(title="Task Tracker API")
@app.get("/health")
def health_check():
return {"status": "ok"}
Run the server:
uvicorn main:app --reload
A few things worth understanding about this command:
main:app — means "look for an object named app in the file main.py". Adjust it if your file or object name differs.--reload — the server automatically restarts every time we save a code change. Very handy during development, but never use it in production.Open http://localhost:8000/health in your browser — you'll see {"status":"ok"}. Then open http://localhost:8000/docs — this is the interactive documentation (Swagger UI) covered in the previous article, already showing up automatically without us writing any documentation at all.
Setting Up In-Memory "Storage"
For now, we'll store tasks in a plain Python list. Add this to main.py, right below the app = FastAPI(...) line:
tasks = [
{"id": 1, "title": "Learn FastAPI concepts", "done": False},
{"id": 2, "title": "Build the first endpoint", "done": False},
]
next_id = 3
This is purely for learning purposes — this data will be lost every time the server restarts. In Part 5, we'll replace this with a real database.
GET Endpoints — Path Parameters and Query Parameters
Add the following three endpoints:
from fastapi import HTTPException
@app.get("/api/tasks")
def list_tasks(done: bool | None = None):
if done is None:
return tasks
return [t for t in tasks if t["done"] == done]
@app.get("/api/tasks/{task_id}")
def get_task(task_id: int):
for task in tasks:
if task["id"] == task_id:
return task
raise HTTPException(status_code=404, detail="Task not found")
Notice two things we already covered conceptually in the previous article — now we get to see them in practice:
list_tasks(done: bool | None = None) — done isn't part of the path, so it automatically becomes an optional query parameter. Try hitting /api/tasks?done=true and /api/tasks?done=false in the browser, and compare the results with plain /api/tasks.get_task(task_id: int) — task_id appears in the path ({task_id}), so it's a path parameter, required to be a number. Try hitting /api/tasks/abc — FastAPI automatically rejects it with a validation error, because abc isn't an int.raise HTTPException(status_code=404, ...) — the explicit way to stop execution and return an error with a specific status code. We'll go deeper on this in the next article.POST Endpoint — Accepting a Request Body
Now for the endpoint that creates a new task. This needs a Pydantic model to define the expected shape of the data:
from pydantic import BaseModel
class TaskCreate(BaseModel):
title: str
@app.post("/api/tasks", status_code=201)
def create_task(payload: TaskCreate):
global next_id
task = {"id": next_id, "title": payload.title, "done": False}
tasks.append(task)
next_id += 1
return task
Save the file and let --reload handle the automatic restart. Now open /docs, find the POST /api/tasks section, click "Try it out", fill in title, then click Execute. Notice three things:
{"title": "..."}, because it reads the schema straight from TaskCreate.title empty, or removing the field entirely — FastAPI automatically rejects it with a 422 status, before our create_task code ever runs.Manual Testing With curl
Besides /docs, we can also test from the terminal:
curl http://localhost:8000/api/tasks
curl -X POST http://localhost:8000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Deploy to production"}'
curl http://localhost:8000/api/tasks/999
That last command returns {"detail":"Task not found"} with a 404 status — exactly what we defined in get_task.
Where main.py Stands Now
After all the steps above, our main.py contains: the app setup, in-memory data, three endpoints (GET /health, GET /api/tasks, GET /api/tasks/{id}), and one POST /api/tasks endpoint. Over the next two articles this code will grow: validation gets stricter, and then it gets split into several separate files once we connect it to a real database.
Summary
We just built our first API with FastAPI: GET and POST endpoints, path parameters, query parameters, a request body via a Pydantic model, and exploring it all through the automatic interactive documentation. We didn't write a single line of documentation by hand — all of it was generated from the exact same code we ran.