# Build a REST API with FastAPI (Python)

> Build a real REST API with FastAPI - routes, validation, CRUD, and a database - set up and run on your own machine, the way you would at work.


---

# Build a REST API with FastAPI (Python)

We're going to build a working REST API together this weekend. Not a toy that
prints "hello" and falls over the moment you send it real data - an actual API
with routes, input validation, the four CRUD operations, sensible error
responses, and a database behind it. The kind of thing you'd be comfortable
opening a pull request for at work.

You'll run all of this **on your own machine**. There's no browser sandbox here:
you'll set up a virtualenv, install a couple of packages, and start a real
server you can hit with `curl` or your browser. By the end you'll have a small
project folder that you understand line by line.

## What you'll build

A "notes" API. It's deliberately small so the shape of the thing stays clear,
but it touches every part you'd find in a production service:

- `GET /notes` - list notes
- `GET /notes/{id}` - fetch one note
- `POST /notes` - create a note (with validated input)
- `PUT /notes/{id}` - update a note
- `DELETE /notes/{id}` - delete a note

We start with the data living in a Python dictionary so you can see the logic
without database noise, then swap that dictionary for SQLite so the data
survives a restart.

## The stack

| Piece | What it does |
|-------|--------------|
| **Python 3.10+** | The language. Type hints are part of how FastAPI works, so a recent version matters. |
| **FastAPI** | The web framework. It turns Python functions into HTTP endpoints and reads your type hints to validate input. |
| **Pydantic** | Comes with FastAPI. Defines the shape of your request and response data, and validates it for you. |
| **Uvicorn** | The server that actually listens on a port and runs your app. |
| **SQLite** | The database. It ships with Python as the `sqlite3` module - no separate install, no server to run. |

## Rough time

About a weekend, taken in pieces. Each phase is 30–60 minutes and ends with
something you can run and poke at. You can stop after any phase and come back -
the project grows one file at a time.

## What you'll learn

- How a framework maps URLs and HTTP methods to your functions
- Path params, query params, and request bodies - and the difference between them
- Why a type hint can replace a wall of `if` checks for validation
- The four CRUD operations and the status codes that go with each
- How to return clean errors (404s, 422s) instead of stack traces
- How to move from in-memory data to a real database without rewriting your routes

## How the phases fit together

```mermaid
graph LR
  A[1. Setup + Hello] --> B[2. Routes + Models]
  B --> C[3. CRUD in memory]
  C --> D[4. Errors + status codes]
  D --> E[5. SQLite + ship]
```

Each phase edits the same `main.py` (and, near the end, adds a `db.py`). You're
not building five separate demos - you're growing one API. Open a terminal,
make yourself a coffee, and let's set up.


---

# Setup and Hello, API

This phase is the boring-but-essential part: a clean project folder, an isolated
Python environment, the two packages we need, and a single endpoint you can hit
in your browser. Get this running and the rest of the weekend is downhill.

You're building this **on your own machine**, so open a real terminal. I'll show
the commands for macOS/Linux and for Windows where they differ.

## Make a project folder

Pick a spot you'll remember and create the folder:

```bash
mkdir notes-api
cd notes-api
```

## Create a virtualenv

A virtualenv is a private copy of Python for this one project. It keeps the
packages you install here from colliding with anything else on your machine.
You make it once per project.

```bash
# macOS / Linux
python3 -m venv .venv
source .venv/bin/activate
```

```bash
# Windows (PowerShell)
python -m venv .venv
.\.venv\Scripts\Activate.ps1
```

After activating, your prompt should show `(.venv)` at the front. That's how you
know commands like `pip` and `python` are pointing at the project's environment
and not the system one. If you open a new terminal later, run the activate
command again - the virtualenv doesn't follow you between windows.

> If PowerShell refuses to run the activate script with a "running scripts is
> disabled" error, run
> `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` once and try again.

## Install FastAPI and Uvicorn

Two packages. FastAPI is the framework; Uvicorn is the server that runs it.

```bash
pip install fastapi uvicorn
```

That pulls in Pydantic too (FastAPI depends on it), which we'll use heavily in
the next phase. Confirm it landed:

```bash
pip show fastapi
```

You should see a version and a location inside your `.venv`. Good.

## Write your first endpoint

Create a file called `main.py` in the project folder:

```python
from fastapi import FastAPI

app = FastAPI()


@app.get("/")
def read_root():
    return {"message": "Hello, API"}
```

Three things are happening here, and they're worth slowing down for because
every endpoint you write follows this pattern:

- `app = FastAPI()` creates the application object. Everything attaches to it.
- `@app.get("/")` is a decorator. It tells FastAPI: when an HTTP `GET` request
  arrives for the path `/`, call the function right below me.
- The function returns a plain Python dict. FastAPI turns that into a JSON
  response automatically - you don't serialize anything by hand.

## Run the server

Start Uvicorn and point it at your app:

```bash
uvicorn main:app --reload
```

Read `main:app` as "the object named `app`, inside the file `main.py`". The
`--reload` flag tells Uvicorn to restart whenever you save a change - leave it
on while you're developing so you're not bouncing the server by hand all
weekend.

You'll see output like:

```
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Application startup complete.
```

Open `http://127.0.0.1:8000/` in your browser. You should see:

```json
{"message":"Hello, API"}
```

You served a real HTTP response from your own machine.

## The part that sells FastAPI

Now visit `http://127.0.0.1:8000/docs`.

That page isn't something you wrote. FastAPI read your code and generated
interactive API documentation for you - every endpoint, the methods, the shapes
of the data. Right now there's only the one root endpoint, but as you add routes
this page fills in automatically. You can click an endpoint, hit "Try it out",
and fire a real request without touching `curl`.

There's a second one at `http://127.0.0.1:8000/redoc` - same information, a
different layout. Pick whichever you like. I lean on `/docs` constantly while
building because the "Try it out" button is faster than typing curl commands.

## A mental model of the request flow

Here's what happens on every request from now on:

```mermaid
graph LR
  A[Browser / curl] -->|HTTP GET /| B[Uvicorn]
  B --> C[FastAPI routing]
  C --> D[Your function]
  D -->|return dict| C
  C -->|JSON response| A
```

Uvicorn accepts the raw connection, FastAPI figures out which of your functions
matches the method and path, your function runs and returns a dict, and FastAPI
hands the JSON back. You'll add more functions, but the pipe stays the same.

## Where we are

You have a project folder, an isolated environment, a running server, one
working endpoint, and free interactive docs. Leave the server running with
`--reload` - in the next phase we'll add real routes that take input, and you'll
watch the docs update as you save. That's the build step done.


---

# Routes and Pydantic Models

Last phase you served a fixed response. A real API takes input - an ID in the
URL, a filter in the query string, a JSON body on a POST. This phase covers all
three, and shows you the part of FastAPI that does the most work for the least
code: validation driven by type hints.

Keep the server running with `--reload`. Edit `main.py` and watch `/docs` update
as you save.

## Path parameters: data in the URL

A path parameter is a piece of the URL that changes - the `42` in `/notes/42`.
You declare it with curly braces in the route and as an argument to the
function:

```python
from fastapi import FastAPI

app = FastAPI()


@app.get("/notes/{note_id}")
def get_note(note_id: int):
    return {"note_id": note_id, "kind": type(note_id).__name__}
```

Notice `note_id: int`. That type hint isn't decoration. Visit
`http://127.0.0.1:8000/notes/42` and you get:

```json
{"note_id": 42, "kind": "int"}
```

FastAPI converted the string `"42"` from the URL into an actual integer. Now try
`http://127.0.0.1:8000/notes/banana`. Instead of crashing, you get a clean
422 response:

```json
{
  "detail": [
    {
      "type": "int_parsing",
      "loc": ["path", "note_id"],
      "msg": "Input should be a valid integer, ...",
      "input": "banana"
    }
  ]
}
```

You wrote zero validation code. The type hint did it. This is the core idea of
FastAPI - you describe the shape of your data with normal Python types, and the
framework enforces it.

## Query parameters: data after the ?

A query parameter is the `?limit=5` part of a URL. FastAPI gives you these for
free: any function argument that *isn't* in the path becomes a query parameter.

```python
@app.get("/notes")
def list_notes(limit: int = 10, q: str | None = None):
    return {"limit": limit, "q": q}
```

Two things to read carefully:

- `limit: int = 10` has a default, so it's optional. Visit `/notes` and you get
  `limit: 10`. Visit `/notes?limit=3` and you get `3`. Pass `/notes?limit=abc`
  and you get a 422 - the `int` hint is doing its job again.
- `q: str | None = None` means "an optional string". It's there when you want a
  search term and absent otherwise.

Path vs query, side by side:

| | Path param | Query param |
|---|---|---|
| Lives in | the URL path: `/notes/42` | after the `?`: `/notes?limit=5` |
| Declared by | `{name}` in the route | a function arg not in the path |
| Optional? | no - it's part of the address | yes, if it has a default |
| Good for | identifying *which* resource | filtering, sorting, paging |

## Request bodies: a Pydantic model

GET requests carry data in the URL. But to *create* a note you need to send a
chunk of JSON - a title and some content - in the request body. For that you
define the expected shape as a Pydantic model.

Add this to the top of `main.py`:

```python
from pydantic import BaseModel


class NoteIn(BaseModel):
    title: str
    content: str
    pinned: bool = False
```

A `BaseModel` subclass is a description of valid input. `title` and `content`
are required strings; `pinned` is an optional boolean that defaults to `False`.

Now write a POST endpoint that takes one:

```python
@app.post("/notes")
def create_note(note: NoteIn):
    return {
        "received": note.model_dump(),
        "title_length": len(note.title),
    }
```

Because `note` is typed as your model, FastAPI knows the data comes from the
request body. It reads the incoming JSON, checks it against `NoteIn`, and hands
you a fully-typed `note` object - `note.title`, `note.pinned`, with editor
autocomplete and everything.

## Try it from the docs

Go to `http://127.0.0.1:8000/docs`. The `POST /notes` endpoint is there, and so
is a schema showing exactly which fields it expects - FastAPI generated that from
your model. Click it, hit **Try it out**, and send this body:

```json
{
  "title": "Buy milk",
  "content": "2% if they have it"
}
```

You'll get back the parsed data plus the title length. Now break it on purpose -
send a body with `title` missing:

```json
{
  "content": "no title here"
}
```

The response is a 422 that points at the exact problem:

```json
{
  "detail": [
    {
      "type": "missing",
      "loc": ["body", "title"],
      "msg": "Field required"
    }
  ]
}
```

That error message is generated from your model. You didn't write a single
`if "title" not in data` check.

## Why this matters

Most of the validation code in a hand-rolled API is checking that fields exist
and have the right type. Pydantic turns that whole category of code into a class
definition. You declare the shape once, and every endpoint that uses the model
gets the checks, the 422 errors, *and* the docs.

## Where we are

You can now take input three ways - path, query, and body - and FastAPI
validates all of it from your type hints. The data still vanishes the moment the
request ends, though; nothing is stored. Next phase we give the notes a place to
live and wire up all four CRUD operations.


---

# CRUD with an In-Memory Store

CRUD is the four things almost every API does: **C**reate, **R**ead,
**U**pdate, **D**elete. This phase wires up all four over a place to keep the
notes. We're using a plain Python dictionary on purpose - it keeps the focus on
the routing and the HTTP, with no database to set up yet. Phase 5 swaps it for
SQLite, and you'll see how little of this code changes.

One catch with an in-memory store: the data lives only while the server runs.
Restart it (or let `--reload` bounce it on a save) and you're back to empty.
That's fine for now - it's exactly why phase 5 exists.

## Your turn: create_note

Before the full file, here's one piece to write yourself. The routing and the
dict-as-database pattern are new this phase, but `create_note`'s job is just
plain dict bookkeeping - the same kind of thing you'd write in any Python
script.

`create_note` is the handler behind `POST /notes`. It builds a record from the
`next_id` counter and the incoming `note`, stores that record in the `notes`
dict under `next_id`, bumps the counter for the next caller, and returns the
record it just stored - id included.

```python
@app.post("/notes")
def create_note(note: NoteIn):
    # your turn
    return None
```

You already used `note.model_dump()` last phase to turn the Pydantic model
into a dict - the rest is a dict literal with `**note.model_dump()` spread in,
an assignment into `notes[next_id]`, and `next_id += 1` (remember `global
next_id` inside the function, since you're reassigning it). My version is in
the file below - once the server's running, create a note with the first curl
command further down and check you get the stored record back with `"id": 1`,
exactly as the walkthrough describes.

## Replace main.py

We've collected enough pieces to write the whole file cleanly. Replace the
contents of `main.py` with this - including my `create_note`, so compare it
with yours:

```python
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class NoteIn(BaseModel):
    title: str
    content: str
    pinned: bool = False


# Our "database" for now: a dict of id -> note, plus a counter for new ids.
notes: dict[int, dict] = {}
next_id = 1


@app.get("/notes")
def list_notes():
    return list(notes.values())


@app.get("/notes/{note_id}")
def get_note(note_id: int):
    return notes[note_id]


@app.post("/notes")
def create_note(note: NoteIn):
    global next_id
    record = {"id": next_id, **note.model_dump()}
    notes[next_id] = record
    next_id += 1
    return record


@app.put("/notes/{note_id}")
def update_note(note_id: int, note: NoteIn):
    record = {"id": note_id, **note.model_dump()}
    notes[note_id] = record
    return record


@app.delete("/notes/{note_id}")
def delete_note(note_id: int):
    del notes[note_id]
    return {"deleted": note_id}
```

Walk through what each route does:

- **list** returns all the note records as a JSON array.
- **get** looks up one note by its id.
- **create** assigns the next id, stores the record, bumps the counter, and
  returns what it stored - including the new id, which the client needs.
- **update** overwrites the note at that id with the new data.
- **delete** removes the entry and confirms which id went.

The `global next_id` line is there because we reassign that module-level variable
inside the function. It's a little ugly - and it's another reason a real database
is nicer, since the database hands out ids for us. We'll get there.

> Notice `get` and `delete` will blow up if the id doesn't exist - a `KeyError`
> that FastAPI turns into an ugly 500. We're leaving that on purpose. Phase 4 is
> all about turning those into clean 404s.

## Test it with curl

Restart isn't needed - `--reload` already reloaded on save. Open a *second*
terminal (leave the server running in the first) and drive the API by hand.

Windows note: PowerShell aliases `curl` to its own command, so use `curl.exe`
there. On macOS/Linux plain `curl` is fine.

**Create a note:**

```bash
curl -X POST http://127.0.0.1:8000/notes \
  -H "Content-Type: application/json" \
  -d '{"title": "Buy milk", "content": "2% if they have it"}'
```

You'll get back the stored record with `"id": 1`. Create one more so we have
something to list:

```bash
curl -X POST http://127.0.0.1:8000/notes \
  -H "Content-Type: application/json" \
  -d '{"title": "Call dentist", "content": "book a cleaning", "pinned": true}'
```

**List them:**

```bash
curl http://127.0.0.1:8000/notes
```

You should see both notes in an array.

**Read one:**

```bash
curl http://127.0.0.1:8000/notes/1
```

**Update it** (PUT replaces the whole note):

```bash
curl -X PUT http://127.0.0.1:8000/notes/1 \
  -H "Content-Type: application/json" \
  -d '{"title": "Buy oat milk", "content": "the barista kind", "pinned": true}'
```

List again and you'll see note 1 has changed.

**Delete one:**

```bash
curl -X DELETE http://127.0.0.1:8000/notes/2
```

List one final time - note 2 is gone.

## The map of methods to operations

This pairing is a convention you'll see in nearly every REST API. Worth
committing to memory:

| Operation | HTTP method | Path | What it does |
|-----------|-------------|------|--------------|
| Create | `POST` | `/notes` | add a new note |
| Read (all) | `GET` | `/notes` | list notes |
| Read (one) | `GET` | `/notes/{id}` | fetch a single note |
| Update | `PUT` | `/notes/{id}` | replace a note |
| Delete | `DELETE` | `/notes/{id}` | remove a note |

Two patterns fall out of this. `POST` and `GET-all` act on the *collection*
(`/notes`), while `GET-one`, `PUT`, and `DELETE` act on a *specific member*
(`/notes/{id}`). And the same path serves different operations depending on the
method - `/notes` is both "list" and "create", the method tells them apart.

## Where we are

You have a full CRUD API. You can create notes, read them back, change them, and
remove them - all driven by the right HTTP methods, all testable with curl or the
`/docs` page. It's missing one thing a real API can't skip: it falls apart the
moment someone asks for a note that doesn't exist. That's the next phase.


---

# Validation, Errors, and Status Codes

Right now, ask the API for note 999 and it throws a `KeyError`, which FastAPI
turns into a 500 Internal Server Error and a stack trace. To anyone calling your
API, a 500 means "the server is broken" - but the server isn't broken, the
caller asked for something that doesn't exist. That's a 404, and saying so
plainly is the difference between an API people can build against and one they
have to guess at.

This phase fixes the error behavior and tightens the input rules. Keep editing
`main.py`.

## The right status code carries meaning

HTTP status codes are how an API tells the caller what happened without them
reading the body. The ones you care about here:

| Code | Name | When you return it |
|------|------|--------------------|
| 200 | OK | a normal successful GET, PUT, or DELETE |
| 201 | Created | you created a resource (POST) |
| 404 | Not Found | the requested resource doesn't exist |
| 422 | Unprocessable Entity | the input failed validation (FastAPI sends this automatically) |

You're already getting 200 and 422 for free. The two to add by hand are **404**
when a note is missing and **201** when a note is created.

## Raise HTTPException for missing notes

FastAPI gives you `HTTPException` for exactly this. You `raise` it, and FastAPI
turns it into a proper HTTP response with your status code and message - no
stack trace, no 500.

Update the import at the top of `main.py`:

```python
from fastapi import FastAPI, HTTPException, status
```

Now add a small helper and use it in every route that looks a note up by id.
Here are the three read/update/delete routes rewritten:

```python
def get_or_404(note_id: int) -> dict:
    note = notes.get(note_id)
    if note is None:
        raise HTTPException(status_code=404, detail=f"Note {note_id} not found")
    return note


@app.get("/notes/{note_id}")
def get_note(note_id: int):
    return get_or_404(note_id)


@app.put("/notes/{note_id}")
def update_note(note_id: int, note: NoteIn):
    get_or_404(note_id)  # 404 if it isn't there
    record = {"id": note_id, **note.model_dump()}
    notes[note_id] = record
    return record


@app.delete("/notes/{note_id}")
def delete_note(note_id: int):
    get_or_404(note_id)
    del notes[note_id]
    return {"deleted": note_id}
```

The `get_or_404` helper is the kind of small thing that pays off fast: the
existence check lives in one place, and every route that needs a real note calls
it. Try it now - ask for a note that doesn't exist:

```bash
curl -i http://127.0.0.1:8000/notes/999
```

The `-i` flag shows the response headers. You'll see `HTTP/1.1 404 Not Found`
and a clean body:

```json
{"detail": "Note 999 not found"}
```

No stack trace. The caller knows exactly what went wrong.

## Return 201 on create

A successful POST should return 201 Created, not a generic 200. You set that on
the decorator:

```python
@app.post("/notes", status_code=status.HTTP_201_CREATED)
def create_note(note: NoteIn):
    global next_id
    record = {"id": next_id, **note.model_dump()}
    notes[next_id] = record
    next_id += 1
    return record
```

`status.HTTP_201_CREATED` is the integer 201 with a readable name - easier
to read in six months than a bare number. Create a note and check the status:

```bash
curl -i -X POST http://127.0.0.1:8000/notes \
  -H "Content-Type: application/json" \
  -d '{"title": "Test", "content": "hi"}'
```

The first line now reads `HTTP/1.1 201 Created`.

## Richer input validation

So far `title: str` accepts any string - including an empty one. A note with a
blank title isn't useful. Pydantic lets you tighten the rules right in the model
with `Field`, and the failures still come back as clean 422s.

Update the model and its import:

```python
from pydantic import BaseModel, Field


class NoteIn(BaseModel):
    title: str = Field(min_length=1, max_length=120)
    content: str = Field(min_length=1)
    pinned: bool = False
```

Now `title` must be 1–120 characters and `content` can't be empty. Send a blank
title and watch the 422:

```bash
curl -i -X POST http://127.0.0.1:8000/notes \
  -H "Content-Type: application/json" \
  -d '{"title": "", "content": "body"}'
```

```json
{
  "detail": [
    {
      "type": "string_too_short",
      "loc": ["body", "title"],
      "msg": "String should have at least 1 character",
      "input": ""
    }
  ]
}
```

The message names the field, the rule, and the bad input. A caller can read that
and fix their request without emailing you.

## How an error flows through

Here's the whole picture of what happens when something's wrong, whether it's
bad input or a missing note:

```mermaid
graph TD
  A[Request arrives] --> B{Body valid?}
  B -->|no| C[422 from Pydantic]
  B -->|yes| D{Note exists?}
  D -->|no| E[raise HTTPException -> 404]
  D -->|yes| F[do the work -> 200 / 201]
```

Two gates: Pydantic checks the shape of the input before your function even runs,
and your `get_or_404` checks existence inside it. Pass both and you do the real
work and return a success code.

## Where we are

Your API now behaves like one you'd trust: missing things return 404 with a
readable message, creates return 201, and bad input is rejected with a specific
422 instead of slipping through. The only weakness left is that everything still
lives in a dictionary that empties on restart. Last phase: give it a real
database and get it ready to run for real.


---

# A Database, then Ship It

The API works, but every restart wipes it clean. Real data has to outlive the
process, so this phase swaps the dictionary for SQLite. SQLite is a full SQL
database that stores everything in one file, and it ships with Python as the
`sqlite3` module - nothing to install, no server to run. Perfect for this.

The thing worth noticing as we go: your routes barely change. That's the payoff
of having kept the storage logic small. We'll put the database code in its own
file, and the routes will call it the same way they called the dict.

## A database module

Create a new file, `db.py`, next to `main.py`:

```python
import sqlite3

DB_PATH = "notes.db"


def connect():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row  # rows behave like dicts
    return conn


def init_db():
    with connect() as conn:
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS notes (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                title TEXT NOT NULL,
                content TEXT NOT NULL,
                pinned INTEGER NOT NULL DEFAULT 0
            )
            """
        )
```

Two details to call out:

- `conn.row_factory = sqlite3.Row` makes each result row act like a dictionary,
  so `dict(row)` gives you `{"id": 1, "title": ...}` - the same shape your API
  already returns.
- `id INTEGER PRIMARY KEY AUTOINCREMENT` means SQLite hands out the ids itself.
  That `global next_id` counter from earlier? Gone. The database owns ids now.

## Rewrite main.py to use the database

Replace `main.py` with this. The models and the error handling are unchanged -
only the storage swaps from a dict to SQL calls:

```python
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field

from db import connect, init_db

app = FastAPI()


@app.on_event("startup")
def startup():
    init_db()


class NoteIn(BaseModel):
    title: str = Field(min_length=1, max_length=120)
    content: str = Field(min_length=1)
    pinned: bool = False


def get_or_404(note_id: int) -> dict:
    with connect() as conn:
        row = conn.execute(
            "SELECT * FROM notes WHERE id = ?", (note_id,)
        ).fetchone()
    if row is None:
        raise HTTPException(status_code=404, detail=f"Note {note_id} not found")
    return dict(row)


@app.get("/notes")
def list_notes():
    with connect() as conn:
        rows = conn.execute("SELECT * FROM notes ORDER BY id").fetchall()
    return [dict(r) for r in rows]


@app.get("/notes/{note_id}")
def get_note(note_id: int):
    return get_or_404(note_id)


@app.post("/notes", status_code=status.HTTP_201_CREATED)
def create_note(note: NoteIn):
    with connect() as conn:
        cur = conn.execute(
            "INSERT INTO notes (title, content, pinned) VALUES (?, ?, ?)",
            (note.title, note.content, int(note.pinned)),
        )
        new_id = cur.lastrowid
    return get_or_404(new_id)


@app.put("/notes/{note_id}")
def update_note(note_id: int, note: NoteIn):
    get_or_404(note_id)
    with connect() as conn:
        conn.execute(
            "UPDATE notes SET title = ?, content = ?, pinned = ? WHERE id = ?",
            (note.title, note.content, int(note.pinned), note_id),
        )
    return get_or_404(note_id)


@app.delete("/notes/{note_id}")
def delete_note(note_id: int):
    get_or_404(note_id)
    with connect() as conn:
        conn.execute("DELETE FROM notes WHERE id = ?", (note_id,))
    return {"deleted": note_id}
```

A few things to take away from this:

- **The routes look the same.** Same paths, same methods, same status codes, same
  404s. Callers can't tell the storage changed - which is the whole point.
- **Those `?` placeholders matter.** Never build SQL by pasting values into the
  string. The `?` lets SQLite insert the value safely, which is what stops SQL
  injection. Always pass values as the tuple, never with f-strings.
- **`@app.on_event("startup")`** runs `init_db()` once when the server boots, so
  the table exists before the first request. `CREATE TABLE IF NOT EXISTS` makes
  that safe to run every time.
- **`int(note.pinned)`** because SQLite has no boolean type - we store `True`/
  `False` as `1`/`0`.

## Run and test it

Start the server the same way as before:

```bash
uvicorn main:app --reload
```

On the first request a file called `notes.db` appears in your folder - that's
your database. Run the same curl commands from phase 3 to create and read notes:

```bash
curl -X POST http://127.0.0.1:8000/notes \
  -H "Content-Type: application/json" \
  -d '{"title": "Buy milk", "content": "2% if they have it"}'

curl http://127.0.0.1:8000/notes
```

Now the real test. Stop the server with `Ctrl+C`, start it again, and list the
notes:

```bash
curl http://127.0.0.1:8000/notes
```

Your note is still there. The data survived the restart. That's the line between
a demo and something you could actually use.

## A note on SQLAlchemy

We used the built-in `sqlite3` module because it's already there and the SQL is
short. On a bigger project you'll likely reach for **SQLAlchemy**, an ORM that
lets you work with Python objects instead of writing SQL by hand, and lets you
switch from SQLite to PostgreSQL by changing a connection string. It's the right
tool once your queries grow - but the concepts you learned here (a connection, a
table, the CRUD statements, parameterized values) are exactly what it wraps. You
haven't learned a throwaway version; you've learned the layer underneath.

## Getting it ready to ship

A few things stand between this and a deployed service. Quick tour so you know
what's next:

| Concern | What to do |
|---------|------------|
| **Pin your deps** | Run `pip freeze > requirements.txt` so anyone (or any server) can recreate your environment with `pip install -r requirements.txt`. |
| **Production server** | `--reload` is for development. In production you run something like `uvicorn main:app --host 0.0.0.0 --port 8000` (no reload), often behind a process manager. |
| **A real database** | SQLite is great for one machine. For a service that scales, move to PostgreSQL - this is where SQLAlchemy earns its keep. |
| **Containerize** | A small `Dockerfile` makes the app run the same everywhere. |

A minimal `Dockerfile` for this project looks like:

```dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
```

Build and run it with:

```bash
docker build -t notes-api .
docker run -p 8000:8000 notes-api
```

Hosts like Railway, Render, Fly.io, or any cloud that runs containers will take
this image and put it on the internet. Each has its own steps, but they all want
the same thing you now have: an app that starts with one command and listens on a
port.

## Where we are - and what you built

Step back and look at the folder. Two files, `main.py` and `db.py`, and you have:

- five REST endpoints covering full CRUD
- input validated from type hints and Pydantic `Field` rules
- proper status codes - 201 on create, 404 on missing, 422 on bad input
- a SQLite database that keeps your data across restarts
- auto-generated interactive docs at `/docs`
- a `Dockerfile` and a clear path to deployment

That's a real REST API, built the way you'd build one at work - start small, add
validation, separate the storage, and only then worry about shipping. The
"notes" subject was an excuse; swap it for tasks, users, products, or anything
else and the same five-phase shape holds. You've got the pattern now.
