◆ Chapter 14
Shipping it: Streamlit UIs, FastAPI from scratch, and SQLite persistence
Streamlit for a quick UI, FastAPI from scratch, and SQLite persistence — the shortest honest path from script to shipped app.
~2,400 words · chapter 14 of 15
You have a model. It classifies reviews, summarises tickets, whatever. Right now it lives in a script you run by hand. This chapter is about the plumbing that turns a script into something other people can touch: a quick UI to demo it, an HTTP API to serve it, and a database to remember its results. Three tools, all pure Python, all runnable in minutes.
Streamlit: a web UI in ~15 lines
Streamlit turns a Python script into a web app. No HTML, no JavaScript, no React, no separate frontend. You write Python; it renders widgets in a browser. AI engineers reach for it constantly because the fastest way to show a stakeholder your model is a text box they can type into and a result that appears below.
Here is a complete, working app. Put it in app.py:
import streamlit as st
st.title("Review Sentiment Demo")
review = st.text_area("Paste a customer review:")
if st.button("Analyze"):
# pretend this calls your real model
label = "negative" if "refund" in review.lower() else "positive"
st.write(f"Sentiment: **{label}**")
Run it from the terminal:
python -m streamlit run app.py
That command starts a local web server and opens a browser tab. st.title renders a heading. st.text_area renders a multi-line input box and returns whatever the user typed as a plain string. st.button renders a button and returns True on the run where it was just clicked. st.write is the Swiss-army display function — hand it a string, a number, a DataFrame, a chart, and it figures out how to show it. That is a real, shareable UI in fifteen lines.
JS → Python: Streamlit is server-rendered reactive UI written entirely in Python — think of it as a tiny Next.js where every widget interaction re-runs the whole script on the server and ships a fresh render to the browser. You never write JSX, you never wire up
useState, and there is no client bundle you maintain. The trade-off is that you get Streamlit's widgets and layout, not arbitrary custom components.
The re-run model (this is the thing to understand)
Streamlit's one big idea: every time the user interacts with any widget, Streamlit re-runs your entire script from top to bottom. Click the button, type in the box, move a slider — the whole app.py executes again, fresh.
This is elegant but has a sharp consequence: ordinary Python variables do not survive between runs. If you write count = 0 at the top and increment it on a click, it resets to 0 on the very next re-run. Your local variables are wiped every time.
To keep values across re-runs, Streamlit gives you st.session_state — a dictionary that persists for the length of the browser session:
import streamlit as st
st.title("Chatbot")
# runs once per session, not on every re-run
if "messages" not in st.session_state:
st.session_state.messages = []
prompt = st.chat_input("Say something")
if prompt:
st.session_state.messages.append({"role": "user", "text": prompt})
reply = f"You said: {prompt}" # your LLM call would go here
st.session_state.messages.append({"role": "bot", "text": reply})
for msg in st.session_state.messages:
st.write(f"**{msg['role']}**: {msg['text']}")
The if "messages" not in st.session_state guard is the pattern to recognise. It initialises the history exactly once. Every re-run after that skips the initialisation and reuses the stored list, so the conversation accumulates instead of resetting. Without session_state, each message would erase the last. This is how you build a chatbot, a running counter, or any UI with memory.
Reading & judging: Streamlit is for prototypes, internal tools, and demos — not customer-facing production. A Streamlit app sitting on a public customer path is a red flag; that job belongs to React/Next with a real backend. Second red flag: expensive work (a model load, an API call, a big query) written to run on every single re-run. Since the script re-runs on every keystroke, that means the model reloads on every keystroke. Good code caches it — with
@st.cache_data(or@st.cache_resourcefor models/connections) or by stashing the result insession_state.
Try it: Add a
st.slider("temperature", 0.0, 1.0, 0.7)to the first app and dropst.write(temperature)below it. Drag the slider and watch the value update — that is a full re-run happening each time you move it.
FastAPI from zero
A UI is for humans. An API is for programs — other services, front-ends, agents — to call your model over HTTP. FastAPI is the standard Python framework for this: fast, modern, and it validates requests for you.
Install it (with the standard server extras):
pip install "fastapi[standard]"
The smallest possible API, in main.py:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def home():
return {"status": "ok", "service": "sentiment"}
Run it:
fastapi dev main.py
app = FastAPI() creates the application object. @app.get("/") is a decorator that says "when someone makes an HTTP GET request to the path /, call this function." The function returns a Python dict, and FastAPI automatically converts it to JSON in the response. You did not write any serialization code — returning a dict is enough.
If
fastapi devis not on your path,uvicorn main:app --reloaddoes the same job (mainis the file,appis the object). With uv it isuv run fastapi dev main.py. All three start a development server that auto-reloads when you save.
Query parameters, typed for free
Real endpoints take input. The simplest input is a query parameter — the ?key=value part of a URL. FastAPI reads them straight from your function's arguments:
@app.get("/margin")
def margin(revenue: float, expense: float):
profit = revenue - expense
return {"profit": profit, "margin_pct": profit / revenue * 100}
Now a request to /margin?revenue=100&expense=40 returns {"profit": 60.0, "margin_pct": 60.0}. The magic is in the type hints. Because you annotated revenue: float and expense: float, FastAPI knows to pull those two values out of the URL, convert the text "100" into the number 100.0, and validate them. Send /margin?revenue=abc and FastAPI rejects it with a clear 422 error before your function ever runs. The type hints are not decoration — they are the validation contract.
GET vs POST, and a request body
GET requests fetch things and carry their input in the URL. They should not change server state, and the URL is visible in logs and browser history — so no large or sensitive payloads. POST requests send data to be processed or stored, and they carry that data in a request body, separate from the URL. A one-line review might fit in a query string; a 500-word document or a chat history belongs in a POST body.
For a body, you describe its shape with a Pydantic model (covered earlier in the guide — a class that declares typed fields):
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Review(BaseModel):
text: str
@app.post("/analyze")
def analyze(review: Review):
label = "negative" if "refund" in review.text.lower() else "positive"
return {"label": label, "length": len(review.text)}
The client sends JSON like {"text": "I want a refund"}. Because the analyze parameter is typed as Review, FastAPI reads the request body, parses the JSON, validates that text is present and is a string, and hands you a ready-made Review object. If the JSON is malformed or text is missing, the caller gets a precise validation error — you never write a single if "text" not in data check.
The docs page comes free
Start the server and visit http://localhost:8000/docs in your browser. FastAPI has generated a full Swagger UI — an interactive page listing every endpoint, its parameters, and its expected body. Each endpoint has a "Try it out" button that lets you fill in values and fire a real request from the browser, no separate tool needed. This is generated from your type hints and Pydantic models automatically; there is nothing to maintain. (For testing by hand outside the browser, Postman is the other common tool — it stores collections of requests you can replay.)
JS → Python: FastAPI is roughly Express, but with two things baked in that you would bolt on separately in Node. Request and response validation is declarative through Pydantic type hints — as if Zod were part of the framework and wired to every route automatically. And the OpenAPI/Swagger docs are generated for you, so there is no hand-written API spec drifting out of sync with the code.
Reading & judging: Good FastAPI endpoints take and return Pydantic models — validated boundaries where bad input is rejected up front. An endpoint that accepts a raw
dictand reaches into it withdata["text"]has thrown away the framework's best feature and will fail in ugly ways on malformed input. When you meet an unfamiliar API in this codebase, the/docspage is the fastest way to understand it — it is always accurate because it is generated from the code.
SQLite: a real database, zero setup
Your API produces results. To remember them across restarts you need a database, and the one built into Python is sqlite3. SQLite is a full relational database — real SQL, real tables — that stores everything in a single file on disk. No server to install, no connection string, no Docker.
First, a correction of a common newcomer mistake: do not run pip install sqlite3. It ships with Python's standard library. There is nothing to install; you just import sqlite3.
import sqlite3
conn = sqlite3.connect("shop.db") # creates the file if missing
cur = conn.cursor()
cur.execute(
"CREATE TABLE IF NOT EXISTS reviews ("
" id INTEGER PRIMARY KEY,"
" text TEXT,"
" label TEXT,"
" score REAL"
")"
)
conn.commit()
conn.close()
sqlite3.connect("shop.db") opens (or creates) the database file. The conn is your connection; the cur is a cursor, the object you run SQL statements through. The CREATE TABLE IF NOT EXISTS runs once and defines four columns: an auto-incrementing id, two text columns, and a score stored as REAL (a floating-point number). IF NOT EXISTS means running this twice is safe — it won't error on the second run.
Inserting data — with placeholders, always
Here is the single most important safety rule in this whole chapter. To put values into a query, use the ? placeholder and pass the values as a separate tuple:
import sqlite3
conn = sqlite3.connect("shop.db")
cur = conn.cursor()
text, label, score = "I want a refund", "negative", 0.91
cur.execute(
"INSERT INTO reviews (text, label, score) VALUES (?, ?, ?)",
(text, label, score),
)
conn.commit() # <-- without this, nothing is saved
conn.close()
Each ? is a slot. The tuple (text, label, score) fills the slots in order, and — crucially — SQLite treats those values strictly as data, never as SQL code. A review containing '; DROP TABLE reviews;-- is stored as harmless text, not executed.
Note conn.commit(). SQLite runs your writes inside a transaction, and they are not durably saved until you commit. Skip the commit and close the connection, and your inserts silently vanish. This trips up nearly everyone once.
To insert many rows at once, executemany takes a list of tuples:
rows = [
("Great product", "positive", 0.88),
("Broke in a week", "negative", 0.79),
("Does the job", "positive", 0.55),
]
cur.executemany(
"INSERT INTO reviews (text, label, score) VALUES (?, ?, ?)",
rows,
)
conn.commit()
Reading it back
Querying uses the same cursor. Run a SELECT, then pull the results:
cur.execute("SELECT id, text, score FROM reviews WHERE label = ?", ("negative",))
negatives = cur.fetchall()
for row in negatives:
print(row) # ('id', 'text', score) as a tuple
The ? placeholder rule applies to reads too — the filter value "negative" goes in as a tuple, never glued into the string. fetchall() returns a list of rows, each row a tuple of column values in the order you selected them. (There is also fetchone() for a single row.)
Closing properly with with
Forgetting to commit or close is easy. The connection works as a context manager, which commits automatically on a clean exit and rolls back if an exception is raised inside the block:
import sqlite3
with sqlite3.connect("shop.db") as conn:
cur = conn.cursor()
cur.execute(
"INSERT INTO reviews (text, label, score) VALUES (?, ?, ?)",
("Late delivery", "negative", 0.7),
)
# commit happens automatically when the block exits cleanly
conn.close()
The with block handles the commit for you, so a stray return in the middle can't lose your write. Note that with here manages the transaction, not the connection itself — you still call conn.close() afterwards to release the file.
Reading & judging: The cardinal sin is building SQL by string formatting —
f"... WHERE label = '{user_input}'"or"..." + value. That is a SQL-injection hole, and with LLM output flowing into your database it is doubly dangerous, because model text is untrusted input just like user text. Always use?placeholders and pass a tuple. The quieter bug is a missingconn.commit()— code that "works" in the same session but loses everything on restart. And know when to graduate: SQLite is superb for prototypes, single-writer tools, and local persistence, but when you need many concurrent writers, real schema migrations, or a shared database across services, move to Postgres with an ORM like SQLAlchemy.
Try it: Run the
executemanyinsert above, then query the negatives withSELECT text, score FROM reviews WHERE label = ? ORDER BY score DESCand a tuple of("negative",). You have just stored LLM sentiment results and pulled back the ones the model was most confident were negative.
Talking to other APIs: the HTTP clients
Serving an API is half the story; often your code also calls other HTTP APIs (an LLM provider, a data service). Here is the quick map from what you know.
JS → Python: In Node you reach for
fetch. Python's closest everyday equivalent isrequests—requests.get(url).json()returns parsed JSON,resp.status_codeis the HTTP status, and you pass query parameters asparams={"q": "hello"}; it is synchronous and beautifully simple. The guide's default ishttpx, which offers the same friendly API but also supportsasync/await(so it fits inside FastAPI's async endpoints and won't block your server on a slow upstream call). Rule of thumb:requestsfor a quick script,httpxfor anything that runs inside a server or needs concurrency.