Build a Bookmark Manager REST API
Build a CRUD REST API with FastAPI, SQLModel, and SQLite.
Introduction
30 Second Summary
Everyone saves links. Articles to read later, tutorials to revisit, tools to try. But bookmarks pile up fast, and browser bookmark folders offer no way to search, filter, or organize them programmatically.
In this project, you will build a REST API that lets users save, organize, and retrieve web bookmarks. You'll create endpoints for adding, listing, searching, updating, and deleting bookmarks using Python and FastAPI.
What You'll Build
A Bookmark Manager REST API built with FastAPI and SQLModel that supports full CRUD operations, URL validation, tag-based filtering, search, and pagination.
By the end of this project, you'll have:
- A fully functional REST API with five endpoints for creating, reading, updating, and deleting bookmarks.
- A SQLite database with automatic table creation and proper data modeling using SQLModel.
- Auto-generated interactive API documentation (Swagger UI) that you can demo to recruiters.
- Secret Mission: Add a Collections feature to group bookmarks into named collections with their own endpoints.
Are there any prerequisites?
You'll need basic Python knowledge (variables, functions, classes). Familiarity with HTTP concepts is helpful but not required.
Set Up Your Development Environment
Every API project starts with a clean workspace. Before writing any API logic, you need an isolated environment where your project's dependencies live separately from anything else on your machine.
In this step, you'll create a project folder, set up a Python virtual environment, install FastAPI and SQLModel, and confirm everything works by launching a test server.
In this step, get ready to:
- Create a project directory and Python virtual environment.
- Install FastAPI and SQLModel.
- Run a hello-world FastAPI app and view the auto-generated docs.
Create a project directory and Python virtual environment
A virtual environment is a self-contained copy of Python for your project. It keeps your project's packages separate from your system Python, so nothing conflicts.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal 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 bookmark-api
cd bookmark-api
- Confirm the folder exists by running:
ls
You should see an empty output since the folder is brand new.
- Create a virtual environment and activate it by running these commands:
macOS
python3 -m venv venv
source venv/bin/activate
Windows
python -m venv venv
venv\Scripts\activate
What does this do?
The python -m venv venv command creates a folder called venv inside your project. This folder holds a dedicated copy of Python and a place to install packages.
The activate command tells your terminal to use this local Python instead of the system-wide one. You'll see (venv) appear at the start of your terminal prompt when it's active.
- Verify your Python version by running:
python --version
You should see Python 3.10 or higher in the output.
Seeing a version lower than 3.10?
FastAPI requires Python 3.10 or higher. If you see an older version, download the latest Python from python.org and install it. After installing, delete your venv folder and recreate it using the new Python.
On macOS, you may need to use python3.12 -m venv venv (replacing 3.12 with the version you installed) to target the correct version.
help me fix my Python version for this FastAPI project
Install FastAPI and SQLModel
With your virtual environment active, any packages you install stay inside this project. They won't affect anything else on your machine.
- Install FastAPI with its standard dependencies by running:
pip install "fastapi[standard]"
- Install SQLModel by running:
pip install sqlmodel
What are these packages?
FastAPI is the framework you'll use to define your API endpoints and handle HTTP requests. The [standard] extra includes Uvicorn (the development server) and other tools FastAPI needs to run.
SQLModel combines a database library (SQLAlchemy) with a validation library (Pydantic) into one. You'll define your data models once and use them for both database storage and request validation.
- Verify both packages installed correctly by running:
python -c "import fastapi; import sqlmodel; print('Ready!')"
You should see Ready! printed in your terminal.
Seeing a ModuleNotFoundError?
Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt. If not, re-run the activate command from the previous substep.
If you see a permissions error, do not use sudo pip install. Deactivate, delete your venv folder, and recreate it.
help me fix my FastAPI and SQLModel installation
Create the project files and run a hello-world app
Your project needs three Python files. Right now you'll create them and add a minimal test endpoint to confirm FastAPI is working.
- Create the three project files by running:
macOS
touch main.py models.py database.py
Windows
New-Item -ItemType File -Path main.py, models.py, database.py
- Confirm the files exist by running:
ls
You should see database.py, main.py, and models.py in the output (alongside the venv folder).
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the bookmark-api folder, and click Open.
- In the Explorer sidebar on the left, click main.py to open it.
- Add the hello-world FastAPI code to main.py by copying this into the file:
from fastapi import FastAPI
# Create the FastAPI application instance
app = FastAPI()
# Define a test endpoint at the root URL
@app.get("/")
def read_root():
return {"message": "Hello, World!"}
What does this code do?
The app = FastAPI() line creates your API application. Every endpoint you build later attaches to this object.
The @app.get("/") decorator tells FastAPI to call read_root() whenever someone sends a GET request to the root URL. The function returns a JSON response automatically.
- Save main.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here's what main.py should look like at this point:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"message": "Hello, World!"}
- Switch back to your terminal and start the development server by running:
fastapi dev main.py
What does fastapi dev do?
The fastapi dev command detects your FastAPI app in main.py, starts a local web server (Uvicorn), and enables auto-reload. Any time you save a file, the server restarts automatically with your latest changes.
- Open your browser and visit http://127.0.0.1:8000/docs.
You should see FastAPI's auto-generated Swagger UI page. It lists your GET / endpoint with a Try it out button you can click to test it live.
What is Swagger UI?
FastAPI automatically generates interactive API documentation from your code. Every endpoint, parameter, and response model you define shows up here.
This is one of FastAPI's biggest advantages for portfolio projects. Recruiters can see and test your API without installing anything.
Don't see the Swagger UI page?
Check your terminal for error messages. If you see ModuleNotFoundError, your virtual environment might not be active. Press Ctrl+C to stop the server, re-activate your venv, and run the command again.
Make sure you saved main.py before running the server. An empty file produces an error because FastAPI can't find an app to run.
help me get my FastAPI dev server running
Your environment is ready and your first endpoint is live. Next up, you'll define the data model for your bookmarks and connect a database.
Define Your Data Model and Database
Your environment is ready and your hello-world app confirms everything runs. Now you need to answer a fundamental question: what does a bookmark actually look like in your system?
Every API needs a data layer that defines the shape of its data and how it gets stored. In this step, you'll use SQLModel to define your data models and connect them to a SQLite database.
In this step, get ready to:
- Set up a SQLite database engine and session dependency.
- Define data models for creating, reading, and updating bookmarks.
- Wire the database into your FastAPI app so tables are created on startup.
Create the database engine and session dependency
Before your API can store bookmarks, it needs a connection to a database and a way to manage database sessions for each request. You'll configure all of this in database.py.
- Open database.py in VS Code and add the following code:
from typing import Annotated
from fastapi import Depends
from sqlmodel import Session, SQLModel, create_engine
# Create the SQLite database connection
sqlite_url = "sqlite:///bookmarks.db"
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)
# Function to create all database tables
def create_db_and_tables():
SQLModel.metadata.create_all(engine)
# Dependency that provides a database session per request
def get_session():
with Session(engine) as session:
yield session
# Type alias for cleaner endpoint signatures
SessionDep = Annotated[Session, Depends(get_session)]
What does this code do?
- sqlite_url points to a file called bookmarks.db that SQLite creates automatically in your project folder.
- check_same_thread: False is required because FastAPI handles multiple requests on different threads, but SQLite defaults to single-thread access.
- get_session() is a generator that opens a fresh database session for each request and closes it when the request finishes.
- SessionDep is a type alias that lets you inject a database session into any endpoint with a single parameter.
- Save database.py.
- Verify the file loads without errors by running this in your terminal:
python -c "from database import engine; print(engine.url)"
You should see sqlite:///bookmarks.db printed in the terminal.
Seeing a ModuleNotFoundError?
- Make sure your virtual environment is still activated. You should see (venv) at the start of your terminal prompt.
- Confirm you're running the command from inside your project folder (the same directory that contains database.py).
- Check for typos in the file. Python is case-sensitive, so Session and session are different.
Why is my database.py import failing?
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
from typing import Annotated
from fastapi import Depends
from sqlmodel import Session, SQLModel, create_engine
sqlite_url = "sqlite:///bookmarks.db"
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)]
Define the Bookmark models
SQLModel lets you define both the database table structure and the request/response shapes using the same class syntax. You'll create a base model with shared fields, then build specialized models on top of it for different operations.
- Open models.py and add the imports and the BookmarkBase class:
from datetime import datetime, timezone
from pydantic import field_validator
from sqlmodel import Field, SQLModel
# Shared fields that every bookmark operation uses
class BookmarkBase(SQLModel):
url: str = Field(index=True)
title: str = Field(index=True)
description: str | None = Field(default=None)
tag: str | None = Field(default=None, index=True)
is_favorite: bool = Field(default=False)
# Reject URLs that don't start with http:// or https://
@field_validator("url")
@classmethod
def validate_url(cls, v: str) -> str:
if not v.startswith(("http://", "https://")):
raise ValueError("URL must start with http:// or https://")
return v
What does this code do?
- BookmarkBase defines the fields shared across all bookmark operations: url, title, description, tag, and is_favorite.
- Field(index=True) tells the database to create an index on that column, making searches faster.
- The field_validator decorator from Pydantic runs automatically whenever someone sends a URL. It rejects anything that doesn't start with http:// or https://.
- Save models.py.
You won't see a change yet. The next chunk completes the models file with the remaining classes.
- Below the BookmarkBase class, add the remaining three model classes:
# The actual database table - adds id and created_at
class Bookmark(BookmarkBase, table=True):
id: int | None = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
# Input model for creating a bookmark (same fields as base)
class BookmarkCreate(BookmarkBase):
pass
# Input model for partial updates - all fields optional
class BookmarkUpdate(SQLModel):
url: str | None = None
title: str | None = None
description: str | None = None
tag: str | None = None
is_favorite: bool | None = None
# Also validate URLs on update
@field_validator("url")
@classmethod
def validate_url(cls, v: str | None) -> str | None:
if v is not None and not v.startswith(("http://", "https://")):
raise ValueError("URL must start with http:// or https://")
return v
Why three separate models?
Bookmark is the database table model. It inherits all fields from BookmarkBase and adds id (auto-generated) and created_at (set automatically to the current UTC time).
BookmarkCreate is the shape of data coming IN from API requests. It has the same fields as BookmarkBase but no id or created_at since those are server-generated.
BookmarkUpdate makes every field optional. This enables partial updates where the client sends only the fields they want to change.
- Save models.py.
- Verify the models load correctly by running:
python -c "from models import Bookmark, BookmarkCreate, BookmarkUpdate; print('Models loaded')"
You should see Models loaded printed in the terminal.
Seeing a syntax error or ImportError?
- Check that the @field_validator("url") decorator appears directly above @classmethod in both BookmarkBase and BookmarkUpdate. The order matters.
- Confirm you have from pydantic import field_validator at the top of the file. This import comes from Pydantic, not SQLModel.
- Make sure BookmarkUpdate inherits from SQLModel (not BookmarkBase) since its fields are all optional.
Help me fix my models.py import error.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
from datetime import datetime, timezone
from pydantic import field_validator
from sqlmodel import Field, SQLModel
class BookmarkBase(SQLModel):
url: str = Field(index=True)
title: str = Field(index=True)
description: str | None = Field(default=None)
tag: str | None = Field(default=None, index=True)
is_favorite: bool = Field(default=False)
@field_validator("url")
@classmethod
def validate_url(cls, v: str) -> str:
if not v.startswith(("http://", "https://")):
raise ValueError("URL must start with http:// or https://")
return v
class Bookmark(BookmarkBase, table=True):
id: int | None = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class BookmarkCreate(BookmarkBase):
pass
class BookmarkUpdate(SQLModel):
url: str | None = None
title: str | None = None
description: str | None = None
tag: str | None = None
is_favorite: bool | None = None
@field_validator("url")
@classmethod
def validate_url(cls, v: str | None) -> str | None:
if v is not None and not v.startswith(("http://", "https://")):
raise ValueError("URL must start with http:// or https://")
return v
Wire up the database in main.py
Your database engine and models are defined, but they aren't connected to your running FastAPI app yet. You need to tell FastAPI to create the database tables when the app starts up.
- Open main.py and replace all of the existing code with the following:
from contextlib import asynccontextmanager
from fastapi import FastAPI
from database import SessionDep, create_db_and_tables
from models import Bookmark, BookmarkCreate, BookmarkUpdate
# Create database tables when the app starts
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db_and_tables()
yield
app = FastAPI(title="Bookmark Manager API", lifespan=lifespan)
What does this code do?
- The lifespan function is an async context manager that runs code when your app starts up (before yield) and when it shuts down (after yield).
- create_db_and_tables() calls the function from database.py that creates all table models in the SQLite database file.
- The imports for SessionDep, Bookmark, BookmarkCreate, and BookmarkUpdate are added now so they're ready when you build the endpoints in the next step.
- Save main.py.
- Start the development server by running:
fastapi dev main.py
You should see output confirming the application started successfully.
- Visit http://127.0.0.1:8000/docs in your browser to confirm the Swagger UI loads.
- Confirm that a bookmarks.db file now exists in your project folder by running this in a new terminal:
ls bookmarks.db
You should see bookmarks.db listed. This means the lifespan function ran and created your SQLite database file on startup.
App won't start or bookmarks.db is missing?
- If you see an ImportError, check that database.py and models.py are saved in the same folder as main.py.
- If the server crashes immediately, look at the error message. A NameError usually means a typo in a class or function name.
- If bookmarks.db doesn't appear, make sure you passed lifespan=lifespan to the FastAPI() constructor. Without it, the startup function never runs.
Help me fix my FastAPI app startup error.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
from contextlib import asynccontextmanager
from fastapi import FastAPI
from database import SessionDep, create_db_and_tables
from models import Bookmark, BookmarkCreate, BookmarkUpdate
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db_and_tables()
yield
app = FastAPI(title="Bookmark Manager API", lifespan=lifespan)
Your data layer is complete. You have a database engine, well-structured models, and an app that creates tables on startup. Next up, you'll build the actual CRUD endpoints that let users create, read, update, and delete bookmarks through the API.
Build Your CRUD Endpoints
Your database and models are in place, but your API doesn't respond to any requests yet. Right now, visiting any URL on your server returns a "Not Found" error because there are no endpoints defined.
CRUD stands for Create, Read, Update, and Delete. These four operations are the backbone of every REST API. In this step, you'll build five endpoints that let users manage bookmarks through HTTP requests.
In this step, get ready to:
- Build endpoints to create a new bookmark and list all bookmarks with pagination.
- Add endpoints to retrieve a single bookmark and delete it with proper error handling.
- Implement a partial update endpoint that only changes the fields you send.
Implement create and list endpoints
Before adding endpoints, you need to import the tools they depend on. Your current main.py only imports FastAPI itself. The endpoints need additional imports for error handling, query validation, and database queries.
- In main.py, replace the entire import section at the top of your file with the following:
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import FastAPI, HTTPException, Query
from sqlmodel import select
from database import SessionDep, create_db_and_tables
from models import Bookmark, BookmarkCreate, BookmarkUpdate
What did we add?
- Annotated lets you attach metadata (like max values) to function parameters.
- HTTPException returns proper error responses (like 404 Not Found) to the client.
- Query adds validation rules to query parameters (like setting a maximum limit).
- select builds database queries to fetch records from your tables.
You won't see a change yet. The next chunk uses these imports to build your first endpoint.
The first endpoint lets clients create a new bookmark by sending a POST request with the bookmark data in the request body.
- In main.py, add the following function below the app = FastAPI(title="Bookmark Manager API", lifespan=lifespan) line:
@app.post("/bookmarks/", response_model=Bookmark)
def create_bookmark(bookmark: BookmarkCreate, session: SessionDep) -> Bookmark:
# Convert the input model to a database model
db_bookmark = Bookmark.model_validate(bookmark)
# Add to the session, save to database, and refresh to get the generated id
session.add(db_bookmark)
session.commit()
session.refresh(db_bookmark)
return db_bookmark
What does this code do?
- @app.post("/bookmarks/") registers this function to handle POST requests at the /bookmarks/ path.
- Bookmark.model_validate(bookmark) converts the incoming BookmarkCreate data into a full Bookmark database record (adding id and created_at).
- session.commit() saves the record to the SQLite database. session.refresh() reloads the object so it includes the auto-generated id.
- Save main.py.
- Switch back to your browser and refresh http://127.0.0.1:8000/docs. You should see a green POST /bookmarks/ section in the Swagger UI.
Don't see the new endpoint?
Make sure your dev server is still running in the terminal. If you stopped it, restart it by running fastapi dev main.py. The server auto-reloads on file save, but only if it's still running.
Check the terminal for error messages. A missing comma in the imports or an indentation error will prevent the reload.
help me debug why my FastAPI endpoint isn't showing up in Swagger UI
Now add the second endpoint. This one returns a list of all bookmarks, with pagination so clients can request results in smaller pages.
- In main.py, add the following function below the create_bookmark function:
@app.get("/bookmarks/", response_model=list[Bookmark])
def read_bookmarks(
session: SessionDep,
offset: int = 0,
limit: Annotated[int, Query(le=100)] = 20,
) -> list[Bookmark]:
# Build a query with pagination
statement = select(Bookmark).offset(offset).limit(limit)
bookmarks = session.exec(statement).all()
return bookmarks
What does this code do?
- offset skips a number of records (for page navigation). limit caps how many records are returned per request.
- Query(le=100) prevents clients from requesting more than 100 bookmarks at once. The default is 20.
- select(Bookmark).offset(offset).limit(limit) builds a SQL query that fetches just the right slice of data from the database.
What is pagination?
Imagine a database with 10,000 bookmarks. Without pagination, every request would return all 10,000 records at once. Pagination lets clients request page 1 (records 0-19), page 2 (records 20-39), and so on.
- Save main.py.
- Refresh http://127.0.0.1:8000/docs. You should now see both a green POST /bookmarks/ and a blue GET /bookmarks/ endpoint.
- Test the create endpoint by clicking POST /bookmarks/, then Try it out.
- Replace the example JSON in the request body with this:
{
"url": "https://fastapi.tiangolo.com",
"title": "FastAPI Docs",
"description": "Official FastAPI documentation",
"tag": "python",
"is_favorite": true
}
- Click Execute. You should see a 200 response with your bookmark data, including an auto-generated id and created_at timestamp.
- Now test the list endpoint by expanding GET /bookmarks/, clicking Try it out, then Execute. You should see an array containing the bookmark you just created.
Getting a 422 Validation Error?
A 422 response means the request body failed validation. Make sure your JSON includes both a url (starting with http:// or https://) and a title field. These are required by your BookmarkBase model.
help me fix a 422 validation error in my FastAPI bookmark endpoint
Implement get and delete endpoints
Listing all bookmarks is useful, but clients also need to fetch a single bookmark by its ID. This endpoint introduces error handling for when a bookmark doesn't exist.
- In main.py, add the following function below the read_bookmarks function:
@app.get("/bookmarks/{bookmark_id}", response_model=Bookmark)
def read_bookmark(bookmark_id: int, session: SessionDep) -> Bookmark:
# Look up the bookmark by its primary key
bookmark = session.get(Bookmark, bookmark_id)
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
return bookmark
What does this code do?
- {bookmark_id} in the URL path captures a value from the URL. A request to /bookmarks/3 sets bookmark_id to 3.
- session.get(Bookmark, bookmark_id) looks up a single record by its primary key. It returns None if no matching record exists.
- raise HTTPException(status_code=404) sends back a 404 Not Found response. This tells the client the bookmark they asked for doesn't exist.
- Save main.py.
- Refresh the Swagger UI. Expand GET /bookmarks/{bookmark_id}, click Try it out, enter 1 for the bookmark_id, and click Execute.
- You should see the bookmark you created earlier. Now try entering 999 as the ID. You should get a 404 response with "Bookmark not found".
Getting a 422 instead of a 404?
A 422 error on a path parameter means the value you entered isn't a valid integer. Make sure you're entering a plain number like 1 or 999 with no extra characters.
help me understand 422 vs 404 errors in my FastAPI endpoint
The delete endpoint follows the same pattern. Look up the bookmark, return 404 if it doesn't exist, and delete it if it does.
- Add the following function below the read_bookmark function:
@app.delete("/bookmarks/{bookmark_id}")
def delete_bookmark(bookmark_id: int, session: SessionDep):
# Look up the bookmark, return 404 if it doesn't exist
bookmark = session.get(Bookmark, bookmark_id)
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
# Remove from the database
session.delete(bookmark)
session.commit()
return {"detail": "Bookmark deleted"}
What does this code do?
- session.delete(bookmark) marks the record for removal. session.commit() permanently removes it from the database.
- The function returns a simple dictionary {"detail": "Bookmark deleted"} as confirmation. FastAPI automatically converts this to JSON.
- Save main.py.
- In the Swagger UI, first create a second bookmark using POST /bookmarks/ (use any URL and title you like).
- Now expand DELETE /bookmarks/{bookmark_id}, click Try it out, enter 2 for the bookmark_id, and click Execute.
- You should see {"detail": "Bookmark deleted"} in the response. Confirm it's gone by using GET /bookmarks/ to list your remaining bookmarks.
Delete returned 404?
If you already deleted that bookmark, it won't exist anymore. Use GET /bookmarks/ to check which bookmark IDs still exist, then try deleting one of those.
help me figure out why my delete endpoint returns 404
Implement the partial update endpoint
The update endpoint is the most nuanced. A partial update (PATCH) only modifies the fields the client sends, leaving everything else unchanged. If someone sends only a new title, the URL, tag, and other fields stay exactly as they are.
- In main.py, add the following function between the read_bookmark function and the delete_bookmark function:
@app.patch("/bookmarks/{bookmark_id}", response_model=Bookmark)
def update_bookmark(
bookmark_id: int, bookmark_update: BookmarkUpdate, session: SessionDep
) -> Bookmark:
# Look up the existing bookmark
bookmark = session.get(Bookmark, bookmark_id)
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
# Only update fields that were explicitly sent in the request
update_data = bookmark_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(bookmark, key, value)
session.add(bookmark)
session.commit()
session.refresh(bookmark)
return bookmark
What does this code do?
- model_dump(exclude_unset=True) converts the update request into a dictionary containing ONLY the fields the client actually sent. Fields left out of the request are excluded.
- The for key, value in update_data.items() loop applies each sent field to the existing bookmark. setattr(bookmark, key, value) dynamically sets each attribute.
- This approach means sending {"title": "New Title"} updates only the title. The URL, description, tag, and is_favorite stay exactly as they were.
Why PATCH instead of PUT?
PUT replaces the entire resource. If you forget a field, it gets set to null. PATCH only touches the fields you include. This is friendlier for clients who just want to toggle a favorite or fix a typo in the title.
- Save main.py.
- In the Swagger UI, expand PATCH /bookmarks/{bookmark_id} and click Try it out.
- Enter 1 for the bookmark_id.
- Replace the request body with this:
{
"title": "FastAPI Official Docs"
}
- Click Execute. The response should show bookmark 1 with the updated title, while all other fields (URL, description, tag, is_favorite) remain unchanged.
All fields came back as null?
Make sure you placed the update_bookmark function BEFORE delete_bookmark in your file. Also double check that your BookmarkUpdate model in models.py has all fields set to None by default. This is what allows exclude_unset=True to distinguish between "not sent" and "sent as null".
help me fix my PATCH endpoint that's overwriting all fields
✔️ Awesome, I've got everything!
Great. Double check you've saved main.py before moving on.
ⓧ I'd like to double check the full code
Here's the complete main.py at this point:
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import FastAPI, HTTPException, Query
from sqlmodel import select
from database import SessionDep, create_db_and_tables
from models import Bookmark, BookmarkCreate, BookmarkUpdate
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db_and_tables()
yield
app = FastAPI(title="Bookmark Manager API", lifespan=lifespan)
@app.post("/bookmarks/", response_model=Bookmark)
def create_bookmark(bookmark: BookmarkCreate, session: SessionDep) -> Bookmark:
db_bookmark = Bookmark.model_validate(bookmark)
session.add(db_bookmark)
session.commit()
session.refresh(db_bookmark)
return db_bookmark
@app.get("/bookmarks/", response_model=list[Bookmark])
def read_bookmarks(
session: SessionDep,
offset: int = 0,
limit: Annotated[int, Query(le=100)] = 20,
) -> list[Bookmark]:
statement = select(Bookmark).offset(offset).limit(limit)
bookmarks = session.exec(statement).all()
return bookmarks
@app.get("/bookmarks/{bookmark_id}", response_model=Bookmark)
def read_bookmark(bookmark_id: int, session: SessionDep) -> Bookmark:
bookmark = session.get(Bookmark, bookmark_id)
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
return bookmark
@app.patch("/bookmarks/{bookmark_id}", response_model=Bookmark)
def update_bookmark(
bookmark_id: int, bookmark_update: BookmarkUpdate, session: SessionDep
) -> Bookmark:
bookmark = session.get(Bookmark, bookmark_id)
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
update_data = bookmark_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(bookmark, key, value)
session.add(bookmark)
session.commit()
session.refresh(bookmark)
return bookmark
@app.delete("/bookmarks/{bookmark_id}")
def delete_bookmark(bookmark_id: int, session: SessionDep):
bookmark = session.get(Bookmark, bookmark_id)
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
session.delete(bookmark)
session.commit()
return {"detail": "Bookmark deleted"}
Your API now supports all five CRUD operations. You can create, list, retrieve, update, and delete bookmarks. Next up, you'll add input validation and filtering so users can search and organize their bookmarks more effectively.
Add Validation and Filtering
Your API can create, read, update, and delete bookmarks. But a real API also needs to protect data quality and help users find what they need quickly.
In this step, you'll confirm the URL validation you built earlier catches bad data. Then you'll add filtering and search to the list endpoint so users can narrow results by tag, title, or favorites.
In this step, get ready to:
- Test that invalid URLs are rejected with a 422 error.
- Add query parameter filtering (tag, search, favorite) to the list endpoint.
- Run a full end-to-end test of all API features through Swagger UI.
Test URL validation with invalid data
The field_validator you added to BookmarkBase in the previous step automatically rejects any URL that doesn't start with http:// or https://. Let's confirm it works by sending bad data through the Swagger UI.
- Make sure your dev server is still running. If it's not, start it again by running:
fastapi dev main.py
- Switch back to your browser and go to http://127.0.0.1:8000/docs.
- Expand the POST /bookmarks/ endpoint and click Try it out.
- In the request body, enter a bookmark with an invalid URL (no http:// or https:// prefix):
{
"url": "not-a-valid-url",
"title": "Bad Bookmark"
}
- Click Execute.
You should see a 422 Unprocessable Entity response with a message explaining the URL must start with http:// or https://.
What is a 422 error?
A 422 status code means the server understood your request format (valid JSON) but the data inside failed validation. FastAPI automatically returns 422 when a Pydantic validator rejects input.
This is different from a 400 (Bad Request, meaning malformed syntax) or a 404 (Not Found). Each status code communicates a specific problem to the API consumer.
- Now test with a valid URL to confirm good data still goes through. Replace the request body with:
{
"url": "https://docs.python.org",
"title": "Python Docs",
"tag": "python"
}
- Click Execute. You should see a 200 response with your bookmark saved, including an id and created_at timestamp.
Seeing a 500 error instead of 422?
Check that the @field_validator("url") decorator in models.py has the @classmethod decorator directly below it. Missing @classmethod causes a runtime error instead of a clean validation response.
Help me debug my field_validator in models.py
Add filtering query parameters to the list endpoint
Right now, GET /bookmarks/ returns every bookmark in the database. As the list grows, users need a way to narrow results by tag, search by title, or filter to favorites only.
What are query parameters?
Query parameters are the key-value pairs after the ? in a URL, like /bookmarks/?tag=python&favorite=true. They let API consumers filter or modify the response without changing the endpoint path.
In FastAPI, any function parameter that isn't part of the path becomes a query parameter automatically. Making them optional (with a default of None) means users can mix and match whichever filters they need.
- In main.py, find the read_bookmarks function. It currently looks like this:
@app.get("/bookmarks/", response_model=list[Bookmark])
def read_bookmarks(
session: SessionDep,
offset: int = 0,
limit: Annotated[int, Query(le=100)] = 20,
) -> list[Bookmark]:
statement = select(Bookmark).offset(offset).limit(limit)
bookmarks = session.exec(statement).all()
return bookmarks
- Replace the entire read_bookmarks function (including the decorator) with this updated version that adds filtering:
@app.get("/bookmarks/", response_model=list[Bookmark])
def read_bookmarks(
session: SessionDep,
offset: int = 0,
limit: Annotated[int, Query(le=100)] = 20,
tag: str | None = None,
search: str | None = None,
favorite: bool | None = None,
) -> list[Bookmark]:
statement = select(Bookmark)
# Filter by exact tag match
if tag:
statement = statement.where(Bookmark.tag == tag)
# Filter by title containing the search term
if search:
statement = statement.where(Bookmark.title.contains(search))
# Filter by favorite status
if favorite is not None:
statement = statement.where(Bookmark.is_favorite == favorite)
statement = statement.offset(offset).limit(limit)
bookmarks = session.exec(statement).all()
return bookmarks
What does this code do?
Three new optional parameters (tag, search, favorite) default to None. When a user provides one, the corresponding if block adds a .where() clause to the SQL query.
- tag uses == for an exact match (only bookmarks with that specific tag).
- search uses .contains() to find bookmarks whose title includes the search term anywhere.
- favorite uses is not None instead of just if favorite because False is a valid filter value (show non-favorites).
The filters stack. If a user sends ?tag=python&favorite=true, both conditions apply together.
- Save main.py.
- Refresh the Swagger UI at http://127.0.0.1:8000/docs. Expand GET /bookmarks/ and you should see the new tag, search, and favorite parameters listed alongside offset and limit.
Don't see the new parameters?
Make sure you saved main.py. The dev server auto-reloads on save, but Swagger UI needs a manual browser refresh to pick up the changes.
If the server crashed, check the terminal for a syntax error. Common causes are a missing comma between parameters or mismatched parentheses in the function signature.
Help me fix my read_bookmarks function
✔️ Awesome, I've got everything!
Great. Double check you've saved main.py and move on to testing.
ⓧ I'd like to double check the full code
Here is the full main.py file for comparison:
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import FastAPI, HTTPException, Query
from sqlmodel import select
from database import SessionDep, create_db_and_tables
from models import Bookmark, BookmarkCreate, BookmarkUpdate
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db_and_tables()
yield
app = FastAPI(title="Bookmark Manager API", lifespan=lifespan)
@app.post("/bookmarks/", response_model=Bookmark)
def create_bookmark(bookmark: BookmarkCreate, session: SessionDep) -> Bookmark:
db_bookmark = Bookmark.model_validate(bookmark)
session.add(db_bookmark)
session.commit()
session.refresh(db_bookmark)
return db_bookmark
@app.get("/bookmarks/", response_model=list[Bookmark])
def read_bookmarks(
session: SessionDep,
offset: int = 0,
limit: Annotated[int, Query(le=100)] = 20,
tag: str | None = None,
search: str | None = None,
favorite: bool | None = None,
) -> list[Bookmark]:
statement = select(Bookmark)
if tag:
statement = statement.where(Bookmark.tag == tag)
if search:
statement = statement.where(Bookmark.title.contains(search))
if favorite is not None:
statement = statement.where(Bookmark.is_favorite == favorite)
statement = statement.offset(offset).limit(limit)
bookmarks = session.exec(statement).all()
return bookmarks
@app.get("/bookmarks/{bookmark_id}", response_model=Bookmark)
def read_bookmark(bookmark_id: int, session: SessionDep) -> Bookmark:
bookmark = session.get(Bookmark, bookmark_id)
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
return bookmark
@app.patch("/bookmarks/{bookmark_id}", response_model=Bookmark)
def update_bookmark(
bookmark_id: int, bookmark_update: BookmarkUpdate, session: SessionDep
) -> Bookmark:
bookmark = session.get(Bookmark, bookmark_id)
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
update_data = bookmark_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(bookmark, key, value)
session.add(bookmark)
session.commit()
session.refresh(bookmark)
return bookmark
@app.delete("/bookmarks/{bookmark_id}")
def delete_bookmark(bookmark_id: int, session: SessionDep):
bookmark = session.get(Bookmark, bookmark_id)
if not bookmark:
raise HTTPException(status_code=404, detail="Bookmark not found")
session.delete(bookmark)
session.commit()
return {"detail": "Bookmark deleted"}
Test the complete API end-to-end
Now you'll run a full test of your API by creating bookmarks with different tags, filtering them, searching by title, updating favorites, and verifying pagination.
- In the Swagger UI, use POST /bookmarks/ to create a few bookmarks with different tags. Try these examples one at a time:
{
"url": "https://fastapi.tiangolo.com",
"title": "FastAPI Documentation",
"tag": "python",
"is_favorite": true
}
{
"url": "https://react.dev",
"title": "React Documentation",
"tag": "javascript"
}
{
"url": "https://sqlmodel.tiangolo.com",
"title": "SQLModel Documentation",
"tag": "python",
"is_favorite": true
}
- Test tag filtering. Expand GET /bookmarks/, click Try it out, and enter python in the tag field.
- Click Execute. You should see only the bookmarks tagged python (not the React one).
- Test search. Clear the tag field and enter Documentation in the search field.
- Click Execute. All bookmarks with "Documentation" in the title should appear.
- Test favorite filtering. Clear the search field and set favorite to true.
- Click Execute. Only bookmarks you marked as favorites should appear.
- Test stacked filters. Enter python in tag AND keep favorite set to true.
- Click Execute. Only bookmarks that are BOTH tagged python and marked as favorites should appear.
- Test pagination by setting limit to 1 (clear all other fields first).
- Click Execute. You should get back only one bookmark.
- Now set offset to 1 and limit to 1 to get the second page.
- Click Execute. You should see a different bookmark than the first page returned.
Why test all these combinations?
In production APIs, filters and pagination often interact in unexpected ways. Testing combinations (tag + favorite, search + limit) ensures your .where() clauses chain correctly and pagination applies after filtering.
This is the kind of thorough testing you'd mention in an interview when a recruiter asks how you verified your API works correctly.
Filters not narrowing results?
If all bookmarks still appear regardless of filter values, check that you removed the old select(Bookmark).offset(offset).limit(limit) one-liner. The new version builds the statement incrementally with separate .where() calls before applying .offset() and .limit() at the end.
Also confirm the tag filter value you enter matches exactly (it's case-sensitive). Python won't match python.
Help me debug my bookmark filters
Your API now validates incoming data and gives users powerful tools to find exactly the bookmarks they need. You have a fully functional REST API with CRUD operations, validation, filtering, search, and pagination. Next up, you'll get this project onto GitHub and ready to share.
Secret mission
Add Collections
Your bookmarks API works great for flat lists. But what if you want to group related bookmarks together? In this secret mission, you'll add a Collections feature that introduces database relationships, a second set of CRUD endpoints, and nested resource querying.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. All resources live in your project folder on your Desktop.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Project folder containing main.py, models.py, and database.py.
- Python virtual environment (venv folder).
- SQLite database file (bookmarks.db).
Keep everything running
No action needed. Choose this if you're still actively building or want to extend the API further.
- Your project folder stays on your Desktop with all files intact.
- The FastAPI dev server only runs when you start it manually, so nothing is consuming resources in the background.
- Your bookmarks.db file retains all the bookmarks and collections you created during testing.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files stay on disk so you can pick up where you left off.
- If the FastAPI dev server is still running, press Ctrl+C in the terminal to stop it.
- Deactivate your virtual environment by running:
deactivate
When you come back, reactivate the virtual environment and start the dev server again.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your code and database.
- If the FastAPI dev server is still running, press Ctrl+C in the terminal to stop it.
- Deactivate your virtual environment by running:
deactivate
- Navigate to your Desktop and delete the project folder. On macOS/Linux, run:
cd ~/Desktop
rm -rf bookmark-manager
On Windows, run:
cd ~\Desktop
rmdir /s /q bookmark-manager
This removes all Python files, the virtual environment, and the bookmarks.db database in one step.
Nice Work!
Nice Work!
You just built a production-quality REST API from scratch using FastAPI and Python. You now have a working backend project on GitHub that demonstrates the exact skills recruiters look for in backend engineering candidates.
You learned how to:
- Built a complete REST API with 5 CRUD endpoints using FastAPI and Python.
- Designed a data model with SQLModel and persisted data to a SQLite database.
- Implemented request validation, error handling, filtering, search, and pagination with query parameters.
- Extended the API with a Collections feature demonstrating database relationships and nested resource querying.
Ready to quiz yourself?