Django from Scratch: Models, Migrations, Views, URLs, Templates and the Admin
Key takeaways
Learn Django from scratch: project layout, models, migrations, function and class-based views, URLs, templates, and the admin site—with runnable examples.
Introduction
”Batteries-included” web framework
Django bills itself as the framework “for perfectionists with deadlines,” and that tagline is a reasonably accurate summary of the trade-off it makes. Where a micro-framework like Flask hands you a routing layer and lets you assemble everything else — an ORM, a templating engine, a migrations tool, session handling, CSRF protection, an admin UI — from separate packages, Django ships all of it in one coherent box. That is the meaning of “batteries-included”: the pieces are not just present, they are designed to interoperate, tested together, and versioned together. When you upgrade Django, the ORM, the forms library, and the admin all move in lockstep, so you are not chasing compatibility issues between five independently maintained third-party packages.
This matters more than it sounds. On a small side project, assembling your own stack from Flask plus SQLAlchemy plus Alembic plus Flask-Admin is a fun afternoon. On a team of six engineers shipping a product over three years, that same assembly becomes an ongoing maintenance cost: someone has to track four changelogs, four sets of breaking changes, and four sets of documentation that may or may not agree with each other on conventions. Django trades some of that flexibility for a single, opinionated set of conventions — and once you have internalized those conventions, moving between Django projects (even ones written by other teams) is far faster than moving between two differently-assembled Flask stacks.
The cost of that convenience is exactly what you would expect: Django is heavier, has a steeper initial learning curve because there is more to learn up front, and is less forgiving if your app’s shape genuinely does not fit the MVT (Model-View-Template) mold — for example, a pure JSON API with no server-rendered pages and no admin needs often does better with something leaner, or with Django REST Framework layered carefully on top rather than the full batteries-included experience. Choosing between Django and something lighter is really a question of how much of the “batteries” you will actually use, and how much your team benefits from shared conventions over raw flexibility.
Getting started with Django
Install and create a project
Installing Django and scaffolding a project takes four commands, but each one does more than it looks like at first glance, so it is worth understanding what actually happens at each step rather than treating this as a black box to copy-paste.
# Install Django
pip install django
# Create project
django-admin startproject myproject
cd myproject
# Create app
python manage.py startapp blog
# Run dev server
python manage.py runserver
django-admin startproject does not just create a folder — it generates a project, which in Django’s vocabulary is the configuration and routing shell that ties together one or more apps. python manage.py startapp blog then creates an app, which is a self-contained, ideally reusable unit of functionality (models, views, templates, and URLs that all relate to one concern — here, blog posts). This project/app split is one of the first conceptual hurdles for newcomers: a Django “project” is not the same thing as what other frameworks call a project. It is closer to a container that wires multiple pluggable apps together through INSTALLED_APPS in settings.py. In principle you could drop the blog app into a completely different Django project and, with minimal changes, have it work there too — that reusability is the whole point of the split, even though in practice most small projects end up with one app that is tightly coupled to the project anyway.
manage.py itself is worth pausing on. It is a thin wrapper around Django’s command-line utility that has already been configured with your project’s settings module (via the DJANGO_SETTINGS_MODULE environment variable it sets internally). Every Django management command — runserver, makemigrations, migrate, createsuperuser, shell, test — goes through this file, which is why you always run these commands from the directory containing manage.py, not from anywhere else in the filesystem.
Project layout
myproject/
├── myproject/
│ ├── __init__.py
│ ├── settings.py # configuration
│ ├── urls.py # URL routing
│ └── wsgi.py
├── blog/
│ ├── models.py # models (data)
│ ├── views.py # views (logic)
│ ├── urls.py
│ └── templates/ # templates (UI)
└── manage.py
The nested myproject/myproject/ naming is the single most common source of confusion for people coming from other frameworks, so it deserves a direct explanation: the outer myproject/ is simply the directory you are working in (you could rename it freely, move it, put it under version control with any name you like). The inner myproject/ is the actual Python package holding project-wide configuration — settings.py (database credentials, installed apps, middleware, static file locations), the root urls.py (the entry point that Django consults for every incoming request before delegating to app-level URL configs), and wsgi.py/asgi.py (the entry points a production server like Gunicorn or Uvicorn actually imports to serve your app). If you rename the inner package, you must also update the DJANGO_SETTINGS_MODULE references and the ROOT_URLCONF setting — Django does not infer the name from the directory structure, it is spelled out explicitly, which is why renaming this folder later in a project’s life is a bigger operation than it looks.
Notice that settings.py lives at the project level, not the app level — this is deliberate. Apps are meant to be portable and should not hard-code environment-specific configuration (database hosts, secret keys, debug flags). All of that belongs in the one settings module, ideally split by environment (development, staging, production) using something like django-environ or separate settings files, a pattern this basics guide does not cover in depth but that you will need almost immediately once you deploy.
Models
Defining a model
Models are where Django earns the “batteries-included” label most visibly. A single Python class definition below generates the database schema, the Python API you use to query and manipulate that schema, and (later, once registered) a full admin UI — all from one source of truth.
# blog/models.py
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
author = models.CharField(max_length=100)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.title
class Meta:
ordering = ['-created_at']
Each field type here is not just a Python type hint — it is a mapping instruction to the underlying SQL column type, plus validation rules the framework enforces at multiple layers. CharField(max_length=200) becomes a VARCHAR(200) column and Django will refuse to save a value longer than 200 characters through model validation (full_clean()), though it is worth knowing that raw .save() calls skip validation by default — only forms and admin invoke it automatically, which is a common source of “why did this get saved without validation” bugs. TextField maps to an unbounded text column, appropriate for the actual blog body. auto_now_add=True sets the timestamp exactly once, at creation, and then never touches it again — useful for created_at. auto_now=True, by contrast, rewrites the timestamp on every single .save() call, which is exactly what you want for updated_at but is a subtle enough distinction that mixing them up (using auto_now where you meant auto_now_add) is a classic bug: your “created” timestamp silently drifts forward every time you edit a row.
The __str__ method is not cosmetic. Django calls it constantly — in the admin’s object list, in the Python shell, in error messages, in <select> dropdowns generated for foreign keys — so a model without a sensible __str__ shows up everywhere as an unhelpful Post object (1). It costs three lines and pays for itself the first time you use shell or admin to debug data.
The nested Meta class is Django’s mechanism for attaching configuration to a model that is not itself a database field — ordering, table name, uniqueness constraints spanning multiple columns, permissions, and more all live there. ordering = ['-created_at'] means every unqualified Post.objects.all() query comes back newest-first, without you needing to remember to add .order_by() at every call site. This is convenient, but it has a real cost worth knowing about: default ordering makes every unfiltered query add an ORDER BY clause at the SQL level, and if created_at is not indexed, that ordering has to sort the whole result set on every query. For a small blog this is irrelevant; for a table with millions of rows it is exactly the kind of thing that shows up in a slow-query log months later.
Migrations
# Create migration files
python manage.py makemigrations
# Apply to database
python manage.py migrate
Migrations are Django’s version control for your database schema, and understanding the two-step split here — makemigrations then migrate — is essential, because conflating them is the single most common source of “it worked on my machine” schema bugs on teams.
makemigrations does not touch the database at all. It diffs your current model definitions against the last known migration state (tracked in the migrations/ folder of each app as a sequence of numbered Python files) and generates a new migration file describing the delta — “add column X,” “alter column Y,” “create table Z.” Because this step only reads your Python code, it is entirely offline and safe to run repeatedly; if there is nothing new to detect, it simply says “No changes detected.” Crucially, the generated migration files are meant to be committed to version control alongside your model changes. They are the historical record of every schema change your project has ever made, in order, and Django uses that ordered history (not the current state of models.py) to figure out what SQL to run.
migrate is the step that actually touches the database: it walks through any migration files that have not yet been applied (tracked in a django_migrations table Django maintains for you) and executes the corresponding SQL. This separation exists precisely so that “what the code says the schema should look like” and “what got run against a specific database” can be reviewed, tested, and rolled back independently. In a team setting, this means a schema change is a normal, reviewable pull request — reviewers can read the generated migration file and see exactly what will happen to production, before anyone runs migrate against it.
The most common real-world failure mode is migration conflicts: two developers each add a migration on separate branches, both numbered as the “next” migration in sequence, and merging both branches produces two competing migration files with the same parent. Django’s makemigrations --merge handles this, but it is much less painful if you simply rebase and regenerate before merging, so the numbering stays linear. A second common failure is forgetting that migrate needs to run in every environment separately — staging and production do not pick up schema changes automatically just because migrate ran on your laptop; it has to be part of your deployment pipeline.
Views
Function-based views
Views are Django’s “controller” layer — the code that receives an HTTP request, does whatever work is needed (querying the database, checking permissions, processing a form), and returns an HTTP response. Django supports two distinct styles for writing them, and understanding when to reach for each one is a real architectural decision, not just a stylistic preference.
# blog/views.py
from django.shortcuts import render, get_object_or_404
from .models import Post
def post_list(request):
posts = Post.objects.all()
return render(request, 'blog/post_list.html', {'posts': posts})
def post_detail(request, pk):
post = get_object_or_404(Post, pk=pk)
return render(request, 'blog/post_detail.html', {'post': post})
A function-based view (FBV) is exactly what it says: a plain Python function that takes request (and any URL-captured parameters, like pk here) and returns an HttpResponse — usually via render(), which combines a template with a context dictionary. get_object_or_404 is a small but important helper: it wraps the common pattern of “fetch a row, and if it does not exist, return a proper 404 page” so you never accidentally let a Post.DoesNotExist exception bubble up as an unhandled server error. The value of FBVs is that the entire request-handling logic is visible in one place, top to bottom, with no inheritance chain to trace through — for a view that does one specific, slightly unusual thing (a multi-step wizard, a webhook handler with bespoke logic, anything that does not fit a generic CRUD shape), a function is often clearer than forcing it into a class-based abstraction.
Class-based views
from django.views.generic import ListView, DetailView
from .models import Post
class PostListView(ListView):
model = Post
template_name = 'blog/post_list.html'
context_object_name = 'posts'
paginate_by = 10
class PostDetailView(DetailView):
model = Post
template_name = 'blog/post_detail.html'
Class-based views (CBVs) solve a different problem: they eliminate boilerplate for the extremely common “list objects” / “show one object” / “create, edit, delete” patterns by pushing that repeated logic into base classes (ListView, DetailView, CreateView, UpdateView, DeleteView, and more) that you configure through class attributes rather than reimplement by hand. Notice that PostListView above does, in four lines, exactly what the function-based post_list did in three — except it also gets pagination (paginate_by = 10) essentially for free, along with a predictable, overridable set of hooks (get_queryset(), get_context_data(), get_template_names()) if you need to customize behavior later without rewriting the whole view.
The trade-off is indirection. When something goes wrong inside a ListView, the actual logic that runs lives several classes up an inheritance chain inside Django’s own source, and tracing “why did this queryset come back filtered like that” means reading through MultipleObjectMixin, ContextMixin, and View rather than one function body. In practice, most teams settle on a rule of thumb: use CBVs for standard CRUD-shaped views where the built-in generics save real boilerplate, and drop down to function-based views for anything with genuinely custom control flow. Mixing both styles in the same project is completely normal and, in fact, the pragmatic default — Django does not force you to pick one paradigm for the whole codebase.
URL routing
URL patterns
# blog/urls.py
from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
path('', views.post_list, name='post_list'),
path('post/<int:pk>/', views.post_detail, name='post_detail'),
]
# myproject/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('blog/', include('blog.urls')),
]
Two things in this pair of files matter more than they first appear to. First, include('blog.urls') in the project-level urls.py delegates any request starting with /blog/ to the app’s own URL configuration, which then only has to worry about the remainder of the path. This is what makes apps genuinely reusable: blog/urls.py never has to know it is mounted at /blog/ specifically — you could remount the exact same app at /articles/ by changing one line in the project-level file, and every {% url %} template tag and reverse() call elsewhere in the codebase would keep working unchanged, because they resolve by name, not by hard-coded path string.
That name-based resolution is the second point worth dwelling on: app_name = 'blog' combined with name='post_detail' on the individual path() call is what lets you write {% url 'blog:post_detail' post.pk %} in a template (as you will see in the next section) instead of hand-typing /blog/post/{{ post.pk }}/. This indirection is not bureaucratic overhead — it is what makes URL structure changes non-breaking. If a product decision later moves blog posts from /blog/post/<id>/ to /articles/<id>/, you change exactly one line in blog/urls.py, and every link in every template across the entire project updates automatically, because none of them hard-coded the path. Skipping this convention and hard-coding URL strings throughout your templates is one of the most common regrets teams have when a site’s URL structure needs to change six months into a project.
<int:pk> is a path converter — it does double duty as both a capture group and a lightweight type constraint: Django will only match this URL pattern if the segment is actually numeric, and it hands the view an int, not a string, saving you a manual int() conversion (and, incidentally, meaning /post/abc/ correctly 404s instead of reaching your view with a string it was not expecting).
Templates
Writing templates
<!-- templates/blog/post_list.html -->
<!DOCTYPE html>
<html>
<head>
<title>Blog</title>
</head>
<body>
<h1>Blog posts</h1>
{% for post in posts %}
<article>
<h2>
<a href="{% url 'blog:post_detail' post.pk %}">
{{ post.title }}
</a>
</h2>
<p>{{ post.content|truncatewords:30 }}</p>
<small>{{ post.created_at|date:"Y-m-d H:i" }}</small>
</article>
{% empty %}
<p>No posts yet.</p>
{% endfor %}
</body>
</html>
Django’s template language is deliberately not Python — you cannot call arbitrary functions with arguments, do arithmetic beyond the basics, or write complex conditionals inline. This is a design decision, not a limitation the maintainers regret: the Django Template Language (DTL) restricts what template authors can do specifically so that presentation logic stays out of templates and business logic stays in views and models, where it can be unit tested. {{ post.content|truncatewords:30 }} is a good example of the philosophy — truncatewords is a filter, applied with the pipe syntax, that transforms the value for display without the template needing to know how truncation works. Django ships dozens of built-in filters (date, default, length, escape, safe, slugify, and many more), and you can register your own when a project’s presentation needs go beyond the defaults.
The {% empty %} clause inside the {% for %} loop is a small but genuinely useful piece of syntax many newcomers miss: it renders only when the queryset passed to {% for %} is empty, saving you from writing a separate {% if posts %}...{% else %}...{% endif %} wrapper around the loop. It is the template-layer equivalent of a Python for...else, though the trigger condition is different (empty iterable, not “loop completed without break”).
One more thing worth flagging explicitly because it is a real security property, not a stylistic detail: {{ post.title }} and {{ post.content }} are auto-escaped by default. If a post’s content somehow contained <script>alert(1)</script>, Django renders it as inert text, not executable HTML, which is your default defense against stored cross-site scripting. You only lose that protection if you explicitly apply the |safe filter or wrap output in {% autoescape off %} — and you should only ever do that for content you trust completely, such as HTML you sanitized yourself with a library like bleach, never for raw user input.
Admin site
Registering models
# blog/admin.py
from django.contrib import admin
from .models import Post
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ['title', 'author', 'created_at']
list_filter = ['created_at', 'author']
search_fields = ['title', 'content']
date_hierarchy = 'created_at'
The admin site is arguably Django’s single biggest differentiator versus lighter frameworks, and it is easy to undervalue until you have used it under real deadline pressure. Registering a model with @admin.register(Post) gives you, immediately and with zero HTML or JavaScript written, a full CRUD interface: list views with sorting, filtering, search, pagination, bulk actions, a detail/edit form generated from the model’s field types (a DateTimeField gets a date picker, a ForeignKey gets a searchable dropdown or autocomplete widget, a BooleanField gets a checkbox), and permission-aware access control tied into Django’s built-in user and group system.
Each ModelAdmin attribute above is doing real work, not just cosmetic configuration. list_display controls which columns appear in the list view — without it, the admin falls back to showing only the __str__ representation of each row, which is far less useful once you have more than a handful of posts. list_filter adds a sidebar of filter options; note that filtering on author, a plain CharField here, produces one filter option per distinct value already in the database, which becomes unwieldy once you have hundreds of distinct authors — at that scale you would typically convert author into a ForeignKey to a proper Author or User model, which is a common refactor once a blog like this grows past the toy stage. search_fields wires up a text search box that runs LIKE queries against the listed fields — cheap to add, and for small-to-medium datasets perfectly adequate, though it is worth knowing it does not use full-text search or any index optimized for it, so it will not scale gracefully to a huge content table without additional work (Postgres full-text search or an external search service like Elasticsearch, at that point). date_hierarchy adds the drill-down date navigation bar at the top of the change list — a small UX touch that is genuinely useful once a table has more than a few dozen rows spread across time.
None of this replaces a real internal admin tool for a production product with non-technical staff using it daily — the generated UI is intentionally generic and not something you would typically expose directly to end customers — but for internal use, debugging, and early-stage products, it routinely eliminates weeks of “build a basic CRUD panel” work that other stacks require from scratch.
Create a superuser
python manage.py createsuperuser
# Username: admin
# Email: [email protected]
# Password: ****
# Open http://localhost:8000/admin/
A superuser is simply a User row with is_staff=True and is_superuser=True, which together grant access to the admin site and bypass Django’s per-model permission checks entirely. This command is interactive by design (it prompts for username, email, and a password that is validated against Django’s configured password validators) so that a strong initial credential does not accidentally end up hard-coded in a script or committed to version control. In a CI/CD or scripted deployment context where an interactive prompt is not possible, Django also supports creating superusers non-interactively via the DJANGO_SUPERUSER_PASSWORD environment variable combined with --noinput, which is the pattern you would actually use in a Docker entrypoint or deployment script rather than typing credentials by hand every time you spin up a fresh environment.
Practical example
Simple blog
# blog/views.py
from django.shortcuts import render, redirect
from django.contrib import messages
from .models import Post
def create_post(request):
if request.method == 'POST':
title = request.POST.get('title')
content = request.POST.get('content')
author = request.POST.get('author')
Post.objects.create(
title=title,
content=content,
author=author
)
messages.success(request, 'Post created successfully!')
return redirect('blog:post_list')
return render(request, 'blog/create_post.html')
This view pulls together several ideas from earlier sections into one realistic request/response cycle, and it is worth walking through the control flow deliberately. On a GET request, the if request.method == 'POST': branch is skipped entirely and the function falls through to render an empty form template — the same view function serves both “show me the form” and “process the form submission,” which is a common and idiomatic Django pattern rather than splitting the two responsibilities across separate views. On a POST, it pulls fields directly out of request.POST (Django’s parsed representation of submitted form data), creates a new Post row in one call via Post.objects.create(...) — a convenience method that combines instantiating the model and calling .save() — and then uses the messages framework (messages.success(...)) to queue a one-time notification that survives the redirect and can be rendered on the next page the user sees, typically via a {% for message in messages %} block in a shared base template.
The redirect('blog:post_list') call at the end is not optional polish — it implements the Post/Redirect/Get pattern, which exists specifically to prevent duplicate form submissions. If the view instead rendered a response directly after saving, a user who refreshes the resulting page would resubmit the same POST request and create a second, duplicate post; redirecting to a fresh GET request after a successful POST avoids that entirely, and it is worth internalizing this pattern because it applies to essentially every “handle a form submission” view you will ever write in Django, not just this one.
It is also worth being explicit about what this example deliberately leaves out, since a real production version would need it: this view uses raw request.POST.get(...) calls with no validation, so it will happily create a Post with an empty title or content well beyond max_length, and it performs no CSRF token check in the template shown here (Django’s {% csrf_token %} tag would need to be present in create_post.html, and Django’s middleware enforces it by default — omitting the tag causes the form submission to be rejected with a 403, not silently skipped, which is at least a safe failure mode). A production version of this view would almost always use a Django Form or ModelForm instead of touching request.POST directly, since forms give you field-level validation, automatic error messages, and CSRF handling as part of the same abstraction rather than something you have to remember to bolt on separately.
What comes after the basics
Everything in this guide covers Django’s core request/response loop in isolation — a single app, no authentication beyond the admin’s built-in User model, no forms library, no API layer. That is intentional for a “basics” guide, but it means the natural next steps are the pieces that turn this into something closer to a real application: proper Form/ModelForm usage instead of raw request.POST access, Django’s authentication and permissions system for user-facing (not just admin) login, and — since a growing number of Django projects serve a frontend framework rather than server-rendered templates — exposing the same models through a REST API, typically with Django REST Framework layered on top of exactly the models and views shown here.
Related posts
Frequently Asked Questions (FAQ)
Q. What has to change before this project goes to production?
A. The settings startproject generates are for development. At minimum: set DEBUG = False (with it on, any error page shows your settings and stack traces to visitors), list your domain in ALLOWED_HOSTS, read SECRET_KEY and database credentials from environment variables instead of committing them, run python manage.py collectstatic and serve STATIC_ROOT from the web server or a CDN, and run the app under a WSGI server such as Gunicorn rather than runserver. python manage.py check --deploy lists the security settings you have not configured yet (HTTPS redirects, secure cookies, HSTS). The deployment guide covers the server side.
Q. Should I use function-based or class-based views?
A. Reach for class-based generics (ListView, DetailView, CreateView, and friends) when a view matches a standard CRUD shape — they eliminate real boilerplate for free. Drop to a function-based view when the logic is genuinely custom, such as a multi-step form or a webhook handler, where forcing it through a generic class’s inheritance chain would add indirection without saving any code.