05 — Normalization and SQLite
Duration: 15 min Prerequisites: chapter 04 (ingestion produces a DataFrame).
The contract
Section titled “The contract”After ingestion we have a DataFrame with date, card, description, category, debit, credit. After normalization we want a clean SQL table that’s easy to query:
CREATE TABLE transactions ( profile TEXT NOT NULL, date TEXT NOT NULL, -- ISO 8601 YYYY-MM-DD card TEXT, description TEXT NOT NULL, category TEXT NOT NULL, amount REAL NOT NULL -- signed: + expense, - refund);One signed amount column (not two) — much easier for the LLM to write SQL against.
Normalization rules in csv-llm-shared/normalize.py
Section titled “Normalization rules in csv-llm-shared/normalize.py”import pandas as pd
def normalize(df: pd.DataFrame, profile: str = "default") -> pd.DataFrame: out = df.copy()
# 1. Parse dates → ISO YYYY-MM-DD out["date"] = pd.to_datetime(out["date"], errors="coerce").dt.strftime("%Y-%m-%d")
# 2. Compute signed amount: debit is expense (+), credit is refund (-) out["amount"] = out["debit"].astype(float) - out["credit"].astype(float)
# 3. Trim strings for col in ("card", "description", "category"): out[col] = out[col].astype(str).str.strip()
# 4. Drop empty rows out = out[(out["description"] != "") & (out["date"].notna())]
# 5. Attach profile out["profile"] = profile
return out[["profile", "date", "card", "description", "category", "amount"]]Now amount > 0 means money out and amount < 0 means money in.
Why signed amounts
Section titled “Why signed amounts”Compare these two SQL queries:
-- Two-column version (debit/credit)SELECT category, SUM(debit) - SUM(credit) AS netFROM transactionsGROUP BY category;
-- Signed version (one column)SELECT category, SUM(amount) AS netFROM transactionsGROUP BY category;The LLM writes the second one much more reliably. Less chance of forgetting one of the two columns.
The SQLite layer in csv-llm-shared/db.py
Section titled “The SQLite layer in csv-llm-shared/db.py”import sqlite3from pathlib import Pathimport pandas as pd
SCHEMA = """CREATE TABLE IF NOT EXISTS transactions ( profile TEXT NOT NULL, date TEXT NOT NULL, card TEXT, description TEXT NOT NULL, category TEXT NOT NULL, amount REAL NOT NULL);CREATE INDEX IF NOT EXISTS idx_profile_date ON transactions(profile, date);CREATE INDEX IF NOT EXISTS idx_category ON transactions(category);"""
def open_db(path: str) -> sqlite3.Connection: Path(path).parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(path) conn.executescript(SCHEMA) return conn
def load_dataframe(conn, df: pd.DataFrame, profile: str) -> int: """(Re)load a profile: delete existing rows then insert.""" conn.execute("DELETE FROM transactions WHERE profile = ?", (profile,)) df.to_sql("transactions", conn, if_exists="append", index=False) conn.commit() return len(df)The SELECT-only safe query
Section titled “The SELECT-only safe query”This is the piece the LLM will use as a tool. We must guarantee it cannot mutate data:
import re
FORBIDDEN = re.compile( r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|REPLACE|ATTACH|DETACH|PRAGMA)\b", re.IGNORECASE,)
def safe_query(conn, sql: str) -> pd.DataFrame: """SELECT-only. Raises ValueError on anything else.""" if not sql.strip().lower().startswith("select"): raise ValueError("SQL refused: only SELECT statements allowed.") if FORBIDDEN.search(sql): raise ValueError(f"SQL refused: forbidden keyword detected.") if ";" in sql.rstrip().rstrip(";"): raise ValueError("SQL refused: multiple statements not allowed.") return pd.read_sql_query(sql, conn)Three defenses:
- Must start with
SELECT. - No forbidden keyword anywhere (including in comments —
--is preserved by sqlite parser). - No multi-statement (a single trailing
;is OK, anything else raises).
End-to-end pipeline
Section titled “End-to-end pipeline”import syssys.path.insert(0, "csv-llm-shared")import ingest, normalize, db
# 1. Read the CSVdf = ingest.read_csv("csv-llm-ollama/data/data1-anonymized.csv")
# 2. Normalizenf = normalize.normalize(df, profile="data1")
# 3. Load into SQLiteconn = db.open_db("csv-llm-ollama/.cache/csv-llm-ollama.sqlite")inserted = db.load_dataframe(conn, nf, profile="data1")print(f"{inserted} rows inserted")# 734 rows inserted
# 4. Run a safe queryresult = db.safe_query(conn, """ SELECT category, SUM(amount) AS total FROM transactions WHERE profile = 'data1' AND amount > 0 GROUP BY category ORDER BY total DESC LIMIT 5""")print(result)Real output:
category total0 Housing 30895.291 Travel 16597.442 Business 14386.393 Groceries 9725.934 Education 8987.85This matches what we computed in chapter 02 — the pipeline is consistent end-to-end.
What about duplicates?
Section titled “What about duplicates?”A naive ingestion would insert the same row twice if you reload the CSV. Two strategies:
- DELETE-then-INSERT by profile (what
load_dataframedoes above). Simple and good enough for our case. - UNIQUE index on
(profile, date, description, amount)withINSERT OR IGNORE. More elegant but trickier if a real merchant legitimately charges you twice on the same day.
We pick strategy 1.
Takeaways
Section titled “Takeaways”- After normalization, we have one signed
amountcolumn, making SQL queries simpler for the LLM. - The DB has one table, two indices, and 734 rows for the demo profile.
safe_queryis the only way the LLM touches the database. SELECT-only, single statement, no keyword bypass.- Reloading a CSV deletes the previous rows for the same profile — no manual cleanup needed.