A Database in Your Standard Library: A Practical Deep Dive into Python's sqlite3
Learn to use Python's built-in sqlite3 module the right way: parameterized queries, row factories, transactions with the connection context manager, executemany, custom type adapters, and the pitfalls that bite newcomers.
You do not need Postgres, a Docker container, or a running server to persist data in Python. Sitting quietly in the standard library is sqlite3, a full SQL database engine that stores everything in a single file (or entirely in memory). It is perfect for local tools, prototypes, test fixtures, desktop apps, caches, and any situation where "a real database" would be overkill but a pile of JSON files would be a mess.
The catch is that the sqlite3 API is thin and low-level, and a lot of code in the wild misuses it in ways that are slow, unsafe, or subtly buggy. This guide walks through the idioms that actually matter: parameterized queries, row factories, transactions, bulk inserts, and type handling. Every example below was tested on Python 3.10 and the concepts apply to any modern interpreter.
Connecting and creating a table
A connection is your handle to the database. Pass a filename to persist to disk, or the special string ":memory:" for a throwaway database that vanishes when you close it — ideal for tests.
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("""
CREATE TABLE book (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
author TEXT NOT NULL,
year INTEGER
)
""")
Notice that id INTEGER PRIMARY KEY gives you an auto-incrementing row id for free — SQLite fills it in automatically when you omit it on insert. You can call execute() directly on the connection object; under the hood it creates a cursor and returns it, which is a convenient shortcut for one-off statements.
Never build SQL with string formatting
This is the single most important rule in the whole article. Do not interpolate values into your SQL with f-strings or +. Use placeholders and pass the values separately. This protects you from SQL injection and from quoting bugs (an apostrophe in a name, for example).
title = "Dune"
# WRONG — vulnerable to injection and breaks on quotes
conn.execute(f"SELECT * FROM book WHERE title = '{title}'")
# RIGHT — the value is bound safely by the driver
conn.execute("SELECT * FROM book WHERE title = ?", (title,))
The ? is a positional placeholder; the second argument is a tuple of values. A very common mistake is forgetting that it must be a sequence: ("Dune",) with the trailing comma is a one-element tuple, whereas ("Dune") is just a string and will raise an error.
SQLite also supports named placeholders, which are easier to read when a query has many parameters:
conn.execute(
"INSERT INTO book (title, author, year) VALUES (:title, :author, :year)",
{"title": "Neuromancer", "author": "William Gibson", "year": 1984},
)
Inserting many rows at once
When you have a batch of rows, do not loop and call execute() once per row. Use executemany(), which is both cleaner and significantly faster because it reuses the prepared statement.
rows = [
("Snow Crash", "Neal Stephenson", 1992),
("The Left Hand of Darkness", "Ursula K. Le Guin", 1969),
]
conn.executemany(
"INSERT INTO book (title, author, year) VALUES (?, ?, ?)", rows
)
conn.commit()
That conn.commit() matters. By default, sqlite3 opens an implicit transaction for you and your changes are not saved until you commit. Forgetting to commit is the number-one reason people complain that "my data disappeared" — it was rolled back when the connection closed.
Reading data back
A query returns a cursor. You can pull results with fetchone(), fetchall(), or — best for large result sets — by iterating over the cursor directly, which streams rows without loading everything into memory at once.
cur = conn.execute(
"SELECT title, year FROM book WHERE year > ? ORDER BY year",
(1970,),
)
print(cur.fetchall())
# [('Neuromancer', 1984), ('Snow Crash', 1992)]
# Streaming, memory-friendly:
for title, year in conn.execute("SELECT title, year FROM book ORDER BY title"):
print(title, year)
By default each row is a plain tuple, so you access columns by position. That gets unreadable fast. The fix is a row factory.
Rows you can read: sqlite3.Row
Set conn.row_factory = sqlite3.Row and every row becomes an object you can index by column name (and still by position). This makes downstream code far more robust — reordering columns in your SELECT no longer breaks anything.
conn.row_factory = sqlite3.Row
row = conn.execute("SELECT * FROM book WHERE title = ?", ("Dune",)).fetchone()
print(row["title"], row["author"], row["year"]) # Dune Frank Herbert 1965
print(row.keys()) # ['id', 'title', 'author', 'year']
sqlite3.Row is lightweight and supports keyword access, integer indexing, and iteration. If you want plain dictionaries instead, you can assign a small custom factory: conn.row_factory = lambda c, r: {col[0]: r[i] for i, col in enumerate(c.description)}.
Transactions the clean way
A transaction groups several statements so they either all succeed or all get undone. The sqlite3 connection is itself a context manager for exactly this: if the with block finishes normally the transaction is committed, and if an exception propagates out, it is rolled back automatically.
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE acct (name TEXT, balance INTEGER)")
db.execute("INSERT INTO acct VALUES ('alice', 100)")
db.commit()
try:
with db: # commits on success, rolls back on exception
db.execute("UPDATE acct SET balance = balance - 50 WHERE name = 'alice'")
raise ValueError("something went wrong mid-transaction")
except ValueError:
pass
# The update was undone because the block raised:
print(db.execute("SELECT balance FROM acct").fetchone()[0]) # 100
One subtlety worth remembering: with db: manages the transaction, not the connection. It does not close the database. You still call db.close() yourself when you are done, ideally from a try/finally or by wrapping the connection in contextlib.closing().
lastrowid and rowcount
After an INSERT, the cursor's lastrowid tells you the auto-generated primary key — invaluable when you need the new id to insert related rows. After an UPDATE or DELETE, rowcount reports how many rows were affected.
cur = db.execute("INSERT INTO acct VALUES ('bob', 200)")
print(cur.lastrowid) # the rowid of the new record
cur = db.execute("UPDATE acct SET balance = 0 WHERE name = 'bob'")
print(cur.rowcount) # 1
Storing dates and custom types
SQLite natively understands only a handful of types (INTEGER, REAL, TEXT, BLOB, NULL). To store something like a datetime, you register an adapter (Python value to storable form) and a converter (stored bytes back to a Python object), and open the connection with detect_types so the converter fires based on the column's declared type.
import sqlite3
import datetime
sqlite3.register_adapter(datetime.datetime, lambda dt: dt.isoformat())
sqlite3.register_converter(
"DATETIME", lambda b: datetime.datetime.fromisoformat(b.decode())
)
conn = sqlite3.connect(":memory:", detect_types=sqlite3.PARSE_DECLTYPES)
conn.execute("CREATE TABLE ev (ts DATETIME)")
conn.execute("INSERT INTO ev VALUES (?)", (datetime.datetime(2026, 7, 28, 9, 30),))
got = conn.execute("SELECT ts FROM ev").fetchone()[0]
print(repr(got)) # datetime.datetime(2026, 7, 28, 9, 30)
Older tutorials rely on the built-in date/time adapters that ship with sqlite3, but those were deprecated in Python 3.12. Registering your own adapter and converter, as above, is the forward-compatible approach and makes the storage format explicit.
Common pitfalls
A few mistakes recur constantly. Forgetting to commit() silently loses writes on disk-backed databases. Passing a bare string instead of a one-tuple to execute() (execute(sql, "Dune") instead of execute(sql, ("Dune",))) raises a confusing error, because a string is a sequence of single characters. Reusing one connection across threads without care will raise ProgrammingError — by default a connection may only be used in the thread that created it, so give each thread its own connection or use a connection pool. And building SQL with f-strings is always wrong for values; placeholders exist precisely so you never have to.
One more performance note: for write-heavy local workloads, enabling write-ahead logging with conn.execute("PRAGMA journal_mode=WAL") often improves concurrency and throughput noticeably, at the cost of a couple of extra files next to your database.
Wrap-up and next steps
The mental model is small. Open a connection, use ? placeholders for every value, batch inserts with executemany(), wrap groups of writes in with conn: for atomic transactions, and turn on sqlite3.Row so your result rows read like records instead of anonymous tuples. Remember to commit, and remember that a connection is single-threaded by default.
From here, worthwhile next steps include reading about SQLite's PRAGMA statements for tuning, exploring full-text search with the FTS5 extension, and — once your schema outgrows hand-written SQL — graduating to a query builder or an ORM like SQLAlchemy, which sits comfortably on top of the very same sqlite3 driver you have just learned. But for a huge range of real tasks, a single file and the standard library are all you need.