Describe your data as Python classes and let Django build the database. Define fields and relationships, then turn them into tables with makemigrations and migrate.
Why: a model is a Python class that maps to one database table — each attribute becomes a column. You never write CREATE TABLE; Django generates the SQL from this class. Where: blog/models.py.
# blog/models.py
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
body = models.TextField()
published = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.titleWhen: CharField for short text (requires max_length), TextField for long text, Integer/DecimalField for numbers, BooleanField for true/false, DateTimeField for timestamps, SlugField for URL-safe labels, and EmailField/URLField for validated strings.
name = models.CharField(max_length=100)
bio = models.TextField()
age = models.IntegerField()
price = models.DecimalField(max_digits=6, decimal_places=2)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True) # set once on create
updated_at = models.DateTimeField(auto_now=True) # set on every save
email = models.EmailField()
avatar = models.ImageField(upload_to="avatars/")Note: null=True allows an empty value in the database; blank=True allows it in forms. They are different — for text fields prefer blank=True alone and leave null=False so "empty" is "" not NULL. choices restricts a field to a fixed set of values.
class Post(models.Model):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
PUBLISHED = "published", "Published"
title = models.CharField(max_length=200)
subtitle = models.CharField(max_length=200, blank=True) # optional in forms
views = models.PositiveIntegerField(default=0)
status = models.CharField(
max_length=10, choices=Status.choices, default=Status.DRAFT
)
slug = models.SlugField(unique=True) # no two posts share a slugWhen ForeignKey: a many-to-one link (many posts, one author). When ManyToManyField: both sides can have many (a post has many tags, a tag has many posts). When OneToOneField: extend another model (one profile per user). related_name is how you go back the other way: author.posts.all().
from django.conf import settings
class Tag(models.Model):
name = models.CharField(max_length=50, unique=True)
class Post(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE, # delete posts when the user is deleted
related_name="posts", # user.posts.all()
)
tags = models.ManyToManyField(Tag, related_name="posts", blank=True)Why __str__: it is the human-readable name shown in the admin and the shell. Why get_absolute_url: Django and the admin use it to link to an object. Where Meta: ordering sets the default sort so every query comes back newest-first without repeating order_by.
from django.urls import reverse
class Post(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"] # newest first by default
indexes = [models.Index(fields=["slug"])]
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("blog:post_detail", args=[self.slug])When: several models need the same columns (created_at, updated_at). An abstract base model is never a table itself — its fields are copied into every model that inherits it. Note: abstract = True is what keeps it out of the database.
class Timestamped(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True # no table for this model itself
class Post(Timestamped): # inherits created_at + updated_at
title = models.CharField(max_length=200)
class Comment(Timestamped): # same two columns, no repetition
body = models.TextField()Note: a new project uses SQLite — a single file, zero setup, perfect for learning. For production you point DATABASES at PostgreSQL (the recommended choice) by installing the psycopg driver and filling in the connection details, often from environment variables.
# config/settings.py — default (SQLite, no setup needed)
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}# PostgreSQL in production — first: pip install "psycopg[binary]"
import os
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ["DB_NAME"],
"USER": os.environ["DB_USER"],
"PASSWORD": os.environ["DB_PASSWORD"],
"HOST": os.environ.get("DB_HOST", "localhost"),
"PORT": os.environ.get("DB_PORT", "5432"),
}
}Why: makemigrations turns your model changes into a numbered migration file (a plan), and migrate runs those plans against the database. Note: you run this pair every time you add or change a model — and the first migrate also creates Django’s own auth and admin tables.
After editing models.py:
python manage.py makemigrationspython manage.py migratePreview the SQL a migration will run (optional):
python manage.py sqlmigrate blog 0001