The magic between Django and SQLite¶
A deep dive using the ``Exercise`` model from Liftlog.
Everything below was captured from this project: Django 5.2.17, SQLite 3.40.1, and Liftlog’s real
db.sqlite3. The SQL statements are the exact ones Django sent. They were recorded with
django.test.utils.CaptureQueriesContext on a copy of the database.
Each numbered section is self-contained, so each one can be its own video.
0. The inputs¶
The model (workouts/models.py)¶
class Exercise(models.Model):
name = models.CharField(max_length=100, unique=True)
muscle = models.ForeignKey(Muscle, on_delete=models.PROTECT, related_name="exercises")
secondary_muscles = models.ManyToManyField(
Muscle, blank=True, related_name="secondary_exercises",
help_text="Other muscles it works. Each counts as half a set in weekly sets.",
)
equipment = models.CharField(max_length=60, blank=True, help_text="Optional, your own words, e.g. \"Hammer Strength\"")
is_custom = models.BooleanField(default=False, help_text="Added by you, not part of the built-in library")
unit = models.CharField(max_length=2, choices=[("lb", "lb"), ("kg", "kg")], default="lb",
help_text="All weights for this lift are stored and shown in this unit")
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
def set_unit(self, unit):
"""Switch lb <-> kg, converting every weight already saved for this lift."""
if unit == self.unit or unit not in ("lb", "kg"):
return
factor = 1 / LB_PER_KG if unit == "kg" else LB_PER_KG
rows = [
*PlannedExercise.objects.filter(exercise=self, weight__isnull=False),
*WorkoutExercise.objects.filter(exercise=self, weight__isnull=False),
*WorkoutSet.objects.filter(workout_exercise__exercise=self, weight__isnull=False),
]
for row in rows:
row.weight = round(row.weight * factor, 1)
row.save(update_fields=["weight"])
self.unit = unit
self.save(update_fields=["unit"])
The schema (sqlite3 db.sqlite3 ".schema workouts_exercise")¶
CREATE TABLE IF NOT EXISTS "workouts_exercise" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" varchar(100) NOT NULL UNIQUE,
"equipment" varchar(60) NOT NULL,
"is_custom" bool NOT NULL,
"unit" varchar(2) NOT NULL,
"muscle_id" bigint NOT NULL REFERENCES "workouts_muscle" ("id") DEFERRABLE INITIALLY DEFERRED
);
CREATE INDEX "workouts_exercise_muscle_id_641d6f26" ON "workouts_exercise" ("muscle_id");
…plus a second table the model created without you ever writing it:
CREATE TABLE IF NOT EXISTS "workouts_exercise_secondary_muscles" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"exercise_id" bigint NOT NULL REFERENCES "workouts_exercise" ("id") DEFERRABLE INITIALLY DEFERRED,
"muscle_id" bigint NOT NULL REFERENCES "workouts_muscle" ("id") DEFERRABLE INITIALLY DEFERRED
);
CREATE UNIQUE INDEX "workouts_exercise_secondary_muscles_exercise_id_muscle_id_cb7e9f56_uniq"
ON "workouts_exercise_secondary_muscles" ("exercise_id", "muscle_id");
CREATE INDEX "workouts_exercise_secondary_muscles_exercise_id_fe2660e7" ON "workouts_exercise_secondary_muscles" ("exercise_id");
CREATE INDEX "workouts_exercise_secondary_muscles_muscle_id_c6c446c6" ON "workouts_exercise_secondary_muscles" ("muscle_id");
The database holds 264 rows in workouts_exercise. A few of them:
id|name |equipment|is_custom|unit|muscle_id
46|Bench Press | |0 |lb |1
47|Paused Bench Press | |0 |lb |1
48|Incline Bench Press | |0 |lb |2
1. The big picture: seven layers¶
When you write Exercise.objects.get(name="Bench Press"), the request passes down through these layers
and the answer comes back up:
Your code Exercise.objects.get(name="Bench Press")
│
① Model class Exercise (built by the ModelBase metaclass; knows its table, columns, relations: Exercise._meta)
│
② Manager Exercise.objects → hands out QuerySets
│
③ QuerySet lazy description of "which rows"; holds a Query object
│
④ Query → SQL Query tree compiled by SQLCompiler into ("SELECT ... WHERE name = %s", ["Bench Press"])
│
⑤ Backend django.db.backends.sqlite3.DatabaseWrapper: converts %s → ?, manages connection & transactions
│
⑥ DB-API driver Python's built-in sqlite3 module (PEP 249) → calls the C library
│
⑦ SQLite engine libsqlite3: parses SQL, plans it, walks B-trees in the file db.sqlite3
The “magic” is mostly layers ① to ⑤. They are plain Python, and you can read every line in
.venv/lib/python3.11/site-packages/django/db/. SQLite itself is an ordinary C library linked into
your Python process. There is no database server. Your Django process is the database engine,
reading and writing one file directly.
2. Magic #1: a class becomes a table description¶
Writing class Exercise(models.Model): runs Django’s metaclass ModelBase.__new__
(django/db/models/base.py). Python calls it while it creates the class. Then, for every attribute
that is a Field, Django calls field.contribute_to_class(cls, name). This step:
Registers the field in
Exercise._meta(anOptionsobject)._metais the model’s complete self-description. TryExercise._meta.db_table,Exercise._meta.get_fields(), andExercise._meta.ordering.Gives each field its database column name (
field.column). For a ForeignKey namedmuscle, that’smuscle_id.Replaces relation attributes with descriptors. A descriptor is a Python object that intercepts
ex.muscle. See section 6.Attaches a default Manager as
Exercise.objects.Adds an
idprimary key, because you didn’t declare one. Its type comes fromDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'inliftlog/settings.py.
The table name is <app_label>_<model name lowercased>, which gives workouts + _ + exercise.
Field-by-field: Python → column¶
Python field |
Column in SQLite |
What’s really going on |
|---|---|---|
implicit |
|
Django’s SQLite backend maps BigAutoField to |
|
|
|
|
|
The FK column points at Muscle’s |
|
no column at all |
A many-to-many can’t fit in one row, so Django auto-creates a hidden through model and table |
|
|
|
|
|
SQLite has no boolean type. |
|
|
|
|
nothing |
Pure Python. They exist for forms, the admin, and your code. |
|
nothing |
Adds a reverse descriptor on Muscle, so |
Key idea for a video: the model is the source of truth for Python, and the schema is the source of truth for SQLite. They overlap but they are not the same. Some rules live only in Python (choices, blank, max_length, on_delete, default). Others live only in the database (UNIQUE, NOT NULL, foreign keys, CHECK constraints).
3. Magic #2: migrations turn class changes into SQL¶
You never typed CREATE TABLE. Two commands did:
makemigrationsloads every migration file and replays them in memory to rebuild the historical state of each model. It compares that with your current classes and writes the difference as Python operations:AddField,AlterField,CreateModel, and so on.migrateexecutes those operations through a SchemaEditor, which turns each one into SQL for your database backend. It then records each applied migration in thedjango_migrationstable:
workouts|0006_exercise_unit|2026-10-07 13:31:30
workouts|0011_normalize_swap_fields|2026-10-07 13:59:47
...
You can see the SQL a migration runs, without running it, using python manage.py sqlmigrate workouts 0006.
Migration 0006 added unit to Exercise:
BEGIN;
CREATE TABLE "new__workouts_exercise" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "unit" varchar(2) NOT NULL,
"name" varchar(100) NOT NULL UNIQUE, "muscle" varchar(20) NOT NULL, "equipment" varchar(60) NOT NULL, "is_custom" bool NOT NULL);
INSERT INTO "new__workouts_exercise" ("id", "name", "muscle", "equipment", "is_custom", "unit")
SELECT "id", "name", "muscle", "equipment", "is_custom", 'lb' FROM "workouts_exercise";
DROP TABLE "workouts_exercise";
ALTER TABLE "new__workouts_exercise" RENAME TO "workouts_exercise";
COMMIT;
This is the table remake, SQLite’s most famous quirk. SQLite’s ALTER TABLE is limited (no
changing a column’s type or constraints, for example). So Django’s SQLite schema editor
(_remake_table) builds a new table with the right shape, copies every row over, drops the old
table, and renames the new one into place. Notice:
default="lb"shows up here, as the literal'lb'used to backfill existing rows. That is the only place the default ever touches SQL.In this migration
musclewas still avarchar(20)text column. Migration 0011 (the normalization) remade the table again: it addedmuscle_idas a real foreign key and dropped the JSONsecondary_musclescolumn in favor of the link table. The schema has a history, anddjango_migrationsis its logbook.During a remake, the schema editor runs
PRAGMA foreign_keys = OFF(so dropping a table other tables point at doesn’t fail). Afterwards it runs a constraint check (PRAGMA foreign_key_check) before turning them back on (django/db/backends/sqlite3/schema.py,__enter__/__exit__).
Why .schema says CREATE TABLE IF NOT EXISTS: that text isn’t stored in the file. Run
sqlite3 db.sqlite3 "SELECT sql FROM sqlite_schema WHERE name='workouts_exercise'" and you’ll see the
stored statement is plain CREATE TABLE "workouts_exercise" .... The sqlite3 command-line shell adds
IF NOT EXISTS when it prints the schema, so the output can be safely re-run as a script.
4. Magic #3: the connection¶
liftlog/settings.py says:
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3"}}
ENGINE is a Python import path. Django imports django.db.backends.sqlite3.base and uses its
DatabaseWrapper class. Every backend (PostgreSQL, MySQL, Oracle) is a sibling package with the same
interface. That’s why your models don’t change if you switch databases.
The connection is lazy and per-thread. Nothing opens until the first query. Then
DatabaseWrapper.get_new_connection() runs, and it does four things worth knowing:
Calls Python’s
sqlite3.connect(...)withdetect_types=PARSE_DECLTYPES | PARSE_COLNAMES. That lets declared column types such asdateanddatetimedrive conversion back into Pythondate/datetimeobjects. It also setscheck_same_thread=False, because Django manages thread safety itself.Registers Python functions inside SQLite (
register_functions(conn)insqlite3/_functions.py). SQLite lacks many SQL functions that PostgreSQL has, such as date truncation, some math functions and regex. Django defines them in Python and hands them to SQLite withcreate_function, so SQL likedjango_date_extract('week_day', "date")calls back into your Python process. When you write.filter(date__week_day=2), a C library calls a Python function for every row.Runs
PRAGMA foreign_keys = ON. SQLite has foreign-key enforcement off by default, per connection, for backward compatibility. You can prove it:$ sqlite3 db.sqlite3 "PRAGMA foreign_keys;" 0 ← the CLI's connection: FKs NOT enforced
>>> connection.cursor().execute("PRAGMA foreign_keys").fetchone() (1,) ← Django's connection: enforced
So the same file has different rules depending on who opens it. If you edit
db.sqlite3by hand in the CLI, nothing stops you from pointingmuscle_idat a muscle that doesn’t exist.Sets autocommit mode. Each statement commits on its own unless you’re inside
transaction.atomic()(more in section 8).
Placeholders: %s becomes ?¶
Internally, every Django backend writes SQL with %s placeholders. The Python sqlite3 module
expects ? (“qmark” style). SQLiteCursorWrapper.convert_query() rewrites one into the other with a
regex (FORMAT_QMARK_REGEX = r"(?<!%)%s") just before execution. Values are never pasted into the
SQL string. They travel separately as parameters, and SQLite binds them. That’s what makes the ORM
immune to SQL injection.
5. Magic #4: QuerySets are lazy, then compiled¶
qs = Exercise.objects.filter(muscle__group__name="Chest").order_by("name")
This line sends no SQL. A QuerySet is a description of a query: a Query object holding a
tree of WHERE conditions, joins, ordering, limits and so on. Each .filter() clones it and adds to
the tree. You can peek at the SQL it would run:
>>> print(qs.query)
SELECT "workouts_exercise"."id", "workouts_exercise"."name", "workouts_exercise"."muscle_id",
"workouts_exercise"."equipment", "workouts_exercise"."is_custom", "workouts_exercise"."unit"
FROM "workouts_exercise"
INNER JOIN "workouts_muscle" ON ("workouts_exercise"."muscle_id" = "workouts_muscle"."id")
INNER JOIN "workouts_musclegroup" ON ("workouts_muscle"."group_id" = "workouts_musclegroup"."id")
WHERE "workouts_musclegroup"."name" = Chest
ORDER BY "workouts_exercise"."name" ASC
Notice = Chest has no quotes. print(qs.query) is only an approximation for humans. The real
execution sends ... = ? plus the parameter ('Chest',).
How muscle__group__name becomes two JOINs: the __ separator is the ORM’s path language.
Query.setup_joins() walks it one piece at a time using _meta:
muscleis a ForeignKey on Exercise, so it joinsworkouts_muscleonmuscle_id = id.groupis a ForeignKey on Muscle, so it joinsworkouts_musclegroupongroup_id = id.nameis an ordinary field, so the final column isworkouts_musclegroup.name.
The lookup has no suffix, which means __exact, which compiles to =. Other lookups compile to
other SQL: __icontains becomes LIKE ... ESCAPE, and __isnull=False becomes IS NOT NULL.
When does SQL actually run? When the QuerySet is evaluated: iterating, list(), len(),
bool(), indexing such as qs[0], or calling .get(), .count(), .exists() or .first(). After
that, the results are cached inside that QuerySet object.
Captured: .get()¶
Exercise.objects.get(name="Bench Press")
SELECT "workouts_exercise"."id", "workouts_exercise"."name", "workouts_exercise"."muscle_id",
"workouts_exercise"."equipment", "workouts_exercise"."is_custom", "workouts_exercise"."unit"
FROM "workouts_exercise" WHERE "workouts_exercise"."name" = 'Bench Press' LIMIT 21
Why LIMIT 21? .get() must raise MultipleObjectsReturned if more than one row matches. Rather
than fetch an unbounded number of rows just to complain, Django fetches at most
MAX_GET_RESULTS = 21 (django/db/models/query.py). That’s enough to say “returned more than 20!”
in the error.
What SQLite does with it (EXPLAIN QUERY PLAN):
SEARCH workouts_exercise USING INDEX sqlite_autoindex_workouts_exercise_1 (name=?)
The unique=True on name built that index, so this is a B-tree lookup, not a scan. Compare a
column with no index:
EXPLAIN QUERY PLAN SELECT * FROM workouts_exercise WHERE unit='kg';
SCAN workouts_exercise ← reads every row
Rows become objects¶
The cursor returns plain tuples: (46, 'Bench Press', 1, '', 0, 'lb'). The compiler knows which
column is which field, applies converters (for example 0 → False), and calls
Exercise.from_db(), which builds the instance without calling your __init__ logic per field.
It also sets instance._state.adding = False and instance._state.db = "default", so later code
knows this row already exists in that database.
6. Magic #5: relationships are descriptors (and the N+1 problem)¶
ex.muscle looks like plain attribute access, but Exercise.muscle is a
ForwardManyToOneDescriptor (django/db/models/fields/related_descriptors.py). Its __get__
runs whenever you read the attribute.
ex = Exercise.objects.get(name="Bench Press") # 1 query (above)
ex.muscle # descriptor fires:
SELECT "workouts_muscle"."id", "workouts_muscle"."code", "workouts_muscle"."name", "workouts_muscle"."order", "workouts_muscle"."group_id"
FROM "workouts_muscle" WHERE "workouts_muscle"."id" = 1 LIMIT 21
ex.muscle # again → NO SQL. The result was stored in ex._state.fields_cache["muscle"]
The row already held muscle_id = 1, so ex.muscle_id never needs a query. Only following the
relation to the object does.
ex.secondary_muscles is a ManyToManyDescriptor. It returns a manager class that Django
builds on the fly (create_forward_many_to_many_manager), already filtered to this exercise
through the hidden link table:
-- list(ex.secondary_muscles.all()) → [Front delts, Triceps lateral head]
SELECT "workouts_muscle"."id", "workouts_muscle"."code", "workouts_muscle"."name", "workouts_muscle"."order", "workouts_muscle"."group_id"
FROM "workouts_muscle"
INNER JOIN "workouts_exercise_secondary_muscles" ON ("workouts_muscle"."id" = "workouts_exercise_secondary_muscles"."muscle_id")
WHERE "workouts_exercise_secondary_muscles"."exercise_id" = 46
ORDER BY "workouts_muscle"."order" ASC, "workouts_muscle"."name" ASC
The ORDER BY came from Muscle’s Meta.ordering = ["order", "name"], not from anything you wrote
in the query.
The N+1 problem and its two cures¶
Looping over 264 exercises and touching e.muscle on each one runs 1 + 264 queries. Django gives
two fixes, and they work in different ways:
select_related("muscle") uses one query with a JOIN. It works for FK and one-to-one:
SELECT "workouts_exercise"."id", ..., "workouts_exercise"."unit",
"workouts_muscle"."id", "workouts_muscle"."code", "workouts_muscle"."name", "workouts_muscle"."order", "workouts_muscle"."group_id"
FROM "workouts_exercise" INNER JOIN "workouts_muscle" ON ("workouts_exercise"."muscle_id" = "workouts_muscle"."id")
ORDER BY "workouts_exercise"."name" ASC LIMIT 2
Each result row is split in two: the first 6 columns build the Exercise, the next 5 build the Muscle, and the Muscle is put straight into the Exercise’s field cache.
prefetch_related("secondary_muscles") uses two queries plus stitching in Python. It works for
many-to-many and reverse FKs, where a JOIN would duplicate rows:
SELECT ... FROM "workouts_exercise" WHERE "workouts_exercise"."id" IN (121, 95) ORDER BY "workouts_exercise"."name" ASC;
SELECT ("workouts_exercise_secondary_muscles"."exercise_id") AS "_prefetch_related_val_exercise_id",
"workouts_muscle"."id", "workouts_muscle"."code", "workouts_muscle"."name", "workouts_muscle"."order", "workouts_muscle"."group_id"
FROM "workouts_muscle"
INNER JOIN "workouts_exercise_secondary_muscles" ON ("workouts_muscle"."id" = "workouts_exercise_secondary_muscles"."muscle_id")
WHERE "workouts_exercise_secondary_muscles"."exercise_id" IN (121, 95)
ORDER BY "workouts_muscle"."order" ASC, "workouts_muscle"."name" ASC
Notice the extra column _prefetch_related_val_exercise_id. Django adds it so it can sort each
muscle back to the exercise it belongs to, in Python, after both queries return. Liftlog’s History
page uses exactly this pattern (Prefetch("exercises__logged_sets", ...)) so it can show every
workout with a fixed handful of queries.
7. Magic #6: writes¶
INSERT with RETURNING¶
Exercise.objects.create(name="Zercher Squat Test", muscle=Muscle.objects.get(code="quads"), is_custom=True)
SELECT ... FROM "workouts_muscle" WHERE "workouts_muscle"."code" = 'quads' LIMIT 21;
INSERT INTO "workouts_exercise" ("name", "muscle_id", "equipment", "is_custom", "unit")
VALUES ('Zercher Squat Test', 11, '', 1, 'lb') RETURNING "workouts_exercise"."id";
equipment=''andunit='lb'were filled in by Python from the field defaults (''is CharField’s implicit default). The database has no DEFAULT clauses.is_custom=Truebecame1.muscle=<Muscle>becamemuscle_id = 11. The FK field stores only the id.idis left out, so SQLite picks the next rowid.RETURNING(supported since SQLite 3.35) hands the new id straight back, so Django setsex.id = 310without a second query.
How save() picks INSERT vs UPDATE: Model._save_table() checks self._state.adding and whether
pk is set. A new object is INSERTed. An object loaded from the database (adding=False) is
UPDATEd. save(update_fields=["unit"]) narrows the UPDATE to just that column, instead of rewriting
every column with possibly stale values.
The database says no: UNIQUE¶
with transaction.atomic():
Exercise.objects.create(name="Bench Press", muscle_id=1)
BEGIN;
INSERT INTO "workouts_exercise" (...) VALUES ('Bench Press', 1, '', 0, 'lb') RETURNING "workouts_exercise"."id";
ROLLBACK;
django.db.utils.IntegrityError: UNIQUE constraint failed: workouts_exercise.name
SQLite rejected the row, because the rule lives in the schema. The Python sqlite3 module raised
sqlite3.IntegrityError, and Django re-raised it as django.db.utils.IntegrityError. That wrapping
(DatabaseErrorWrapper) is why your code catches the same exception class on every database.
The database says yes, when it shouldn’t¶
Exercise(name="Bad Unit", muscle_id=1, unit="stone").save()
INSERT INTO "workouts_exercise" ("name", "muscle_id", "equipment", "is_custom", "unit")
VALUES ('Bad Unit', 1, '', 0, 'stone') RETURNING "workouts_exercise"."id";
It succeeded. Two “rules” were broken and nobody noticed:
'stone'isn’t inchoices. Choices live only in Python.'stone'is 5 characters in avarchar(2)column. SQLite uses type affinity, not strict types:varchar(2)just means “prefer TEXT”, and the(2)is decoration. (PostgreSQL would have refused this.)
save() does not validate. Validation (full_clean(), which checks choices, max_length,
blank and so on) runs automatically only in ModelForms and the admin. Liftlog’s set_unit
guards against this itself (unit not in ("lb", "kg")). To make the database enforce it, add a
constraint. Django turns it into a SQL CHECK clause in the schema:
class Meta:
constraints = [models.CheckConstraint(condition=Q(unit__in=["lb", "kg"]), name="exercise_unit_lb_or_kg")]
8. Magic #7: tracing the set_unit method¶
Calling hack_squat.set_unit("kg") produced exactly this SQL:
-- rows = [...] three QuerySets, each evaluated by the * unpacking:
SELECT ... FROM "workouts_plannedexercise"
INNER JOIN "workouts_splitday" ON ("workouts_plannedexercise"."day_id" = "workouts_splitday"."id")
WHERE ("workouts_plannedexercise"."exercise_id" = 217 AND "workouts_plannedexercise"."weight" IS NOT NULL)
ORDER BY "workouts_splitday"."split_id" ASC, "workouts_splitday"."order" ASC, "workouts_plannedexercise"."order" ASC;
SELECT ... FROM "workouts_workoutexercise"
INNER JOIN "workouts_workout" ON ("workouts_workoutexercise"."workout_id" = "workouts_workout"."id")
WHERE ("workouts_workoutexercise"."exercise_id" = 217 AND "workouts_workoutexercise"."weight" IS NOT NULL)
ORDER BY "workouts_workout"."date" DESC, "workouts_workout"."id" DESC, "workouts_workoutexercise"."order" ASC;
SELECT ... FROM "workouts_workoutset"
INNER JOIN "workouts_workoutexercise" ON ("workouts_workoutset"."workout_exercise_id" = "workouts_workoutexercise"."id")
INNER JOIN "workouts_workout" ON ("workouts_workoutexercise"."workout_id" = "workouts_workout"."id")
WHERE ("workouts_workoutset"."weight" IS NOT NULL AND "workouts_workoutexercise"."exercise_id" = 217)
ORDER BY "workouts_workout"."date" DESC, "workouts_workout"."id" DESC, "workouts_workoutexercise"."order" ASC, "workouts_workoutset"."order" ASC;
-- for row in rows: row.save(update_fields=["weight"])
UPDATE "workouts_plannedexercise" SET "weight" = 122.5 WHERE "workouts_plannedexercise"."id" = 39;
UPDATE "workouts_plannedexercise" SET "weight" = 122.5 WHERE "workouts_plannedexercise"."id" = 62;
UPDATE "workouts_workoutexercise" SET "weight" = 122.5 WHERE "workouts_workoutexercise"."id" = 9;
UPDATE "workouts_workoutset" SET "weight" = 122.5 WHERE "workouts_workoutset"."id" = 7;
UPDATE "workouts_workoutset" SET "weight" = 122.5 WHERE "workouts_workoutset"."id" = 8;
UPDATE "workouts_workoutset" SET "weight" = 122.5 WHERE "workouts_workoutset"."id" = 9;
-- self.save(update_fields=["unit"])
UPDATE "workouts_exercise" SET "unit" = 'kg' WHERE "workouts_exercise"."id" = 217;
Things to point out on video:
Hidden JOINs. The code only filtered by
exercise=self, but the SQL joinssplitday,workoutandworkoutexercise. Those joins come purely from each model’sMeta.ordering, which orders by fields on related tables (["day", "order"],["-date", "-id"]and so on). Default ordering isn’t free.exercise=selfcompiled toexercise_id = 217. Passing a model instance to a filter uses its pk.workout_exercise__exercise=selfspans a relation. That’s one JOIN, and the WHERE runs on the joined table’s column.The math happens in Python. Each weight was read into Python, multiplied by
1 / 2.20462, rounded, and written back with one UPDATE per row. With 300 logged sets, that’s 300 UPDATEs.It is not atomic. Django runs in autocommit mode, so each UPDATE commits on its own. If the process died halfway, some sets would be in kg and some in lb, and
unitwould still saylb.
A version that fixes 4 and 5 pushes the math into SQL with an F expression, which refers to a column’s value inside the database, and wraps everything in a transaction:
from django.db import transaction
from django.db.models import F
from django.db.models.functions import Round
with transaction.atomic(): # BEGIN ... COMMIT (or ROLLBACK on error)
new = Round(F("weight") * factor, 1)
PlannedExercise.objects.filter(exercise=self, weight__isnull=False).update(weight=new)
WorkoutExercise.objects.filter(exercise=self, weight__isnull=False).update(weight=new)
WorkoutSet.objects.filter(workout_exercise__exercise=self, weight__isnull=False).update(weight=new)
self.unit = unit
self.save(update_fields=["unit"])
That’s 4 UPDATE statements total, no matter how many sets you have. They look like
UPDATE "workouts_workoutset" SET "weight" = ROUND(("workouts_workoutset"."weight" * 0.4535...), 1) WHERE ...,
and they commit together or not at all. QuerySet.update() skips save(), so no per-object Python
runs. That’s the trade-off for the speed.
9. Magic #8: inside the file¶
db.sqlite3 is a single file, and its format is documented and stable. The first 16 bytes are always
the same:
$ od -A d -c -N 32 db.sqlite3
0000000 S Q L i t e f o r m a t 3 \0
0000016 020 \0 001 001 \0 @ \0 \0 \0 352 \0 \0 \0 L
Bytes 0–15: the magic string
SQLite format 3\0.Bytes 16–17:
020 \0= 0x1000 = 4096, the page size.Bytes 28–31:
\0 \0 \0 L= 0x4C = 76, the page count.76 pages × 4096 bytes = 311,296 bytes. That’s exactly the size
ls -lshows for Liftlog’s database.
$ sqlite3 db.sqlite3 "PRAGMA page_size; PRAGMA page_count; PRAGMA journal_mode;"
4096
76
delete
The whole file is an array of 4 KB pages. Every table and every index is a separate B-tree, and
its root page number is stored in the master table sqlite_schema:
$ sqlite3 db.sqlite3 "SELECT name, rootpage FROM sqlite_schema WHERE tbl_name='workouts_exercise';"
workouts_exercise|53 ← the table: B-tree keyed by rowid (= id)
sqlite_autoindex_workouts_exercise_1|54 ← from unique=True on name: B-tree keyed by name → rowid
workouts_exercise_muscle_id_641d6f26|52 ← the FK index: keyed by muscle_id → rowid
So Exercise.objects.get(name="Bench Press") physically means:
Read page 1 (
sqlite_schema) to learn where things live. This is cached after the first time.Walk the name index B-tree from page 54 to find
'Bench Press'→ rowid 46.Walk the table B-tree from page 53 to rowid 46 and decode the row’s bytes into 6 values.
Hand them up through the C API to Python’s
sqlite3module, then to Django’s converters, then toExercise.from_db(), and finally to your variableex.
Journal mode delete: during a write transaction, SQLite first copies the original pages it’s
about to change into db.sqlite3-journal. Then it modifies the main file. On COMMIT it deletes the
journal. If the process crashes mid-write, the next connection finds the journal and rolls the file
back. That is how a single file gets atomic transactions. It’s also why SQLite allows one writer
at a time: a write takes a lock on the whole file.
10. Cheat sheet¶
You write (Python) |
SQLite sees |
|---|---|
|
table |
implicit |
|
|
|
|
|
|
(nothing; enforced in Python) |
|
separate link table with a unique (pair) index |
|
(nothing; Python only) |
|
|
|
(nothing yet; lazy) |
iterate / |
|
|
one |
|
|
|
one query with JOIN |
|
second query with |
|
|
|
|
|
one |
|
|
|
|
11. Try it yourself (good for screen recordings)¶
# Look at the schema and the real stored SQL
sqlite3 db.sqlite3 ".schema workouts_exercise"
sqlite3 db.sqlite3 "SELECT sql FROM sqlite_schema WHERE name='workouts_exercise';"
# See how SQLite will run a query
sqlite3 db.sqlite3 "EXPLAIN QUERY PLAN SELECT * FROM workouts_exercise WHERE name='Bench Press';"
# Foreign keys: off in the CLI, on in Django
sqlite3 db.sqlite3 "PRAGMA foreign_keys;"
# See the SQL a migration would run
python manage.py sqlmigrate workouts 0006
# Open a SQLite shell with Django's settings
python manage.py dbshell
# python manage.py shell
from django.db import connection
from django.test.utils import CaptureQueriesContext
from workouts.models import Exercise
qs = Exercise.objects.filter(muscle__group__name="Chest")
print(qs.query) # SQL, without running it
with CaptureQueriesContext(connection) as ctx:
ex = Exercise.objects.get(name="Bench Press")
ex.muscle
ex.muscle # cached: no second query
list(ex.secondary_muscles.all())
for q in ctx.captured_queries:
print(q["sql"])
print(len(ctx.captured_queries), "queries")
Exercise._meta.db_table # 'workouts_exercise'
[f.column for f in Exercise._meta.concrete_fields]
Warning: run write experiments on a copy of db.sqlite3, or inside a transaction you roll
back. set_unit really converts your data.
12. Quiz questions (for the game)¶
Where does
default="lb"appear in SQL? (Only in the migration that added the column, as a backfill literal, and in INSERTs, where Python supplies it. Never as a column DEFAULT.)Why is
iddeclaredintegerwhen the model usesBigAutoField? (Only ``INTEGER PRIMARY KEY`` aliases SQLite’s rowid.)Saving
unit="stone"succeeds. Name two reasons. (``choices`` isn’t in the schema, and SQLite ignores ``varchar(2)`` length.)Why does
.get()addLIMIT 21? (To detect ``MultipleObjectsReturned`` without fetching every row.)How many queries does
ex.muscle; ex.musclerun? (One. The second read comes from the field cache.)select_relatedorprefetch_relatedforsecondary_muscles? (prefetch: it’s many-to-many.)What runs
PRAGMA foreign_keys = ON, and why must it? (Django’s ``get_new_connection``. SQLite enforcement is off by default, per connection.)Why did
set_unitJOINworkouts_workoutwhen it only filtered by exercise? (``Meta.ordering`` on WorkoutExercise orders by the workout’s date.)What happens to
set_unit’s data if the process crashes halfway? (Half-converted. Each UPDATE autocommits, because there’s no ``atomic()``.)What does
on_delete=PROTECTcompile to in SQLite? (Nothing. Django’s Collector checks it in Python before deleting.)