SQLite3 and Database Basics
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Flask 3.1.3 (released 2026-02-18) and Werkzeug 3.1.9, with the sqlite3 module of Python 3.13
(SQLite library 3.53.1 in the test environment), checked on 2026-10-07. Flask 3.2 was not released on that date, so what is
said about it is marked as upcoming. Every Python block was executed with app.test_client() and app.test_cli_runner()
on Python 3.13; nothing needs a database server, because SQLite is a library that stores the whole database in one file.
The running scenario is the Bookshelf catalogue of the other pages of this section: authors and books in a small
relational database that the application reads and writes with plain SQL.
Why Start With the Standard Library
Flask has no database layer, on purpose. The official tutorial (Flaskr) and the "Patterns for Flask" chapter both start
with the sqlite3 module that ships with Python, for three reasons: there is nothing to install or to run, the whole
database is one file that can be copied, and the code teaches the two ideas that every Flask database extension is built
on. The first is one connection per context: open it on first use, keep it on flask.g, close it when the application
context ends. The second is binding: the SQL text and the values travel separately.
SQLite is a good production choice for read-heavy sites, internal tools and small services, and a poor one when many processes write at the same time, because each write takes a lock on the whole database file. The official tutorial says it plainly: concurrent writers slow down as each write happens sequentially, and a growing application may want another database. The SQLite project’s own guidance on when to use it is linked below. The SQL itself (types, joins, constraints, transactions) is covered on the SQL pages of this site and is not repeated here.
| Need | Plain sqlite3 |
Move to an ORM or another database |
|---|---|---|
Prototype, script, internal tool, tests |
Ideal: zero setup, file or memory database |
Not needed |
A handful of tables and hand-written queries |
Fine; the queries stay visible and fast |
Optional |
Many models, relationships, migrations |
The SQL and the Python classes drift apart |
Flask-SQLAlchemy and Alembic (the Flask-SQLAlchemy and Database Migrations pages of this section) |
Several app servers or containers writing to the same data |
The file is local to one host; writers queue on a lock |
A server database such as PostgreSQL (psycopg, SQLAlchemy) |
Autoscaled, stateless containers |
The file disappears with the container unless a volume is mounted |
A managed database |
One Connection Per Context
A database connection is not a global. A module-level sqlite3.connect() is created at import time, is shared by every
thread of the server, never closed, and by default raises ProgrammingError when a second thread touches it. The Flask
answer is to tie the connection to the application context, the object that lives for the duration of a request (or a CLI
command, or a with app.app_context(): block) and that carries g, the namespace for "data that might be accessed by multiple
functions during the request". The details of contexts and of the order of the hooks are on the
Application and Request Lifecycle and
Hooks and View Decorators pages; what matters here is the three-step recipe:
The first call to get_db() connects, later calls in the same context reuse the connection, and the teardown function closes
it if it exists. Teardown functions run even when a before-request hook short-circuited or the view raised, and even when
get_db() was never called, so the teardown must check before it closes. This is the original pattern from the Flask
documentation, in a single file:
import sqlite3
import tempfile
from pathlib import Path
from flask import Flask, g
WORK = Path(tempfile.mkdtemp(prefix="bookshelf-sqlite-"))
DATABASE = WORK / "pattern.db"
app = Flask(__name__)
def get_db():
db = getattr(g, "_database", None)
if db is None:
db = g._database = sqlite3.connect(DATABASE)
return db
@app.teardown_appcontext
def close_connection(exception):
db = getattr(g, "_database", None)
if db is not None:
db.close()
@app.route("/version")
def version():
return get_db().execute("select sqlite_version()").fetchone()[0]
client = app.test_client()
print(client.get("/version").status_code)
# outside a request the application context has to be pushed by hand
with app.app_context():
print(get_db().execute("select 1").fetchone())
Source: Flask: Using SQLite 3 with Flask
Three details are worth knowing. The route returns a plain str, which Flask turns into a text/html response. The
teardown_appcontext function receives the exception that ended the context, or None; closing a connection does not
need it. And g belongs to the application context, not to the request, which is why the same code works in a CLI command
and in a script that pushes app.app_context().
A tidier version of the same idea, and the one used for the rest of the page, is the one from the Flaskr tutorial. It has
four changes: g.pop("db", None) replaces the getattr dance, the connection is configured once in get_db(), the database
path comes from current_app.config (the code does not import the application, so it works with the
application factory), and registering the teardown and the
command is done by an init_app(app) function that the factory calls.
The Bookshelf Layout
The tutorial’s layout puts the database code in its own module next to the SQL file that creates the tables:
bookshelf/
├── __init__.py # create_app()
├── db.py # get_db, close_db, init_db, init-db command, init_app
├── schema.sql # CREATE TABLE statements, read by init_db
├── catalogue.py # a blueprint with the views
└── ...
instance/
└── bookshelf.sqlite # created by "flask --app bookshelf init-db"; never committed
schema.sql is package data: it travels with the code, is opened with current_app.open_resource() (which resolves the
path relative to the application’s root folder, wherever the package is installed) and is the single place that describes
the tables. The database file is data: it lives in the instance folder (app.instance_path), outside the package and outside
version control. Add instance/ to .gitignore.
import os
import sqlite3
from datetime import datetime
import click
from flask import Flask, current_app, g
from flask.cli import with_appcontext
# --- bookshelf/schema.sql (shown as a string so that this example is self-contained) ---
SCHEMA = """
DROP TABLE IF EXISTS book;
DROP TABLE IF EXISTS author;
CREATE TABLE author (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL
);
CREATE TABLE book (
id INTEGER PRIMARY KEY AUTOINCREMENT,
author_id INTEGER NOT NULL REFERENCES author (id),
title TEXT NOT NULL,
isbn TEXT UNIQUE,
rating INTEGER CHECK (rating BETWEEN 1 AND 5),
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX book_author_idx ON book (author_id);
"""
PKG = WORK / "bookshelf"
PKG.mkdir()
(PKG / "schema.sql").write_text(SCHEMA)
# --- bookshelf/db.py ---
def get_db() -> sqlite3.Connection:
if "db" not in g:
g.db = sqlite3.connect(
current_app.config["DATABASE"],
detect_types=sqlite3.PARSE_DECLTYPES,
)
g.db.row_factory = sqlite3.Row
g.db.execute("PRAGMA foreign_keys = ON")
return g.db
def close_db(e=None) -> None:
db = g.pop("db", None)
if db is not None:
db.close()
def init_db() -> None:
db = get_db()
with current_app.open_resource("schema.sql") as f:
db.executescript(f.read().decode("utf8"))
@click.command("init-db")
@with_appcontext
def init_db_command() -> None:
"""Clear the existing data and create new tables."""
init_db()
click.echo("Initialized the database.")
# SQLite stores TIMESTAMP as text; this converter returns datetime objects.
sqlite3.register_converter("timestamp", lambda v: datetime.fromisoformat(v.decode()))
def init_app(app: Flask) -> None:
app.teardown_appcontext(close_db)
app.cli.add_command(init_db_command)
The pieces, one by one:
-
"db" not in gandg.pop("db", None):gsupportsin,get,popandsetdefault, so nogetattris needed. Because the teardown can run when no connection was opened,popwith a default is the safe form. -
current_app.config["DATABASE"]: a setting, not a constant, so that tests can point to a temporary file and production can point to a mounted volume. The key nameDATABASEis the tutorial’s own; Flask does not define it. -
detect_types=sqlite3.PARSE_DECLTYPES: the declared column type (TIMESTAMP) selects a converter, so acreatedvalue comes back asdatetime. SQLite has no real date type; it stores text, numbers or blobs. Python 3.12 deprecated the default adapters and converters of the module, which is why the example registers its own converter instead of relying on the built-in one. Withoutdetect_typesthe value is just a string, which is often all a JSON API needs. -
row_factory = sqlite3.Row: rows can be read by index or by column name (case-insensitively), can be iterated and havekeys(). Without it a row is a plain tuple. The "Patterns" page calls these rowsnamedtupleobjects; they are in fact a separate C type, and they are notdict: usedict(row)before passing one tojsonify. -
PRAGMA foreign_keys = ON: SQLite parsesREFERENCESclauses but does not enforce them unless this pragma is set on every new connection. It is a pitfall that the Flaskr tutorial does not mention, so it is set here inget_db(). -
open_resource(): a method of theFlaskobject that opens a file relative toapp.root_path. Its default mode is binary, hence.decode("utf8"); passmode="r"to read text (the Patterns page does). -
executescript(): runs several statements separated by semicolons. If a transaction is pending it commits it first, and it cannot take parameters, so it is for trusted files such asschema.sqland never for request data.
Wiring It Into the Factory
def create_app(test_config=None) -> Flask:
app = Flask(__name__, instance_relative_config=True)
app.config.from_mapping(DATABASE=os.path.join(app.instance_path, "bookshelf.sqlite"))
if test_config is None:
app.config.from_pyfile("config.py", silent=True)
else:
app.config.update(test_config)
os.makedirs(app.instance_path, exist_ok=True)
init_app(app)
# Only because this page is not a real package: point the root folder at PKG so that
# open_resource("schema.sql") finds the file written above.
app.root_path = str(PKG)
return app
app = create_app({"DATABASE": str(WORK / "bookshelf.sqlite"), "TESTING": True})
runner = app.test_cli_runner()
result = runner.invoke(args=["init-db"])
print(result.output.strip())
check = sqlite3.connect(app.config["DATABASE"])
print(sorted(name for (name,) in check.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")))
check.close()
The real factory is shorter than this block (the root-path line is an artefact of running a page, not a recipe).
instance_relative_config=True makes from_pyfile("config.py") read from the instance folder, where production values
are placed; the Configuration page lists every loader. With the app created,
the database is initialised from the shell:
flask --app bookshelf init-db
# Initialized the database.
app.test_cli_runner() returns Click’s test runner bound to the app, as the block above shows. The flask script pushes an
application context before it runs a command registered on app.cli (since Flask 2.2 @with_appcontext is no longer
required for commands created with @app.cli.command(), which wraps them). The test runner, however, invokes app.cli
directly, so a command built with click.command() and attached with add_command() has no context there and get_db() would
fail with "Working outside of application context". The @with_appcontext decorator (from flask.cli) on the command makes it work
in both places, and costs nothing when a context is already active.
The command is destructive: schema.sql starts with DROP TABLE IF EXISTS, so running init-db twice on a live system
erases it. That is acceptable for a tutorial and for tests, and dangerous in production. Safer options are to use
CREATE TABLE IF NOT EXISTS and a versioned migration (see Schema Versions Without a Tool), or to make the command
ask first. Click’s confirmation_option does that; the next block shows it as a variation and executes it:
@click.command("reset-db")
@click.confirmation_option(prompt="This erases every book. Continue?")
@with_appcontext
def reset_db_command() -> None:
init_db()
click.echo("Database reset.")
app.cli.add_command(reset_db_command)
print(runner.invoke(args=["reset-db"], input="n\n").exit_code) # aborted: 1
print(runner.invoke(args=["reset-db", "--yes"]).output.strip()) # confirmed
Source: Click: confirmation_option
Running Queries
The sqlite3 module follows the Python DB-API (PEP 249): a connection creates cursors, a cursor executes SQL, and
fetchone() / fetchall() read the results. connection.execute() is a shortcut that creates a cursor and runs the
statement on it. Reading is easy; the discipline is in how values get into the SQL.
Bind Values, Never Format Them
Every value that comes from a request (a query-string argument, a JSON field, a path segment, a header) must reach SQL
as a bound parameter. The statement text contains a placeholder, the driver sends the value separately, and the database
never parses the value as SQL. The sqlite3 module supports two placeholder styles:
| Style | Example | Parameters |
|---|---|---|
qmark (positional) |
|
A sequence: a tuple or list, even for one value: |
named |
|
A mapping: a |
The official Flask page puts the rule in one sentence: never add arguments to the SQL statement with string formatting, because
it makes the application attackable with SQL injection. The next example runs the same lookup three times: with the
vulnerable string formatting (shown on purpose, on an in-memory demo table), then with ?, then with a named placeholder.
demo = sqlite3.connect(":memory:")
demo.execute("CREATE TABLE secret_note (id INTEGER PRIMARY KEY, owner TEXT, body TEXT)")
demo.executemany(
"INSERT INTO secret_note (owner, body) VALUES (?, ?)",
[("ana", "ana's private note"), ("ben", "ben's private note")],
)
attacker_input = "x' OR '1'='1"
# WRONG: the input becomes part of the SQL text.
unsafe = demo.execute(f"SELECT body FROM secret_note WHERE owner = '{attacker_input}'").fetchall()
print("string formatting:", len(unsafe), "rows leaked")
# RIGHT: qmark placeholder and a one-element tuple.
safe = demo.execute("SELECT body FROM secret_note WHERE owner = ?", (attacker_input,)).fetchall()
print("qmark:", len(safe), "rows")
# RIGHT: named placeholder and a dict.
safe = demo.execute("SELECT body FROM secret_note WHERE owner = :owner", {"owner": attacker_input}).fetchall()
print("named:", len(safe), "rows")
demo.close()
Two things are not protected by placeholders, because they are not values:
-
Table and column names, and the
ASC/DESCkeyword. A user-selectable sort order must be checked against an allow-list and then inserted as a constant chosen by the code. -
The
%and_wildcards ofLIKE. A bound parameter cannot inject SQL, but a search term of%still matches everything; escape the wildcards with anESCAPEclause if that matters.
SORTABLE = {"title": "book.title", "rating": "book.rating DESC", "added": "book.created DESC"}
def list_books(order: str = "title", q: str = ""):
order_by = SORTABLE.get(order, SORTABLE["title"]) # a constant from the code, never the raw input
like = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
sql = (
"SELECT book.id, book.title, book.isbn, book.rating, book.created, author.name AS author "
"FROM book JOIN author ON author.id = book.author_id "
"WHERE book.title LIKE ? ESCAPE '\\' "
f"ORDER BY {order_by}"
)
return get_db().execute(sql, (like,)).fetchall()
The f-string above is safe only because order_by can take one of three constant values. Searching the code for an
f-string or % formatting that contains a variable and ends up in execute() is a useful review habit. The same rule holds
for every other driver (psycopg, PyMySQL, SQLAlchemy’s text() with bind parameters); only the placeholder syntax changes.
The Views
The next block is the Bookshelf blueprint. It reads with fetchone() and fetchall(), writes with INSERT, uses the
cursor’s lastrowid, and turns the typical database failures into HTTP answers: a missing row into 404 and a
violated UNIQUE or CHECK constraint into 409.
from flask import Blueprint, abort, jsonify, request, url_for
bp = Blueprint("catalogue", __name__, url_prefix="/books")
def book_to_dict(row: sqlite3.Row) -> dict:
data = dict(row)
data["created"] = row["created"].isoformat()
return data
@bp.get("")
def index():
rows = list_books(request.args.get("order", "title"), request.args.get("q", ""))
return jsonify([book_to_dict(r) for r in rows])
@bp.post("")
def create():
payload = request.get_json(silent=True) or {}
title, author = payload.get("title"), payload.get("author")
if not isinstance(title, str) or not isinstance(author, str) or not title or not author:
abort(400, description="title and author are required strings")
db = get_db()
try:
with db: # one transaction: commit on success, rollback on exception
db.execute("INSERT INTO author (name) VALUES (?) ON CONFLICT (name) DO NOTHING", (author,))
author_id = db.execute("SELECT id FROM author WHERE name = ?", (author,)).fetchone()["id"]
cur = db.execute(
"INSERT INTO book (author_id, title, isbn, rating) VALUES (?, ?, ?, ?)",
(author_id, title, payload.get("isbn"), payload.get("rating")),
)
except sqlite3.IntegrityError as exc:
abort(409, description=f"constraint failed: {exc}")
return jsonify(book_to_dict(fetch_book(cur.lastrowid))), 201, {
"Location": url_for("catalogue.detail", book_id=cur.lastrowid)
}
def fetch_book(book_id: int):
return get_db().execute(
"SELECT book.id, book.title, book.isbn, book.rating, book.created, author.name AS author "
"FROM book JOIN author ON author.id = book.author_id WHERE book.id = ?",
(book_id,),
).fetchone()
@bp.get("/<int:book_id>")
def detail(book_id: int):
row = fetch_book(book_id)
if row is None:
abort(404)
return jsonify(book_to_dict(row))
app.register_blueprint(bp)
client = app.test_client()
print(client.post("/books", json={"title": "Dune", "author": "Frank Herbert", "isbn": "978-0441172719", "rating": 5}).status_code) # 201
print(client.post("/books", json={"title": "Dune", "author": "Frank Herbert", "isbn": "978-0441172719"}).status_code) # 409, UNIQUE isbn
print(client.post("/books", json={"title": "Emma", "author": "Jane Austen", "rating": 9}).status_code) # 409, CHECK rating
print(client.post("/books", json={"title": "No author"}).status_code) # 400
print(client.get("/books/1").json["author"], client.get("/books/99").status_code)
print([b["title"] for b in client.get("/books?order=rating&q=du").json])
Source: Flask: Using SQLite 3 with Flask
The last request in the block uses an order and a search term: with rating as the order and du as the term, only
Dune comes back. The blueprint is registered on an application that was already created, which Flask 2.3 and later
forbids once the app has handled a request; here the app had not yet served any. In a real project the registration belongs in
the factory (see the Blueprints page). The same view with curl:
curl -X POST http://127.0.0.1:5000/books -H "Content-Type: application/json" \
-d '{"title": "Dune", "author": "Frank Herbert", "isbn": "978-0441172719", "rating": 5}'
curl "http://127.0.0.1:5000/books?order=rating&q=du"
The helper query_db(query, args=(), one=False) of the official Patterns page wraps execute, fetchall and a
"first row or `None`" in one function. It is a taste decision: it shortens views, and it hides which cursor is open. Keep
the helper if the application has dozens of queries; write the SQL in the view if it has a handful.
Reading Rows
| Call | Result |
|---|---|
|
The next row, or |
|
A list of every remaining row; fine for a page of results, wasteful for a whole table |
|
At most |
|
Iterates lazily, one row at a time |
|
The |
|
Rows changed by the last |
|
The inserted row in one statement (SQLite 3.35 and later), instead of |
If a single custom type is wanted, row_factory can be any callable that receives (cursor, row). The Patterns page shows
a make_dicts function that builds a dict from cursor.description; the example below maps rows to a dataclass, which
gives attribute access and type hints:
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class BookRow:
id: int
title: str
author: str
def book_factory(cursor: sqlite3.Cursor, row: tuple) -> BookRow:
names = [col[0] for col in cursor.description]
return BookRow(**dict(zip(names, row)))
with app.app_context():
db = get_db()
db.row_factory = book_factory
print(db.execute("SELECT book.id, book.title, author.name AS author "
"FROM book JOIN author ON author.id = book.author_id").fetchall())
Transactions
By default (autocommit left at its legacy value) the sqlite3 module opens a transaction before an INSERT, UPDATE,
DELETE or REPLACE and keeps it open until commit() or rollback(). A SELECT does not start one. Closing a connection
with a pending transaction discards it, so a write that is never committed is lost without an error. That is the second most
common beginner bug after the missing g check: the view "works", the response is 201, and the row is not there.
The connection is also a context manager, but not the way a file is: with db: commits when the block ends normally and rolls
back if it raises, and it does not close the connection. The create() view above relies on it so that the author row and
the book row appear together or not at all.
with app.app_context():
db = get_db()
db.row_factory = sqlite3.Row
before = db.execute("SELECT count(*) FROM author").fetchone()[0]
try:
with db:
db.execute("INSERT INTO author (name) VALUES (?)", ("Temporary Author",))
db.execute("INSERT INTO book (author_id, title) VALUES (?, ?)", (9999, "Orphan")) # no author 9999
except sqlite3.IntegrityError as exc:
print("rolled back:", exc)
after = db.execute("SELECT count(*) FROM author").fetchone()[0]
print(before == after, db.in_transaction)
Source: Python: Transaction control
The foreign key that makes the second insert fail only exists because get_db() enabled the pragma. Without it the orphan row
would have been written. A summary of the transaction knobs:
| Setting | Effect |
|---|---|
|
Pre-3.12 behaviour driven by |
|
PEP 249 behaviour: a transaction is always open; |
|
SQLite’s own autocommit: every statement commits by itself. Use explicit |
|
Which |
Changing the mode has to happen when the connection is created, in get_db(). For a read-modify-write sequence such as
"decrease stock, then insert the order" prefer isolation_level="IMMEDIATE": the second writer waits at the beginning
instead of failing at the end with database is locked.
Threads, Workers and Locking
Flask servers are concurrent: the development server and Waitress use threads, Gunicorn uses worker processes (and threads
with the gthread worker), and any of them can run several requests at the same time. SQLite handles this correctly if each
request has its own connection, which the g pattern guarantees. What changes between servers is the cost.
| Topic | What to know |
|---|---|
|
A connection may only be used by the thread that created it, otherwise |
|
Reports how much of the module can be shared across threads (0, 1 or 3; it is read from the SQLite library since Python 3.11). It is |
|
How long a connection waits for a lock held by another before raising |
Journal mode |
|
|
Commonly paired with WAL; fewer fsyncs, with the durability trade-off described in the SQLite documentation |
|
Per-connection wait time in milliseconds; the |
Several machines or containers |
The file must be on the local disk of one host. A shared network volume is not safe for SQLite locking |
import threading
wal_db = sqlite3.connect(WORK / "wal.db", timeout=10)
print(wal_db.execute("PRAGMA journal_mode = WAL").fetchone()[0])
wal_db.execute("CREATE TABLE hits (n INTEGER)")
wal_db.commit()
wal_db.close()
def worker(count: int) -> None:
conn = sqlite3.connect(WORK / "wal.db", timeout=10) # one connection per thread
for _ in range(count):
with conn:
conn.execute("INSERT INTO hits VALUES (1)")
conn.close()
threads = [threading.Thread(target=worker, args=(50,)) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
check = sqlite3.connect(WORK / "wal.db")
print(check.execute("SELECT count(*) FROM hits").fetchone()[0])
check.close()
Source: SQLite: Write-Ahead Logging
Four threads, each with its own connection, wrote 200 rows with no error. WAL mode is the standard advice for web
applications on SQLite; it adds two side files (-wal and -shm) next to the database, which matters for backups
and for container volumes.
Gunicorn with several workers means several processes sharing the file: this works, and the lock is what serialises writes. A
g connection is also not an object pool: opening a SQLite connection costs microseconds, so no pooling is needed. For a
server database, the equivalent of this section is a connection pool created once in the factory and borrowed per context.
Testing and Throw-Away Databases
A test needs a fresh database each time. The Flaskr tutorial’s testing chapter uses a fixture that creates a temporary
file, passes its path as DATABASE, runs init_db() inside an application context and removes the file afterwards. The
full pytest layout belongs to the Testing page later in this section; the idea in the form of a context manager, which
runs here:
import contextlib
import shutil
@contextlib.contextmanager
def make_test_app():
tmp = Path(tempfile.mkdtemp(prefix="bookshelf-test-"))
test_app = create_app({"DATABASE": str(tmp / "test.sqlite"), "TESTING": True})
test_app.register_blueprint(bp) # blueprint objects can be registered on more than one app
with test_app.app_context():
init_db()
try:
yield test_app
finally:
shutil.rmtree(tmp, ignore_errors=True)
with make_test_app() as test_app:
c = test_app.test_client()
assert c.get("/books").json == []
assert c.post("/books", json={"title": "Emma", "author": "Jane Austen"}).status_code == 201
assert len(c.get("/books").json) == 1
with make_test_app() as test_app: # a second app: its database is empty again
assert test_app.test_client().get("/books").json == []
print("isolated")
Source: Flask Tutorial: Test Coverage
Notes on testing:
-
An in-memory database (
":memory:") is a poor fit for a per-context connection: everysqlite3.connect(":memory:")creates a different database, so the tables created byinit-dbdo not exist in the connection that the next request opens. Use a temporary file, or a shared-cache URI (file:name?mode=memory&cache=sharedwithuri=True) and keep one connection open for the life of the test. -
Python 3.13 emits a
ResourceWarningwhen a connection is garbage-collected withoutclose(). Theteardown_appcontextfunction exists to prevent that, so a warning in the test output means a connection was opened somewhere other thanget_db(). -
Test the CLI with
app.test_cli_runner(), as above, and test constraint violations by posting the bad data: the409of thecreate()view above is a behaviour worth a test.
Schema Versions Without a Tool
A schema.sql that drops and recreates tables is only right for the first deployment. After that, changes to a schema have to
keep the data. A full migration tool is the subject of the Database Migrations page of this section (Alembic through
Flask-Migrate, for SQLAlchemy models); for a plain sqlite3 application a numbered list of statements is often enough, and
SQLite has a free integer in the file header for it, PRAGMA user_version:
MIGRATIONS = [
"CREATE TABLE IF NOT EXISTS shelf (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE)",
"ALTER TABLE book ADD COLUMN shelf_id INTEGER REFERENCES shelf (id)",
]
def migrate(db: sqlite3.Connection) -> int:
current = db.execute("PRAGMA user_version").fetchone()[0]
for number, statement in enumerate(MIGRATIONS, start=1):
if number > current:
with db:
db.execute(statement)
db.execute(f"PRAGMA user_version = {number}") # a pragma takes no bound value; number is our own int
return db.execute("PRAGMA user_version").fetchone()[0]
with app.app_context():
print(migrate(get_db()), migrate(get_db())) # the second call changes nothing
Source: SQLite: PRAGMA user_version
ALTER TABLE in SQLite is limited (adding a column is easy; changing or dropping a constraint usually means creating a new
table, copying the rows and renaming it), which is one of the reasons Alembic has a "batch" mode for SQLite. The migrate
function can be called from init_db_command or from its own CLI command, and it must run before the workers start, not in
each of them. The PRAGMA statement accepts no placeholders, which is why the number is formatted in, and why it must never
be request data.
Backups and Inspection
A SQLite database in WAL mode cannot be safely backed up by copying the main file while the application is writing. The
Connection.backup() method copies a consistent snapshot to another database, even while other connections are in use:
def backup_database(target: Path) -> None:
source = sqlite3.connect(app.config["DATABASE"])
dest = sqlite3.connect(target)
with dest:
source.backup(dest)
source.close()
dest.close()
backup_database(WORK / "bookshelf-backup.sqlite")
restored = sqlite3.connect(WORK / "bookshelf-backup.sqlite")
print(restored.execute("SELECT count(*) FROM book").fetchone()[0])
restored.close()
Source: Python: Connection.backup
For quick inspection the sqlite3 command-line shell, if installed, opens the file (sqlite3 instance/bookshelf.sqlite, then
.tables and .schema), and Python itself provides python -m sqlite3 instance/bookshelf.sqlite as an interactive shell
(since Python 3.12).
Using the Database Outside a Request
Seed scripts, report commands and background jobs use the same get_db(); they only need the context. A command registered
on app.cli already has one, and a plain script pushes it:
@click.command("seed")
@with_appcontext
def seed_command() -> None:
db = get_db()
with db:
db.execute("INSERT OR IGNORE INTO author (name) VALUES (?)", ("Ursula K. Le Guin",))
click.echo(f"{db.execute('SELECT count(*) FROM author').fetchone()[0]} authors")
app.cli.add_command(seed_command)
print(runner.invoke(args=["seed"]).output.strip())
with app.app_context():
print(get_db().execute("SELECT count(*) FROM book").fetchone()[0])
Source: Flask: Command Line Interface
A thread started from a view does not inherit the context of the request, so it must push its own with
app.app_context() before calling get_db(). Never hand the request’s connection to another thread.
Pitfalls
| Pitfall | Fix |
|---|---|
SQL built with f-strings, |
Placeholders for every value; allow-lists for identifiers and sort orders |
A module-level connection |
A connection per context on |
|
|
Forgetting |
|
Foreign keys silently ignored |
|
|
Confirmation option, |
|
A temporary file or a shared-cache URI |
|
Short transactions, |
The database file inside the package or committed to git |
The instance folder, ignored by git; mount a volume in containers |
A dictionary-like row sent to |
|
|
It takes no parameters and commits implicitly; use it only for files you ship |
Returning raw |
Map it to |
Security Notes
-
Placeholders are the defence against SQL injection. Add input validation as a second layer (types, lengths, allow-lists), and keep the database account’s rights as small as possible on servers that support it; SQLite has no accounts, so its protection is the operating system’s file permissions on the file and on the instance folder.
-
The database file contains the data in the clear. Keep it out of any directory served by the web server or by Flask’s static folder, and out of the container image layers if it holds real data.
-
Never put credentials in code: a server database URL belongs in the environment or the instance configuration, as described on the Configuration page; SQLite needs only a path.
-
Do not show database error messages to the user, and do not run the debugger in production.
-
Back up files that are in WAL mode with
Connection.backup()or the SQLite shell’s.backup, not with a file copy.
Recent and Upcoming Changes
| Version | Component | Change |
|---|---|---|
Flask 2.2 |
Flask |
Commands run through the |
Flask 2.3 |
Flask |
|
Python 3.11 |
|
|
Python 3.12 |
|
The |
Python 3.13 |
|
Positional use of most |
Python 3.14 |
|
Named placeholders with a sequence of parameters raise |
Flask 3.2 (unreleased) |
Flask |
The request context is merged into the application context and all teardown functions run even if one fails. |
Source: Python: sqlite3
Where the Books Are Outdated
The books of the bibliography use databases only in passing before they move to SQLAlchemy, and some of their habits are no longer current:
| In the books | Today |
|---|---|
One global connection, or |
A connection on |
A database created with |
An explicit |
SQL assembled with |
Bound parameters, always |
|
The SQLAlchemy 2.0 style on the Flask-SQLAlchemy page ( |
|
Not needed for commands created with |
Secrets and database passwords in source code |
Environment variables or the instance folder; see the Configuration page |
|
Deprecated since Python 3.12; register converters or store ISO strings |
When to Move to SQLAlchemy
Stay with sqlite3 while the schema is small and the queries are few. Move when most of these start to hurt: the same SQL
for the same entity is repeated in several views, relationships need joins in many places, you maintain the schema with
hand-written migrations, a second database engine is on the horizon, or a test needs the same models in several
states. Flask-SQLAlchemy (the next page of this section) keeps the pattern of this page, because it also keeps a session
per application context and removes it in teardown_appcontext; it replaces the SQL strings with a model layer, a connection
pool and a migration path through Alembic. The sqlite3 knowledge carries over: placeholders become bound parameters
automatically, transactions become session.commit(), and PRAGMA foreign_keys becomes an engine event.