Bookmark Manager API with FastAPI

Build a REST API backed by PostgreSQL using SQLAlchemy ORM and Alembic.

Introduction

30 Second Summary

Think about all the links you save throughout the day. Articles, documentation, tools, tutorials. Most bookmark systems store data locally in your browser, but production apps need a real database behind an API.

In this project, you will build a Bookmark Manager API that stores web bookmarks in PostgreSQL. You'll wire up FastAPI to a real database using the same stack that Python backend teams use in production.

What You'll Build

A REST API with full CRUD endpoints for managing bookmarks (URLs, titles, descriptions, tags), connected to PostgreSQL through the SQLAlchemy ORM and managed through FastAPI's interactive Swagger UI.

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

  • A FastAPI application with full CRUD operations connected to a real PostgreSQL database where bookmarks persist between server restarts.
  • A proper project structure with separated models, schemas, routers, and configuration that mirrors real production codebases.
  • Working Alembic migrations so you can evolve your database schema without losing data.
  • Your project version-controlled with Git and pushed to a GitHub repository for backup and collaboration.
  • Secret Mission: Add a "collections" feature where bookmarks can be grouped into named collections using database relationships and a new migration.

Are there any prerequisites?

This project assumes basic Python knowledge and at least one completed FastAPI project (routing, Pydantic models). You'll also need PostgreSQL installed locally on your machine.

Before We Start

Every production Python backend starts with the same foundation: a clean project structure, isolated dependencies, and a running database. Without these, code has nowhere to live and data has nowhere to go.

In this step, you'll create your project folder structure, install the packages your API needs, and set up a PostgreSQL database for storing bookmarks.

In this step, get ready to:
  • Create the project directory structure and virtual environment.
  • Install FastAPI, SQLAlchemy, Alembic, and supporting packages.
  • Set up a PostgreSQL database for the project.
Create the project directory and virtual environment

Your project needs a folder structure that separates concerns: database models in one place, API schemas in another, route handlers in a third. This mirrors how real production codebases are organized.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir bookmark-manager
cd bookmark-manager
  • Create the subdirectory structure by running this command:

macOS

mkdir -p app/core app/models app/schemas app/routers

Windows

mkdir app/core, app/models, app/schemas, app/routers

What does this structure do?

  • app/core/ holds shared infrastructure like config and database setup.
  • app/models/ holds SQLAlchemy ORM models (your database tables).
  • app/schemas/ holds Pydantic schemas (your API request/response contracts).
  • app/routers/ holds FastAPI route handlers (your API endpoints).
  • Create the required __init__.py files so Python treats each folder as a package:

macOS

touch app/__init__.py app/core/__init__.py app/models/__init__.py app/schemas/__init__.py app/routers/__init__.py

Windows

New-Item app/__init__.py, app/core/__init__.py, app/models/__init__.py, app/schemas/__init__.py, app/routers/__init__.py -ItemType File
  • Confirm the folder structure looks correct by running:
ls app/

You should see core, models, routers, and schemas listed in the output.

  • Create a virtual environment by running this command:

macOS

python3 -m venv venv

Windows

python -m venv venv

What is a virtual environment?

A virtual environment is an isolated Python installation specific to your project. It keeps your project's packages separate from your system Python, preventing version conflicts across projects.

  • Activate the virtual environment:

macOS

source venv/bin/activate

You should see (venv) appear at the beginning of your terminal prompt.

Windows

.\venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt.

Don't see (venv) in your prompt?

On Windows, you may need to allow script execution first. Run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned in PowerShell, then try activating again.

On macOS, make sure you used source before the path. Without it, the command won't work.

help me activate my Python virtual environment

Install all dependencies

Your project needs five packages: FastAPI for the web framework, SQLAlchemy for the ORM, psycopg2-binary for PostgreSQL connectivity, Alembic for database migrations, and pydantic-settings for configuration management.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type your editor name (e.g. Cursor or VS Code) and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the bookmark-manager folder on your Desktop, then click Open.
  • Create a new file called requirements.txt in the root of your bookmark-manager folder.
  • Paste the following content into requirements.txt:
fastapi[standard]==0.136.3
sqlalchemy==2.0.49
psycopg2-binary==2.9.12
alembic==1.18.4
pydantic-settings==2.14.1

What does this file do?

A requirements.txt file pins your dependencies to exact versions. This ensures everyone working on the project (including future you) installs the same package versions, preventing "works on my machine" bugs.

  • Save requirements.txt.
  • Install all dependencies by running this command in your terminal:
pip install -r requirements.txt

You should see output ending with Successfully installed followed by a list of package names.

Installation failed?

Make sure (venv) is visible in your terminal prompt. If it's missing, your virtual environment isn't active. Go back and activate it before retrying.

If you see an error about psycopg2-binary, make sure PostgreSQL is installed on your system. The package needs PostgreSQL client libraries to compile.

help me fix my pip install error

✔️ Awesome, I've got everything!

Great. Double check you've saved requirements.txt and that your install completed successfully.

ⓧ I'd like to double check the full code

Here's the complete requirements.txt for comparison:

fastapi[standard]==0.136.3
sqlalchemy==2.0.49
psycopg2-binary==2.9.12
alembic==1.18.4
pydantic-settings==2.14.1
Verify PostgreSQL is running and create the project database

Your API needs a database to store bookmarks. You'll create a dedicated database called bookmarks_db so your project data stays separate from anything else on your PostgreSQL server.

  • Connect to PostgreSQL by running this command:
psql -U postgres

What is psql?

psql is the command-line client for PostgreSQL. The -U postgres flag tells it to connect as the postgres superuser, which is the default admin account.

If prompted for a password, enter postgres (the default password for local installations). You should see a postgres=# prompt, confirming PostgreSQL is running and you're connected.

Connection refused or role does not exist?

If you see "connection refused", PostgreSQL is not running. Start it with brew services start postgresql (macOS with Homebrew) or check that the PostgreSQL service is running in your Services panel (Windows).

If you see "role postgres does not exist", your PostgreSQL was installed with a different default user. Try running psql without the -U flag to connect as your system username instead.

help me connect to my local PostgreSQL server

  • Create the project database by running this SQL command at the postgres=# prompt:
CREATE DATABASE bookmarks_db;
  • Verify the database was created by listing all databases:
\l

You should see bookmarks_db in the list of databases.

  • Exit psql by running:
\q

Database already exists?

If you see "database bookmarks_db already exists", that's fine. It means you (or a previous attempt) already created it. Run \l to confirm it's there and continue.

help me create a PostgreSQL database

Your foundation is ready. You have a clean project structure, all packages installed, and a waiting database. Next up, you'll wire SQLAlchemy to PostgreSQL and write the database configuration that powers everything.

Configure the Database Connection

Your project skeleton is in place and all the packages are installed. Now it's time to connect your application to the PostgreSQL database you created in Step 1.

In this step, you'll wire up the database connection layer. This involves loading the database URL from an environment file, creating the SQLAlchemy engine and session factory, and writing a FastAPI app that verifies the connection on startup.

In this step, get ready to:
  • Create environment-based configuration with pydantic-settings.
  • Build the database engine, session factory, and dependency function.
  • Launch the FastAPI server and verify the database connection.
Create the environment configuration

Hardcoding database URLs directly in source code is a security risk and makes it impossible to switch between environments (development, staging, production). The production pattern is to store connection strings in a .env file that never gets committed to version control.

  • In your code editor, create a new file called .env in the root of your bookmark-manager folder (the same level as requirements.txt).
  • Add the database connection URL by pasting this into .env:
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/bookmarks_db

What does this connection string mean?

  • postgresql:// tells SQLAlchemy which database driver to use.
  • postgres:postgres is the username and password for your local PostgreSQL server.
  • localhost:5432 is the default PostgreSQL host and port.
  • bookmarks_db is the database you created in Step 1.

💡 Different PostgreSQL password?

If you set a custom password when installing PostgreSQL, replace the second postgres in the URL with your actual password.

Python needs __init__.py files to recognize directories as importable packages. Without them, your imports between modules will fail.

  • Create an empty file at app/__init__.py.
  • Create another empty file at app/core/__init__.py.

Why are these files empty?

These files just mark the folders as Python packages. They don't need any code inside them. Import statements like from app.core.config import settings work because of these files.

Now create the configuration module that reads your .env file automatically.

  • Create a new file at app/core/config.py and paste in this code:
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    database_url: str

    class Config:
        env_file = ".env"


settings = Settings()

What does this code do?

  • Settings extends BaseSettings from pydantic-settings. It automatically reads environment variables that match its field names.
  • database_url: str declares that a DATABASE_URL value must exist in the environment (case-insensitive matching).
  • env_file = ".env" tells pydantic-settings to load variables from your .env file automatically.
  • settings = Settings() creates a single instance you can import anywhere in the project.
  • Save app/core/config.py.

Seeing an import error for pydantic_settings?

Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt. If not, activate it again before continuing.

Help me troubleshoot this pydantic_settings import error.

Create the database module

The database module is the central connection layer between your FastAPI app and PostgreSQL. It creates the connection engine, defines how sessions are managed, and provides a reusable dependency function that gives each API request its own database session.

  • Create a new file at app/core/database.py and paste in this code:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase

from app.core.config import settings

engine = create_engine(settings.database_url)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)


class Base(DeclarativeBase):
    pass


def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

What does this code do?

  • create_engine() establishes the connection pool to PostgreSQL. SQLAlchemy reuses connections from this pool instead of opening a new one per query.
  • SessionLocal is a factory. Each time you call it, you get a fresh database session to run queries through.
  • Base is the parent class for all your ORM models. Every table you define will inherit from this class.
  • get_db() is a dependency injection function. FastAPI calls it for each request, gives the endpoint a session, then guarantees the session closes when the request finishes.

💡 Why use dependency injection for database sessions?

Without dependency injection, you would manually open and close database sessions in every endpoint. If you forget to close one, it leaks connections until your database stops accepting new ones.

The get_db() pattern guarantees cleanup happens automatically. FastAPI's dependency system calls the function, yields the session to your endpoint code, then runs the finally block no matter what.

  • Save app/core/database.py.

Seeing a ModuleNotFoundError?

If Python cannot find app.core.config, check that both app/__init__.py and app/core/__init__.py exist. They can be empty, but they must be present.

Help me debug this ModuleNotFoundError.

Create the application entry point and verify the connection

The last piece is the FastAPI app itself. You'll use a lifespan event to test the database connection the moment the server starts. This way, you know immediately if something is misconfigured instead of discovering it on the first API request.

  • Create a new file at app/main.py and paste in this code:
from contextlib import asynccontextmanager

from fastapi import FastAPI
from sqlalchemy import text

from app.core.database import engine


@asynccontextmanager
async def lifespan(app: FastAPI):
    with engine.connect() as connection:
        connection.execute(text("SELECT 1"))
    print("Database connection verified.")
    yield


app = FastAPI(title="Bookmark Manager API", lifespan=lifespan)


@app.get("/")
def root():
    return {"message": "Bookmark Manager API is running"}

What does this code do?

  • lifespan() is an async context manager that runs once when the server starts. Everything before yield executes on startup. Everything after yield would execute on shutdown.
  • text("SELECT 1") is the simplest possible database query. If it succeeds, your connection is working.
  • The root() endpoint at / gives you a quick health-check URL to confirm the server is responding.
  • Save app/main.py.
  • Start the development server by running this command in your terminal:
fastapi dev
  • Look at the terminal output. You should see Database connection verified. printed, followed by the Uvicorn startup message.

What is fastapi dev?

The fastapi dev command automatically finds your app/main.py file and starts a local server with auto-reload enabled. Any time you save a file, the server restarts automatically.

🙋‍♀️ Server won't start?

  • If you see a connection refused error, make sure PostgreSQL is running. On macOS, check with brew services list. On Windows, check the Services app for the PostgreSQL service.
  • If you see an authentication error, double-check the username and password in your .env file match your PostgreSQL credentials.
  • If you see a message about the database not existing, go back to Step 1 and create the database with psql.

Help me fix my database connection error.

  • Stop the server by pressing Ctrl+C in your terminal.

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files before moving on.

ⓧ I'd like to double check the full code

DATABASE_URL=postgresql://postgres:postgres@localhost:5432/bookmarks_db
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    database_url: str

    class Config:
        env_file = ".env"


settings = Settings()
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase

from app.core.config import settings

engine = create_engine(settings.database_url)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)


class Base(DeclarativeBase):
    pass


def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()
from contextlib import asynccontextmanager

from fastapi import FastAPI
from sqlalchemy import text

from app.core.database import engine


@asynccontextmanager
async def lifespan(app: FastAPI):
    with engine.connect() as connection:
        connection.execute(text("SELECT 1"))
    print("Database connection verified.")
    yield


app = FastAPI(title="Bookmark Manager API", lifespan=lifespan)


@app.get("/")
def root():
    return {"message": "Bookmark Manager API is running"}

Your database connection layer is locked in. Every future endpoint will use the get_db dependency to get a safe, auto-closing session. Next up, you'll define the Bookmark model that tells SQLAlchemy what your database table looks like.

Define the Bookmark Model and Schemas

Your database connection works, but PostgreSQL doesn't know what shape your data should take yet. You need a model that tells the database exactly what columns the bookmarks table should have.

In this step, you'll define a SQLAlchemy ORM model (the database blueprint) and Pydantic schemas (the API contract that controls what data goes in and out).

In this step, get ready to:
  • Create the Bookmark SQLAlchemy model that maps to a PostgreSQL table.
  • Define Pydantic schemas that control what data your API accepts and returns.
  • Test that the model creates a real table in your database.
Create the Bookmark ORM model

An ORM model is a Python class that maps directly to a database table. Each attribute on the class becomes a column in the table.

  • Create the package __init__.py files that Python needs to treat your folders as importable packages, by running:
touch app/models/__init__.py app/schemas/__init__.py app/routers/__init__.py
  • Confirm the files exist by running:
ls app/models/__init__.py app/schemas/__init__.py app/routers/__init__.py

You should see all three file paths listed in the output.

  • Create the Bookmark model by adding the following code to app/models/bookmark.py:
from datetime import datetime

from sqlalchemy import String, Text, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column

from app.core.database import Base


class Bookmark(Base):
    __tablename__ = "bookmarks"

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    url: Mapped[str] = mapped_column(String(2048))
    title: Mapped[str] = mapped_column(String(255))
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    tags: Mapped[str | None] = mapped_column(String(500), nullable=True)
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now()
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
    )

What does this code do?

This class maps to a PostgreSQL table called bookmarks. Each Mapped[] attribute becomes a column in that table.

  • String(2048) and String(255) set maximum lengths for URL and title columns.
  • Text allows unlimited-length descriptions.
  • server_default=func.now() tells PostgreSQL to set the timestamp automatically when a row is inserted.
  • onupdate=func.now() automatically refreshes updated_at whenever the row changes.
  • Now update app/models/__init__.py to import the Bookmark model:
from app.models.bookmark import Bookmark

Why import in __init__.py?

This lets other parts of your app write from app.models import Bookmark instead of the longer from app.models.bookmark import Bookmark. It also ensures SQLAlchemy discovers the model when reading the package.

  • Save both app/models/bookmark.py and app/models/__init__.py.

Seeing a red underline on the import?

If your editor shows an import error on from app.core.database import Base, make sure your terminal is in the project root directory (the folder containing the app/ folder) and that your virtual environment is active.

help me fix import errors in my SQLAlchemy model file

Create Pydantic schemas for the API

ORM models represent database rows. Pydantic schemas represent what your API accepts and returns. Keeping them separate means your database structure and API contract can evolve independently.

Why separate schemas from models?

Your Bookmark model has fields like id, created_at, and updated_at that the database assigns automatically. A user creating a bookmark should never send those fields.

Schemas let you define different shapes for different operations: one shape for creating, one for updating, and one for responses. The model stays the same underneath.

  • Add the base and creation schemas to app/schemas/bookmark.py:
from datetime import datetime

from pydantic import BaseModel, HttpUrl


class BookmarkBase(BaseModel):
    # Fields shared across create and response schemas
    url: str
    title: str
    description: str | None = None
    tags: str | None = None


class BookmarkCreate(BookmarkBase):
    # Inherits all fields from BookmarkBase, nothing extra needed
    pass

What does this code do?

  • BookmarkBase defines the common fields that multiple schemas share.
  • BookmarkCreate inherits from BookmarkBase with no additions. This is the shape your API expects when a user creates a new bookmark.
  • description and tags default to None, making them optional on creation.
  • Add the update schema below BookmarkCreate in the same file:
class BookmarkUpdate(BaseModel):
    # All fields optional so the user can update any subset
    url: str | None = None
    title: str | None = None
    description: str | None = None
    tags: str | None = None

Why does BookmarkUpdate inherit from BaseModel directly?

Unlike BookmarkCreate, the update schema makes every field optional. A user might want to update just the title without resending the entire bookmark. Inheriting from BookmarkBase would make url and title required, which is wrong for partial updates.

  • Add the response schema below BookmarkUpdate in the same file:
class BookmarkResponse(BookmarkBase):
    # Server-assigned fields returned to the client
    id: int
    created_at: datetime
    updated_at: datetime

    class Config:
        from_attributes = True

What does from_attributes do?

By default, Pydantic expects dictionaries. Setting from_attributes = True tells Pydantic to read data from SQLAlchemy model attributes (like bookmark.title) instead of requiring bookmark["title"]. This is what connects your ORM objects to your API responses.

  • Save app/schemas/bookmark.py.

Seeing a type error on str | None?

The str | None syntax requires Python 3.10 or higher. If you see a syntax error, check your Python version with python --version. You need 3.10+ for this project.

help me fix a type syntax error in my Pydantic schema

✔️ Awesome, I've got everything!

Great. Double check you've saved app/schemas/bookmark.py before moving on.

ⓧ I'd like to double check the full code

from datetime import datetime

from pydantic import BaseModel, HttpUrl


class BookmarkBase(BaseModel):
    url: str
    title: str
    description: str | None = None
    tags: str | None = None


class BookmarkCreate(BookmarkBase):
    pass


class BookmarkUpdate(BaseModel):
    url: str | None = None
    title: str | None = None
    description: str | None = None
    tags: str | None = None


class BookmarkResponse(BookmarkBase):
    id: int
    created_at: datetime
    updated_at: datetime

    class Config:
        from_attributes = True
Verify the model creates a table

Before setting up Alembic migrations in the next step, let's confirm that your model actually works by creating the table directly. This is a temporary test to prove the wiring is correct.

  • Create the bookmarks table in PostgreSQL by running this command:
python -c "from app.core.database import engine, Base; from app.models.bookmark import Bookmark; Base.metadata.create_all(engine); print('Table created successfully!')"

What does this command do?

Base.metadata.create_all(engine) reads every model that inherits from Base and generates the corresponding SQL CREATE TABLE statements. It then executes them against your PostgreSQL database.

This is a quick-and-dirty way to create tables. In the next step, you'll replace this with Alembic migrations, which track schema changes over time.

You should see Table created successfully! printed in your terminal.

  • Verify the table exists in PostgreSQL by connecting with psql:
psql -d bookmarks_db -c "\dt"

You should see a table called bookmarks listed in the output.

Don't see the bookmarks table?

  • Make sure your virtual environment is active. The python -c command needs access to your installed packages.
  • Check that your .env file has the correct database name (bookmarks_db). A typo here means the table was created in the wrong database.
  • If you see ModuleNotFoundError, confirm you're running the command from your project root (the folder that contains the app/ directory).

help me troubleshoot why my bookmarks table wasn't created

Your model and schemas are defined, and the database has a real table to prove it. Next up, you'll set up Alembic so future schema changes are tracked properly instead of relying on create_all.

Set Up Alembic Migrations

Your Bookmark model is defined and the table exists in PostgreSQL. But you created it with Base.metadata.create_all(), which can only create tables from scratch. It cannot rename a column, add a field, or change a type on an existing table.

Alembic is the migration tool built for SQLAlchemy. It tracks every schema change as a versioned Python file. You can upgrade your database forward or roll it back without losing data.

In this step, get ready to:
  • Initialize Alembic and configure it to connect to your database.
  • Generate your first migration file from the Bookmark model.
  • Apply the migration and confirm Alembic manages your table.
Initialize Alembic and configure it

Alembic needs its own directory to store migration scripts, a configuration file to know where the database lives, and a modified env.py so it can read your SQLAlchemy models.

  • Initialize Alembic in your project by running this command:
alembic init alembic

What does this command do?

This creates an alembic/ directory containing env.py (the migration runner), script.py.mako (the template for new migrations), and a versions/ folder where migration files live.

It also creates alembic.ini in your project root with database connection settings.

  • Confirm the new files exist by running:
ls alembic

You should see env.py, script.py.mako, README, and a versions directory in the output.

Now you need to tell Alembic where your database lives. The connection URL goes in alembic.ini.

  • Open alembic.ini in your editor (it is in the project root, same level as requirements.txt).
  • Find this line near the top of the file:
sqlalchemy.url = driver://user:pass@localhost/dbname
  • Replace it with your actual database URL:
sqlalchemy.url = postgresql://postgres:postgres@localhost:5432/bookmarks_db

Why hardcode the URL here?

In a production app, you would have env.py read the URL from an environment variable. For this project, putting it directly in alembic.ini keeps the setup simple and matches what Alembic generates by default.

Next, you need to configure env.py so Alembic can see your SQLAlchemy models when generating migrations.

  • Open alembic/env.py in your editor.
  • Find this block near the top of the file:
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None
  • Replace the entire block (including the comments) with the following:
# Import Base and all models so Alembic can detect table definitions
from app.core.database import Base
from app.models import Bookmark  # noqa: F401

target_metadata = Base.metadata

What does this code do?

Importing Base gives Alembic access to the metadata registry where all your table definitions live.

Importing Bookmark ensures the model is loaded into that registry before Alembic inspects it. Without this import, Alembic would see zero tables and generate empty migrations.

  • Save alembic.ini and alembic/env.py.

Seeing a ModuleNotFoundError?

If you see an error about app not being found when you run Alembic commands later, make sure your virtual environment is active and that you are running commands from the project root (the folder that contains alembic.ini).

help me fix a ModuleNotFoundError when running Alembic commands

Generate the first migration

Alembic's autogenerate feature compares your model definitions to the current state of the database. It produces a migration file describing the difference.

The bookmarks table already exists from the temporary create_all you ran earlier. You need to drop it first so Alembic can detect that the table needs to be created.

  • Drop the existing table by running these commands in your terminal:
psql -U postgres -d bookmarks_db -c "DROP TABLE IF EXISTS bookmarks;"
  • Generate the first migration file by running:
alembic revision --autogenerate -m "create bookmarks table"

You should see output showing a new file was generated in alembic/versions/ with a revision ID and the slug create_bookmarks_table.

What does this command do?

The --autogenerate flag tells Alembic to compare target_metadata (your models) against the live database. Since the table no longer exists, it generates an upgrade() function that creates the bookmarks table with all its columns.

The -m flag adds a human-readable message to the migration, like a Git commit message.

  • Open the generated migration file inside alembic/versions/ to inspect it.

You should see an upgrade() function that calls op.create_table('bookmarks', ...) with all your columns defined. There is also a downgrade() function that drops the table, allowing you to roll back.

Always review autogenerated migrations

Autogenerate is not perfect. It sometimes misses index changes or produces unnecessary operations. Always read the generated file before applying it. Think of it like reviewing a pull request.

🙋‍♀️ Migration file is empty?

If the upgrade() function has only pass with no table creation, Alembic did not detect a difference. Check that you dropped the bookmarks table before running the command. Also confirm that alembic/env.py imports Bookmark from your models.

help me fix an empty Alembic autogenerated migration

Apply the migration and verify

The migration file is just a script sitting on disk. You need to apply it to actually create the table in the database.

  • Apply all pending migrations by running:
alembic upgrade head

You should see output confirming the upgrade to your revision.

What does this command do?

The head keyword means "apply all migrations up to the latest one." Alembic creates an alembic_version table in your database to track which migration was last applied. Future upgrades only run migrations newer than that recorded version.

  • Verify the table was created by connecting to the database:
psql -U postgres -d bookmarks_db -c "\dt"

You should see two tables listed: alembic_version and bookmarks. The alembic_version table is proof that Alembic now manages your schema.

Why Alembic over create_all?

The create_all approach works for prototyping, but it can only create tables from scratch. If you rename a column, add a field, or change a type, create_all silently does nothing because the table already exists.

Alembic tracks incremental changes. Each migration is a step you can apply, undo, or share with teammates. It is the production standard for Python backends.

🙋‍♀️ Seeing a 'relation already exists' error?

This means the bookmarks table was not dropped before you ran alembic upgrade head. Drop it manually, then re-run the upgrade:

psql -U postgres -d bookmarks_db -c "DROP TABLE IF EXISTS bookmarks;"
alembic upgrade head

help me fix an 'already exists' error when running alembic upgrade

Your database schema is now version-controlled. Every future change will be a migration file you can review, apply, and roll back. Next up, you will build the actual CRUD endpoints that read and write bookmarks through this database.

Build CRUD Endpoints

Your database is connected and your table is ready. Now you need a way for users to actually interact with bookmarks.

In this step, you'll build the full set of CRUD endpoints (Create, Read, Update, Delete) inside a FastAPI router. Each endpoint receives a database session through dependency injection, uses your SQLAlchemy model to talk to PostgreSQL, and returns validated responses through your Pydantic schemas.

In this step, get ready to:
  • Create a bookmarks router with POST and GET endpoints.
  • Add GET by ID, PUT, and DELETE endpoints with proper status codes.
  • Test all endpoints through the Swagger UI and confirm data persists.
Create the bookmarks router with POST and GET endpoints

FastAPI uses routers to group related endpoints together. You'll create a dedicated router for bookmark operations that handles URL prefixing and Swagger UI grouping automatically.

  • Create a new file called app/routers/bookmarks.py in your code editor.
  • Add the imports, router setup, and create endpoint by pasting this code:
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import select, func

from app.core.database import get_db
from app.models.bookmark import Bookmark
from app.schemas.bookmark import BookmarkCreate, BookmarkUpdate, BookmarkResponse

# Create a router that groups all bookmark endpoints under /bookmarks
router = APIRouter(prefix="/bookmarks", tags=["bookmarks"])


@router.post("/", response_model=BookmarkResponse, status_code=201)
def create_bookmark(bookmark: BookmarkCreate, db: Session = Depends(get_db)):
    # Convert the Pydantic schema to an ORM model instance
    db_bookmark = Bookmark(**bookmark.model_dump())
    db.add(db_bookmark)
    db.commit()
    db.refresh(db_bookmark)
    return db_bookmark

What does this code do?

The APIRouter groups endpoints under a shared prefix. Every route you add to this router automatically gets /bookmarks prepended to its path.

The create_bookmark function uses Depends(get_db) to get a fresh database session for each request. It converts the incoming Pydantic schema to a SQLAlchemy model with model_dump(), saves it, and returns the refreshed object (which now has its auto-generated id and timestamps).

You won't test this yet. The next chunk adds the list endpoint, then you'll start the server.

  • Add the list endpoint below the create_bookmark function in the same file:
@router.get("/", response_model=list[BookmarkResponse])
def list_bookmarks(
    tag: str | None = None,
    search: str | None = None,
    skip: int = Query(default=0, ge=0),
    limit: int = Query(default=20, ge=1, le=100),
    db: Session = Depends(get_db),
):
    # Start with a base query for all bookmarks
    query = select(Bookmark)

    # Filter by tag if provided (case-insensitive partial match)
    if tag:
        query = query.where(Bookmark.tags.ilike(f"%{tag}%"))

    # Search by title if provided (case-insensitive partial match)
    if search:
        query = query.where(Bookmark.title.ilike(f"%{search}%"))

    # Sort newest first and apply pagination
    query = query.order_by(Bookmark.created_at.desc())
    query = query.offset(skip).limit(limit)

    results = db.execute(query).scalars().all()
    return results

What does this code do?

This endpoint returns a list of bookmarks with optional filtering and pagination. All query parameters have defaults, so calling GET /bookmarks/ with no parameters returns the first 20 bookmarks sorted by newest first.

The ilike method performs a case-insensitive search using SQL's ILIKE operator. The Query() wrapper adds validation constraints. For example, ge=0 ensures skip can never be negative.

  • Save app/routers/bookmarks.py.
  • Start the development server by running this command in your terminal:
fastapi dev app/main.py
  • Open http://127.0.0.1:8000/docs in your browser. You should see a bookmarks section with POST /bookmarks/ and GET /bookmarks/ listed.

Why is the router already registered?

If you look at app/main.py, you'll see app.include_router(bookmarks.router) is already there from the previous step. That line tells FastAPI to include all endpoints from your bookmarks router in the application.

🙋‍♀️ Server won't start?

Check the terminal for an ImportError. If you see one, make sure your file is saved at exactly app/routers/bookmarks.py (not app/bookmarks.py or routers/bookmarks.py).

Also confirm that app/routers/__init__.py exists as an empty file. Python needs it to treat the folder as a package.

Help me debug why my FastAPI server won't start after adding the bookmarks router.

Add GET by ID, PUT, and DELETE endpoints

A complete API needs endpoints to fetch a single resource, update it, and delete it. Each of these operates on a specific bookmark identified by its ID in the URL path.

  • Add the GET by ID endpoint below list_bookmarks in app/routers/bookmarks.py:
@router.get("/{bookmark_id}", response_model=BookmarkResponse)
def get_bookmark(bookmark_id: int, db: Session = Depends(get_db)):
    # Use Session.get() to fetch by primary key
    bookmark = db.get(Bookmark, bookmark_id)
    if not bookmark:
        raise HTTPException(status_code=404, detail="Bookmark not found")
    return bookmark

What does this code do?

The db.get(Bookmark, bookmark_id) call fetches a bookmark by its primary key. If nothing is found, it returns None, and you raise a 404 error. FastAPI automatically converts the HTTPException into a proper JSON error response.

  • Save the file and confirm the terminal shows a successful reload with no errors.
  • Add the PUT (update) endpoint below get_bookmark:
@router.put("/{bookmark_id}", response_model=BookmarkResponse)
def update_bookmark(
    bookmark_id: int, bookmark_update: BookmarkUpdate, db: Session = Depends(get_db)
):
    # Fetch the existing bookmark or return 404
    bookmark = db.get(Bookmark, bookmark_id)
    if not bookmark:
        raise HTTPException(status_code=404, detail="Bookmark not found")

    # Only update fields that were actually sent in the request
    update_data = bookmark_update.model_dump(exclude_unset=True)
    for field, value in update_data.items():
        setattr(bookmark, field, value)

    db.commit()
    db.refresh(bookmark)
    return bookmark

Why use exclude_unset=True?

The BookmarkUpdate schema has all fields as optional. Using exclude_unset=True means only fields the client actually sent get updated. If someone sends just {"title": "New Title"}, only the title changes. The URL, description, and tags stay untouched.

  • Save the file and confirm a clean reload in the terminal.
  • Add the DELETE endpoint below update_bookmark:
@router.delete("/{bookmark_id}", status_code=204)
def delete_bookmark(bookmark_id: int, db: Session = Depends(get_db)):
    # Fetch the bookmark or return 404
    bookmark = db.get(Bookmark, bookmark_id)
    if not bookmark:
        raise HTTPException(status_code=404, detail="Bookmark not found")

    db.delete(bookmark)
    db.commit()

Why no return value?

A 204 status code means "No Content". The server confirms the deletion succeeded but sends no response body. This is the standard REST pattern for DELETE operations.

  • Save app/routers/bookmarks.py.
  • Refresh http://127.0.0.1:8000/docs in your browser. You should now see five endpoints listed under the bookmarks section: POST, two GETs, PUT, and DELETE.

Seeing fewer than five endpoints?

Make sure each decorator starts with @router (not @app). Also check that there's no indentation mismatch causing a function to be nested inside another function.

Help me figure out why not all my bookmark endpoints are showing in Swagger UI.

\u2714\ufe0f Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

\u24e7 I'd like to double check the full code

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import select, func

from app.core.database import get_db
from app.models.bookmark import Bookmark
from app.schemas.bookmark import BookmarkCreate, BookmarkUpdate, BookmarkResponse

router = APIRouter(prefix="/bookmarks", tags=["bookmarks"])


@router.post("/", response_model=BookmarkResponse, status_code=201)
def create_bookmark(bookmark: BookmarkCreate, db: Session = Depends(get_db)):
    db_bookmark = Bookmark(**bookmark.model_dump())
    db.add(db_bookmark)
    db.commit()
    db.refresh(db_bookmark)
    return db_bookmark


@router.get("/", response_model=list[BookmarkResponse])
def list_bookmarks(
    tag: str | None = None,
    search: str | None = None,
    skip: int = Query(default=0, ge=0),
    limit: int = Query(default=20, ge=1, le=100),
    db: Session = Depends(get_db),
):
    query = select(Bookmark)

    if tag:
        query = query.where(Bookmark.tags.ilike(f"%{tag}%"))

    if search:
        query = query.where(Bookmark.title.ilike(f"%{search}%"))

    query = query.order_by(Bookmark.created_at.desc())
    query = query.offset(skip).limit(limit)

    results = db.execute(query).scalars().all()
    return results


@router.get("/{bookmark_id}", response_model=BookmarkResponse)
def get_bookmark(bookmark_id: int, db: Session = Depends(get_db)):
    bookmark = db.get(Bookmark, bookmark_id)
    if not bookmark:
        raise HTTPException(status_code=404, detail="Bookmark not found")
    return bookmark


@router.put("/{bookmark_id}", response_model=BookmarkResponse)
def update_bookmark(
    bookmark_id: int, bookmark_update: BookmarkUpdate, db: Session = Depends(get_db)
):
    bookmark = db.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 field, value in update_data.items():
        setattr(bookmark, field, value)

    db.commit()
    db.refresh(bookmark)
    return bookmark


@router.delete("/{bookmark_id}", status_code=204)
def delete_bookmark(bookmark_id: int, db: Session = Depends(get_db)):
    bookmark = db.get(Bookmark, bookmark_id)
    if not bookmark:
        raise HTTPException(status_code=404, detail="Bookmark not found")

    db.delete(bookmark)
    db.commit()
Test your endpoints in Swagger UI

FastAPI generates interactive documentation at /docs where you can test every endpoint directly from your browser. No external tools needed.

  • In your browser at http://127.0.0.1:8000/docs, expand the POST /bookmarks/ section.
  • Click Try it out.
  • Replace the example JSON body with this:
{
  "url": "https://fastapi.tiangolo.com",
  "title": "FastAPI Documentation",
  "description": "The official FastAPI documentation",
  "tags": "python, api, web"
}
  • Click Execute. You should see a 201 response with the bookmark data including an auto-generated id and timestamps.
  • Create a second bookmark with different data (any URL and title you like).
  • Expand GET /bookmarks/, click Try it out, then click Execute. You should see both bookmarks returned in a list.
  • Test GET /bookmarks/{bookmark_id} with bookmark_id set to 1. You should get back just the first bookmark.
  • Test PUT /bookmarks/{bookmark_id} with bookmark_id set to 1 and a body of {"title": "Updated Title"}. Confirm only the title changed.
  • Test DELETE /bookmarks/{bookmark_id} with bookmark_id set to 1. You should get a 204 (no content) response.
  • Run GET /bookmarks/ again. Only the second bookmark should remain.

Now confirm your data survives a server restart.

  • Go to your terminal and press Ctrl+C to stop the server.
  • Restart it by running:
fastapi dev app/main.py
  • Go back to http://127.0.0.1:8000/docs and run GET /bookmarks/ again. Your bookmark is still there. The data persisted in PostgreSQL across restarts.

Why does persistence matter?

Earlier FastAPI projects you may have built stored data in Python lists or dictionaries. Those reset every time the server restarts. With PostgreSQL, your data lives in a real database on disk. Restart the server, reboot your machine, it stays.

🙋‍♀️ Getting a 422 Validation Error?

A 422 means FastAPI rejected the request body. Check that your JSON is valid (no trailing commas, all keys in double quotes). Also make sure url and title are present in your POST body since those fields are required.

Help me understand why my POST /bookmarks/ request is returning a 422 error.

Your API is fully functional. You can create, read, update, and delete bookmarks with real database persistence. Next up, you'll take this further by adding a collections feature that groups bookmarks together using database relationships.

Add Filtering and Pagination

Your CRUD endpoints work, but the list endpoint returns every single bookmark in the database with no way to narrow results. Real APIs let users search, filter, and page through results efficiently.

In this step, you'll upgrade the list endpoint with query parameters for filtering by tag, searching by title, and paginating results. These are table-stakes features for any production API.

In this step, get ready to:
  • Add tag and title filtering using SQL ILIKE.
  • Add pagination with skip and limit query parameters.
  • Test filtering and pagination through Swagger UI.
Add filtering, search, and sorting to the list endpoint

Right now, the list_bookmarks function returns all bookmarks with no filtering options. You'll update it to accept optional query parameters that let users narrow their results.

  • In app/routers/bookmarks.py, find the list_bookmarks function and replace it with the following updated version:
@router.get("/", response_model=list[BookmarkResponse])
def list_bookmarks(
    tag: str | None = None,
    search: str | None = None,
    skip: int = Query(default=0, ge=0),
    limit: int = Query(default=20, ge=1, le=100),
    db: Session = Depends(get_db),
):
    query = select(Bookmark)

    if tag:
        query = query.where(Bookmark.tags.ilike(f"%{tag}%"))

    if search:
        query = query.where(Bookmark.title.ilike(f"%{search}%"))

    query = query.order_by(Bookmark.created_at.desc())
    query = query.offset(skip).limit(limit)

    results = db.execute(query).scalars().all()
    return results

What does this code do?

This updated function introduces four new query parameters that FastAPI automatically exposes in the Swagger UI.

  • tag filters bookmarks where the tags column contains the given value. ILIKE is PostgreSQL's case-insensitive pattern matching, so "python" matches "Python" or "PYTHON".
  • search filters bookmarks where the title contains the search term. The % wildcards on both sides mean "match anywhere in the string."
  • skip and limit handle pagination. Query(default=0, ge=0) adds validation constraints that FastAPI enforces automatically.
  • order_by(Bookmark.created_at.desc()) sorts results so the newest bookmarks appear first.

💡 Why ILIKE instead of exact matching?

Exact matching requires users to know the precise tag or title string. ILIKE with % wildcards means a search for "fast" matches "FastAPI Tutorial", "A fast guide", or "FAST reference".

This is the PostgreSQL equivalent of a case-insensitive LIKE. For larger datasets, you'd add a full-text search index, but ILIKE is the right starting point.

  • Save app/routers/bookmarks.py.

Seeing an import error?

Make sure your imports at the top of app/routers/bookmarks.py include Query from fastapi and select from sqlalchemy. Both were added in Step 5, but check they are still present.

Help me debug an import error in my bookmarks router

Test filtering and pagination through Swagger UI

The best way to verify filtering and pagination work is to create some test data, then query it with different parameters. FastAPI's Swagger UI at /docs exposes all query parameters as form fields you can fill in.

  • Start the dev server (if it is not already running) by running:
fastapi dev app/main.py
  • Open your browser and go to http://127.0.0.1:8000/docs.
  • Use the POST /bookmarks/ endpoint to create at least 3 bookmarks with different tags and titles. For example:
{
  "url": "https://docs.python.org",
  "title": "Python Official Docs",
  "description": "The official Python documentation",
  "tags": "python, docs"
}
{
  "url": "https://fastapi.tiangolo.com",
  "title": "FastAPI Documentation",
  "description": "Learn FastAPI from the official docs",
  "tags": "python, fastapi, web"
}
{
  "url": "https://react.dev",
  "title": "React Documentation",
  "description": "The official React docs",
  "tags": "javascript, frontend"
}
  • Expand the GET /bookmarks/ endpoint and click Try it out.
  • Enter python in the tag field and click Execute.

You should see only the two bookmarks tagged with "python" in the response. The React bookmark is excluded.

  • Clear the tag field. Enter official in the search field and click Execute.

You should see only bookmarks whose titles contain "official" (case-insensitive).

  • Clear the search field. Set limit to 1 and click Execute.

You should see only one bookmark (the most recently created). This confirms pagination is working.

  • Set skip to 1 and keep limit at 1. Click Execute.

You should see the second bookmark. This is how clients implement "page 2" of results.

How does offset-based pagination work?

skip tells the database how many rows to jump over. limit tells it how many rows to return after skipping. A frontend requesting "page 3 with 20 items per page" sends skip=40&limit=20.

The Query(ge=0) and Query(ge=1, le=100) constraints prevent clients from requesting negative offsets or absurdly large pages. FastAPI returns a 422 validation error if these constraints are violated.

🙋‍♀️ Filters not narrowing results?

Make sure you created bookmarks with actual tag values. If the tags field was left empty or null when creating bookmarks, the ILIKE filter has nothing to match against.

Also check that you are clearing previous parameter values before testing a new filter. Swagger UI keeps old values filled in.

Help me debug why my bookmark filters are not working

✔️ Awesome, I've got everything!

Great. Double check you have saved app/routers/bookmarks.py.

ⓧ I'd like to double check the full code

Here is the complete app/routers/bookmarks.py file with all CRUD endpoints plus filtering and pagination:

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import select, func

from app.core.database import get_db
from app.models.bookmark import Bookmark
from app.schemas.bookmark import BookmarkCreate, BookmarkUpdate, BookmarkResponse

router = APIRouter(prefix="/bookmarks", tags=["bookmarks"])


@router.post("/", response_model=BookmarkResponse, status_code=201)
def create_bookmark(bookmark: BookmarkCreate, db: Session = Depends(get_db)):
    db_bookmark = Bookmark(**bookmark.model_dump())
    db.add(db_bookmark)
    db.commit()
    db.refresh(db_bookmark)
    return db_bookmark


@router.get("/", response_model=list[BookmarkResponse])
def list_bookmarks(
    tag: str | None = None,
    search: str | None = None,
    skip: int = Query(default=0, ge=0),
    limit: int = Query(default=20, ge=1, le=100),
    db: Session = Depends(get_db),
):
    query = select(Bookmark)

    if tag:
        query = query.where(Bookmark.tags.ilike(f"%{tag}%"))

    if search:
        query = query.where(Bookmark.title.ilike(f"%{search}%"))

    query = query.order_by(Bookmark.created_at.desc())
    query = query.offset(skip).limit(limit)

    results = db.execute(query).scalars().all()
    return results


@router.get("/{bookmark_id}", response_model=BookmarkResponse)
def get_bookmark(bookmark_id: int, db: Session = Depends(get_db)):
    bookmark = db.get(Bookmark, bookmark_id)
    if not bookmark:
        raise HTTPException(status_code=404, detail="Bookmark not found")
    return bookmark


@router.put("/{bookmark_id}", response_model=BookmarkResponse)
def update_bookmark(
    bookmark_id: int, bookmark_update: BookmarkUpdate, db: Session = Depends(get_db)
):
    bookmark = db.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 field, value in update_data.items():
        setattr(bookmark, field, value)

    db.commit()
    db.refresh(bookmark)
    return bookmark


@router.delete("/{bookmark_id}", status_code=204)
def delete_bookmark(bookmark_id: int, db: Session = Depends(get_db)):
    bookmark = db.get(Bookmark, bookmark_id)
    if not bookmark:
        raise HTTPException(status_code=404, detail="Bookmark not found")

    db.delete(bookmark)
    db.commit()

Your API now supports filtering, searching, sorting, and pagination. These are the features that separate a toy API from one ready for a real frontend. Next up, you'll push your finished project to GitHub so it's version-controlled and shareable.

Push Your Project to GitHub

Your API is feature-complete, but right now it only lives on your machine. If your hard drive fails or you want to collaborate with someone, the code is gone. Version control solves this by tracking every change you make and storing a copy in the cloud.

In this step, you'll initialize a Git repository, create a .gitignore to keep secrets out of version control, and push your project to GitHub.

In this step, get ready to:
  • Create a .gitignore file to protect sensitive files.
  • Initialize a Git repository and make your first commit.
  • Push your project to a GitHub repository.
Create a .gitignore file

Before tracking any files, you need to tell Git which files to ignore. Your .env file contains your database password, and folders like venv/ and __pycache__/ are generated artifacts that don't belong in a repository.

  • Create a new file called .gitignore in the root of your bookmark-manager folder (the same level as requirements.txt) and paste in this content:
# Virtual environment
venv/

# Environment variables (contains secrets)
.env

# Python bytecode
__pycache__/
*.pyc

# IDE settings
.vscode/
.idea/

Why is .env in the .gitignore?

Your .env file contains your database password. If you push it to GitHub, anyone who finds your repository can see those credentials. The .gitignore file tells Git to pretend the listed files don't exist.

  • Save .gitignore.
Initialize Git and make your first commit

Now you'll create a Git repository inside your project folder, stage all the tracked files, and commit them as your initial snapshot.

  • Make sure your terminal is in the bookmark-manager folder (the same folder that contains requirements.txt).
  • Initialize a new Git repository by running this command:
git init
  • Stage all files for your first commit by running:
git add .

What does git add . do?

The . means "everything in the current directory." Git stages all files except those listed in .gitignore. Your .env and venv/ are automatically excluded.

  • Verify which files are staged by running:
git status

You should see a list of green filenames (staged files). Confirm that .env and venv/ are NOT in the list.

Seeing .env in the staged files?

If .env shows up in green, your .gitignore file might be in the wrong location or has a typo. Make sure it is in the same folder as requirements.txt and the line reads exactly .env with no leading spaces.

Help me fix my .gitignore so that .env is excluded from Git tracking.

  • Create your first commit by running:
git commit -m "Initial commit: bookmark manager API with CRUD, migrations, filtering"

You should see a summary of files committed and the message "Initial commit" in the output.

Create a GitHub repository and push

Your commit is saved locally. Now you'll create a remote repository on GitHub and push your code there so it's backed up and shareable.

  • Open your browser and go to github.com/new.
  • Enter bookmark-manager-api in the Repository name field.
  • Leave the repository set to Public.
  • Do NOT check any of the initialization options (no README, no .gitignore, no license). You already have your own files.
  • Click Create repository.

GitHub shows you setup commands. You'll use the "push an existing repository" option.

  • Back in your terminal, connect your local repo to GitHub by running these commands:
git branch -M main
git remote add origin https://github.com/[[GITHUB_USERNAME="your-github-username"]]/bookmark-manager-api.git
git push -u origin main

What do these commands do?

  • git branch -M main renames your default branch to main (GitHub's default branch name).
  • git remote add origin tells Git where to push your code. origin is the conventional name for your primary remote.
  • git push -u origin main uploads your commit to GitHub. The -u flag links your local main branch to the remote, so future pushes only need git push.
  • Refresh your GitHub repository page in the browser.

You should see all your project files listed on GitHub. Confirm that .env is NOT visible in the file list.

Push rejected or permission denied?

If you see a "permission denied" or "authentication failed" error, your Git credentials may have expired. Try running gh auth login if you use the GitHub CLI, or check that your SSH key or personal access token is still valid.

If Git says the remote already exists, you may have already added an origin. Run git remote -v to check, and use git remote set-url origin <URL> to update it.

Help me fix a Git push error to GitHub.

Secret mission

Add Collections with Database Relationships

Your bookmarks are useful, but they're a flat list. What if you could group them into named collections like "Python Resources" or "Design Inspiration"? Build a one-to-many database relationship, a new migration, and a full set of collection endpoints.

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • The bookmarks_db PostgreSQL database.
  • Your project folder on the Desktop (all application code, virtual environment, Alembic migrations, and .env file).

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with the API.

  • Your FastAPI dev server continues running in the terminal.
  • The bookmarks_db database stays available through PostgreSQL.
  • Your project folder, virtual environment, and all code remain on disk.

Pause - I'll come back to this later

Shut down running processes to free up system resources. Your database and code remain on disk for when you return.

  • Stop the FastAPI dev server by pressing Ctrl+C in the terminal where it is running.
  • Deactivate your virtual environment by running:
deactivate
  • Your bookmarks_db database stays intact. PostgreSQL keeps it available whenever you restart your server.
  • To resume later, navigate back to your project folder, activate the virtual environment, and run fastapi dev again.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is irreversible.

  • Stop the FastAPI dev server by pressing Ctrl+C in the terminal where it is running.
  • Deactivate your virtual environment by running:
deactivate
  • Drop the bookmarks_db PostgreSQL database by running:
dropdb bookmarks_db
  • Navigate to your Desktop and delete the project folder.

On macOS or Linux:

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

On Windows:

cd ~\Desktop
rmdir /s /q bookmark-manager-api

Renamed your project folder?

Replace bookmark-manager-api with whatever you named your project folder in Step 1.

Nice Work!

Nice Work!

You just built a production-grade backend API from scratch. Your Bookmark Manager API stores real data in PostgreSQL, evolves its schema through migrations, and follows the exact project structure that Python backend teams use daily.

You've learned how to:

  • Build a FastAPI REST API with full CRUD operations, filtering, search, and pagination connected to a real PostgreSQL database using SQLAlchemy 2.0 ORM.
  • Manage database schema changes with Alembic migrations so your database can evolve without losing data.
  • Version-control your project with Git and push it to GitHub with a proper .gitignore protecting your secrets.
  • Built a collections feature with database relationships, foreign keys, and a new Alembic migration.

Ready to quiz yourself?