Yesterday, I learned URLs, views, and templates. Everything was hardcoded, though. Today, for day 67, I connected Django to an actual database. Models, migrations, the ORM, and the admin panel. By the end, I had a page pulling real data from a real database and displaying it in the browser.
What is a Model?
A model is a Python class that represents a database table. Each attribute is a column. Django handles all the SQL; you never write CREATE TABLE or SELECT * directly.
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
is_published = models.BooleanField(default=False)
def __str__(self):
return self.title
This class tells Django to create a post table with four columns. The __str__ method controls how a Post object is displayed in the admin panel, in the shell, everywhere.
Common Field Types
| Field | Use |
|---|---|
CharField |
Short text, requires max_length
|
TextField |
Long text, no max length |
IntegerField |
Whole numbers |
FloatField |
Decimal numbers |
BooleanField |
True or False |
DateTimeField |
Date and time |
DateField |
Date only |
EmailField |
Validates email format |
ForeignKey |
Many-to-one relationship |
ManyToManyField |
Many-to-many relationship |
Field Options
Most fields accept these common options:
class Post(models.Model):
title = models.CharField(max_length=200, unique=True)
content = models.TextField(blank=True)
views = models.IntegerField(default=0)
slug = models.SlugField(null=True, blank=True)
-
null=True— allowsNULLin the database -
blank=True— allows empty value in forms -
default— sets a default value -
unique=True— enforces unique values across rows
null and blank are different. null is database-level, blank is validation-level.
The Meta Class
The inner Meta class lets you configure model-level behavior:
class Post(models.Model):
title = models.CharField(max_length=200)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at'] # newest first
verbose_name = 'Post'
verbose_name_plural = 'Posts'
ordering sets the default order for all queries on this model. The - prefix means descending.
Migrations
After defining your model, you don't touch the database directly. You run two commands:
python manage.py makemigrations
python manage.py migrate
makemigrations looks at your models, detects what changed, and generates a migration file in migrations/. This file is a Python description of the database change.
migrate takes all pending migration files and applies them to the actual database.
Check migration status anytime:
python manage.py showmigrations
Every time you change a model, add a field, remove a field, change a field type. You run both commands again. Never edit the database manually.
Django ORM — CRUD
The ORM lets you interact with the database using Python instead of SQL. Every model gets a built-in objects manager.
Create
# option 1
post = Post(title="My First Post", content="Hello world")
post.save()
# option 2 — cleaner
post = Post.objects.create(title="My First Post", content="Hello world")
Read
# get all posts
posts = Post.objects.all()
# get one post by id — raises exception if not found
post = Post.objects.get(id=1)
# filter — returns a queryset
published = Post.objects.filter(is_published=True)
# exclude
drafts = Post.objects.exclude(is_published=True)
# chaining
recent_published = Post.objects.filter(is_published=True).order_by('-created_at')
# first and last
latest = Post.objects.order_by('-created_at').first()
Update
# update one object
post = Post.objects.get(id=1)
post.title = "Updated Title"
post.save()
# bulk update
Post.objects.filter(is_published=False).update(is_published=True)
Delete
# delete one
post = Post.objects.get(id=1)
post.delete()
# bulk delete
Post.objects.filter(is_published=False).delete()
QuerySets Are Lazy
When we write:
posts = Post.objects.filter(is_published=True)
Django does not hit the database yet. It builds a QuerySet: a description of the query. The database is only hit when you actually use the data, iterating over it, slicing it, calling len() on it.
posts = Post.objects.filter(is_published=True) # no DB hit yet
for post in posts: # DB hit happens here
print(post.title)
This matters for performance. You can chain filters and conditions before Django ever touches the database.
Django Admin
Django ships with a built-in admin panel. You register your models there and get a full CRUD interface for free.
First, create a superuser:
python manage.py createsuperuser
Then register your model in core/admin.py:
from django.contrib import admin
from .models import Post
admin.site.register(Post)
Visit http://127.0.0.1:8000/admin/, log in, and you can create, edit, and delete posts directly from the browser. No code needed.
Customizing the Admin
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ['title', 'is_published', 'created_at']
list_filter = ['is_published']
search_fields = ['title', 'content']
list_display controls which columns show in the list view. list_filter adds a sidebar filter. search_fields adds a search bar.
Connecting Models to Views and Templates
Now the full picture: fetching real data in a view and displaying it in a template.
# core/views.py
from django.shortcuts import render, get_object_or_404
from .models import Post
def home(request):
posts = Post.objects.filter(is_published=True).order_by('-created_at')
return render(request, 'core/home.html', {'posts': posts})
def post_detail(request, pk):
post = get_object_or_404(Post, pk=pk)
return render(request, 'core/post_detail.html', {'post': post})
get_object_or_404 is a Django shortcut. It tries to get the object and automatically returns a 404 response if it doesn't exist. Use this instead of get() in views.
# core/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('', views.home, name='home'),
path('post/<int:pk>/', views.post_detail, name='post_detail'),
]
<int:pk> is a URL parameter — Django captures whatever integer is in that position and passes it to the view as pk.
<!-- core/templates/core/home.html -->
{% extends 'core/base.html' %}
{% block content %}
<h1>Posts</h1>
{% for post in posts %}
<div>
<h2>{{ post.title }}</h2>
<p>{{ post.created_at }}</p>
<a href="{% url 'post_detail' post.pk %}">Read more</a>
</div>
{% empty %}
<p>No posts yet.</p>
{% endfor %}
{% endblock %}
<!-- core/templates/core/post_detail.html -->
{% extends 'core/base.html' %}
{% block content %}
<h1>{{ post.title }}</h1>
<p>{{ post.created_at }}</p>
<p>{{ post.content }}</p>
{% endblock %}
Two new things here:
-
{% url 'post_detail' post.pk %}— generates a URL by name instead of hardcoding it -
{% empty %}— inside aforloop, renders if the list is empty
The Full Picture So Far
Browser requests /
↓
urls.py → views.home
↓
views.home → Post.objects.filter() → hits database
↓
QuerySet passed to render() as context
↓
home.html loops through posts, renders HTML
↓
Response sent back to browser
The only thing that changed from yesterday is step 3: instead of hardcoded data, the view now fetches real data from the database.
Wrapping Up
Today was the missing piece from yesterday. The page is now alive, real data, real database, real queries. Models define the structure, migrations apply it, the ORM handles all reads and writes, and the admin panel gives you a free interface to manage everything.
Tomorrow: Django Forms: letting users actually submit data.
Thanks for reading. Feel free to share your thoughts!
Top comments (0)