Build a Task Manager REST API

Build a CRUD REST API with FastAPI, SQLModel, and SQLite.

Introduction

30 Second Summary

Every app you use talks to a server behind the scenes. When you check the weather, scroll social media, or order food, your phone sends requests to a backend API that fetches and stores data.

In this project, you will build a Task Manager REST API from scratch using Python and FastAPI. You'll create endpoints that let users create, read, update, and delete tasks, all backed by a real database.

What You'll Build

A fully functional REST API with five endpoints for managing tasks, powered by FastAPI and a SQLite database.

By the end of this project, you'll have:

  • A FastAPI application with full CRUD endpoints running on localhost.
  • A SQLite database that persists task data between server restarts.
  • Interactive API documentation auto-generated at /docs where you can test every endpoint.
  • Secret Mission: Add filtering and search capabilities with dynamic query building.

Are there any prerequisites?

You should be comfortable with basic Python concepts like variables, functions, and classes. No prior web development or API experience is needed.

Before We Start

Before We Start...

Set Up Your Project

Before writing any API code, you need a clean workspace. A dedicated project directory keeps your files organized. A virtual environment isolates your project's packages so they don't conflict with anything else on your system.

In this step, you'll set up that workspace and install two packages: FastAPI (the web framework that handles HTTP requests) and SQLModel (the library that connects your API to a database).

In this step, get ready to:
  • Create a project directory and virtual environment.
  • Install FastAPI and SQLModel.
  • Create the project file and open it in VS Code.
Create your project directory and virtual environment

A virtual environment is an isolated Python installation for your project. Packages you install inside it stay separate from your system Python.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir task-manager-api
cd task-manager-api

Your terminal prompt should now show task-manager-api as the current directory.

Next, create and activate a virtual environment. The commands differ slightly between macOS and Windows.

macOS

  • Create and activate your virtual environment by running these commands:
python3 -m venv .venv
source .venv/bin/activate

Windows

  • Create and activate your virtual environment by running these commands:
python -m venv .venv
.venv\Scripts\activate

You should see (.venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

Don't see (.venv) in your prompt?

Make sure you ran the activate command (not just the python3 -m venv command). Creating the environment and activating it are two separate steps.

On Windows, if you get a permissions error, run this command first, then try activating again:

Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned

help me activate my Python virtual environment

Install FastAPI and SQLModel

With your virtual environment active, any packages you install go into the .venv folder instead of your system Python.

  • Install FastAPI with its bundled Uvicorn server by running this command:
pip install "fastapi[standard]"

What does [standard] mean?

The [standard] extra installs FastAPI along with Uvicorn, the server that actually runs your API. Without it, you'd have to install the server separately.

  • Install SQLModel by running this command:
pip install sqlmodel

What is SQLModel?

SQLModel is a library that lets you define your database tables as Python classes. It's built on top of SQLAlchemy (the industry-standard database toolkit) and Pydantic (for data validation).

It's made by the same author as FastAPI, so the two libraries work together seamlessly.

  • Verify that FastAPI installed correctly by running this command:
python -c "import fastapi; print(fastapi.__version__)"

You should see a version number like 0.138.1 printed in your terminal.

Seeing a ModuleNotFoundError?

This usually means your virtual environment is not active. Check that (.venv) appears in your terminal prompt.

If it doesn't, re-run the activate command from the previous substep.

help me fix a ModuleNotFoundError when importing fastapi

Create the project file

Your project needs a single Python file where all the API code will live. You'll create it now and open the project in VS Code.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the task-manager-api folder, then click Open.

You should see the task-manager-api folder name in the VS Code Explorer sidebar on the left. The .venv folder should also be visible there.

  • Right-click in the empty space of the Explorer sidebar.
  • Click New File.
  • Type main.py and press Enter.

You should see main.py appear in the Explorer sidebar. The file opens automatically in the editor pane. It's empty for now.

Don't see the Explorer sidebar?

Press Cmd+Shift+E (macOS) or Ctrl+Shift+E (Windows) to toggle the Explorer panel.

help me find the Explorer sidebar in VS Code

Your workspace is ready. Next up, you'll define the data models that describe what a task looks like in your API.

Define Your Data Models

Your project folder is set up and your dependencies are installed. Now it's time to define the core of your API: the data models that describe what a "task" looks like.

Data models are blueprints that tell your API what shape incoming data should have, what gets stored in the database, and what gets sent back in responses. You'll use SQLModel to define these blueprints as Python classes.

In this step, get ready to:
  • Define data models for creating, reading, and updating tasks.
  • Configure a SQLite database connection.
  • Start the FastAPI dev server and see the auto-generated docs.
Create the base task model

Every task in your API shares a common set of fields: a title, an optional description, a priority level, and a completion status. You'll define these shared fields once in a base model, then build specialized models on top of it.

  • In VS Code, open main.py and add the following imports and base model:
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, Query
from sqlmodel import Field, Session, SQLModel, create_engine, select


# Base model with shared fields for all task operations
class TaskBase(SQLModel):
    title: str = Field(index=True)
    description: str | None = Field(default=None)
    priority: str = Field(default="medium")
    completed: bool = Field(default=False)

What does this code do?

The imports bring in everything you need from FastAPI and SQLModel. You won't use every import immediately, but they'll all be needed by the end of this step.

TaskBase defines four fields that every task shares. Field(index=True) on title makes database lookups by title faster. The | None syntax means a field is optional.

  • Save main.py.

You won't see a visible change yet. The next chunk builds the remaining models on top of TaskBase.

Define the model variants

A single model can't do everything safely. You need separate models for creating tasks (no ID yet), reading tasks (ID included), and updating tasks (all fields optional).

  • Below the TaskBase class, add these four model variants:
# Database table model - inherits shared fields, adds auto-generated id
class Task(TaskBase, table=True):
    id: int | None = Field(default=None, primary_key=True)


# Input model for creating tasks (same fields as TaskBase)
class TaskCreate(TaskBase):
    pass


# Response model - includes the id assigned by the database
class TaskPublic(TaskBase):
    id: int


# Update model - all fields optional so you can update just one
class TaskUpdate(SQLModel):
    title: str | None = None
    description: str | None = None
    priority: str | None = None
    completed: bool | None = None

Why so many models?

Task is the actual database table (table=True). Its id starts as None because the database auto-generates it.

TaskCreate validates incoming data. TaskPublic shapes the response (always includes id). TaskUpdate makes every field optional so a client can update just one field without sending all the others.

  • Save main.py.

You won't see a change yet. The next substep connects these models to a database.

Set up the database engine and session

Your models describe the shape of a task, but they need a database to actually store data. SQLite is a file-based database that requires zero setup. It stores everything in a single .db file inside your project folder.

  • Below the TaskUpdate class, add the database configuration, session management, and app startup:
# SQLite database file stored in your project folder
sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"

# check_same_thread=False allows FastAPI to use the same connection across threads
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)


# Creates all tables defined by SQLModel in the database
def create_db_and_tables():
    SQLModel.metadata.create_all(engine)


# Provides a database session for each request, closes it when done
def get_session():
    with Session(engine) as session:
        yield session


# Type alias so endpoints can declare a session dependency cleanly
SessionDep = Annotated[Session, Depends(get_session)]

app = FastAPI()


# Runs once when the server starts - ensures tables exist
@app.on_event("startup")
def on_startup():
    create_db_and_tables()

What does this code do?

  • sqlite_file_name and sqlite_url tell SQLModel where to store your data (a file called database.db).
  • check_same_thread is a SQLite-specific setting that lets FastAPI handle multiple requests safely.
  • create_db_and_tables() builds the actual database tables from your model classes.
  • get_session() opens a connection to the database for each request and closes it automatically when the request finishes.
  • SessionDep is a shortcut so you can write session: SessionDep in your endpoint functions instead of repeating the full dependency declaration.
  • The @app.on_event("startup") decorator runs create_db_and_tables() once when the server starts.
  • Save main.py.

Seeing red underlines in VS Code?

If VS Code shows import errors, make sure your virtual environment is selected as the Python interpreter. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) and type Python: Select Interpreter. Choose the one inside your .venv folder.

help me fix red underlines for FastAPI imports in VS Code

✔️ Awesome, I've got everything!

Great. Double check you've saved main.py and move on to run the server.

ⓧ I'd like to double check the full code

from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, Query
from sqlmodel import Field, Session, SQLModel, create_engine, select


class TaskBase(SQLModel):
    title: str = Field(index=True)
    description: str | None = Field(default=None)
    priority: str = Field(default="medium")
    completed: bool = Field(default=False)


class Task(TaskBase, table=True):
    id: int | None = Field(default=None, primary_key=True)


class TaskCreate(TaskBase):
    pass


class TaskPublic(TaskBase):
    id: int


class TaskUpdate(SQLModel):
    title: str | None = None
    description: str | None = None
    priority: str | None = None
    completed: bool | None = None


sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"

connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)


def get_session():
    with Session(engine) as session:
        yield session


SessionDep = Annotated[Session, Depends(get_session)]

app = FastAPI()


@app.on_event("startup")
def on_startup():
    create_db_and_tables()
Run the server and verify

Your models and database are wired up. Time to start the FastAPI dev server and see the auto-generated documentation.

  • Start the development server by running this command in your terminal:
fastapi dev main.py

What does this command do?

fastapi dev launches the Uvicorn server with auto-reload enabled. Every time you save main.py, the server restarts automatically so you see your changes immediately.

  • Open your browser and visit http://127.0.0.1:8000/docs.

You should see the Swagger UI page. It won't show any endpoints yet because you haven't defined any routes. You'll add those in the next step.

Server not starting?

Make sure your virtual environment is active (you should see (.venv) in your terminal prompt). If not, run source .venv/bin/activate (macOS/Linux) or .venv\Scripts\activate (Windows).

Also confirm you're running the command from inside the task-manager-api folder where main.py lives.

help me debug why fastapi dev main.py is failing

Your data layer is ready and your server is running. Next up, you'll build the actual API endpoints that let you create, read, update, and delete tasks.

Build the CRUD Endpoints

Your data models and database are ready. Now it's time to build the actual API endpoints that let clients create, read, update, and delete tasks.

Each endpoint maps to an HTTP method (POST, GET, PATCH, DELETE) and a URL path. Together, these form a CRUD interface. This is the core pattern behind every REST API you'll encounter in production.

In this step, get ready to:
  • Build the POST and GET endpoints for creating and reading tasks.
  • Build the PATCH and DELETE endpoints for updating and removing tasks.
  • Test all five endpoints interactively in the Swagger UI.
Create the POST and GET endpoints

You'll start with the three endpoints that let you add tasks and retrieve them. Each endpoint is a Python function decorated with the matching HTTP method.

  • In main.py, scroll to the bottom of the file (below the on_startup function).
  • Add the POST endpoint that creates a new task by pasting this code:
# Create a new task and save it to the database
@app.post("/tasks/", response_model=TaskPublic, status_code=201)
def create_task(task: TaskCreate, session: SessionDep):
    # Convert the input model to a database table model
    db_task = Task.model_validate(task)
    session.add(db_task)
    session.commit()
    session.refresh(db_task)
    return db_task

What does this code do?

This endpoint handles POST requests to /tasks/. When a client sends task data, the function:

  • Task.model_validate(task) converts the incoming TaskCreate data into a full Task database record.
  • session.add() stages the new task, session.commit() saves it to the database, and session.refresh() reloads the record (so it now has the auto-generated id).
  • status_code=201 tells FastAPI to return a 201 Created response instead of the default 200.
  • Save main.py.
  • Refresh http://127.0.0.1:8000/docs in your browser. You should see a green POST /tasks/ endpoint appear in the list.

Don't see the new endpoint?

  • Check your terminal for a syntax error. If fastapi dev detected a Python error, the server won't reload successfully. Fix the error and save again.
  • Make sure the decorator @app.post is at the same indentation level as @app.on_event above it (no extra indentation).
  • The browser won't auto-refresh. You need to manually refresh the page after saving.

Help me debug why my POST endpoint isn't showing in the Swagger UI.

  • Below the create_task function, add the GET endpoint that retrieves all tasks with pagination:
# Retrieve all tasks with pagination support
@app.get("/tasks/", response_model=list[TaskPublic])
def read_tasks(
    session: SessionDep,
    offset: int = 0,
    limit: Annotated[int, Query(le=100)] = 100,
):
    # Fetch tasks from the database with offset and limit
    tasks = session.exec(select(Task).offset(offset).limit(limit)).all()
    return tasks

What does this code do?

This endpoint handles GET requests to /tasks/ and returns a list of tasks. The offset and limit parameters let clients paginate through results instead of fetching everything at once.

Query(le=100) sets a maximum limit of 100 tasks per request. This prevents clients from accidentally overloading your server with a single massive query.

  • Save main.py.
  • Refresh the Swagger UI. You should now see GET /tasks/ listed below the POST endpoint.

Seeing a server error in your terminal?

  • Make sure the function signature uses a comma after limit: Annotated[int, Query(le=100)] = 100, and the closing parenthesis with colon is on its own line.
  • Confirm that Query is already imported at the top of your file (it should be from the previous step).

Help me fix the syntax error in my read_tasks function.

  • Below the read_tasks function, add the GET endpoint that retrieves a single task by its ID:
# Retrieve a single task by its ID
@app.get("/tasks/{task_id}", response_model=TaskPublic)
def read_task(task_id: int, session: SessionDep):
    # Look up the task in the database
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    return task

What does this code do?

This endpoint uses a path parameter ({task_id}) to fetch a specific task. If no task with that ID exists, it raises a 404 error with the message "Task not found".

session.get(Task, task_id) is a shortcut for looking up a record by its primary key. It returns None if no match is found.

  • Save main.py.
  • Refresh the Swagger UI. You should now see GET /tasks/{task_id} appear in the endpoint list.

Getting a validation error when testing?

  • Make sure task_id: int uses a lowercase int type hint. Python's type system is case-sensitive.
  • Confirm HTTPException is imported at the top of your file from the previous step.

Help me debug my GET /tasks/{task_id} endpoint.

Create the PATCH and DELETE endpoints

Now you'll add the endpoints that let clients modify and remove tasks. The PATCH endpoint uses a partial update pattern, so clients only need to send the fields they want to change.

  • Below the read_task function, add the PATCH endpoint for updating tasks:
# Update an existing task with partial data
@app.patch("/tasks/{task_id}", response_model=TaskPublic)
def update_task(task_id: int, task: TaskUpdate, session: SessionDep):
    # Find the existing task
    task_db = session.get(Task, task_id)
    if not task_db:
        raise HTTPException(status_code=404, detail="Task not found")
    # Only update fields that were explicitly provided
    task_data = task.model_dump(exclude_unset=True)
    task_db.sqlmodel_update(task_data)
    session.add(task_db)
    session.commit()
    session.refresh(task_db)
    return task_db

What does this code do?

model_dump(exclude_unset=True) is the key technique here. It converts the request body into a dictionary, but only includes fields the client actually sent. If a client sends {"completed": true}, only completed gets updated. The title, description, and priority stay unchanged.

sqlmodel_update() applies that dictionary of changes to the existing database record. This is the standard partial-update pattern in SQLModel.

  • Save main.py.
  • Refresh the Swagger UI. You should now see PATCH /tasks/{task_id} in the list.

Server crashed after saving?

  • Check for mismatched parentheses. The update_task function has multiple method calls chained together. Make sure each opening parenthesis has a matching close.
  • Confirm you're using task_db (the database record) and task (the incoming update data) as separate variables. Mixing them up causes attribute errors.

Help me fix the error in my PATCH endpoint.

  • Below the update_task function, add the DELETE endpoint:
# Delete a task by its ID
@app.delete("/tasks/{task_id}")
def delete_task(task_id: int, session: SessionDep):
    # Find the task to delete
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    session.delete(task)
    session.commit()
    return {"ok": True}

What does this code do?

This endpoint removes a task from the database permanently. It follows the same pattern as read_task: look up the task, return 404 if it doesn't exist, otherwise perform the action.

It returns {"ok": True} as a simple confirmation. Some APIs return the deleted object instead. Either approach is valid.

  • Save main.py.
  • Refresh the Swagger UI. You should now see all five endpoints listed: POST, two GETs, PATCH, and DELETE.

Only seeing some endpoints?

  • Make sure every function has a decorator (@app.post, @app.get, @app.patch, @app.delete) directly above it with no blank line between the decorator and def.
  • If your terminal shows an error, the server stopped reloading at the broken function. Fix that function and save again.

Help me figure out why some endpoints are missing from the Swagger UI.

✔️ Awesome, I've got everything!

Great. Double check you've saved main.py and all five endpoints appear in the Swagger UI.

ⓧ I'd like to double check the full code

Here is the complete main.py at this point. Compare it with yours:

from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, Query
from sqlmodel import Field, Session, SQLModel, create_engine, select


class TaskBase(SQLModel):
    title: str = Field(index=True)
    description: str | None = Field(default=None)
    priority: str = Field(default="medium")
    completed: bool = Field(default=False)


class Task(TaskBase, table=True):
    id: int | None = Field(default=None, primary_key=True)


class TaskCreate(TaskBase):
    pass


class TaskPublic(TaskBase):
    id: int


class TaskUpdate(SQLModel):
    title: str | None = None
    description: str | None = None
    priority: str | None = None
    completed: bool | None = None


sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"

connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)


def get_session():
    with Session(engine) as session:
        yield session


SessionDep = Annotated[Session, Depends(get_session)]

app = FastAPI()


@app.on_event("startup")
def on_startup():
    create_db_and_tables()


@app.post("/tasks/", response_model=TaskPublic, status_code=201)
def create_task(task: TaskCreate, session: SessionDep):
    db_task = Task.model_validate(task)
    session.add(db_task)
    session.commit()
    session.refresh(db_task)
    return db_task


@app.get("/tasks/", response_model=list[TaskPublic])
def read_tasks(
    session: SessionDep,
    offset: int = 0,
    limit: Annotated[int, Query(le=100)] = 100,
):
    tasks = session.exec(select(Task).offset(offset).limit(limit)).all()
    return tasks


@app.get("/tasks/{task_id}", response_model=TaskPublic)
def read_task(task_id: int, session: SessionDep):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    return task


@app.patch("/tasks/{task_id}", response_model=TaskPublic)
def update_task(task_id: int, task: TaskUpdate, session: SessionDep):
    task_db = session.get(Task, task_id)
    if not task_db:
        raise HTTPException(status_code=404, detail="Task not found")
    task_data = task.model_dump(exclude_unset=True)
    task_db.sqlmodel_update(task_data)
    session.add(task_db)
    session.commit()
    session.refresh(task_db)
    return task_db


@app.delete("/tasks/{task_id}")
def delete_task(task_id: int, session: SessionDep):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    session.delete(task)
    session.commit()
    return {"ok": True}
Test all endpoints in the interactive docs

The Swagger UI lets you send real requests to your API directly from the browser. You'll create some tasks, then verify that every endpoint works correctly.

  • Open http://127.0.0.1:8000/docs in your browser.
  • Click on the POST /tasks/ endpoint to expand it.
  • Click Try it out.
  • Replace the request body with this JSON:
{
  "title": "Review pull request",
  "description": "Check the new auth module for security issues",
  "priority": "high",
  "completed": false
}
  • Click Execute. You should see a 201 response with the task data plus a generated id field.
  • Create 1-2 more tasks with different titles to build up some test data.
  • Expand GET /tasks/, click Try it out, then click Execute. You should see all your created tasks returned as a list.
  • Expand GET /tasks/{task_id}, enter 1 in the task_id field, and click Execute. You should see just the first task.
  • Expand PATCH /tasks/{task_id}, enter 1 in the task_id field, and set the request body to:
{
  "completed": true
}
  • Click Execute. The response should show the task with completed now set to true, while all other fields remain unchanged.
  • Expand DELETE /tasks/{task_id}, enter 1 in the task_id field, and click Execute. You should see {"ok": true} in the response.
  • Try the GET /tasks/1 again. You should now see a 404 response with "Task not found".

Where is my data stored?

Your tasks are saved in a file called database.db in your project folder. SQLite creates this file automatically the first time the server starts. Your data persists between server restarts.

All five CRUD endpoints are working. Next up, you'll add input validation to reject invalid priority values and build a task count endpoint for reporting.

Add Validation and Error Handling

Your API can create, read, update, and delete tasks. But what happens when someone sends garbage data to your endpoints?

A production-quality API needs to reject invalid input with clear error messages and return the right HTTP status codes. In this step, you'll explore how FastAPI handles validation automatically, add a summary endpoint, and verify your complete API.

In this step, get ready to:
  • Test FastAPI's built-in request validation.
  • Add a task count endpoint for summary statistics.
  • Verify data persistence and review the API schema.
Test priority validation

FastAPI validates every incoming request against your model's type annotations. If a request body is missing a required field or sends the wrong data type, FastAPI rejects it with a 422 status code before your function even runs.

  • Return to the Swagger UI at http://127.0.0.1:8000/docs in your browser.
  • Expand the POST /tasks/ endpoint and click Try it out.
  • Delete the title field entirely from the request body, then click Execute.

You should see a 422 response with a detailed error message explaining that the title field is required.

What is a 422 response?

HTTP status code 422 means "Unprocessable Entity." The server understood your request format (valid JSON) but the content violates the data rules. FastAPI generates these automatically from your model's type hints.

The error response includes a detail array showing exactly which field failed and why. This saves you from writing validation logic by hand.

  • Now try sending an invalid type. Add the title field back, but change completed to a string like "yes" instead of true or false.
  • Click Execute again.

FastAPI returns another 422 error because "yes" cannot be interpreted as a boolean value.

Why does this matter for priority?

Your priority field is typed as str, so FastAPI accepts any string value right now. In a production app, you could use Python's Literal type to restrict priority to only "low", "medium", or "high". That would automatically reject invalid priority values with a 422 error.

The key takeaway is that FastAPI's validation is driven entirely by your type annotations. Stronger types mean stronger validation with zero extra code.

Add a task count endpoint

Your API currently lets you list all tasks, but it does not give you a quick summary. A count endpoint returns how many tasks exist and how many are completed versus pending.

Why does route order matter?

FastAPI matches routes top to bottom. If /tasks/{task_id} comes before /tasks/count, FastAPI tries to parse "count" as an integer task ID and returns a validation error.

Fixed routes like /tasks/count must always appear before dynamic routes like /tasks/{task_id} in your code.

  • In main.py, find the read_task function (the one with @app.get("/tasks/{task_id}").
  • Add the following code above that function, directly below the read_tasks function:
@app.get("/tasks/count")
def get_task_count(session: SessionDep):
    # Fetch all tasks to calculate statistics
    all_tasks = session.exec(select(Task)).all()
    total = len(all_tasks)
    # Count completed tasks using a generator expression
    completed = sum(1 for t in all_tasks if t.completed)
    pending = total - completed
    return {"total": total, "completed": completed, "pending": pending}

What does this code do?

This endpoint queries all tasks from the database and calculates three numbers: the total count, how many are marked as completed, and how many are still pending.

The sum(1 for t in all_tasks if t.completed) expression counts each task where completed is True. The endpoint returns a plain dictionary, so FastAPI does not need a response model for it.

  • Save main.py.
  • The dev server auto-reloads. In the Swagger UI, refresh the page. You should see a new GET /tasks/count endpoint listed.
  • Click Try it out, then Execute. You should see a JSON response with total, completed, and pending counts.

Seeing a validation error on /tasks/count?

If you get a 422 error saying the path parameter is not a valid integer, your get_task_count function is defined below the /tasks/{task_id} route. Move it above that route so FastAPI matches /tasks/count first.

Help me fix my route ordering

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py before moving on.

ⓧ I'd like to double check the full code

from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, Query
from sqlmodel import Field, Session, SQLModel, create_engine, select


class TaskBase(SQLModel):
    title: str = Field(index=True)
    description: str | None = Field(default=None)
    priority: str = Field(default="medium")
    completed: bool = Field(default=False)


class Task(TaskBase, table=True):
    id: int | None = Field(default=None, primary_key=True)


class TaskCreate(TaskBase):
    pass


class TaskPublic(TaskBase):
    id: int


class TaskUpdate(SQLModel):
    title: str | None = None
    description: str | None = None
    priority: str | None = None
    completed: bool | None = None


sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"

connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)


def get_session():
    with Session(engine) as session:
        yield session


SessionDep = Annotated[Session, Depends(get_session)]

app = FastAPI()


@app.on_event("startup")
def on_startup():
    create_db_and_tables()


@app.post("/tasks/", response_model=TaskPublic, status_code=201)
def create_task(task: TaskCreate, session: SessionDep):
    db_task = Task.model_validate(task)
    session.add(db_task)
    session.commit()
    session.refresh(db_task)
    return db_task


@app.get("/tasks/", response_model=list[TaskPublic])
def read_tasks(
    session: SessionDep,
    offset: int = 0,
    limit: Annotated[int, Query(le=100)] = 100,
):
    tasks = session.exec(select(Task).offset(offset).limit(limit)).all()
    return tasks


@app.get("/tasks/count")
def get_task_count(session: SessionDep):
    all_tasks = session.exec(select(Task)).all()
    total = len(all_tasks)
    completed = sum(1 for t in all_tasks if t.completed)
    pending = total - completed
    return {"total": total, "completed": completed, "pending": pending}


@app.get("/tasks/{task_id}", response_model=TaskPublic)
def read_task(task_id: int, session: SessionDep):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    return task


@app.patch("/tasks/{task_id}", response_model=TaskPublic)
def update_task(task_id: int, task: TaskUpdate, session: SessionDep):
    task_db = session.get(Task, task_id)
    if not task_db:
        raise HTTPException(status_code=404, detail="Task not found")
    task_data = task.model_dump(exclude_unset=True)
    task_db.sqlmodel_update(task_data)
    session.add(task_db)
    session.commit()
    session.refresh(task_db)
    return task_db


@app.delete("/tasks/{task_id}")
def delete_task(task_id: int, session: SessionDep):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    session.delete(task)
    session.commit()
    return {"ok": True}
Final verification

Your API is feature-complete. Time to confirm everything works end to end, including data persistence across server restarts.

  • Stop the dev server by pressing Ctrl+C in your terminal.
  • Restart the server by running:
fastapi dev main.py
  • Open http://127.0.0.1:8000/docs and test the GET /tasks/ endpoint.
  • Confirm that the tasks you created earlier are still there. This proves your SQLite database persists data between server restarts.

Why does data persist?

Your tasks are stored in the database.db file on disk. Unlike in-memory storage, SQLite writes data to a physical file. Restarting the server creates a new Python process, but the database file stays intact.

  • Review your API's full specification by visiting http://127.0.0.1:8000/openapi.json in your browser.

You should see a JSON document describing every endpoint, their parameters, request bodies, and response schemas. This is the OpenAPI specification that powers the Swagger UI you have been using.

What is the OpenAPI schema?

The OpenAPI schema is a machine-readable description of your entire API. It lists every endpoint, what data each one accepts, and what it returns. Tools like Swagger UI read this schema to generate the interactive documentation you have been testing with.

FastAPI generates this automatically from your Python type annotations and route decorators. You never write documentation separately because the code is the documentation.

HTTP status codes in your API

  • 201 Created: returned by POST /tasks/ when a new task is saved.
  • 200 OK: returned by GET, PATCH, and DELETE on success.
  • 404 Not Found: returned when a task ID does not exist.
  • 422 Unprocessable Entity: returned automatically when request data fails validation.

Your API is now fully validated, documented, and persistent. Next up, you'll take on the Secret Mission and add filtering capabilities to make your API even more powerful.

Secret mission

Add Filtering and Search

Your API can create, read, update, and delete tasks. But what if a user has hundreds of tasks and wants to find only the high-priority incomplete ones? In this mission, you'll add a search endpoint with dynamic query filtering.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The task-manager-api project folder on your Desktop (contains main.py, database.db, and the .venv virtual environment).
  • The FastAPI dev server process (if still running).

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the API further.

  • Your project folder stays on your Desktop ready to use.
  • Run fastapi dev main.py from the project folder any time you want to start the server again.

Pause - I'll come back to this later

Stop the running server to free up your terminal. Your files and database stay intact for next time.

  • Switch to the terminal where the FastAPI server is running.
  • Press Ctrl+C to stop the server.
  • Deactivate the virtual environment by running:
deactivate

When you come back, navigate to your project folder, activate the virtual environment with source .venv/bin/activate, and run fastapi dev main.py to pick up where you left off.

Delete - I don't want to use this again

Remove all project files, the database, and the virtual environment completely.

  • If the FastAPI server is still running, press Ctrl+C in that terminal to stop it.
  • Deactivate the virtual environment by running:
deactivate
  • Delete the entire project folder by running this from your Desktop:

macOS

cd ~/Desktop
rm -rf task-manager-api

Windows

cd ~\Desktop
rmdir /s /q task-manager-api
  • Confirm the folder is gone by running:
ls

You should no longer see task-manager-api in the output.

Nice Work!

Nice Work!

You just built a fully functional REST API from scratch using FastAPI and SQLModel. Your Task Manager API handles real CRUD operations, validates incoming data, persists tasks in a database, and even generates its own interactive documentation.

Here's what you learned:

  • Built a complete REST API with 5 CRUD endpoints using FastAPI, handling creation, reading, updating, and deleting of tasks.
  • Connected to a SQLite database using SQLModel with proper session management and separate data models for request validation and response shaping.
  • Generated interactive API documentation automatically at /docs, letting you test every endpoint without a separate tool.
  • Secret Mission: Added filtering and search capabilities with dynamic query building using optional query parameters.

Ready to quiz yourself?