18.1 PDBC Concepts and the DB-API
A .txt/.csv file (16) is fine for small, single-program data — but a real
relational database (an RDBMS: SQLite, PostgreSQL, MySQL, ...) adds what a plain
file can't: many programs reading/writing safely at once, complex queries across
related tables, transactions (18.5), and data too large to hold in memory at all.
Database connectivity is simply a Python program talking to one of these over a
driver library.
Rather than every database vendor inventing its own unrelated Python API, PEP 249
defines the DB-API — a single common shape every driver module implements:
connect(), a Cursor with .execute()/.fetchone()/.fetchall(), and so on. Learn
the DB-API once, and switching the underlying database is mostly a matter of swapping
which driver's connect() gets called (18.6) — not rewriting the querying code.
sqlite3 ships in the standard library (9.6), needs no server or install, and follows
the DB-API exactly — making it the natural module to learn the DB-API itself with:
import sqlite3
conn = sqlite3.connect(":memory:") # an in-memory database - gone once the connection closes
cur = conn.cursor()
cur.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)")
cur.execute("INSERT INTO users (name, age) VALUES ('Ada', 28)")
conn.commit()
cur.execute("SELECT name, age FROM users")
print(cur.fetchall()) # [('Ada', 28)]
conn.close()
[('Ada', 28)]In practice — this is the layer under every Python web backend. Django's ORM, Flask
paired with SQLAlchemy, and FastAPI talking to Postgres all sit on top of exactly this
same DB-API shape — a Connection, a Cursor, .execute(), .fetchall(). Learning the
raw DB-API first (rather than jumping straight to an ORM, 18.7) is what makes it possible
to read what an ORM is actually doing underneath, and to drop down to raw SQL yourself
when a query is too complex or too performance-sensitive for the ORM to express well.
18.2 Connections and Cursors
Two objects do all the work in the demo above:
| Object | Represents |
|---|---|
Connection (sqlite3.connect(...)) |
the actual link to the database — owns .commit()/.rollback() (18.5) and .close() |
Cursor (conn.cursor()) |
a "current position" through query results — the thing .execute() and the fetch...() methods (18.3) live on |
One Connection can hand out several independent Cursors, each stepping through its
own result set. Both should be closed when done — .close() on the cursor first, then
the connection — though letting a short-lived script simply end also releases them.
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
conn.executemany("INSERT INTO users (name) VALUES (?)", [("Ada",), ("Grace",), ("Alan",)])
conn.commit()
cur_a = conn.cursor() # two independent cursors, same connection
cur_b = conn.cursor()
cur_a.execute("SELECT name FROM users ORDER BY id")
cur_b.execute("SELECT name FROM users ORDER BY id")
print(cur_a.fetchone()) # ('Ada',) -> cur_a's own position: row 1
print(cur_a.fetchone()) # ('Grace',) -> cur_a's own position: row 2
print(cur_b.fetchone()) # ('Ada',) -> cur_b started fresh, unaffected by cur_a
conn.close()
('Ada',)
('Grace',)
('Ada',)18.3 Executing Queries and Fetching Results
.execute(sql) runs one statement; .executemany(sql, rows) runs it once per row —
far faster than a Python loop calling .execute() repeatedly, since the driver batches
the work. Fetching back a SELECT's results has three levels of granularity:
| Call | Gets |
|---|---|
.fetchone() |
the next single row, or None once exhausted |
.fetchmany(n) |
the next n rows, as a list |
.fetchall() |
every remaining row, as a list |
conn = sqlite3.connect(":memory:")
cur = conn.cursor()
cur.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)")
cur.executemany( # one call, many rows
"INSERT INTO users (name, age) VALUES (?, ?)",
[("Ada", 28), ("Grace", 34), ("Alan", 41)],
)
conn.commit()
print(cur.rowcount) # 3 -> rows affected by the LAST statement
cur.execute("SELECT name, age FROM users ORDER BY age")
print(cur.fetchone()) # ('Ada', 28) -> just the next row
print(cur.fetchmany(1)) # [('Grace', 34)] -> the next 1 row(s), as a list
print(cur.fetchall()) # [('Alan', 41)] -> everything still remaining
3
('Ada', 28)
[('Grace', 34)]
[('Alan', 41)]A Cursor is also, itself, a lazy iterator (14.1, 14.2) — looping over it directly
pulls one row at a time, same idea as iterating a file object (16.5), without a
separate fetchall() call. .description exposes column metadata from the last
query:
print([col[0] for col in cur.description]) # ['name', 'age'] -> column names, from the last query
cur.execute("SELECT name, age FROM users ORDER BY age")
for row in cur: # the cursor is its own iterator - no fetchall() needed
print(row)
# ('Ada', 28)
# ('Grace', 34)
# ('Alan', 41)
conn.close()
['name', 'age']
('Ada', 28)
('Grace', 34)
('Alan', 41)A two-table JOIN — the "complex queries across related tables" 18.1 mentioned as a
reason to reach for a database in the first place. A second table with a foreign
key (orders.user_id, referencing users.id) lets a single query pull matching rows
from both tables at once, something a plain file has no equivalent for:
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, item TEXT)") # user_id -> users.id
conn.executemany("INSERT INTO users (name) VALUES (?)", [("Ada",), ("Grace",)])
conn.executemany(
"INSERT INTO orders (user_id, item) VALUES (?, ?)",
[(1, "Laptop"), (1, "Mouse"), (2, "Keyboard")],
)
conn.commit()
cur = conn.execute("""
SELECT users.name, orders.item
FROM users
JOIN orders ON users.id = orders.user_id
ORDER BY orders.id
""")
print(cur.fetchall()) # [('Ada', 'Laptop'), ('Ada', 'Mouse'), ('Grace', 'Keyboard')]
conn.close()
[('Ada', 'Laptop'), ('Ada', 'Mouse'), ('Grace', 'Keyboard')]In practice — this is what an analytics dashboard query looks like. "Show total
orders per customer this month" or "which products were bought together" are exactly
this shape: a JOIN (often several) pulling matching rows across orders, customers,
and products tables. Reporting and analytics features in a real backend are almost
always JOINs like this one, not separate queries stitched together in Python.
18.4 Parameterized Queries
Common mistake — building SQL with an f-string. It looks harmless right up until a value contains SQL syntax of its own — at which point the query's actual meaning changes, a classic SQL injection:
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)")
conn.executemany("INSERT INTO users (name, age) VALUES (?, ?)", [("Ada", 28), ("Grace", 34)])
conn.commit()
name = "Ada"
print(conn.execute(f"SELECT age FROM users WHERE name = '{name}'").fetchall()) # [(28,)] - looks fine...
malicious = "x' OR '1'='1" # ...but a hostile value breaks the query's actual meaning
print(f"SELECT age FROM users WHERE name = '{malicious}'") # the ACTUAL SQL sent - "OR '1'='1'" is always true
print(conn.execute(f"SELECT age FROM users WHERE name = '{malicious}'").fetchall())
# [(28,), (34,)] -> leaked EVERY row, not just a matching one
[(28,)] SELECT age FROM users WHERE name = 'x' OR '1'='1' [(28,), (34,)]
The fix — a parameterized query — passes values through a placeholder (? in
sqlite3; other drivers use %s, 18.6) instead of splicing them into the SQL text.
The driver sends the value separately from the statement, so it's never interpreted as
SQL syntax no matter what it contains:
print(conn.execute("SELECT age FROM users WHERE name = ?", (malicious,)).fetchall())
# [] -> correctly finds nothing - the malicious string is just DATA here, never SQL
conn.close()
[]
In practice — this is on the OWASP Top 10. SQL injection has been one of the most common and most damaging real-world web vulnerabilities for over two decades — it's how attackers have dumped entire user/password databases from production systems. "Never build SQL with string formatting/concatenation, always use parameterized queries" is one of the very few absolute rules enforced in essentially every serious Python code review and security audit.
18.5 Transactions and Error Handling
A transaction groups several statements into one all-or-nothing unit —
.commit() makes every change in it permanent; .rollback() undoes all of them, as if
none had run. Wrapping risky statements in try/except (7.2) and rolling back on
failure keeps the database from being left half-updated:
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE accounts (name TEXT, balance INTEGER)")
conn.execute("INSERT INTO accounts VALUES ('Ada', 100)")
conn.commit()
try:
conn.execute("UPDATE accounts SET balance = balance - 50 WHERE name = 'Ada'")
raise RuntimeError("something went wrong before commit")
except RuntimeError:
conn.rollback() # undo the UPDATE - as far as the database is concerned, it never happened
print(conn.execute("SELECT balance FROM accounts WHERE name = 'Ada'").fetchone()) # (100,) -> unchanged
(100,)
Common mistake — expecting with conn: to close the connection. Unlike
with open(...) (16.6), sqlite3's Connection context manager only wraps
.commit()/.rollback() around the block — committing on a clean exit, rolling back
on an exception — and leaves the connection itself open afterward. This is a
deliberate design choice, not an oversight: closing a connection is a separate, heavier
operation (tearing down the actual link to the database) from the commit/rollback
lifecycle a single transaction represents, so sqlite3 keeps the two decoupled — one
connection can run many transactions, one after another, without being reopened each
time:
with conn: # commits automatically on a clean exit - no explicit .commit() needed
conn.execute("UPDATE accounts SET balance = balance - 50 WHERE name = 'Ada'")
print(conn.execute("SELECT balance FROM accounts WHERE name = 'Ada'").fetchone()) # (50,)
try:
with conn: # rolls back automatically if the block raises
conn.execute("UPDATE accounts SET balance = balance - 999999 WHERE name = 'Ada'")
raise RuntimeError("oops")
except RuntimeError:
pass
print(conn.execute("SELECT balance FROM accounts WHERE name = 'Ada'").fetchone()) # (50,) -> rolled back
conn.execute("SELECT 1") # still works - "with conn:" never closed the connection
conn.close()
(50,) (50,)
In practice — an e-commerce checkout. Placing an order typically has to debit
inventory, record a payment, and create the order row together, as one unit: if the
payment step fails, the inventory debit must roll back too, or the store ends up
selling a product it no longer has. This is exactly the all-or-nothing guarantee
commit()/rollback() provide, and it's why checkout code almost always runs inside
one transaction rather than several independent statements.
18.6 Working With Specific Drivers
Everything in 18.1–18.5 was demonstrated against sqlite3, but the DB-API shape it
follows is exactly what every other driver follows too:
| Database | Common driver | Built-in? | Placeholder style |
|---|---|---|---|
| SQLite | sqlite3 |
yes (stdlib) | ? (qmark) |
| PostgreSQL | psycopg2 / psycopg |
no — pip install (9.7) |
%s (pyformat) |
| MySQL / MariaDB | mysql-connector-python, PyMySQL |
no — pip install (9.7) |
%s (pyformat) |
Switching databases mostly means changing which driver's connect() is called (with
that database's own connection details — host, user, password) and the placeholder
style in parameterized queries (18.4) — .execute(), .fetchall(), .commit(), and
the rest of the DB-API stay the same. A PostgreSQL connection, for reference (not run
here — it needs a real server and the psycopg2 package):
import psycopg2
conn = psycopg2.connect(host="localhost", dbname="mydb", user="ada", password="...")
cur = conn.cursor()
cur.execute("SELECT age FROM users WHERE name = %s", (name,)) # %s, not ?
print(cur.fetchall())
conn.close()
Common mistake — hardcoding credentials in source. The password="..." above is
for illustration only — a real connection string like that, checked into source
control, leaks the database password to anyone with repository access (and to its
entire commit history, even after being "removed" later). Load credentials from an
environment variable (os.environ["DB_PASSWORD"], 9.5) or a secrets manager instead —
never as a literal in code.
print(sqlite3.apilevel) # 2.0 -> every DB-API-compliant driver declares this
print(sqlite3.paramstyle) # qmark -> confirms the "?" placeholder style used all through 18.4
print(sqlite3.threadsafety) # 3 -> a required module-level constant every DB-API driver exposes
2.0 qmark 3
18.7 Object-Relational Mapping (ORM)
Every demo so far wrote raw SQL as a string and got a tuple of columns back — a
noticeable mismatch with a language where data is normally an object with named
attributes. An ORM (Object-Relational Mapper) bridges that gap: it maps a database
table to a Python class, and each row to an instance of it, so the
database is queried and updated through ordinary Python (attribute access, method
calls) instead of hand-written SQL strings.
Commonly used Python ORMs:
| ORM | Notes |
|---|---|
| SQLAlchemy | the most widely used; a full toolkit — usable as just a DB-API wrapper (its "Core"), or with its ORM layer on top; works standalone or inside a web framework |
| Django ORM | built into the Django web framework; tightly integrated with Django's models, migrations, and admin site — not typically used outside Django |
| Peewee | small and deliberately simple; a lighter-weight alternative when SQLAlchemy's full feature set is more than a project needs |
SQLAlchemy isn't installed in this environment, so the example below (pip install
sqlalchemy, 9.7, to actually run it) is shown as a plain code cell for reference
rather than executed here:
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.orm import declarative_base, sessionmaker
Base = declarative_base()
class User(Base): # a Python class - maps to a "users" table
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String)
age = Column(Integer)
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine) # generates the CREATE TABLE for us
Session = sessionmaker(bind=engine)
session = Session()
session.add(User(name="Ada", age=28)) # no SQL written at all
session.commit()
ada = session.query(User).filter_by(name="Ada").first()
print(ada.name, ada.age) # Ada 28 -> a Python object, not a raw tuple
Ada 28
Compare that to 18.1's raw sqlite3 version of the same task — no CREATE TABLE
string, no INSERT INTO ... VALUES (?, ?), and the result comes back as ada.name
instead of row[0]. The trade-off: an ORM adds a real layer of abstraction between the
code and the SQL actually being sent, which can make performance-critical or highly
complex queries harder to reason about — raw DB-API SQL (18.1–18.5) is still the more
direct tool for those.