Build a Notes CRUD API with FastAPI

Build a REST API with full CRUD operations backed by PostgreSQL.

Introduction

30 Second Summary

You jot a note on your phone, close the app, and when you come back the note is still there. That persistence feels obvious, but behind the scenes an API is storing your text in a database and handing it back on demand.

In this project, you will build a Notes API from scratch using FastAPI and PostgreSQL. You will start with an in-memory version that loses data on every restart, feel that pain firsthand, then upgrade to a real database so your notes survive.

What You'll Build

By the end, you will have a running API at localhost:8000 where you can create, read, update, and delete notes through an interactive docs page, then show anyone exactly how your endpoints work by clicking through Swagger UI in the browser.

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

  • A fully functional REST API where you can create a note, fetch it back by ID, update its content, and delete it, all with proper HTTP status codes.
  • A PostgreSQL-backed database that keeps your notes alive across server restarts, proving you can wire a Python app to a real data store.
  • Built-in input validation that rejects empty or malformed data before it ever touches the database, so bad requests get a clear error instead of corrupting your notes.
  • Secret Mission: Add a priority system to your notes and build filter and sort endpoints so users can query only what matters.

Are there any prerequisites?

You need basic Python knowledge (functions, classes, dictionaries) and a local PostgreSQL installation running on your machine. Step 1 walks you through everything else.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Your Project and Database

Every API needs a stable foundation before you write any endpoint logic. In this step you will create a dedicated project folder, install the libraries your API depends on, and prepare a PostgreSQL database to store notes.

Getting this environment right now means every future step starts cleanly. You will know the setup works when you can import FastAPI and connect to a fresh database.

In this step, get ready to:
  • Create a project folder, virtual environment, and the initial file structure.
  • Install FastAPI, uvicorn, SQLAlchemy, and the PostgreSQL driver.
  • Create and verify the PostgreSQL database your API will use.
Create the project folder and virtual environment

A virtual environment isolates your project's packages from your system Python. This prevents version conflicts between projects.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir notes-api
cd notes-api
  • Create a virtual environment by running:
python3 -m venv venv
  • Confirm the venv folder appeared by running:
ls

You should see a single venv directory listed.

Seeing command not found for python3?

On Windows, the command is often python instead of python3. Try python -m venv venv instead.

help me fix a python3 command not found error when creating a virtual environment

  • Activate the virtual environment:

macOS

  • Run this command to activate (this also works on Linux):
source venv/bin/activate

Windows

  • Run this command to activate:
venv\Scripts\activate

Your terminal prompt now shows (venv) at the beginning, confirming the virtual environment is active.

  • Create the app/ subfolder and its empty init file by running:
mkdir app
touch app/__init__.py
  • Verify the new file exists by running:
ls app

You should see __init__.py listed. This file tells Python that app/ is a package it can import from.

On Windows and touch is not recognized?

Windows does not have touch by default. Use this PowerShell command instead:

New-Item -Path app/__init__.py -ItemType File

help me create an empty __init__.py file on Windows

Install dependencies

Your API needs four packages. FastAPI is the web framework. Uvicorn is the server that runs it. SQLAlchemy is the ORM that talks to the database. psycopg2-binary is the PostgreSQL driver that SQLAlchemy uses under the hood.

  • Install all four packages by running:
pip install fastapi==0.139.2 uvicorn==0.51.0 sqlalchemy==2.0.51 psycopg2-binary==2.9.12

What do these version pins do?

Pinning exact versions (the == syntax) ensures everyone running this project gets the same behaviour. Without pins, a future release could introduce breaking changes.

  • Verify the installation succeeded by running:
python -c "import fastapi; print(fastapi.__version__)"

You should see 0.139.2 printed in your terminal.

Seeing a ModuleNotFoundError?

Make sure your terminal still shows (venv) in the prompt. If it disappeared, re-activate the virtual environment and run the install again.

help me fix ModuleNotFoundError for fastapi after pip install

Why FastAPI over Flask?

FastAPI generates interactive API documentation automatically, validates request data with type hints, and uses modern Python async patterns. Flask requires separate libraries for each of these features.

For building APIs specifically, FastAPI gives you more out of the box with less setup code.

Create the PostgreSQL database

Your notes need a dedicated database to live in. PostgreSQL organises data into separate databases, so you will create one called notes_db.

  • Connect to PostgreSQL by running:
psql -U postgres

What does this command do?

psql is the PostgreSQL interactive terminal. The -U postgres flag connects as the default superuser account named postgres.

Getting a password prompt or connection refused?

If prompted for a password, enter the password you set when installing PostgreSQL. If you see a connection refused error, make sure your PostgreSQL server is running.

help me connect to PostgreSQL with psql when I get a password prompt or connection error

Once connected, you will see a postgres=# prompt.

  • Create the database by running this SQL command:
CREATE DATABASE notes_db;
  • Before you verify, think about what you expect to see. What would a list of all databases include now that you have created notes_db?
  • List all databases by running:
\l

You should see notes_db in the list alongside the default databases like postgres and template1.

  • Exit the PostgreSQL prompt by running:
\q

Don't see notes_db in the list?

Make sure the CREATE DATABASE command ended with a semicolon. Without the semicolon, PostgreSQL waits for more input instead of executing.

help me create a PostgreSQL database when my CREATE DATABASE command did not work

Your project environment is fully set up. You have a virtual environment with all dependencies, and a fresh notes_db database waiting for data. Next up, you will write your first API endpoints and see them respond in the browser.

Build Your First Running API

You have your project folder ready, dependencies installed, and a PostgreSQL database waiting. Now you need something you can actually see and interact with.

In this step, you will create a simple API with in-memory storage so you can immediately create and list notes in the browser. This gives you a running server you can demo right now, before worrying about databases.

In this step, get ready to:
  • Create a FastAPI app with two endpoints (POST and GET) for notes.
  • Run the server and test your API through interactive Swagger docs.
  • Discover why in-memory storage is a problem.
Create the app and POST endpoint

Every FastAPI app starts with an app instance and at least one endpoint. You will create a POST endpoint first so you can immediately add notes to the app.

  • Create a new file called app/main.py inside your notes-api folder, then paste the following code:
from fastapi import FastAPI, status

# Create the app instance with a title
app = FastAPI(title="Notes API", version="1.0.0")

# In-memory storage (a simple Python list)
notes = []
next_id = 1


@app.post("/notes", status_code=status.HTTP_201_CREATED)
def create_note(note: dict):
    global next_id
    # Build a note with an auto-incrementing ID
    new_note = {"id": next_id, "title": note["title"], "content": note["content"]}
    notes.append(new_note)
    next_id += 1
    return new_note

What does this code do?

This sets up a minimal API with one endpoint that accepts notes.

  • notes is a plain Python list that stores all notes in memory.
  • next_id gives each note a unique number so you can tell them apart.
  • The @app.post("/notes") decorator registers the function as the handler for POST requests to /notes.
  • status.HTTP_201_CREATED tells the client that a new resource was successfully created.
  • Save app/main.py.
  • Start the server by running this command in your terminal:
uvicorn app.main:app --reload

You should see output including a line like:

INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

Your server is now running and watching for file changes.

What does this command do?

uvicorn is the server that runs your FastAPI app. app.main:app tells it to look in the app/ folder, find main.py, and use the object named app.

The --reload flag makes the server restart automatically whenever you save a file change.

Server won't start?

  • Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
  • Make sure you are in the notes-api folder (the parent of app/), not inside app/ itself.
  • Check that app/__init__.py exists. Without it, Python does not recognize app as a package.

Help me debug why uvicorn can't find my app

Add the GET endpoint

You can create notes, but you cannot see them yet. A GET endpoint lets you retrieve all the notes currently stored in the list.

  • In app/main.py, add the following code at the bottom of the file (below the create_note function):
@app.get("/notes")
def list_notes():
    # Return everything currently stored in the notes list
    return notes

What does this code do?

The @app.get("/notes") decorator registers a handler for GET requests to /notes. It simply returns the full notes list. FastAPI automatically converts it to JSON for the response.

  • Save app/main.py.
  • Check your terminal. You should see the server print a reload message confirming it picked up your change.

No reload message?

  • Make sure you saved the file. The server only reloads when it detects a file change on disk.
  • If the server crashed earlier due to a syntax error, restart it by pressing Ctrl+C and running uvicorn app.main:app --reload again.

Help me figure out why my server isn't reloading

✔️ Awesome, I've got everything!

Great. Double check you have saved app/main.py and your server is still running.

ⓧ I'd like to double check the full code

Here is what your complete app/main.py should look like:

from fastapi import FastAPI, status

app = FastAPI(title="Notes API", version="1.0.0")

notes = []
next_id = 1


@app.post("/notes", status_code=status.HTTP_201_CREATED)
def create_note(note: dict):
    global next_id
    new_note = {"id": next_id, "title": note["title"], "content": note["content"]}
    notes.append(new_note)
    next_id += 1
    return new_note


@app.get("/notes")
def list_notes():
    return notes
Test your API in the browser

FastAPI generates interactive documentation automatically. You can test every endpoint directly in the browser without any extra tools.

  • Open your browser and go to http://localhost:8000/docs.

You should see a page titled Notes API with your two endpoints listed: POST /notes and GET /notes. This is Swagger UI, generated from your code.

  • Click on POST /notes to expand it.
  • Click Try it out.
  • Replace the request body with this JSON:
{
  "title": "My first note",
  "content": "Hello from the Notes API!"
}
  • Click Execute.

You should see a response with status 201 and your note returned with "id": 1.

  • Create 1-2 more notes with different titles and content using the same process.
  • Now click on GET /notes to expand it.
  • Click Try it out, then Execute.

You should see all the notes you just created returned as a JSON array.

Discover the in-memory storage problem

Your API works. But there is a hidden flaw. Everything is stored in a Python list, which lives only as long as the server process is running.

Before you test this, what do you think will happen to your notes if you stop and restart the server?

  • Go back to your terminal and press Ctrl+C to stop the server.
  • Start the server again by running:
uvicorn app.main:app --reload
  • Go back to http://localhost:8000/docs in your browser.
  • Expand GET /notes, click Try it out, then Execute.

The response is an empty list: []. Every note you created is gone.

Why did the notes disappear?

The notes list lives in your Python process's memory. When you stop the server, that process ends and all its memory is released.

Restarting the server creates a fresh process with a brand new empty list. This is exactly why databases exist. They store data on disk so it survives restarts, crashes, and deployments.

This is the problem you will solve in the next step by connecting PostgreSQL.

Your API is alive and testable through Swagger UI. Next up, you will connect PostgreSQL so your notes survive server restarts.

Persist Notes in PostgreSQL

You saw it happen. Notes you created vanished the instant you restarted the server. That is because Python's in-memory list only lives as long as the process runs.

A database fixes this permanently. In this step, you will connect PostgreSQL to your API using SQLAlchemy so every note survives server restarts.

In this step, get ready to:
  • Create a database connection module that talks to PostgreSQL.
  • Define a Note model that maps to a database table.
  • Replace the in-memory endpoints with database-backed ones that persist data.
Create the database connection module

SQLAlchemy needs three pieces to connect your app to PostgreSQL: an engine (the connection itself), a session factory (for managing transactions), and a base class (for your models to inherit from).

  • Create a new file called app/database.py in your app/ folder and add the following code:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase

# Connection string pointing to your local PostgreSQL database
DATABASE_URL = "postgresql://postgres:postgres@localhost/notes_db"

# Engine manages the actual database connection
engine = create_engine(DATABASE_URL)

# SessionLocal is a factory that creates new database sessions
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)


# Base class that all your models will inherit from
class Base(DeclarativeBase):
    pass

What does this code do?

DATABASE_URL tells SQLAlchemy where your PostgreSQL database lives. The format is postgresql://user:password@host/database_name.

SessionLocal is a factory. Every time your API handles a request, it creates a fresh session for that request's database work, then closes it when done.

Base uses SQLAlchemy 2.0's DeclarativeBase pattern. Any model class that inherits from Base automatically becomes a database table.

  • Save app/database.py.
Define the Note model

A model maps a Python class to a database table. Each attribute becomes a column, and each instance becomes a row.

  • Create a new file called app/models.py in your app/ folder and add the following code:
from sqlalchemy import Column, Integer, String, Text, DateTime, func

from app.database import Base


# Note model maps to a "notes" table in PostgreSQL
class Note(Base):
    __tablename__ = "notes"

    # Primary key auto-increments for each new note
    id = Column(Integer, primary_key=True, autoincrement=True)
    # Title has a 200 character limit
    title = Column(String(200), nullable=False)
    # Content can hold unlimited text
    content = Column(Text, nullable=False)
    # Timestamp set automatically by PostgreSQL when the row is created
    created_at = Column(DateTime, server_default=func.now())

What does this code do?

__tablename__ = "notes" tells SQLAlchemy to create (or look for) a table called notes in PostgreSQL.

Each Column defines a database column with its type and constraints. nullable=False means the database will reject any note missing a title or content.

server_default=func.now() makes PostgreSQL stamp the creation time automatically. You never have to set it manually.

  • Save app/models.py.
  • Verify both modules load correctly by running this command:
python -c "from app.models import Note; print(Note.__tablename__)"

You should see notes printed in the terminal. This confirms both database.py and models.py are wired together correctly.

Seeing a ModuleNotFoundError?

  • Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
  • Confirm you are running the command from inside the notes-api folder (the parent of app/).
  • Check that app/__init__.py exists. It can be empty, but it must be present for Python to treat app/ as a package.

Still stuck? Help me debug a ModuleNotFoundError when importing my Note model

Replace in-memory storage with database queries

Now the big swap. You will rewrite app/main.py to use real database sessions instead of the in-memory list.

  • Open app/main.py and delete all the existing code.
  • Paste in the following imports, setup, and database dependency:
from fastapi import FastAPI, Depends, status
from sqlalchemy.orm import Session

from app.database import engine, SessionLocal, Base
from app.models import Note

# Create all database tables defined by models that inherit from Base
Base.metadata.create_all(bind=engine)

app = FastAPI(title="Notes API", version="1.0.0")


# Dependency: creates a fresh DB session per request, then closes it
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

What does this setup do?

Base.metadata.create_all(bind=engine) scans all models that inherit from Base and creates their tables in PostgreSQL if they do not already exist.

get_db() is a FastAPI dependency. Every endpoint that needs the database declares db: Session = Depends(get_db) in its parameters. FastAPI calls get_db() automatically, hands the session to your function, and closes it when the request finishes.

  • Below the get_db function, add the two database-backed endpoints:
@app.post("/notes", status_code=status.HTTP_201_CREATED)
def create_note(note: dict, db: Session = Depends(get_db)):
    # Create a new Note row from the request data
    db_note = Note(title=note["title"], content=note["content"])
    db.add(db_note)
    db.commit()
    db.refresh(db_note)
    return db_note


@app.get("/notes")
def list_notes(db: Session = Depends(get_db)):
    # Query all notes from the database
    notes = db.query(Note).all()
    return notes

What do these endpoints do?

create_note builds a Note object, adds it to the session, commits the transaction to PostgreSQL, then refreshes the object to load its auto-generated id and created_at values.

list_notes runs SELECT * FROM notes under the hood and returns every row.

  • Save app/main.py.

✔️ Awesome, I've got everything!

Great. Make sure you have saved all three files: app/database.py, app/models.py, and app/main.py.

ⓧ I'd like to double check the full code

Scroll up and confirm your app/main.py contains both code blocks in order:

  • First block: imports, Base.metadata.create_all, the app instance, and the get_db dependency function.
  • Second block: the create_note and list_notes endpoint functions.

For app/database.py, here is the full file:

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase

DATABASE_URL = "postgresql://postgres:postgres@localhost/notes_db"

engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)


class Base(DeclarativeBase):
    pass

For app/models.py, here is the full file:

from sqlalchemy import Column, Integer, String, Text, DateTime, func

from app.database import Base


class Note(Base):
    __tablename__ = "notes"

    id = Column(Integer, primary_key=True, autoincrement=True)
    title = Column(String(200), nullable=False)
    content = Column(Text, nullable=False)
    created_at = Column(DateTime, server_default=func.now())
Verify notes persist across restarts

This is the moment of truth. Before you run the server, think about what you expect to happen this time when you restart it.

  • Start the server by running:
uvicorn app.main:app --reload
  • Open http://localhost:8000/docs in your browser.
  • Use the POST /notes endpoint to create a note. Click Try it out, enter a title and content, then click Execute.
  • Use GET /notes to confirm your note appears in the list.

Now for the real test. Do you think your note will survive a restart this time?

  • Stop the server by pressing Ctrl+C in the terminal.
  • Restart the server by running:
uvicorn app.main:app --reload
  • Open http://localhost:8000/docs again and call GET /notes.

Your note is still there. PostgreSQL stored it on disk, completely independent of whether your Python process is alive or dead.

Seeing a connection refused error?

  • Make sure PostgreSQL is running. On macOS, check with brew services list. On Windows, check the Services app for postgresql.
  • Verify the database exists by running psql -U postgres -l and looking for notes_db in the list.
  • If your PostgreSQL uses a different password, update the DATABASE_URL in app/database.py to match your local credentials.

Still stuck? Help me fix a PostgreSQL connection error in my FastAPI app

Your API now has real persistence. Notes are safe in PostgreSQL no matter how many times you restart the server. Next up, you will add update and delete operations plus input validation to make this a complete CRUD API.

Complete CRUD with Validation

Your notes now survive server restarts thanks to PostgreSQL. Your API can create and list notes, but that's only half of CRUD.

A complete API needs update and delete operations, proper error handling when a note doesn't exist, and input validation to prevent bad data from reaching the database. Right now, anyone can send an empty title and your API happily saves it.

In this step, get ready to:
  • Create Pydantic schemas that validate note input and shape response data.
  • Add GET single note, PUT update, and DELETE endpoints with proper error handling.
  • Wire schemas to all endpoints and verify validation rejects bad data.
Create Pydantic schemas for input validation and response shaping

Before adding new endpoints, you need to define the shape of data your API accepts and returns. Pydantic models act as gatekeepers that reject malformed input before it reaches the database.

  • Create a new file app/schemas.py inside your app/ folder.
  • Add the imports and the NoteCreate schema by pasting this code:
from datetime import datetime
from typing import Optional

from pydantic import BaseModel, ConfigDict, field_validator


class NoteCreate(BaseModel):
    title: str
    content: str

    # Reject empty or whitespace-only titles
    @field_validator("title")
    @classmethod
    def title_must_not_be_empty(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("Title cannot be empty")
        return v.strip()

    # Reject empty or whitespace-only content
    @field_validator("content")
    @classmethod
    def content_must_not_be_empty(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("Content cannot be empty")
        return v.strip()

What does this code do?

NoteCreate inherits from BaseModel and requires two string fields: title and content.

The @field_validator decorators run custom checks on each field. If someone sends a title that's just whitespace, the validator raises a ValueError and FastAPI automatically returns a 422 error before the data ever touches your database.

  • Save app/schemas.py.
  • Verify the schema loads by running this in your terminal:
python -c "from app.schemas import NoteCreate; print('NoteCreate loaded')"

You should see NoteCreate loaded printed.

Seeing an ImportError?

  • Make sure your virtual environment is activated. You should see (venv) in your terminal prompt.
  • Confirm the file is saved as app/schemas.py (inside the app/ folder, not the project root).
  • help me fix this ImportError in schemas.py
  • Now add the NoteUpdate schema below NoteCreate in app/schemas.py:
class NoteUpdate(BaseModel):
    title: Optional[str] = None
    content: Optional[str] = None

    # Only validate title if one was actually provided
    @field_validator("title")
    @classmethod
    def title_must_not_be_empty(cls, v: Optional[str]) -> Optional[str]:
        if v is not None and not v.strip():
            raise ValueError("Title cannot be empty")
        return v.strip() if v else v

    # Only validate content if one was actually provided
    @field_validator("content")
    @classmethod
    def content_must_not_be_empty(cls, v: Optional[str]) -> Optional[str]:
        if v is not None and not v.strip():
            raise ValueError("Content cannot be empty")
        return v.strip() if v else v

Why are the fields Optional here?

When updating a note, you might want to change only the title or only the content. By defaulting both fields to None, the learner can send a partial update. The validators only fire when a value is actually provided.

  • Finally, add the NoteResponse schema below NoteUpdate:
class NoteResponse(BaseModel):
    # Allow Pydantic to read data from SQLAlchemy model attributes
    model_config = ConfigDict(from_attributes=True)

    id: int
    title: str
    content: str
    created_at: datetime

What is from_attributes?

SQLAlchemy models store data as object attributes (note.title), not as dictionary keys. The from_attributes=True config tells Pydantic to read attributes directly from the ORM object, so FastAPI can serialize your database rows into clean JSON responses.

  • Save app/schemas.py.
  • Confirm all three schemas load by running:
python -c "from app.schemas import NoteCreate, NoteUpdate, NoteResponse; print('All schemas loaded')"

You should see All schemas loaded printed.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Scroll up and confirm your app/schemas.py contains these sections in order:

  • Imports (datetime, Optional, BaseModel, ConfigDict, field_validator).
  • NoteCreate class with title and content fields plus two validators.
  • NoteUpdate class with Optional fields defaulting to None plus two validators.
  • NoteResponse class with model_config = ConfigDict(from_attributes=True) and four fields.
Add GET, PUT, and DELETE endpoints

Your API can create and list notes, but it can't fetch a single note, update one, or delete one. These three endpoints complete the CRUD operations.

  • In app/main.py, find the import lines at the top of the file:
from fastapi import FastAPI, Depends, status
from sqlalchemy.orm import Session

from app.database import engine, SessionLocal, Base
from app.models import Note
  • Replace those lines with the updated imports that include HTTPException and your new schemas:
from fastapi import FastAPI, Depends, HTTPException, status
from sqlalchemy.orm import Session

from app.database import engine, SessionLocal, Base
from app.models import Note
from app.schemas import NoteCreate, NoteUpdate, NoteResponse

What is HTTPException?

HTTPException lets you return proper error responses with the correct status code. When a note isn't found, you'll raise a 404 instead of letting the code crash with an unhandled error.

  • Add the GET single note endpoint below the existing list_notes function in app/main.py:
@app.get("/notes/{note_id}", response_model=NoteResponse)
def get_note(note_id: int, db: Session = Depends(get_db)):
    # Look up one note by its primary key
    note = db.query(Note).filter(Note.id == note_id).first()
    if not note:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Note with id {note_id} not found",
        )
    return note

What does this endpoint do?

The {note_id} in the path becomes a function parameter. FastAPI automatically converts it to an integer. If no note matches that ID, the endpoint raises a 404 with a helpful detail message.

  • Save app/main.py and start the server by running:
uvicorn app.main:app --reload
  • Open http://localhost:8000/docs in your browser.
  • Expand the GET /notes/{note_id} section, click Try it out, enter 9999 as the note_id, and click Execute.

You should see a 404 response with the message Note with id 9999 not found.

Server won't start?

  • Check that you saved both app/schemas.py and app/main.py. A missing schema file causes an import error.
  • Look for a SyntaxError in the terminal output. It usually points to the exact line number.
  • help me fix this server startup error
  • Now add the PUT update endpoint below get_note in app/main.py:
@app.put("/notes/{note_id}", response_model=NoteResponse)
def update_note(note_id: int, note_data: NoteUpdate, db: Session = Depends(get_db)):
    # Find the existing note or return 404
    note = db.query(Note).filter(Note.id == note_id).first()
    if not note:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Note with id {note_id} not found",
        )
    # Only update fields that were actually provided
    if note_data.title is not None:
        note.title = note_data.title
    if note_data.content is not None:
        note.content = note_data.content
    db.commit()
    db.refresh(note)
    return note

Why check for None before updating?

Since NoteUpdate fields default to None, checking if note_data.title is not None ensures you only overwrite fields the user explicitly sent. This gives you partial updates without requiring all fields every time.

  • Add the DELETE endpoint below update_note in app/main.py:
@app.delete("/notes/{note_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_note(note_id: int, db: Session = Depends(get_db)):
    # Find the note or return 404
    note = db.query(Note).filter(Note.id == note_id).first()
    if not note:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Note with id {note_id} not found",
        )
    db.delete(note)
    db.commit()

Why 204 No Content?

A 204 status means the action succeeded but there's nothing to return. The note is gone, so there's no body to send back. This is the standard HTTP convention for successful deletes.

  • Save app/main.py. The server auto-reloads.
  • In the Swagger UI at http://localhost:8000/docs, use POST /notes to create a test note (any title and content).
  • Use PUT /notes/{note_id} with the note's ID and a new title to update it.
  • Use DELETE /notes/{note_id} with the same ID to delete it.

The PUT should return 200 with the updated note. The DELETE should return 204 with an empty body.

Wire up schemas to existing endpoints and test validation

The new endpoints use schemas, but the original POST and GET endpoints still accept a raw dict and return unstructured data. Wiring the schemas to them adds validation and consistent response shaping across the entire API.

  • In app/main.py, find the create_note function. It currently looks like this:
@app.post("/notes", status_code=status.HTTP_201_CREATED)
def create_note(note: dict, db: Session = Depends(get_db)):
    db_note = Note(title=note["title"], content=note["content"])
  • Replace those three lines with this version that uses NoteCreate and NoteResponse:
@app.post("/notes", response_model=NoteResponse, status_code=status.HTTP_201_CREATED)
def create_note(note: NoteCreate, db: Session = Depends(get_db)):
    db_note = Note(title=note.title, content=note.content)

What changed?

  • note: dict became note: NoteCreate. FastAPI now validates the request body against your schema before the function runs.
  • note["title"] became note.title. Pydantic models use attribute access, not dictionary keys.
  • response_model=NoteResponse shapes the output so only the fields you defined get returned.
  • Now find the list_notes function:
@app.get("/notes")
def list_notes(db: Session = Depends(get_db)):
  • Update the decorator to include response_model:
@app.get("/notes", response_model=list[NoteResponse])
def list_notes(db: Session = Depends(get_db)):
  • Save app/main.py. The server auto-reloads.

Before you test, predict what will happen if you POST a note with a title of just spaces. Will the API accept it or reject it?

  • In the Swagger UI, expand POST /notes, click Try it out, and send this body:
{
  "title": "   ",
  "content": "some content"
}

You get a 422 Unprocessable Entity response. Your validator caught the whitespace-only title and rejected it.

  • Now test the full CRUD cycle. Use POST /notes to create a real note with title Test Note and content Hello world.
  • Use GET /notes to see it in the list.
  • Use PUT /notes/{note_id} with the returned ID to change the title to Updated Note.
  • Use GET /notes/{note_id} to confirm the title changed.
  • Use DELETE /notes/{note_id} to delete it.
  • Use GET /notes/{note_id} again with the same ID.

That final GET returns a 404. The note is gone and the API correctly reports it.

Seeing a 500 Internal Server Error?

  • Check the terminal for the full traceback. A common cause is forgetting to update note["title"] to note.title in the create_note function.
  • Make sure both the decorator line and the function signature match. The response_model goes in the decorator, not the function.
  • help me debug this 500 error in my CRUD API

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Scroll up and confirm your app/main.py contains these sections in order:

  • Imports: FastAPI, Depends, HTTPException, status from fastapi. NoteCreate, NoteUpdate, NoteResponse from app.schemas.
  • Base.metadata.create_all(bind=engine) and app creation.
  • get_db dependency function.
  • create_note using NoteCreate with response_model=NoteResponse and 201 status.
  • list_notes with response_model=list[NoteResponse].
  • get_note with 404 handling.
  • update_note with partial update logic and 404 handling.
  • delete_note with 204 status and 404 handling.

You now have a fully validated REST API with all four CRUD operations. Next up, you'll tackle the Secret Mission and add filtering and sorting to make this API truly production-ready.

Secret mission

Add Priority and Filtering

Real-world APIs need more than basic CRUD. In this mission, you'll add a priority system to your notes and build filter and sort endpoints so users can query exactly what they need.

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 notes-api project folder on your Desktop (includes virtual environment, all application code).
  • The notes_db PostgreSQL database storing your notes data.

Keep everything running

No action needed. Choose this if you are still actively building on this API or plan to continue with the next project in the series.

  • Your notes-api folder and virtual environment stay on your Desktop.
  • Your notes_db PostgreSQL database remains available for querying.
  • If the uvicorn server is still running, it continues to serve your API at localhost:8000.

Pause - I'll come back to this later

Shut down running processes to free up system resources. Your files and database stay intact for when you return.

  • Stop the uvicorn server if it is still running by pressing Ctrl+C in the terminal.
  • Deactivate your virtual environment by running:
deactivate
  • Your project folder and database remain on disk. When you come back, navigate to notes-api, reactivate the venv, and start the server again.

Delete - I don't want to use this again

Remove all project files and the database to start fresh. This cannot be undone.

  • Stop the uvicorn server if it is still running by pressing Ctrl+C in the terminal.
  • Deactivate your virtual environment by running:
deactivate
  • Drop the PostgreSQL database by running:
psql -U postgres -c "DROP DATABASE notes_db;"
  • Delete the project folder. On macOS/Linux:
rm -rf ~/Desktop/notes-api
  • On Windows:
rmdir /s /q %USERPROFILE%\Desktop\notes-api
  • Confirm the folder is gone by running ls ~/Desktop (macOS/Linux) or dir %USERPROFILE%\Desktop (Windows). You should no longer see notes-api listed.

Nice Work!

Nice Work!

Nice work! You just built a fully functional REST API from scratch using FastAPI and PostgreSQL. You can create, read, update, and delete notes through HTTP endpoints, all backed by a real database.

You learned how to:

  • Built a REST API with full CRUD operations (Create, Read, Update, Delete) and proper HTTP status codes, explorable through auto-generated Swagger documentation.
  • Experienced firsthand why in-memory storage fails and replaced it with PostgreSQL to persist data across server restarts.
  • Added Pydantic validation to reject bad input before it ever reaches the database, keeping your data clean and consistent.
  • Secret Mission: Added a priority system with filtering and sorting query parameters, making your API production-ready for real query patterns.

Ready to quiz yourself?