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:
=
=
= # rows behave like dicts
return
Two details to call out:
conn.row_factory = sqlite3.Rowmakes each result row act like a dictionary, sodict(row)gives you{"id": 1, "title": ...}- the same shape your API already returns.id INTEGER PRIMARY KEY AUTOINCREMENTmeans SQLite hands out the ids itself. Thatglobal next_idcounter 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:
=
: =
: =
: = False
=
return
=
return
return
=
=
return
return
return
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")runsinit_db()once when the server boots, so the table exists before the first request.CREATE TABLE IF NOT EXISTSmakes that safe to run every time.int(note.pinned)because SQLite has no boolean type - we storeTrue/Falseas1/0.
Run and test it
Start the server the same way as before:
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:
Now the real test. Stop the server with Ctrl+C, start it again, and list the
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:
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:
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
Fieldrules - 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
Dockerfileand 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.