Python for AI Engineers

◆ 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_resource for models/connections) or by stashing the result in session_state.

Try it: Add a st.slider("temperature", 0.0, 1.0, 0.7) to the first app and drop st.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 dev is not on your path, uvicorn main:app --reload does the same job (main is the file, app is the object). With uv it is uv 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 dict and reaches into it with data["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 /docs page 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 missing conn.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 executemany insert above, then query the negatives with SELECT text, score FROM reviews WHERE label = ? ORDER BY score DESC and 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 is requestsrequests.get(url).json() returns parsed JSON, resp.status_code is the HTTP status, and you pass query parameters as params={"q": "hello"}; it is synchronous and beautifully simple. The guide's default is httpx, which offers the same friendly API but also supports async/await (so it fits inside FastAPI's async endpoints and won't block your server on a slow upstream call). Rule of thumb: requests for a quick script, httpx for anything that runs inside a server or needs concurrency.