Django Crash Course - Introduction + Python Web Development Tutorial

Timestamps

  • - Introduction
  • - Check Python version
  • - Create a virtual environment
  • - Activate a virtual environment
  • - Install a package
  • - Deactivate a virtual environment
  • - Create a Django project
  • - Git and Django
  • - Upload project to GitHub
  • - Model view template explained
  • - Create our first view
  • - Create our first template
  • - Working with complex data
  • - More complex template
  • - Intro to models and migrations
  • - View the admin site
  • - ORM explained
  • - Create our first Model
  • - Create a migration
  • - Add table to admin site
  • - Override __str__()
  • - Working with real data
  • - Parameterized URLs
  • - Create dynamic links
  • - Create a form
  • - Delete data
  • - 404

Django is a web framework for building websites and APIs with Python. An API you can think of as a website too, it just hands back data instead of a web page.

Python runs the back end. The front end is still HTML, CSS, and JavaScript, and we'll touch a little of that, but this is mostly the back end side.

If you don't have a ton of Python behind you, you can probably still follow along and pick it up as we go. Pause and research a concept when one trips you up, then come back.

We're building a movie site. By the end you'll have a page that lists movies out of a database, a detail page for each one, a form to add one, and a delete link.

Checking Python

First off, make sure you have Python 3:

Bash
python3 --version

I get 3.9.9 in the video. Yours does not have to match mine, but this is one spot where the video has aged: Python 3.9 hit end of life in October 2025, and Django dropped it after 4.2. Current Django wants at least 3.10, and Django 6 wants 3.12 or newer. The install FAQ has the full version table. Grab a recent Python 3 and you're fine.

Virtual environments

Make a directory for the project and open it in your editor. I'm using VS Code, and I'll run every terminal command from the built in terminal inside that folder:

Bash
mkdir movies

Now the very first thing, before anything Django, is a virtual environment.

A virtual environment is just a lens on your project and what packages it has installed. Say you had two Python websites that need different versions of the same package. The virtual environment is how you say which versions belong to which app. Without one, the packages get installed on your computer globally and every app shares them.

Create it with the venv module:

Bash
python3 -m venv .venv

I put a dot at the front on purpose. That makes it a hidden directory, and it's a clear signal to everybody that nothing in that folder is meant to be touched.

You get no output, but a new .venv directory shows up in your files. Creating it is not the same as using it, so next we activate it:

Bash
source .venv/bin/activate

On Windows that's .venv\Scripts\activate instead.

Your prompt now has (.venv) stuck on the front. Going back to the lens idea, we just put the glasses on, and anything we install from here lands inside this environment only. It's a strange concept the first few times and then it's second nature.

Now we can install stuff:

Bash
pip install django

pip is the package tool for Python. You'll see it pull down Django plus a couple of things Django depends on, and you can find them all inside .venv. Another way to see them:

Bash
pip freeze

To show what it'd be like without the environment, back out of it:

Bash
deactivate

The (.venv) disappears, and now pip3 freeze gives you a completely different list, the packages installed globally on my machine. On a team you really don't want mismatched versions floating around, which is the whole reason for this.

Activate it again and stay there:

Bash
source .venv/bin/activate

Any time you're working on this Django project, you want to be inside the activated environment or things won't work right. It also cleans up your commands a bit, python --version and pip work without the 3 on the end once you're in.

Creating the project

django-admin is the command that came with the install. Run it with --help to see what's available. The one we want is startproject:

Bash
django-admin startproject movies .

That dot means "in the current directory". Without it, Django makes an extra wrapper folder for you.

Look at your files and you'll see a movies directory inside the movies directory you made. Two of them, which looks wrong the first time. The outer one is just a container to hold everything, the inner one is the actual Python package for the project. You'll see the same shape in the official tutorial with mysite. The outer folder can be named whatever you want, it doesn't matter to Django.

💡 Tip[!tip]
The docs will also have you run python manage.py startapp <name> to make a separate app package. I'm skipping that here and putting our views.py, models.py, admin.py, and templates straight in to the project package, which works fine for something this small. Once a project grows past one thing, use startapp and give each piece its own app.

Git and .gitignore

Now that we have a project, source control is a good next step. Get git from git-scm.com if you don't have it, then:

Bash
git init

VS Code will immediately tell you there are 5,652 pending changes. We do not want to check in every single one of those. The repository is a store of the essential files, the ones that cannot be regenerated.

Make a .gitignore and put the virtual environment in it:

.venv

Save it and the count drops to seven. Way better.

There's other stuff we don't want in there either, though, so what I actually recommend is grabbing a thorough Python gitignore off the internet instead of hand-rolling one. I like gitignore.io for this. Paste it over the whole file. Somewhere in there is .venv plus every other variation Python uses, along with build files, caching stuff, and db.sqlite3 for once we get to databases.

Then the first commit:

Bash
git add .
git status
git commit -m "initial commit"

If this is your first time using git you'll get a complaint about setting your name and email. Follow those instructions and re-run it. git log shows the commit.

I pushed mine to a new public GitHub repo so you can diff your files against mine as you go, and check the commit history if you get lost. Create the repo, don't check any of the initialize options since we already have one locally, then paste the lines GitHub gives you.

Model, view, template

Django splits an application in to three pieces, and you'll hear it called MVT. I'd have called it MTV, but we'll go with MVT.

The model is the structure of your data. The view accepts requests and gets the appropriate data. The template is the visual structure for that data being sent to the client.

Think of the template literally as a template, with pieces you fill in. A movie has a title, a description, a year, whatever else, and the template says where each of those goes on the page.

That's also the order the documentation teaches it in: models, then views, then templates.

Our first view

Open movies/urls.py and you'll see one path already there for the admin site. That path is attached to the URL of our website and it returns something, and we're going to copy that structure to make our own.

To see anything at all we need a server running, and everything server related goes through manage.py:

Bash
python manage.py runserver

You get a red warning saying you have 18 unapplied migrations. That's models stuff, we'll get to it. The development server comes up at http://127.0.0.1:8000/, and in VS Code you can command-click that (control-click on Windows) to open it.

Visit /admin and you get an error, no such table. Same root cause as the red warning, we haven't created the database tables yet. Fine for now, I only want you to see that a path goes somewhere.

Now add our own path. movies/urls.py:

Python
from django.contrib import admin
from django.urls import path

from movies import views

urlpatterns = [
    path("admin/", admin.site.urls),
    path("movies", views.movies),
]

If you're coming from an older Django tutorial, you may have seen url() imported from django.conf.urls instead. That one is gone, it was removed in Django 4.0, and path() from django.urls is what you use now. The video is already on path(), so nothing to change here, but it's worth knowing since a lot of older blog posts still show the old import.

That views file doesn't exist yet. Right-click the inner movies folder, new file, movies/views.py:

Python
from django.http import HttpResponse


def movies(request):
    return HttpResponse("hello there")

A view is just a function. The first argument is the request. You can name it whatever you want, I call it request.

Save, check the terminal for import errors, then visit /movies in the browser. There's our message.

So we made a URL path, said which view to hit, and the view returned a response. Let's do it one more time for the homepage:

Python
def home(request):
    return HttpResponse("homepage")

And an empty string for the path, which is the root:

Python
path("", views.home),

Refresh the root URL and you get homepage instead of Django's default rocket page.

Our first template

That view returns one very simple string. Real data needs actual formatting, and that's the template's job. We tell the view to render one:

Python
from django.http import HttpResponse
from django.shortcuts import render


def movies(request):
    return render(request, "movies/movies.html", {"movies": ["movie 1", "movie 2"]})

render takes three things: the request passed along, the template to render, and the data.

Now the template itself, and the nesting looks odd. Inside the inner movies package, make a templates folder, then a movies folder inside that, then the file. So the path on disk is movies/templates/movies/movies.html.

The doubled up movies is for namespacing, so two apps with a movies.html don't collide. You'll appreciate it more on a bigger project.

Inside the template, render a value with double curly braces:

HTML
{{ movies }}

One more thing or none of this works. In movies/settings.py, add the app to INSTALLED_APPS:

Python
INSTALLED_APPS = [
    "movies",
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
]

That's how Django finds your templates. Now /movies shows the list.

The data you pass is often called the context. If the one-liner is hard to read you can pull it out, it works exactly the same:

Python
def movies(request):
    data = [
        "movie 1",
        "movie 2",
    ]
    return render(request, "movies/movies.html", {"movies": data})

Working with complex data

A list of strings is not what a real movie looks like. Let's use dictionaries instead, and later this gets swapped out for data pulled from the database:

Python
def movies(request):
    data = [
        {"id": 5, "title": "Jaws", "year": 1974},
        {"id": 6, "title": "Sharknado", "year": 2013},
        {"id": 7, "title": "The Meg", "year": 2018},
    ]
    return render(request, "movies/movies.html", {"movies": data})

I got an error the first time through here and it was just missing commas after the dictionaries. Check yours if it complains.

Refresh and you get all the raw data dumped on the page. That is literally what the client sees. Time to templatize it.

A more complex template

To loop, you use single curly braces with percent signs instead of the double braces. And you have to end the loop, which may be new to you if you've never done template engine stuff before:

HTML
{% for movie in movies %}
    {{ movie.title }}
{% endfor %}

Refresh and you get Jaws, Sharknado, The Meg. It iterates each movie and prints movie.title.

The magic is you can mix that in with HTML, which is why the syntax looks funny, it has to be able to tell when you're doing HTML versus when you're doing template logic:

HTML
<h1>Movies</h1>
<ul>
  {% for movie in movies %}
  <li>{{ movie.title }}</li>
  {% endfor %}
</ul>

Right-click, view page source, and you'll see none of the curly braces made it to the browser. The template gets rendered on the server and all of that is replaced before it's sent.

You'll also notice it isn't a full HTML document. Let's fix that:

HTML
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Movies</title>
  </head>
  <body>
    <h1>Movies</h1>
    <ul>
      {% for movie in movies %}
      <li>{{ movie.title }}</li>
      {% endfor %}
    </ul>
  </body>
</html>

The title is what shows up in the browser tab, which is nicer than staring at the URL. I auto-formatted this with the Prettier extension, that's just editor stuff, format it by hand if you'd rather.

This is also where CSS classes would go on these elements, and where you'd attach your stylesheets. We're not going deep on the front end here, but now you know how the HTML is supposed to be structured.

Migrations and the admin site

We have a view and a template and completely fake data. Real data means models, and that circles back to those migration warnings.

Migrations represent changes to a database. Think about wanting to move a database from one computer to another. Migrations let us do that without hand-keeping a pile of SQL CREATE statements, Django tracks it all for us.

There are 18 of them already there in a brand new project. Apply them:

Bash
python manage.py migrate

A wall of OKs. I ran that in a second terminal tab so the server could keep running in the first, and remember to activate the virtual environment in the new tab.

Now /admin gives us a login page instead of an error. We need a user to log in with:

Bash
python manage.py createsuperuser

It asks for a username, an email, and a password. I used admin and password, and it warns you the password is too common. You can bypass that, and I'm only doing it for learning's sake.

Log in and you get the site administration page with an authentication and authorization section. Those are the default tables that came from the migrations. Open Users and there's the account we just made, with a hashed password, since the actual password value is never stored in the database, only the hash, which gets checked against on login.

Anything we build ourselves, we'll want to add to this panel so we can do CRUD on it without writing a form for everything.

All of this is sitting in db.sqlite3 in your files. SQLite is a fine database for learning, and moving to Postgres later is not bad because of migrations and the abstraction Django gives you.

The ORM

Which is the other half of it. You are not writing SQLite directly. You're using an ORM, an object relational mapper, which takes Python objects and relates them to rows in the database.

That means the database underneath can be swapped for another relational database without rewriting all of your code. You get to stay in plain Python and still work with a database. It costs a little extra learning up front with migrations and models, and after that it's pretty good.

Our first model

A model is the columns of a table. We are not creating an individual movie here, we're describing what a movie looks like.

New file, movies/models.py:

Python
from django.db import models


class Movie(models.Model):
    title = models.CharField(max_length=200)
    year = models.IntegerField()

The naming of the file matters. The class inherits from models.Model with a capital M, and by convention the class name is capitalized too.

Each attribute gets a field type assigned to it. CharField is for characters and needs a max_length. IntegerField is for the year. You could give either one a default if you wanted, we're leaving them without.

So the difference between this and a regular Python class is that it inherits from models.Model and it lives in a models.py.

Our movie has no relationships, so it doesn't come up here, but the moment you add a ForeignKey to another model you have to pass on_delete, like models.ForeignKey(Studio, on_delete=models.CASCADE). That argument used to be optional in much older Django and it is required now, so a relationship copied out of an old tutorial without it will refuse to even load.

Creating a migration

Make sure your app is in INSTALLED_APPS (we did that already) because that's how migrations find it. Then:

Bash
python manage.py makemigrations movies

You'll get Migrations for 'movies' and + Create model Movie, plus the filename it wrote in the new migrations directory. Open it if you're curious. It describes the change to the data structure, and you don't really need to be able to read it.

It gets turned in to SQL, and you can see exactly what SQL:

Bash
python manage.py sqlmigrate movies 0001

There's the CREATE TABLE with an id field we never defined, auto incrementing, plus title and year. You never have to run sqlmigrate, it doesn't do anything, it just shows you what applying the migration will do.

One difference from what's on screen in the video: that id column comes out as a bigint now, not a plain integer. Newer Django puts DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" in the generated settings.py, so the primary key it makes for you is the bigger type by default. Nothing for you to change, your SQL just reads a little different than mine.

Then apply it:

Bash
python manage.py migrate

You can name the app on the end if you want, python manage.py migrate movies. Either works. Naming it matters when you have several apps and need to be specific, otherwise it applies everything not already applied.

Adding the table to the admin site

We have a movies table and you're just going to have to trust me bro, because you can't see it anywhere yet. New file, movies/admin.py:

Python
from django.contrib import admin

from .models import Movie

admin.site.register(Movie)

I don't have this one memorized either, I looked it up. It's in the docs and it's the same three lines every time.

Refresh the admin site and nothing happens, because this runs on server startup. Restart the server and there's our Movies table.

Add a movie through it. Jaws, and I looked up the year, 1974. Save.

Overriding __str__()

The list shows Movie object (1), which tells you nothing. We can change the string representation of the object by overriding a method on the model:

Python
class Movie(models.Model):
    title = models.CharField(max_length=200)
    year = models.IntegerField()

    def __str__(self):
        return f"{self.title} from {self.year}"

self gets us at the attributes on that instance, and the f-string fills them in. Refresh and it says "Jaws from 1974". Any time we print the object, that's what shows up.

Working with real data

Now let's throw out the mock data and pull movies from the database. Back in movies/views.py:

Python
from django.http import HttpResponse
from django.shortcuts import render

from .models import Movie


def movies(request):
    data = Movie.objects.all()
    return render(request, "movies/movies.html", {"movies": data})

Movie.objects.all() gets every row. If you forget the dictionary and pass the query set straight through, you get "context must be a dict rather than QuerySet", so wrap it.

I also had a naming mismatch the first time, the template is looping over movies and I'd called the key data. Keep the key and the template variable the same and it works.

Refresh and there's Jaws. Add the other two through the admin page and the list fills out.

Parameterized URLs

The next logical step is clicking a movie to get a detail page. That needs a parameter in the URL.

This time let's write the view first, in views.py:

Python
def detail(request, id):
    data = Movie.objects.get(pk=id)
    return render(request, "movies/detail.html", {"movie": data})

pk is the primary key, and id is coming in as an extra argument to the view. Where from? The URL. In urls.py:

Python
path("movies/<int:id>", views.detail),

The angle brackets capture that piece of the URL, int says what type it is, and id is the name it arrives under in the view. So the name inside the brackets has to match the parameter name on your function.

Visit /movies/1 and you get "template does not exist", along with every directory Django searched, which is genuinely useful output. Make movies/templates/movies/detail.html:

HTML
{{ movie }}

There's Jaws from 1974, which is our __str__ doing its thing.

Dynamic links

If Jaws is id 1, Sharknado is 2, and The Meg is 3, then clicking each one should go to /movies/1, /movies/2, /movies/3. So the href is just the id of whatever element we're on.

In movies.html, wrap the title in an anchor:

HTML
<li><a href="/movies/{{ movie.id }}">{{ movie.title }}</a></li>

I'm writing the leading slash on that href, which is a small fix on what's typed on camera. Without it the browser resolves the link relative to the current URL, and since our path is movies with no trailing slash you'd land on /1 instead of /movies/1. The absolute path works either way.

The whole movie object is passed to the page, so we can reach movie.id as we iterate. Hover the links and you'll see /movies/1 and friends down in the corner. Click through and you get the detail page.

If you want to go further, look up removing hard-coded URLs and namespacing URLs with the {% url %} tag, covered in tutorial 3. We're not doing it here, it mostly pays off when you move your app around and don't want to fix a pile of links. Ours is one link repeated three times, so not a big deal.

Creating a form

Now imagine a link that brings up a form for adding a movie. New path:

Python
path("movies/add", views.add),

And a link at the bottom of movies.html, after the unordered list:

HTML
<a href="/movies/add">add a movie</a>

Refresh and it's broken, module 'movies.views' has no attribute 'add'. Makes sense, it doesn't exist. Add it:

Python
def add(request):
    return render(request, "movies/add.html")

Now it says template does not exist, so we're on the right track.

It doesn't matter how many times I write a form in HTML, I always forget the exact setup. Here's movies/templates/movies/add.html:

HTML
<form action="/movies/add" method="post">
  {% csrf_token %}
  <input type="text" name="title" id="title" placeholder="movie title" />
  <input type="text" name="year" id="year" placeholder="movie year" />
  <input type="submit" value="add" />
</form>

The action is where the data gets sent, and we're sending it right back to the same path. The submit input's value is the text on the button.

On the text inputs, you have two options for the sample text. The one you'll usually see is value, but that's not very friendly, the user has to delete it before typing. placeholder disappears as soon as you type, which is a much better experience.

You can also add a label instead, or both:

HTML
<label for="title">movie title</label>

I removed mine and kept the placeholders.

Submit that form without the {% csrf_token %} line and you get CSRF verification failed. That's cross-site request forgery. Since we're posting back to our own URL, Django gives us a token to make it work, and that one template tag is all it takes.

Handling the POST

The add view now does double duty: it shows the form page, and it accepts the data typed in to it. So inside the view, we check whether data came in.

A POST is different from a GET. A GET is what happens when you visit a page, like /movies/1 where the value rides along in the URL. In a POST that information is not in the URL, it's in the request body. Open Chrome's network tab, submit the form, and look at the payload, you'll see title and year sitting in there.

You pull those out with request.POST, which is a dictionary:

Python
from django.http import HttpResponseRedirect
from django.shortcuts import render

from .models import Movie


def add(request):
    title = request.POST.get("title")
    year = request.POST.get("year")
    if title and year:
        movie = Movie(title=title, year=year)
        movie.save()
        return HttpResponseRedirect("/movies")
    return render(request, "movies/add.html")

I use get rather than square brackets on purpose. get does not throw if the key isn't there, and it isn't there the first time you load the form page with no post data. Square brackets would work the same when the data exists, but blow up when it doesn't, and then I'd need a try around it, which I don't want to deal with.

There are other request types, PUT and DELETE among them, but GET and POST are the two you'll see constantly. Do your own research on the rest.

Creating the object doesn't save it, save() does. Then we send the user back to the list with a redirect.

Add a movie and it drops you back on /movies with the new one in the list, and its detail link works right away.

Deleting data

Same idea, going the other direction. First let's make detail.html a little less bare, and add the delete link:

HTML
<p>{{ movie.title }}</p>
<p>{{ movie.year }}</p>
<a href="/movies/delete/{{ movie.id }}">delete</a>

Hover it and you get /movies/delete/11 or whatever the id is. Now the URL and the view. In urls.py:

Python
path("movies/delete/<int:id>", views.delete),

And the view, which takes the id the same way detail does:

Python
def delete(request, id):
    Movie.objects.get(pk=id).delete()
    return HttpResponseRedirect("/movies")

Get the object, call delete() on it, redirect back to the list. Click delete on a movie and it's gone.

404s

We haven't done any error checking. Go to /movies/delete/39 for an id that doesn't exist and you get an exception, Movie matching query does not exist. It breaks, and then it's broken.

So instead of deleting immediately, hold the object in a variable and put the lookup in a try:

Python
from django.http import Http404, HttpResponseRedirect


def delete(request, id):
    try:
        movie = Movie.objects.get(pk=id)
    except Movie.DoesNotExist:
        raise Http404("movie does not exist")
    movie.delete()
    return HttpResponseRedirect("/movies")

If we make it past the except, no exception was thrown and we still have the object, so we can delete it then. 404 means not found, by the way.

Now /movies/delete/39 gives page not found and deleting a real one still works.

💡 Tip[!tip]
Django has a shortcut for exactly this, get_object_or_404(Movie, pk=id), which does the try and the raise in one line. The detail views section of the docs covers it. I wrote the long version so you can see what it's doing.

That 404 page says you're seeing it because DEBUG = True in your settings. In deployment debug should be false, so let's see what that looks like. In movies/settings.py:

Python
DEBUG = False

Save and you get an error, you must set ALLOWED_HOSTS if debug is false. I tried localhost first and got a bad request 400, because I'm browsing the loopback address, not localhost. The host has to match what's in the URL bar:

Python
ALLOWED_HOSTS = ["127.0.0.1"]

Restart the server, refresh, and now you get a plain "Not Found, the requested resource was not found on this server". That's what a visitor sees in deployment. You can customize the 404 page, we're not getting in to that here.

At this point you have essentially all of CRUD: create, read, and delete. We never did update. Our data is simple enough that an update is really just a delete followed by a create, but if you want a next challenge, build a form that edits an existing movie instead of making a new one. That matters a lot once records have ten fields and you only want to change one of them, like someone editing a profile.