Models & the ORM
So far the blog has had no real data — Phase 2 mapped URLs to views and returned hard-coded responses. Now we give it a memory. The question this phase answers is the one every web app eventually has to: where does the data live, and how does my Python code talk to it?
Here's the mental model to carry through everything below. Your database thinks in tables — rows and columns and foreign keys, numbers pointing at numbers. Your Python code thinks in objects — a Post with a .title and a .body, a Comment that belongs to a post. Those are two different shapes for the same information, and something has to translate between them. In Django, that something ships in the box: the ORM.
If you've read What an ORM Is for Java, this is the exact same idea — Hibernate for Java, Django's ORM for Python. And if "table," "row," and "foreign key" are fuzzy, What a Database Actually Is is the prerequisite mental model. This phase assumes you know that a database stores tables; it teaches you how Django turns those tables into Python.
Models = tables
📝 A model is a Python class that maps to a database table. You write a class that subclasses models.Model; Django treats that class as a table, each instance as a row, and each class attribute as a column. You never hand-write CREATE TABLE — you describe the shape in Python and Django builds the SQL.
The big relief for Python developers coming from other ecosystems: there's no separate ORM library to install and wire up. Java reaches for Hibernate; Node reaches for Prisma or TypeORM; Python web apps often reach for SQLAlchemy. Django's ORM is built in. It's already there the moment you start a project — one less decision, one less dependency.
Here are the blog's two models. They go in your app's models.py:
=
=
=
=
=
=
=
What just happened: you described two tables in Python. Post becomes a post table with columns id (Django adds an auto-incrementing primary key for free), title, body, and created. Each field is a class attribute whose type decides the column type: CharField is a short string (it needs max_length so the database knows the column width), TextField is unbounded long text, and DateTimeField stores a timestamp — auto_now_add=True means "stamp it once, when the row is first created." The Comment.post field is a ForeignKey pointing at Post: that's the "this comment belongs to that post" relationship, stored in the database as a post_id column holding a number. We'll come back to on_delete and related_name shortly.
💡 The model is just a class. It doesn't touch the database when Python imports it — it's a description. Turning that description into an actual table is the next step, and it's deliberately a separate, explicit action.
Migrations: version control for your schema
You've written a class that describes a table. The database doesn't have that table yet. Migrations are how the description becomes reality — and how it stays in sync every time you change a model later.
📝 A migration is a file that records a change to your database schema. You don't write it by hand. You run makemigrations, Django compares your models to the last known state, and it generates a Python file describing the difference ("create a post table with these columns"). Then migrate runs those files against the actual database.
It's a two-step rhythm, and the names tell you exactly what each does:
What just happened: makemigrations looked at your models.py, saw two brand-new models, and wrote a migration file (something like blog/migrations/0001_initial.py) capturing "create these two tables." Nothing has hit the database yet — this step only plans the change. Then migrate took that plan and executed it, running the actual CREATE TABLE SQL. Here's what the console shows:
$ python manage.py makemigrations
Migrations for 'blog':
blog/migrations/0001_initial.py
+ Create model Post
+ Create model Comment
$ python manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, blog, contenttypes, sessions
Running migrations:
Applying blog.0001_initial... OK
What just happened: the first command reported the migration file it created and what's in it. The second applied it — and notice it also applied migrations for admin, auth, and friends. Those are Django's own built-in apps (you'll meet the admin next phase); they ship migrations too, and migrate brings the whole database up to date in one pass.
💡 Migrations are version control for your database schema. That's the whole point. The migration files are committed to git alongside your code, so a teammate who pulls your branch runs migrate and gets the exact same tables — no "works on my machine" schema drift. They're reviewable in a pull request, repeatable on every environment, and they record the full history of how your schema got to where it is. The schema stops being a thing that lives only in someone's local database and becomes part of the codebase.
⚠️ Every model change needs both steps. Add a field, rename one, change max_length — the moment you touch models.py, the database is out of sync until you run makemigrations and then migrate. Forgetting makemigrations means the change is never even recorded; forgetting migrate means it's recorded but never applied. The classic confusing error — a column the database swears doesn't exist, even though it's right there in your model — is almost always a migration you forgot to apply.
The ORM: querying in Python
The tables exist. Now the payoff — reading and writing data without writing SQL. The single best way to learn this is the Django shell, an interactive Python prompt with your whole project loaded:
Every model has an attribute called objects — its manager — and that's your entry point for talking to the table. Let's create some posts and read them back:
>>>
>>>
<: >
>>>
<: >
>>>
< >
>>>
<: >
>>>
< >
>>>
< >
What just happened: you ran five different database operations and never wrote a word of SQL. objects.create(...) inserted a new row and handed back the saved Post object. objects.all() fetched every row. objects.get(id=1) fetched exactly one row by its primary key (it raises an error if zero or more than one match — get is for "I expect exactly one"). objects.filter(title__contains="Django") returned every post whose title contains "Django" — that __contains is a lookup, the double-underscore syntax Django uses for "WHERE this column does that." And order_by("-created") sorted newest-first (the leading - means descending). The <QuerySet [...]> wrapper is just Django's name for "a collection of rows from a query."
💡 You write Python; the ORM writes SQL. Each of those calls became a real query underneath. Post.objects.filter(title__contains="Django") generated roughly:
SELECT id, title, body, created
FROM blog_post
WHERE title LIKE '%Django%';
What just happened: your Python lookup title__contains="Django" became a SQL LIKE clause, against the blog_post table (Django names tables <app>_<model> by default). This is the deal an ORM offers: you stay in Python, it produces and runs the SQL. The convenience is real — and like any ORM, it can quietly generate wasteful SQL if you're not paying attention. We'll meet that trap (the famous N+1 problem) head-on in Phase 7; for now, just hold the idea that there's always real SQL underneath.
Relationships: following the foreign key
The ForeignKey on Comment is what makes this a relational database and not two unrelated tables. It gives you navigation in both directions — and Django generates a tidy Python accessor for each.
Let's attach a comment to a post and then walk the relationship from both ends:
>>>
>>> =
>>>
<: >
>>> # Forward: from a comment to its post
>>> =
>>>
<: >
>>>
>>> # Reverse: from a post to all its comments
>>>
< >
What just happened: you created a comment by handing it a whole Post object (post=post) — Django stores the post's id in the post_id column for you. Then you walked the link two ways. Forward (the direction the ForeignKey points): comment.post follows the foreign key from the comment back to its single owning post, and you can chain straight on to .post.title. Reverse (against the arrow): post.comments.all() finds every comment whose post_id matches this post. That comments name is exactly the related_name="comments" we set on the field — without it, Django would default the reverse accessor to post.comment_set.all(). Setting related_name is how you get a readable post.comments instead.
And on_delete=models.CASCADE? That's Django answering a question the database insists on: if this post is deleted, what happens to its comments? CASCADE means "delete them too" — a post's comments shouldn't outlive the post. (Other options exist, like PROTECT to forbid the deletion, but CASCADE is the sensible default for "comments belong to a post.")
We're keeping relationship queries deliberately shallow here. The deeper material — how QuerySets are lazy, why looping over post.comments can secretly fire a query per row, and how to fix it — is all in Phase 7.
__str__, Meta, and field options
Three finishing touches turn rough models into ones that are pleasant to work with — and that the rest of Django can present nicely.
First, __str__. Notice every object above printed as the unhelpful <Post: Post object (1)>. Add a __str__ method and that changes everywhere:
=
=
=
=
return
=
=
=
=
return f
What just happened: __str__ defines how an object turns into a readable string — now a post prints as Hello world instead of Post object (1), in the shell, in error messages, and (crucially) in the admin you'll build next phase. The class Meta block holds model-level settings; ordering = ["-created"] makes every query for posts come back newest-first by default, so you don't have to remember .order_by() each time. (Meta is for options about the table; the methods like __str__ are behavior on a row.)
Now the field option that trips up nearly everyone: null versus blank. They sound identical and are not.
⚠️ null is about the database; blank is about validation.
null=Truelets the database column storeNULL(no value at all). It's a schema decision.blank=Truelets a form accept an empty value without complaining. It's a validation decision.
They operate in completely different layers and you often want different combinations. For a text field you'd usually leave both off (required) or set blank=True without null=True — because Django stores "empty text" as an empty string "", not NULL, so adding null=True to a CharField/TextField just creates two different ways to say "empty" and invites bugs. You reach for null=True mainly on non-text fields (numbers, dates, foreign keys) that genuinely have "no value." A field with default=... supplies a value when none is given; unique=True tells the database to reject duplicates. The pairing to remember: null answers "can the column be empty in the DB?"; blank answers "can the form leave it empty?"
💡 The model is the single source of truth. This is the idea that ties the whole framework together. That one class definition drives three things at once: the database schema (via migrations, this phase), the admin interface (Phase 4 — which reads your fields and __str__ to build a back-office for free), and forms (Phase 6 — where blank, max_length, and field types become validation rules). Define your data well in models.py and Django propagates it everywhere. That's the payoff of the conventions.
Recap
- A model is a Python class that maps to a table — subclass
models.Model, and each field attribute (CharField,TextField,DateTimeField,ForeignKey) becomes a column. Django's ORM is built in; no SQLAlchemy or separate library required. - Migrations turn model changes into schema changes:
makemigrationswrites a migration file describing the diff,migrateapplies it to the database. They're version control for your schema — committed, reviewable, repeatable. - ⚠️ Every model edit needs both
makemigrationsandmigrate— skip either and your code and database fall out of sync. - The ORM lets you query in Python:
objects.create/all/get/filter/order_byvia each model'sobjectsmanager. You write Python; Django writes and runs the SQL underneath. - A
ForeignKeygives two-way navigation: forward withcomment.post, reverse withpost.comments.all()(the name set byrelated_name).on_deletedecides what happens to children when the parent is deleted. __str__,Meta, and field options polish the model:__str__for readable objects,Meta.orderingfor default sort, and field options wherenullis a database concern andblankis a forms concern — different layers, not synonyms. The model is the single source of truth feeding the schema, the admin, and forms.
Quick check
Three questions on the ideas that have to stick before the admin in Phase 4:
[
{
"q": "You add a new field to your Post model. What must you do for the database to actually have that column?",
"choices": [
"Run `makemigrations` to record the change, then `migrate` to apply it to the database",
"Nothing — Django updates the database automatically when it imports the model",
"Hand-write an ALTER TABLE statement and run it in the SQL shell",
"Only run `migrate`; `makemigrations` is just for brand-new projects"
],
"answer": 0,
"explain": "Touching models.py puts your code ahead of the schema. `makemigrations` generates a migration file describing the diff; `migrate` runs it against the database. You need both — forgetting either leaves code and database out of sync."
},
{
"q": "Given `post = models.ForeignKey(Post, related_name=\"comments\", on_delete=models.CASCADE)` on Comment, how do you get all comments for a given post object?",
"choices": [
"post.comments.all()",
"post.comment.all()",
"Comment.objects.post(post)",
"post.foreignkey('Comment')"
],
"answer": 0,
"explain": "The reverse accessor's name comes from related_name, so it's post.comments.all(). Without related_name, Django would default it to post.comment_set.all(). Forward navigation (comment to its post) is just comment.post."
},
{
"q": "What is the difference between `null=True` and `blank=True` on a model field?",
"choices": [
"`null=True` lets the database column store NULL (a schema concern); `blank=True` lets a form accept an empty value (a validation concern)",
"They are synonyms — both make the field optional in exactly the same way",
"`null=True` is for text fields and `blank=True` is for number fields",
"`blank=True` deletes the row when the field is empty; `null=True` keeps it"
],
"answer": 0,
"explain": "They operate in different layers. null is about whether the database column can hold NULL; blank is about whether a form will accept an empty value. They're independent, and for text fields you usually use blank without null to avoid two different ways of saying 'empty'."
}
]
← Phase 2: URLs & Views · Guide overview · Phase 4: The Django Admin →
Check your understanding
1. You add a new field to your Post model. What must you do for the database to actually have that column?
2. Given `post = models.ForeignKey(Post, related_name="comments", on_delete=models.CASCADE)` on Comment, how do you get all comments for a given post object?
3. What is the difference between `null=True` and `blank=True` on a model field?