Learning Goals
3 min- Connect to a SQLite file with
sqlite3.connect. - Open a cursor and run SQL with
cur.execute(sql). - Fetch results with
fetchall/fetchone. - Commit writes; use parameter placeholders (
?) to prevent SQL injection.
Warm-Up · A Database in One File
5 minSQLite stores a whole database in one file. No server, no setup, no password. Open VS Code, create school.db implicitly:
import sqlite3 con = sqlite3.connect("school.db") print(con) con.close()
That run creates school.db on disk (empty) — and gives you back a connection. Two more commands open a cursor and run SQL.
Every database interaction follows the same five steps: connect → cursor → execute → fetch → commit (if you wrote). With with blocks and parameter binding, you write safe code from day one.
New Concept · connect, cursor, execute
14 minCreate + insert
import sqlite3 con = sqlite3.connect("school.db") cur = con.cursor() cur.execute(""" CREATE TABLE IF NOT EXISTS students ( id INTEGER PRIMARY KEY, name TEXT, score INTEGER ) """) cur.execute("INSERT INTO students (name, score) VALUES (?, ?)", ("Aisyah", 88)) con.commit() # writes are not saved until commit con.close()
Read
con = sqlite3.connect("school.db") cur = con.cursor() cur.execute("SELECT id, name, score FROM students") print(cur.fetchall()) con.close()
[(1, 'Aisyah', 88)]
Each row is a tuple. Use fetchone() for a single row, fetchall() for everything.
Parameters — the only safe way to plug values in
# ✗ Wrong — opens you to SQL injection cur.execute(f"SELECT * FROM students WHERE name = '{user_input}'") # ✓ Right cur.execute("SELECT * FROM students WHERE name = ?", (user_input,))
The ? is a placeholder. The driver escapes the value for you — even if user_input is "Bobby'; DROP TABLE students;--" it's just a name to look up.
Many rows at once — executemany
rows = [("Wei Jie", 75), ("Suresh", 92), ("Mei", 80)] cur.executemany("INSERT INTO students (name, score) VALUES (?, ?)", rows) con.commit()
Connection as a context manager
The cleanest pattern — auto-commit on success, auto-rollback on exception:
with sqlite3.connect("school.db") as con: cur = con.cursor() cur.execute("UPDATE students SET score = score + 5 WHERE name = ?", ("Aisyah",)) # commit happens automatically when the block exits cleanly
Bonus — rows as dicts
con.row_factory = sqlite3.Row # rows behave like dicts AND tuples cur = con.cursor() cur.execute("SELECT * FROM students LIMIT 1") row = cur.fetchone() print(row["name"], row["score"])
Worked Example · Migrate sales.csv to SQLite
12 min# csv_to_sqlite.py — load sales.csv into a sales table import csv, sqlite3 from pathlib import Path DB = Path("shop.db") CSV = Path("sales.csv") with sqlite3.connect(DB) as con: cur = con.cursor() cur.execute("DROP TABLE IF EXISTS sales") cur.execute(""" CREATE TABLE sales ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT, product TEXT, quantity INTEGER, price REAL ) """) with CSV.open(newline="") as f: rows = [ (r["date"], r["product"], int(r["quantity"]), float(r["price"])) for r in csv.DictReader(f) ] cur.executemany( "INSERT INTO sales (date, product, quantity, price) VALUES (?,?,?,?)", rows, ) cur.execute("SELECT COUNT(*) FROM sales") print(f"loaded {cur.fetchone()[0]} rows into {DB}") cur.execute(""" SELECT product, SUM(quantity*price) AS revenue FROM sales GROUP BY product ORDER BY revenue DESC """) print("\nrevenue by product:") for product, rev in cur.fetchall(): print(f" {product:<8} RM {rev:.2f}")
Sample output
loaded 6 rows into shop.db revenue by product: nasi RM 24.00 milo RM 18.00 roti RM 12.00
Read the diff
Three reusable habits: drop-and-recreate the table for clean reloads, executemany for bulk inserts, and writing the aggregate query in pure SQL instead of looping in Python. The CSV is now a queryable database — Lesson 23 turns this into a full CRUD app.
Try It Yourself
13 minCreate test.db with a notes(id, text) table. Insert one row, fetch and print it.
Hint
import sqlite3 with sqlite3.connect("test.db") as con: cur = con.cursor() cur.execute("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, text TEXT)") cur.execute("INSERT INTO notes (text) VALUES (?)", ("hello, sqlite",)) cur.execute("SELECT * FROM notes") print(cur.fetchall())
In the students DB, look up students by partial name match. Use a parameter placeholder.
Hint
name = input("partial name: ").strip() cur.execute("SELECT name, score FROM students WHERE name LIKE ?", (f"%{name}%",)) for n, s in cur.fetchall(): print(f" {n} {s}")
Notice the % wildcards go in the value, not in the SQL. That keeps the SQL static — safer.
In your shop.db, compute revenue per day and save the result into a NEW table daily_revenue(date, revenue) using INSERT … SELECT.
Hint
cur.execute(""" CREATE TABLE IF NOT EXISTS daily_revenue ( date TEXT PRIMARY KEY, revenue REAL ) """) cur.execute("DELETE FROM daily_revenue") cur.execute(""" INSERT INTO daily_revenue (date, revenue) SELECT date, SUM(quantity * price) FROM sales GROUP BY date """) con.commit()
Mini-Challenge · SQL-Injection Demo
8 minBuild injection_demo.py showing the wrong vs right way. Two functions:
vulnerable(name)uses f-string concatenation.safe(name)uses?.
Pass name = "Aisyah' OR '1'='1" to both. Compare the rows returned. Then pass the malicious input "x'; DROP TABLE students;--" only to the safe one.
Show one possible solution
# injection_demo.py import sqlite3 con = sqlite3.connect(":memory:") cur = con.cursor() cur.execute("CREATE TABLE students (name TEXT, score INTEGER)") cur.executemany("INSERT INTO students VALUES (?, ?)", [("Aisyah", 88), ("Wei Jie", 75), ("Suresh", 92)]) def vulnerable(name): cur.execute(f"SELECT * FROM students WHERE name = '{name}'") return cur.fetchall() def safe(name): cur.execute("SELECT * FROM students WHERE name = ?", (name,)) return cur.fetchall() attack = "Aisyah' OR '1'='1" print("vulnerable:", vulnerable(attack)) # leaks everyone print("safe :", safe(attack)) # no rows (literal name lookup) # Try the destructive one only on safe — vulnerable would actually drop the table! print("safe drop:", safe("x'; DROP TABLE students;--"))
Non-negotiables: literal lesson — parameter binding is the only correct way. Don't run the destructive payload against the vulnerable version unless on an in-memory DB you don't care about.
Recap
3 minFive steps: connect, cursor, execute, fetch, commit. ? placeholders are mandatory; never paste user input into SQL strings. executemany is the fast path for bulk inserts. with sqlite3.connect(...) handles commit/rollback. Tomorrow we package these into a full CRUD program.
Vocabulary Card
- connect
- Open a connection to a SQLite file (or
:memory:). - cursor
- The object you call
executeon to run statements. - commit
- Persist pending writes to disk. Read-only queries don't need it.
- placeholder ?
- Parameterised value. The library handles escaping safely.
Homework
4 minConvert any CSV from earlier in the level (sales, students, customers, books) into a SQLite database. Write a separate query.py that exposes 3 prompt-driven queries:
- Filter by a column the user names.
- An aggregate the user picks (sum / average / count).
- A sorted top-N list the user controls (N from input).
All user inputs must use placeholders.
# query.py import sqlite3 con = sqlite3.connect("shop.db") cur = con.cursor() product = input("product name: ").strip() cur.execute("SELECT * FROM sales WHERE product = ?", (product,)) for row in cur.fetchall(): print(row) cur.execute("SELECT SUM(quantity * price) FROM sales WHERE product = ?", (product,)) print("total revenue:", cur.fetchone()[0]) n = int(input("top N: ")) cur.execute( "SELECT product, SUM(quantity*price) AS r FROM sales GROUP BY product " "ORDER BY r DESC LIMIT ?", (n,), ) for product, rev in cur.fetchall(): print(f" {product:<8} RM {rev:.2f}")