Databases & SQLite — CRUD, Queries & Connecting OOP to Persistence
Databases solve a problem you've already hit: data disappears when your program stops. This week you'll
use Python's built-in sqlite3 module to read and write a real database — no extra
installation needed.
.py file and run it.
SQLite creates a .db file automatically in the same directory. Each example builds on
the one before it, so read them in order.
A database stores data in structured tables — like a spreadsheet. Each table has named columns (fields) and rows (records). Unlike a JSON file, a database can handle millions of records efficiently and lets you query specific subsets instantly.
# JSON file — good for:
# - Small amounts of data (hundreds of records, not thousands)
# - Simple structure (no relationships between data)
# - Human-readable backup/config files
# - Quick prototypes
# SQLite database — better when:
# - You need to search / filter records efficiently
# - Data has relationships (e.g. orders belong to customers)
# - You need to update individual records without rewriting everything
# - Data will grow large over time
sqlite3.connect() opens a database file (or creates it if it doesn't exist). You then
create a cursor to send SQL commands.
import sqlite3
# Connect — creates "library.db" if it doesn't exist
conn = sqlite3.connect("library.db")
cursor = conn.cursor()
# CREATE TABLE IF NOT EXISTS — safe to run every startup
cursor.execute("""
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author TEXT NOT NULL,
pages INTEGER NOT NULL,
genre TEXT
)
""")
conn.commit() # save changes
conn.close() # always close when done
print("Database ready.")
conn.close() should always be the last step.
# SQLite has four main column types:
#
# INTEGER — whole numbers (1, 42, -7)
# REAL — decimal numbers (3.14, 99.99)
# TEXT — strings ("Alice", "2026-03-10")
# BLOB — raw binary data (images, files)
#
# Common constraints:
# PRIMARY KEY AUTOINCREMENT — unique ID, auto-increments
# NOT NULL — value is required
# UNIQUE — no two rows can have the same value
Parameterized queries use ? placeholders instead of putting values directly into the SQL
string. This is not optional — it's how you prevent SQL injection.
cursor.execute(f"INSERT INTO books (title) VALUES ('{title}')")title contains '); DROP TABLE books; --, you've just deleted your database.
Always use ? placeholders.
import sqlite3
conn = sqlite3.connect("library.db")
cursor = conn.cursor()
# The tuple of values matches the ? placeholders positionally
cursor.execute(
"INSERT INTO books (title, author, pages, genre) VALUES (?, ?, ?, ?)",
("The Martian", "Andy Weir", 369, "Sci-Fi")
)
conn.commit()
# cursor.lastrowid — the auto-generated ID of the row just inserted
print(f"Inserted with ID: {cursor.lastrowid}")
conn.close()
import sqlite3
conn = sqlite3.connect("library.db")
cursor = conn.cursor()
books = [
("Clean Code", "Robert Martin", 431, "Programming"),
("The Pragmatic Programmer", "David Thomas", 352, "Programming"),
("Dune", "Frank Herbert", 412, "Sci-Fi"),
]
cursor.executemany(
"INSERT INTO books (title, author, pages, genre) VALUES (?, ?, ?, ?)",
books
)
conn.commit()
print(f"Inserted {cursor.rowcount} rows")
conn.close()
SELECT retrieves rows from a table. Use fetchall() to get every match,
fetchone() to get only the first.
import sqlite3
conn = sqlite3.connect("library.db")
cursor = conn.cursor()
# Select all rows
cursor.execute("SELECT * FROM books")
rows = cursor.fetchall()
for row in rows:
book_id, title, author, pages, genre = row
print(f" [{book_id}] {title} by {author}")
conn.close()
import sqlite3
conn = sqlite3.connect("library.db")
cursor = conn.cursor()
# All books in a specific genre
cursor.execute("SELECT * FROM books WHERE genre = ?", ("Sci-Fi",))
scifi_books = cursor.fetchall()
print(f"Sci-Fi books: {len(scifi_books)}")
# Books over 400 pages
cursor.execute("SELECT title, pages FROM books WHERE pages > ?", (400,))
long_books = cursor.fetchall()
for title, pages in long_books:
print(f" {title}: {pages} pages")
# Find one specific book by title
cursor.execute("SELECT * FROM books WHERE title = ?", ("Dune",))
book = cursor.fetchone() # returns one tuple, or None if not found
if book:
print(f"Found: {book[1]} (ID {book[0]})")
conn.close()
import sqlite3
conn = sqlite3.connect("library.db")
cursor = conn.cursor()
# Sort by pages, longest first; show only top 3
cursor.execute("""
SELECT title, pages
FROM books
ORDER BY pages DESC
LIMIT 3
""")
top_three = cursor.fetchall()
for title, pages in top_three:
print(f" {title}: {pages} pages")
conn.close()
UPDATE changes existing rows. DELETE removes them. Both require a
WHERE clause — otherwise you change or delete every row in the table.
import sqlite3
conn = sqlite3.connect("library.db")
cursor = conn.cursor()
# UPDATE — change the genre of one book
cursor.execute(
"UPDATE books SET genre = ? WHERE title = ?",
("Classic Sci-Fi", "Dune")
)
print(f"Rows updated: {cursor.rowcount}")
# UPDATE multiple columns at once
cursor.execute(
"UPDATE books SET pages = ?, genre = ? WHERE title = ?",
(370, "Sci-Fi Thriller", "The Martian")
)
# DELETE a specific row
cursor.execute("DELETE FROM books WHERE title = ?", ("Clean Code",))
print(f"Rows deleted: {cursor.rowcount}")
conn.commit()
conn.close()
Real databases split related data across multiple tables and link them with foreign keys. This avoids storing the same information repeatedly and keeps data consistent.
import sqlite3
conn = sqlite3.connect("library.db")
cursor = conn.cursor()
# First table: members
cursor.execute("""
CREATE TABLE IF NOT EXISTS members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
)
""")
# Second table: checkouts — each row links a member to a book
cursor.execute("""
CREATE TABLE IF NOT EXISTS checkouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
member_id INTEGER NOT NULL,
book_title TEXT NOT NULL,
checked_out TEXT NOT NULL,
FOREIGN KEY (member_id) REFERENCES members(id)
)
""")
conn.commit()
# Insert a member and a checkout
cursor.execute("INSERT INTO members (name) VALUES (?)", ("Alice",))
member_id = cursor.lastrowid
cursor.execute(
"INSERT INTO checkouts (member_id, book_title, checked_out) VALUES (?, ?, ?)",
(member_id, "Dune", "2026-03-10")
)
conn.commit()
# Query with a JOIN — combine rows from both tables
cursor.execute("""
SELECT members.name, checkouts.book_title, checkouts.checked_out
FROM checkouts
JOIN members ON checkouts.member_id = members.id
""")
for name, book, date in cursor.fetchall():
print(f" {name} checked out '{book}' on {date}")
conn.close()
When saving an object that might already exist in the database, INSERT OR REPLACE handles
both cases: insert if new, replace if the primary key already exists.
import sqlite3
conn = sqlite3.connect("library.db")
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
# First run: inserts. Every run after: replaces.
cursor.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
("theme", "dark")
)
cursor.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
("font_size", "14")
)
conn.commit()
cursor.execute("SELECT * FROM settings")
for key, value in cursor.fetchall():
print(f" {key} = {value}")
conn.close()
In a real application, your OOP classes handle business logic and a separate database class handles persistence. They talk to each other but each does only its own job.
Book class knows nothing about
SQL. The BookDB class knows nothing about book behavior. When you need to swap the
database out, you only change BookDB.
import sqlite3
# ── Business logic class ──────────────────────────────────────────────────────
class Book:
def __init__(self, title, author, pages):
self._title = title
self._author = author
self._pages = pages
def summary(self):
return f"'{self._title}' by {self._author} ({self._pages} pages)"
def __str__(self):
return self.summary()
# ── Database layer class ──────────────────────────────────────────────────────
class BookDB:
def __init__(self, filename):
self._conn = sqlite3.connect(filename)
self._cursor = self._conn.cursor()
self._create_table()
def _create_table(self):
self._cursor.execute("""
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author TEXT NOT NULL,
pages INTEGER NOT NULL
)
""")
self._conn.commit()
def save(self, book):
self._cursor.execute(
"INSERT INTO books (title, author, pages) VALUES (?, ?, ?)",
(book._title, book._author, book._pages)
)
self._conn.commit()
def load_all(self):
self._cursor.execute("SELECT title, author, pages FROM books")
return [Book(t, a, p) for t, a, p in self._cursor.fetchall()]
def find_by_author(self, author):
self._cursor.execute(
"SELECT title, author, pages FROM books WHERE author = ?",
(author,)
)
return [Book(t, a, p) for t, a, p in self._cursor.fetchall()]
def close(self):
self._conn.close()
# ── Using them together ───────────────────────────────────────────────────────
db = BookDB("catalog.db")
db.save(Book("The Martian", "Andy Weir", 369))
db.save(Book("Project Hail Mary", "Andy Weir", 476))
db.save(Book("Dune", "Frank Herbert", 412))
print("All books:")
for book in db.load_all():
print(f" {book}")
print("\nBooks by Andy Weir:")
for book in db.find_by_author("Andy Weir"):
print(f" {book}")
db.close()
load_all() returns a list of Book objects — not raw
database tuples. The rest of the program works with Book objects and never sees any SQL.
This clean separation is the pattern you'll apply in the assignment.