FastAPI Intro - Full CRUD REST API Tutorial (1 HOUR) - Backend Engineering

Timestamps

  • - Intro
  • - What is FastAPI?
  • - Getting Started
  • - Building an API
  • - Paths and Path Parameters
  • - GET Requests
  • - Mock data with dictionary
  • - How to get a single campaign
  • - Interacting with the API
  • - Mocked POST, PUT, DELETE
  • - Working with a Database
  • - SQL Databases
  • - GET multiple
  • - Lifespan Events
  • - Building the type
  • - Seeding the Database
  • - GET
  • - Types
  • - GET BY ID
  • - Generic Response Type
  • - POST - Creating data
  • - Custom create type
  • - PUT - Updating Data
  • - Retrieve data
  • - DELETE - Remove data
  • - Outro

FastAPI is one of a few popular frameworks for web development in Python. The other two you'll hear about are Django and Flask, and I've done videos on both of those as well.

This one is hands on the whole way through. We build a CRUD API from an empty directory to full create, read, update, delete against a SQLite database.

If you want the concepts first, the prerequisite is API Design and Full Introduction. We are not going to assume much previous HANDS ON experience, but I do recommend you understand the major API concepts.

The repo for this lesson: https://github.com/CalebCurry/fastapi-example

What is FastAPI?

FastAPI is the framework that gives us a lot of tools to build applications. Specifically focused on building the backend. The frontend displays the data while the backend provides the data. This data will be stored in a database.

This data is given in a predictable way, JSON provided by an API. An API allows you to request information and receive it in JSON.

It's fast, but the bigger thing is that it's fast to CODE. By the end of this you will have an API that adheres to the OpenAPI spec, with the documentation generated for you, which makes consuming the API so much easier. The docs will state the types and the structure the client should use, and that structure gets enforced on the backend.

That enforcement comes from Pydantic, the most widely used data validation library for Python, which FastAPI is built with. It's also built on Starlette, which is what gives you the async web service underneath.

This is probably my favorite feature. I built a Rust API and had to build all this documentation out manually. It wasn't that difficult, you can just write the OpenAPI JSON files by hand even, but the library I was using was supposed to make it easier and it was still much more work than doing it with FastAPI.

Getting Started

I'm going to build a project called omnicopy. The idea is an app that helps with copywriting content for marketing campaigns. You write some copy, then you want it distributed across social media, a newsletter, the website, basically have it in all locations. You can follow along exactly or come up with your own folder name.

I'm running this from a Mac. If you're on Windows you can follow along too, but a couple of the commands will differ (the venv activate one especially), or you can run WSL.

Bash
mkdir omnicopy
cd omnicopy
python3 -m venv .venv
. .venv/bin/activate
echo ".venv" > .gitignore
pip install --upgrade pip
pip install "fastapi[standard]"
pip freeze > requirements.txt
git init
git add .
git commit -m "initial commit"
touch main.py

The [standard] in quotes is you picking which optional features get installed. The pip freeze gives you a requirements.txt so you can recreate this virtual environment later on another machine.

On Windows the activate line is something like .venv\Scripts\activate instead, look up the exact command for your shell.

Now open your editor:

Bash
code .

I'd switch to the VSCode terminal but be sure to reactivate your virtual environment. Mine activates it automatically, yours may not, and you'll see .venv show up on the left when it's active.

Bash
. .venv/bin/activate
Python
from fastapi import FastAPI

app = FastAPI()


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

💡 Tip[!tip]
For Language Server support / proper error messages you can install Pylance in VSCode, and set your user settings with "python.analysis.typeCheckingMode": "strict"
You may need to reset VSCode.

Run the project:

Bash
fastapi dev main.py

You can now visit http://127.0.0.1:8000 or http://127.0.0.1:8000/docs for the API documentation.

hzQBKRZiFGRR

This is swagger, which reads from an openapi.json file. You can find that at http://127.0.0.1:8000/openapi.json.

OpenAPI and swagger are closely related but slightly different. OpenAPI is the actual spec, the structure describing your API. Swagger is a suite of open source tools that implement and work with that spec, and the page you're looking at is one of them.

Quick summary of info so far. We create an instance of the FastAPI class. We define a path operation function. We associate it with a particular HTTP method (FastAPI calls these path operations) using a decorator.

Python
@app.get("/")
async def root():

Operation in OpenAPI and FastAPI refer to the HTTP methods. So for every method and every path, we're going to have a function that handles that request.

These functions are defined async. Concurrency and async / await - FastAPI

App Idea

A marketing campaign will consist of many pieces, like content pieces, social media posts, newsletters, blog posts, etc. So we will build this up, but our basic data starting structure will be something like:

Python
Campaigns
- campaign_id
- name
- due_date
- created_at

pieces
- piece_id
- campaign_id
- name
- content
- content_type
- created_at

A piece relies on a campaign existing first, so in this video we ONLY worry about campaigns. Relationships between the two come in a later lesson.

Worth noting on the naming: created_at is generated by the backend or the database, while due_date is set by the user. I end the automatic timestamp stuff with at so you can tell them apart at a glance. And campaign_id versus just id is a toss-up, I've grown to like the table name prefixing the ID because I think it makes joins easier, but plain id is fine too. Naming primary keys "id" vs "something_id" in SQL

Building an API

We will build up to a fully functional API.

Paths and Path Parameters

Every endpoint is going to start with /api/v1, and repeating that on every decorator gets annoying. So instead we create a root_path by providing it as an argument when we instantiate FastAPI, and it gets prepended to each path.

Additionally, we take a value through the URL. This is called a path parameter.

Python
from fastapi import FastAPI

app = FastAPI(root_path="/api/v1")


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


@app.get("/campaigns/{id}")
async def read_campaign(id: int):
    return {"id": id}

The naming for these functions is typically the action (create, read, update, delete) followed by the resource. So read_campaigns for the list and read_campaign for one of them.

The URL to access this new endpoint is http://127.0.0.1:8000/api/v1/campaigns/5. Whatever ID you put in here will be given back as a response like an echo.

GET Requests

Jumping directly to working with the database is quite the jump, so first I want to mock the data and get all the endpoints built out. That way the interface to our API is pretty much done, and the next step is just swapping the data layer.

Python
from datetime import datetime
from fastapi import FastAPI, HTTPException
from typing import Any
app = FastAPI(root_path="/api/v1")

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

data : Any = [
    {
        "campaign_id": 1,
        "name": "Summer Launch",
        "due_date": datetime.now(),
        "created_at": datetime.now()
    },
    {
        "campaign_id": 2,
        "name": "Black Friday",
        "due_date": datetime.now(),
        "created_at": datetime.now()
    }
]

"""
Campaigns
- campaign_id
- name
- due_date
- created_at
"""

@app.get("/campaigns")
async def read_campaigns():
    return {"campaigns": data}

@app.get("/campaigns/{id}")
async def read_campaign(id: int):
    for campaign in data:
        if campaign.get("campaign_id") == id:
            return {"campaign": campaign}
    raise HTTPException(status_code=404)

First, we create an array of mock data. Dictionaries will be used as this converts directly to JSON. I said dictionary out loud and then immediately made it a list of dictionaries, because we're returning multiple campaigns.

Pylance is going to complain about the types on that list. Typing data as Any shuts it up for now, and we circle back to real types once we get to the database.

We can now visit http://127.0.0.1:8000/api/v1/campaigns
as well as http://127.0.0.1:8000/api/v1/campaigns/2

JSON
{
    "campaign": {
        "campaign_id": 2,
        "name": "Black Friday",
        "due_date": "2025-08-23T15:10:39.680870",
        "created_at": "2025-08-23T15:10:39.680870"
    }
}

This will return a 404 if no entry exists with the provided ID.

You can analyze the request in the network tab of the browser. Do a refresh, click the request, and you'll see the 200 response code, the GET request method, and application/json as the response content type.

Interacting with the API

You can test requests in the API docs directly, which is very handy. This will give you a starting structure, too. Everything in this video that isn't a plain GET gets done from this page, because you can't fire a POST from the browser URL bar.

That said, I still like to use some other clients, and this is worth knowing even though I don't use it on camera. I recommend Postman or the REST Client extension in Visual Studio Code, which lets you keep the requests as a file in your repo.

Full info can be found here - marketplace.visualstudio.com

We can create a variable for easy URL reuse:

@base_url=http://localhost:8000/api/v1

{{base_url}}/campaigns

###

{{base_url}}/campaigns/2

This is handy because we don't have to leave our editor and we can include this in our repo.

At this point you can jump directly to working with the database, in a real project this is likely what I would do, but for the sake of learning I do believe mocking the POST, PUT and DELETE endpoints is a healthy first step. At that point, you have the API interface defined and you just have to replace the internal implementation to use a database, so we can focus on that separately.

Mocked POST, PUT, DELETE

Here is the API code for mocked POST, PUT, and DELETE.

Python
from datetime import datetime
from random import randint
from fastapi import FastAPI, HTTPException, Response
from typing import Any
app = FastAPI(root_path="/api/v1")

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

data : Any = [
    {
        "campaign_id": 1,
        "name": "Summer Launch",
        "due_date": datetime.now(),
        "created_at": datetime.now()
    },
    {
        "campaign_id": 2,
        "name": "Black Friday",
        "due_date": datetime.now(),
        "created_at": datetime.now()
    }
]

@app.get("/campaigns")
async def read_campaigns():
    return {"campaigns": data}

@app.get("/campaigns/{id}")
async def read_campaign(id: int):
    for campaign in data:
        if campaign.get("campaign_id") == id:
            return {"campaign": campaign}
    raise HTTPException(status_code=404)

@app.post("/campaigns", status_code=201)
async def create_campaign(body: dict[str, Any]):

    new : Any = {
        "campaign_id": randint(100, 1000),
        "name": body.get("name"),
        "due_date": body.get("due_date"),
        "created_at": datetime.now()
    }

    data.append(new)
    return {"campaign": new}

@app.put("/campaigns/{id}")
async def update_campaign(id: int, body: dict[str, Any]):

    for index, campaign in enumerate(data):
        if campaign.get("campaign_id") == id:

            updated : Any = {
                "campaign_id": id,
                "name": body.get("name"),
                "due_date": body.get("due_date"),
                "created_at": campaign.get("created_at")
            }
            data[index] = updated
            return {"campaign": updated}
    raise HTTPException(status_code=404)


@app.delete("/campaigns/{id}")
async def delete_campaign(id: int):

    for index, campaign in enumerate(data):
        if campaign.get("campaign_id") == id:
            data.pop(index)
            return Response(status_code=204)
    raise HTTPException(status_code=404)

💡 Tip[!tip]
In the video I had update_campaign and delete_campaign swapped on these two decorators and only caught it much later. The code above has them the right way round.

On the POST, I started out with request: Request and body = await request.json(). That works, but the docs page won't show a request body field, because FastAPI has no idea a body is coming. Typing the parameter as body: dict[str, Any] instead is what tells FastAPI to expect a body, and then the field shows up in the docs.

If you want a different HTTP code than 200 you can pass an additional status_code parameter in the decorator. Otherwise the create will default to 200 (not the end of the world).

Some other things worth calling out:

  • This maintains the state until the server is reset. It's all in memory, so a restart puts you back at two campaigns.
  • Updating maintains the same ID, returns 200 with the updated element in the body.
  • For the PUT we deliberately carry the ORIGINAL created_at over instead of generating a new one. It's an update, not a new element, and the client shouldn't have to send it.
  • Deleting removes the data but returns a 204 with no body.
  • Current limitation is that we are parsing the body manually and have no concept of typing. This makes the API less clear and limits our API documentation. We will fix that soon.

If the docs page hands you a 422 back on the PUT, look at your JSON before you look at your Python. I got one from a trailing comma in the request body.

For reference, here are those same requests as REST Client / .http calls:

###

# Create new campaign
POST {{base_url}}/campaigns
Content-Type: application/json

{
  "name": "new campaign",
  "due_date": "2025-12-25"
}

###

# Update campaign
PUT {{base_url}}/campaigns/2
Content-Type: application/json

{
  "name": "updated campaign",
  "due_date": "2026-01-01"
}

###

# Delete campaign
DELETE {{base_url}}/campaigns/2

At this point we have a pretty solid starter interface to working with our data. You can now look at the swagger.

http://127.0.0.1:8000/docs

This would be a pretty good check point if you want to commit your code as the next section will be a bit destructive.

Bash
git add .
git commit -m "mocked CRUD"

I did not add __pycache__ to the .gitignore, which probably would have been smart, but it's not the end of the world.

Working with a Database

To talk to the database I'm going to use an ORM, an object relational mapper. This takes database records and converts them to objects in our code, so we can mostly skip writing SQL. ORMs have limitations and challenges, but I think it's a great place to start.

The two big options are SQLAlchemy and SQLModel. SQLModel is what FastAPI recommends, it's by the same creator, and you can think of it as SQLAlchemy plus Pydantic for the typing. The typing is something I want anyways so that's the route I'm going. There's various debate about which to use, I'm just going with the default recommendation instead of overthinking it. If you do end up switching to SQLAlchemy later, a lot of this transfers, since SQLModel is built on top of it.

SQL (Relational) Databases - FastAPI

Bash
pip install sqlmodel
pip freeze > requirements.txt

We start with SQLite, but it's easy to swap out for another database since that level is abstracted away. If you're on Postgres the only major thing that changes is the connection URL. Don't fret if you're using something else, still follow along with this material.

GET multiple

At the top of our file:
(Much of the DB connection code I am getting directly from the docs).
The only thing I changed from the time of this writing is the approach to initializing the database as the example in the docs was deprecated, I instead used lifespan events.

Python
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from datetime import datetime, timezone
from typing import Annotated
from sqlmodel import Field, Session, SQLModel, create_engine, select

from pydantic import BaseModel

class Campaign(SQLModel, table=True):
    campaign_id: int | None = Field(default=None, primary_key=True)
    name: str = Field(index=True)
    due_date: datetime | None = Field(default=None, index=True)
    created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), index=True)


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)]

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup: migrate + seed
    create_db_and_tables()
    with Session(engine) as session:
        if not session.exec(select(Campaign)).first():
            session.add_all([
                Campaign(name="Summer Launch", due_date=datetime.now(timezone.utc)),
                Campaign(name="Black Friday", due_date=datetime.now(timezone.utc)),
            ])
            session.commit()
    yield


app = FastAPI(root_path="/api/v1", lifespan=lifespan)


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


class CampaignsResponse(BaseModel):
    campaigns: list[Campaign]

@app.get("/campaigns", response_model=CampaignsResponse)
async def read_campaigns(session: SessionDep):
    data = session.exec(select(Campaign)).all()
    return {"campaigns": data}

This is a MAJOR change so let's walk through as much as we can.

First, the database setup. The engine maintains the connection to the db. This is shared by anyone making a request to the API and is used when working with the database. Setting check_same_thread to false allows FastAPI to use the same SQLite database across multiple threads.

Sessions then use this engine to do work with the db. The session keeps track of any changes needed in the data and is the thing that actually interacts with the database.

SessionDep is a FastAPI dependency with yield, which gives a new session for each request. That's what ensures one session per request, and it means we can pass the session around by just adding a session: SessionDep parameter to any route that needs it.

The function create_db_and_tables creates the table, but we only want to do this on app startup, and behind the scenes it won't try to create new stuff if it already exists.

Lifespan Events

The way we run that on startup is with this lifespan function. Anything before the yield runs on startup and anything after the yield runs on shutdown, so that second half is where you'd release resources. Then you pass lifespan=lifespan when you instantiate the app and FastAPI invokes it for you.

Lifespan Events - FastAPI

When you run this, you should see a database.db show up in your files. It gets created in whatever directory you run from. If it doesn't appear, check three things: that you passed lifespan to FastAPI(...), that the lifespan function actually calls create_db_and_tables, and that create_db_and_tables is doing what you think it is.

The nice part of this is that anytime you mess up, you can just delete database.db and it gets recreated.

Building the type

And what tables does it make exactly? It goes off of our model.

On a normal class this would be a class level variable with a default, however this is the structure used to define columns in the database table. table=True is how SQLModel knows this class maps to an actual table, and that matters because we're about to create other types based on Campaign that are NOT connected to a table.

The main thing I want to call out is default_factory=lambda: datetime.now(timezone.utc). This takes a function to use for generating a value each time an object is created, but the function is not invoked immediately with (), so for example we could use datetime.now (doesn't allow for args), but because I want to provide an argument, we need to use another function, in this case a lambda function. This gets invoked whenever an object is created, which then invokes datetime.now passing in timezone.utc.

I first wrote that as default= and the app blew up. Because it's a callable and not a value, it has to be default_factory.

We can pass echo=True to create_engine to see the SQL used for this:

CREATE TABLE campaign (
        campaign_id INTEGER NOT NULL,
        name VARCHAR NOT NULL,
        due_date DATE,
        created_at DATETIME NOT NULL,
        PRIMARY KEY (campaign_id)
)

2025-08-23 23:37:07,818 INFO sqlalchemy.engine.Engine [no key 0.00003s] ()
2025-08-23 23:37:07,819 INFO sqlalchemy.engine.Engine CREATE INDEX ix_campaign_name ON campaign (name)
2025-08-23 23:37:07,819 INFO sqlalchemy.engine.Engine [no key 0.00004s] ()
2025-08-23 23:37:07,819 INFO sqlalchemy.engine.Engine CREATE INDEX ix_campaign_created_at ON campaign (created_at)
2025-08-23 23:37:07,819 INFO sqlalchemy.engine.Engine [no key 0.00023s] ()
2025-08-23 23:37:07,819 INFO sqlalchemy.engine.Engine CREATE INDEX ix_campaign_due_date ON campaign (due_date)
2025-08-23 23:37:07,820 INFO sqlalchemy.engine.Engine [no key 0.00002s] ()
2025-08-23 23:37:07,820 INFO sqlalchemy.engine.Engine COMMIT
2025-08-23 23:37:07,820 INFO sqlalchemy.engine.Engine BEGIN (implicit)
2025-08-23 23:37:07,821 INFO sqlalchemy.engine.Engine SELECT campaign.campaign_id, campaign.name, campaign.due_date, campaign.created_at
FROM campaign
2025-08-23 23:37:07,821 INFO sqlalchemy.engine.Engine [generated in 0.00005s] ()
2025-08-23 23:37:07,822 INFO sqlalchemy.engine.Engine INSERT INTO campaign (name, due_date, created_at) VALUES (?, ?, ?) RETURNING campaign_id
2025-08-23 23:37:07,822 INFO sqlalchemy.engine.Engine [generated in 0.00007s (insertmanyvalues) 1/2 (ordered; batch not supported)] ('Summer Launch', '2025-09-01', '2025-08-24 03:37:07.821967')
2025-08-23 23:37:07,823 INFO sqlalchemy.engine.Engine INSERT INTO campaign (name, due_date, created_at) VALUES (?, ?, ?) RETURNING campaign_id
2025-08-23 23:37:07,823 INFO sqlalchemy.engine.Engine [insertmanyvalues 2/2 (ordered; batch not supported)] ('Black Friday', '2025-11-28', '2025-08-24 03:37:07.822038')
2025-08-23 23:37:07,823 INFO sqlalchemy.engine.Engine COMMIT
      INFO   Application startup complete.

Seeding the Database

Since you can wipe the database whenever you want, I like to seed it with a couple of rows so there's always something to read back. That's the session.add_all block inside the lifespan.

The check in front of it, if not session.exec(select(Campaign)).first():, is kinda like a create table if not exists, but for an insert. If the table already has data we do nothing, if it's empty we create two rows.

Note this needs the Campaign model to exist, which is why the model has to be defined above the lifespan function.

GET

Now the endpoint itself. session.exec(select(Campaign)).all() is what you use to retrieve multiple values, and we wrap the result in a dictionary the same way we did with the mock data.

If you hit the endpoint from the docs page you'll get the two seeded campaigns back. Their due dates will look identical since I generated both with datetime.now(timezone.utc), but you could set custom values in the seed.

Types

Before replacing the rest of the functions, take a look at the docs page. The response structure just says string, so a consumer of your API learns nothing from it.

That's what response_model fixes. We have an expected return type which we created as a custom object containing the response. This matches the expected structure which can be seen in the docs:

JSON
{
    "campaigns": [
        {
            "name": "Summer Launch",
            "due_date": "2025-09-01",
            "campaign_id": 1,
            "created_at": "2025-08-24T03:38:58.592405"
        },
        {
            "name": "Black Friday",
            "due_date": "2025-11-28",
            "campaign_id": 2,
            "created_at": "2025-08-24T03:38:58.592468"
        }
    ]
}

If you are using the REST Client:

HTTP/1.1 200 OK
date: Mon, 25 Aug 2025 07:50:53 GMT
server: uvicorn
content-length: 237
content-type: application/json
connection: close

{
  "data": [
    {
      "name": "Summer Launch",
      "due_date": "2025-09-01T00:00:00",
      "campaign_id": 1,
      "created_at": "2025-08-25T07:50:50.346637"
    },
    {
      "name": "Black Friday",
      "due_date": "2025-11-28T00:00:00",
      "campaign_id": 2,
      "created_at": "2025-08-25T07:50:50.346703"
    }
  ]
}

Without the nested type we would only return an array or an object.

Let's look at the docs. You'll see our custom type as the return.

Defining these explicit types helps with the API documentation so consumers of this know exactly what to expect. You may also be able to generate client code off of it.

You may see variations in the typing, another common approach is to return a specific type directly. Here is an example returning a list of campaigns using Python type hints. However I found this approach makes it easier to run in to type issues (forcing me to convert it to a list, or not liking me nest data in a dictionary).

Python
@app.get("/campaigns")
async def read_campaigns(session: SessionDep) -> list[Campaign]:
    data = list(session.exec(select(Campaign)).all())
    return data

See this doc for more info on this problem - Response Model - Return Type - FastAPI

For example I tried this:

Python
@app.get("/campaigns")
async def read_campaigns(session: SessionDep) -> dict[str, list[Campaign]]:
    data = list(session.exec(select(Campaign)).all())
    return {"campaigns": data}

But the expected response type was way less clear:

JSON
{
  "additionalProp1": [
    {
      "campaign_id": 0,
      "name": "string",
      "due_date": "2025-08-24T04:03:50.877Z",
      "created_at": "2025-08-24T04:03:50.877Z"
    }
  ],
  "additionalProp2": [
    {
      "campaign_id": 0,
      "name": "string",
      "due_date": "2025-08-24T04:03:50.877Z",
      "created_at": "2025-08-24T04:03:50.877Z"
    }
  ],
  "additionalProp3": [
    {
      "campaign_id": 0,
      "name": "string",
      "due_date": "2025-08-24T04:03:50.877Z",
      "created_at": "2025-08-24T04:03:50.877Z"
    }
  ]
}

Didn't explicitly state there would only be one attribute, or that the attribute would always have a certain name. Wasn't sure if there was a way to improve it but ultimately I went the response_model route.

GET BY ID

For a single row you use session.get instead of session.exec. Pass it the table and the ID and you get one record or None.

Generic Response Type

If every endpoint needs its own response class, you end up writing a lot of near-identical classes. So instead of a CampaignsResponse, we make ONE generic Response type, and provide the inner type at the point of use. The attribute will just be data, but this simplifies our type surface area.

If you've seen square brackets after a type in this lesson, like list[Campaign], that's a generic. Same idea here, except we're making our own, and the thing we substitute in is a TYPE, not a value.

Python
from typing import Generic, TypeVar

T = TypeVar("T")

class Response(BaseModel, Generic[T]):
    data: T

@app.get("/campaigns", response_model=Response[list[Campaign]])
async def read_campaigns(session: SessionDep):
    data = session.exec(select(Campaign)).all()
    return {"data": data}

@app.get("/campaigns/{id}", response_model=Response[Campaign])
async def read_campaign(id: int, session: SessionDep):
    data = session.get(Campaign, id)
    if not data:
        raise HTTPException(status_code=404)
    return {"data": data}

Note that both routes now return {"data": ...}. That's the trade-off, the key is always data, but the consumer knows it's consistently the same attribute, and the structure underneath differs (a list of campaigns in one, a single campaign in the other).

💡 Tip[!tip]
Response is also the name of the class we imported from FastAPI for the mocked DELETE. Once the mock endpoints are gone we don't need that import, so I just removed it. If you still need it, alias the import or rename your custom type.

💡 Tip[!tip]
What are the key differences between BaseModel and SQLModel without table=True? Would be good to do additional research here.

You will see two return type possibilities for read_campaign.

Test this out by visiting a path that does not have a proper ID:
http://127.0.0.1:8000/api/v1/campaigns/hello

Test it with an ID that doesn't exist as well and make sure you get a 404.

POST - Creating data

Python
@app.post("/campaigns", status_code=201, response_model=Response[Campaign])
async def create_campaign(campaign: CampaignCreate, session: SessionDep):
    db_campaign = Campaign.model_validate(campaign)
    session.add(db_campaign)
    session.commit()
    session.refresh(db_campaign)
    return {"data": db_campaign}

This will process the body automatically when we have the campaign parameter, so we don't have to manually grab anything from the body. It's all taken care of for you by the time the function is hit. This will show in the API docs.

The session.refresh matters because the database does additional stuff on insert. It assigns the ID, it fills the created_at, it might do transformations. Refreshing makes sure db_campaign has all of that before we hand it back to the user.

💡 Tip[!tip]
I intentionally did not embed the request type in an attribute like data. Usually the request body is flat, but anything is possible.

Custom create type

My first attempt at the POST typed the body as Campaign directly, and it 500'd:

sqlalchemy.exc.StatementError: (builtins.TypeError) SQLite Date type only accepts Python date objects as input.
[SQL: INSERT INTO campaign (name, due_date, created_at) VALUES (?, ?, ?)]
[parameters: [{'created_at': datetime.datetime(2025, 8, 25, 2, 7, 23, 351328, tzinfo=datetime.timezone.utc), 'name': 'new campaign', 'due_date': '2025-12-25'}]]

The problem is that the request body type has an ID and a created_at that should be handled server side, and the client shouldn't be sending them at all. Our POST request should look something like this:

JSON
{
  "name": "new campaign",
  "due_date": "2025-08-24T04:03:50.877Z"
}

Really we need to create specific types for every variation we may expect.

Let's start with a CampaignCreate. Naming convention starts with the noun otherwise it sounds like a function. That way every variation of this type starts with Campaign and they're easy to find together.

We will only use table=True for the type that exactly aligns with the database structure. CampaignCreate has two attributes, the table has four, so it does not get table=True.

Python
class Campaign(SQLModel, table=True):
    campaign_id: int | None = Field(default=None, primary_key=True)
    name: str = Field(index=True)
    due_date: datetime | None = Field(default=None, index=True)
    created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), index=True)

class CampaignCreate(SQLModel):
    name: str
    due_date: datetime | None = None

We invoke model_validate to check the body of the request against the database structure, we add it to the db and commit the change. We then refresh which will ensure the latest data is on db_campaign, which we then return to the user.

This is the only variation we NEED, but you'll definitely want to familiarize yourself with this idea for when you end up working with passwords and other sensitive data, as you can use this approach to manipulate what fields are sent back to the client so sensitive info doesn't get leaked. For now we have nothing sensitive.

PUT - Updating Data

We can use that same CampaignCreate model for updating, since name and due date are the only two things a client would change. You could make a CampaignUpdate instead but I think this is fine.

Also note for PUT the timestamps should be system owned, so for those specific attributes it should act more like a PATCH (not removing the attribute if not provided by the client).

Deleting data is EZ too.

Python
@app.put("/campaigns/{id}", response_model=Response[Campaign])
async def update_campaign(id: int, campaign: CampaignCreate, session: SessionDep):
    data = session.get(Campaign, id)
    if not data:
        raise HTTPException(status_code=404)
    data.name = campaign.name
    data.due_date = campaign.due_date
    session.add(data)
    session.commit()
    session.refresh(data)
    return {"data": data}

@app.delete("/campaigns/{id}", status_code=204)
async def delete_campaign(id: int, session: SessionDep):
    data = session.get(Campaign, id)
    if not data:
        raise HTTPException(status_code=404)
    session.delete(data)
    session.commit()

One gotcha on the PUT. I named the path parameter campaign_id in the signature and then used id in the body of the function, which gave me a 500. That works because id is a Python builtin, so it resolves to something, just not what I wanted. I've been shadowing id this whole lesson, which is not the end of the world if you're not using the builtin, but prefixing with campaign_id is the healthier habit.

Congrats, at this point you now have full CRUD capability on a pretty basic example.
It's a lot to learn and we covered a lot. There's more I want to cover, so please continue in this playlist.

At this point you'll want to commit the project.

Bash
git add .
git commit -m "basic DB CRUD"
git push origin main

That push will fail if you never made the remote repo, which is exactly what happened to me, so create it on GitHub first. Mine is at https://github.com/CalebCurry/fastapi-example.

Everything is still in one single file, and obviously as you grow you'll want to break things out. You might group everything campaign related together, or go more of an MVC route with the models, views, and controllers separated. That's a later lesson.

What's next:

  • relationships, this one will be huge
  • pagination and URL parameters
  • organizing our code structure
  • middleware
  • User authentication and more!

If you've enjoyed any of this material I encourage you to check out my courses. These cover topics comprehensively and are a great way to level up your engineering skills and career. You'll also gain access to the CodeBreakthrough community for networking and accountability.
To get started, check out Software Engineering Fundamentals.
You can use coupon code NOTES to get 15% off.