description: "Databases (SQLite & SQLAlchemy) — Never build SQL with an f-string or %-formatting when values come from user input — always use ? placeholders, which…"---
06 · Databases (SQLite & SQLAlchemy)¶
🎥 Video walkthrough¶
Most applications need to persist structured data beyond a single run. This
module covers sqlite3 — a full SQL database built into the standard
library, requiring no server — and then SQLAlchemy, the most widely used ORM
(Object-Relational Mapper) in the Python ecosystem, which lets you work with
rows as Python objects instead of writing raw SQL everywhere.
sqlite3 — connecting and creating tables¶
import sqlite3
connection = sqlite3.connect("app.db") # creates the file if it doesn't exist
cursor = connection.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
)
""")
connection.commit()
Inserting data — always use parameters¶
cursor.execute(
"INSERT INTO users (name, email) VALUES (?, ?)",
("Ada Lovelace", "ada@example.com"),
)
connection.commit()
print(cursor.lastrowid) # the auto-generated id of the row just inserted
# inserting many rows at once
people = [("Grace Hopper", "grace@example.com"), ("Alan Turing", "alan@example.com")]
cursor.executemany("INSERT INTO users (name, email) VALUES (?, ?)", people)
connection.commit()
Never build SQL with an f-string or %-formatting when values come from user
input — always use ? placeholders, which sqlite3 escapes safely and which
protect you from SQL injection.
# NEVER do this with untrusted input:
# cursor.execute(f"SELECT * FROM users WHERE name = '{name}'") # SQL injection risk
# always do this instead:
cursor.execute("SELECT * FROM users WHERE name = ?", (name,))
Querying data¶
cursor.execute("SELECT id, name, email FROM users WHERE name LIKE ?", ("%Ada%",))
row = cursor.fetchone()
print(row) # (1, 'Ada Lovelace', 'ada@example.com') — a plain tuple by default
cursor.execute("SELECT * FROM users")
for row in cursor.fetchall():
print(row)
Getting dict-like rows¶
connection.row_factory = sqlite3.Row # rows behave like dicts AND tuples
cursor = connection.cursor()
cursor.execute("SELECT * FROM users")
for row in cursor.fetchall():
print(row["name"], row["email"])
Using sqlite3 as a context manager¶
The connection object supports with, which commits automatically on success
or rolls back on an exception — but note it does not close the connection.
with connection:
connection.execute(
"INSERT INTO users (name, email) VALUES (?, ?)",
("Margaret Hamilton", "margaret@example.com"),
)
# committed automatically here (or rolled back if an exception was raised)
connection.close()
SQLAlchemy — the ORM approach¶
Writing raw SQL for every operation gets repetitive and error-prone as an application grows. SQLAlchemy's ORM lets you define Python classes that map to database tables, and work with rows as objects.
from sqlalchemy import create_engine, String, Integer
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(100))
email: Mapped[str] = mapped_column(String(200), unique=True)
def __repr__(self):
return f"User(id={self.id}, name={self.name!r})"
engine = create_engine("sqlite:///app_orm.db")
Base.metadata.create_all(engine) # creates tables from the model classes
CRUD with SQLAlchemy sessions¶
with Session(engine) as session:
# Create
new_user = User(name="Katherine Johnson", email="katherine@example.com")
session.add(new_user)
session.commit()
# Read
from sqlalchemy import select
stmt = select(User).where(User.name.like("%Johnson%"))
for user in session.scalars(stmt):
print(user)
# Update
user = session.scalars(select(User).where(User.email == "katherine@example.com")).first()
user.name = "Katherine G. Johnson"
session.commit()
# Delete
session.delete(user)
session.commit()
Relationships between tables¶
from sqlalchemy import ForeignKey
from sqlalchemy.orm import relationship
from typing import List
class Author(Base):
__tablename__ = "authors"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
books: Mapped[List["Book"]] = relationship(back_populates="author")
class Book(Base):
__tablename__ = "books"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
author_id: Mapped[int] = mapped_column(ForeignKey("authors.id"))
author: Mapped["Author"] = relationship(back_populates="books")
with Session(engine) as session:
author = Author(name="Robert Martin", books=[Book(title="Clean Code")])
session.add(author)
session.commit()
fetched = session.scalars(select(Author).where(Author.name == "Robert Martin")).first()
for book in fetched.books:
print(book.title)
A short intro to migrations¶
As your models change over time (new columns, renamed tables), you need a controlled way to evolve the actual database schema without losing data — that's what a migration tool provides. Alembic is SQLAlchemy's standard migration tool.
pip install alembic
alembic init migrations # scaffolds a migrations/ folder + alembic.ini
alembic revision --autogenerate -m "create users table" # diff models vs. DB, generate a migration script
alembic upgrade head # apply all pending migrations
alembic downgrade -1 # roll back the most recent migration
Each migration script has an upgrade() and downgrade() function, so schema
changes are tracked, reversible, and repeatable across environments.
sqlite3 vs. SQLAlchemy¶
sqlite3 (stdlib) |
SQLAlchemy | |
|---|---|---|
| Setup | zero dependencies | pip install sqlalchemy |
| Style | raw SQL strings | Python classes/objects |
| Portability | SQLite only | works with SQLite, PostgreSQL, MySQL, etc. with little code change |
| Best for | small scripts, simple embedded storage | applications that grow, need multiple DB backends, or many relationships |
How It Actually Works¶
sqlite3.connect("app.db") doesn't start a separate database server — SQLite is
embedded: the entire database engine (parser, query planner, B-tree storage
engine, transaction manager) is a C library linked directly into your Python process,
and "the database" is just that one .db file on disk, formatted as a set of
B-tree pages. Every table and every index is its own B-tree inside that file; a
CREATE TABLE writes a new B-tree root page and records its schema in a special
sqlite_master table stored the same way.
Parameterized queries (? placeholders) matter mechanically, not just stylistically:
cursor.execute("... WHERE name = ?", (name,)) sends the SQL text and the value
separately to SQLite's prepared-statement API — SQLite compiles the query's
structure once (with a placeholder hole) and later binds name directly as a typed
value into that hole, never re-parsing it as SQL syntax. An f-string
(f"... = '{name}'"), by contrast, splices untrusted text directly into the SQL
before parsing, so a value like ' OR '1'='1 becomes part of the query's grammar
instead of staying a data value — that's the entire mechanism behind SQL injection,
and exactly what parameter binding structurally prevents.
with connection: context-manager behavior (commit on success, rollback on
exception, but no close) exists because a Connection's __enter__/__exit__ are
scoped specifically to the SQL transaction, not the OS-level file handle — SQLite
wraps every write in an implicit transaction anyway (journaling the original page
contents before modifying them, so a crash mid-write can roll back to a consistent
state), and the context manager just decides whether to COMMIT (making changes
durable) or ROLLBACK (discarding them) based on whether an exception propagated out
of the block.
SQLAlchemy's ORM sits on top of all this as a translation layer, not a different
storage engine: select(User).where(User.name.like("%Johnson%")) builds an
in-memory expression tree of Python objects representing the query, which
SQLAlchemy's SQL compiler then renders into an actual SQL string with ?
placeholders (or %s for other backends) — the exact same parameterized-query
mechanism as raw sqlite3, just generated programmatically. A Session additionally
implements the unit-of-work pattern: it tracks every object you've loaded or
modified in an "identity map" keyed by primary key, and session.commit() diffs
each tracked object's current attribute values against what was loaded from the
database, generating exactly the UPDATE/INSERT/DELETE statements needed —
which is why simply setting user.name = "..." on a session-attached object, with no
explicit UPDATE call, is enough to persist the change.
Exercise¶
Using plain sqlite3, create a tasks table (id, title, done) and write
functions add_task, complete_task, list_tasks using parameterized
queries. Then rebuild the same thing with SQLAlchemy: a Task model class and
equivalent CRUD functions using a Session. Compare how much code each
approach needs, and note in a comment which you'd pick for a project expected
to grow to 20+ tables.