Posts tagged with #django

Why Django Uses POST for Logout (And What's a CSRF Token?)

A GET request is triggered simply by visiting a URL. Your browser hits the link, the server responds. Simple.

A POST request, on the other hand, requires a form submission — the user has to actively submit data through a form.

This distinction matters more than it sounds.

The Problem with GET-based Logout

Imagine your logout view is wired to a GET request at /logout. Now imagine a malicious website drops this somewhere in their HTML:

<img src="https://yoursite.com/logout">

When a logged-in user visits that page, their browser dutifully tries to load the "image" — which is actually your logout URL. No click required. No warning. The user is silently logged out without knowing why.

This is called a Cross-Site Request Forgery (CSRF) attack — a malicious site tricks the browser into making a request on the user's behalf.

Why POST Stops This

POST requests can't be triggered by a simple <img> tag or a link. They require a proper form submission. But that alone isn't enough — a malicious site could still craft a hidden form and auto-submit it with JavaScript.

That's where the CSRF token comes in.

What Is a CSRF Token?

Django automatically generates a secret, one-time token and embeds it in every form using the {% csrf_token %} template tag:

<form method="POST">
    {% csrf_token %}  <!-- Django injects a hidden secret key here -->
    ...
</form>

This renders in the browser as something like:

<input type="hidden" name="csrfmiddlewaretoken" value="abc123xyz...">

Django generates a hidden secret token: html When form is submitted Django checks: Token matches → legitimate request → process it Token missing → possible attack → reject it Every POST form in Django must have {% csrf_token %} or Django will reject the submission with a 403 error.

When the form is submitted, Django checks:

  • Does this token match what I generated for this session?
  • ✅ Yes → legitimate request, process it
  • ❌ No → reject it, possible attack

A malicious site has no way to know your token. It's unique per session and never exposed cross-origin. So even if they craft a fake form pointing at your server, the token check will fail and Django will reject it.

The Rule of Thumb

Action Method Why
Reading data GET Safe, no side effects
Changing data (login, logout, submit) POST + CSRF token Protected from forgery

Any view that changes state — logging in, logging out, saving a form — should use POST and include Django's CSRF protection. It's a small habit that closes a surprisingly common attack vector.

How Does `document.id` End Up in Your Django Template?

If you've written a Django URL like this:

path('documents/<int:pk>/', views.document_detail, name='document_detail'),

...and then used {{ document.id }} inside your HTML template, you might have wondered: how does the template even know what document is?

The answer is not magic. It's a clean three-step handoff — URL → View → Template. Let's walk through each step.


Step 1: The URL Captures the ID

When a user visits /documents/5/, Django looks through your urls.py to find a matching pattern.

# urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('documents/<int:pk>/', views.document_detail, name='document_detail'),
]

The <int:pk> part is a URL parameter. It tells Django:

  • Capture whatever integer is in that position of the URL
  • Give it the name pk
  • Pass it to the view function automatically

So when someone visits /documents/5/, Django extracts pk = 5 and hands it to your view.


Step 2: The View Receives the ID and Fetches the Object

Your view function gets called with the captured value as an argument:

# views.py
from django.shortcuts import render, get_object_or_404
from .models import Document

def document_detail(request, pk):
    document = get_object_or_404(Document, pk=pk)
    return render(request, 'documents/detail.html', {'document': document})

Here's what happens line by line:

  1. def document_detail(request, pk) — Django passes the captured pk=5 directly here as an argument.
  2. get_object_or_404(Document, pk=pk) — This queries the database for the Document with id=5. If it doesn't exist, it returns a 404 error.
  3. render(request, 'documents/detail.html', {'document': document}) — This is the key part. The context dictionary {'document': document} is what makes the object available inside the template. The string 'document' becomes the variable name you use in the template.

Think of the context dictionary as the bridge between Python and HTML. Whatever you put in it, the template can access.


Step 3: The Template Uses the Object

Now the template has access to the full Document object under the name document:

<!-- documents/detail.html -->
<h1>{{ document.title }}</h1>
<p>Document ID: {{ document.id }}</p>
<p>Status: {{ document.status }}</p>
<p>Created by: {{ document.author.username }}</p>

When Django renders this template, {{ document.id }} becomes 5 — because document is the Python object you fetched, and .id is just accessing its id attribute.


The Full Picture

Here's the entire flow in one view:

User visits: /documents/5/
       ↓
urls.py captures: pk = 5
       ↓
view receives: document_detail(request, pk=5)
       ↓
view queries DB: Document.objects.get(pk=5) → returns document object
       ↓
view sends context: {'document': <Document: id=5>}
       ↓
template renders: {{ document.id }} → "5"

A Common Mistake to Avoid

The variable name in your context dictionary must match what you use in the template.

# ✅ Correct — 'document' in context, {{ document.id }} in template
return render(request, 'detail.html', {'document': document})

# ❌ Wrong — 'doc' in context, but template uses {{ document.id }}
return render(request, 'detail.html', {'doc': document})
# This will render as empty string — no error, just blank output

Django won't throw an error if the variable name doesn't match. The template will just silently render nothing. This is a subtle bug that can be confusing at first.


Quick Recap

Layer What it does
urls.py Captures the integer from the URL and names it pk
views.py Receives pk, queries the database, builds context dict
template.html Accesses the object using the key from the context dict

The template itself has no idea what the URL looks like. It only knows what the view passed into the context. The view is the translator between the URL world and the template world. {% url 'document_detail' document.id %} Read it like this:

{% url              → "go to urls.py and find the pattern named..."
'document_detail'   → "...document_detail"
document.id %}      → "fill in <int:id> with this id from the database"

So Django does this behind the scenes:

urls.py has → path('documents/<int:id>/', ...)

document.id = 5

result → /documents/5/
  1. Looks up the URL pattern by name
  2. Takes the id from the database object
  3. Builds the final URL string

That's the complete pipeline. Once you see it this way — URL captures → view fetches → context bridges → template renders — it becomes a natural mental model for every Django view you write going forward.

HTML Forms: `action` and `method` Explained

When you visit /login/ in your browser, you're making a GET request — the server sends back a page with a form on it.

When you fill in the form and hit submit, the browser needs to know where to send the data. That's what the action attribute controls.


No action — Submit to the Same URL

If you don't write an action, the browser thinks:

"Send it back to the same URL I'm already on."

So if you're on /login/, the form data goes right back to /login/ — but this time as a POST request.

<form method="POST">
    <input type="text" name="username">
    <button type="submit">Login</button>
</form>

Your Django view handles both cases:

def login_view(request):
    if request.method == 'POST':
        # process the login credentials
    else:
        # show the empty form

Same URL /login/, same view — just handled differently depending on how the request arrived.


With action — Submit to a Different URL

Sometimes you want the form to send data somewhere else. For example, imagine a search bar sitting on your homepage /:

<form method="GET" action="/search/">
    <input type="text" name="q" placeholder="Search...">
    <button type="submit">Search</button>
</form>

You're on / but the form submits to /search/. A completely different view handles the results:

def search_view(request):
    query = request.GET.get('q')
    results = Post.objects.filter(title__icontains=query)
    return render(request, 'search.html', {'results': results})

Quick Summary

Scenario What to write
Form submits to the same URL Leave action empty
Form submits to a different URL action="/that-url/"

The rule of thumb: if your view handles both showing the form (GET) and processing it (POST) at the same URL, you don't need action. Simple as that.

How to Debug Django Correctly — Step by Step

Step 1 — Read the Error Message Carefully

The error message usually tells you exactly what is wrong.

Examples:

TemplateDoesNotExist

Meaning: - Django cannot find the HTML template file - Usually caused by: - wrong folder structure - wrong template path - missing file


NoReverseMatch

Meaning: - Django cannot find a URL name - Usually caused by: - wrong URL name in urls.py - typo in {% url %} tag


Example:

return render(request, "core/login.html")

If the file is not inside the correct templates folder:

TemplateDoesNotExist

will appear.

Always read: - Exception type - Exception message

before changing code.


Step 2 — Check the Terminal

Always watch the terminal where the Django server is running.

Example:

python manage.py runserver

When an error happens: - Django prints the full traceback - Shows: - file name - line number - exact error

Example:

File "views.py", line 12

This tells you exactly where the issue happened.

The terminal is one of the best debugging tools.


Step 3 — Check Django's Browser Error Page

When DEBUG=True, Django shows a detailed error page.

It includes:

  • Exception type
  • Exception value
  • Which view caused the error
  • Full traceback
  • Variables and request details

Example:

TemplateDoesNotExist at /login/

This helps you understand: - what failed - where it failed - why it failed

Read the error page slowly.

Most answers are already there.


Step 4 — Isolate the Problem

Do not change many things at once.

Remove code until the error disappears.

Then add code back step by step.

Example:

Full template → broken

Try:

hello world

If that works:

Add extends → works

Then:

Add full content → broken

Now you know: - the problem is inside the template content - not in the view - not in the URL - not in Django

This method helps you find the exact problem quickly.


Step 5 — Check the Basics

Many bugs are simple mistakes.

Always check:

  • Is the server running?
  • Did you save the file?
  • Is the file in the correct folder?
  • Does the URL match urls.py?
  • Is the template name correct?
  • Is there a typo?

90% of bugs are caused by basic issues.


Useful Django Debug Commands

Check if Django Can Find a Template

python manage.py shell -c "import django; print(django.template.loader.get_template('core/login.html'))"

What this does:

  • Opens Django shell
  • Tries loading the template
  • Prints the result

If Django finds the template: - the file path is correct

If not: - Django raises:

TemplateDoesNotExist

Useful for debugging template path issues.


Check for Project Errors

python manage.py check

This checks: - project configuration - app configuration - model problems - URL issues

Useful before running the server.


Open Django Interactive Shell

python manage.py shell

Used for: - testing queries - testing imports - debugging models - checking database data

Example:

from blog.models import Post
Post.objects.all()

Show All URLs

python manage.py show_urls

Useful for checking: - registered URLs - URL names - route conflicts

Note: This command usually requires installing:

django-extensions

Install:

pip install django-extensions

Understanding find . -name "*.html"

This is a Linux terminal command.

Example:

find . -name "*.html"

What It Means

find

Search for files and folders.

.

Start searching from the current folder.

-name

Search by file name.

"*.html"

Find all files ending with .html.


Example

Suppose your project contains:

templates/base.html
core/templates/core/login.html
blog/templates/blog/post.html

Running:

find . -name "*.html"

will show:

./templates/base.html
./core/templates/core/login.html
./blog/templates/blog/post.html

Why This Is Useful

Useful for debugging: - missing templates - wrong folder structure - duplicate template files

If Django says:

TemplateDoesNotExist

You can quickly verify: - whether the file exists - where it exists


Final Advice

Debugging is not guessing.

Good debugging means:

  1. Read the error
  2. Check the traceback
  3. Isolate the issue
  4. Test step by step
  5. Verify basics first

The more you debug carefully, the faster you improve as a developer.

Django's `get_status_display()`: Showing Pretty Labels Instead of Raw Values

The Problem

Say you have a document with a status field. You define it like this in your model:

class Document(models.Model):
    STATUS_CHOICES = [
        ('under_review', 'Under Review'),
        ('approved', 'Approved'),
        ('rejected', 'Rejected'),
    ]

    status = models.CharField(max_length=20, choices=STATUS_CHOICES)

Django stores the left side (under_review) in the database. That's the raw value — short, clean, and easy to work with in code.

But when you display it in a template like this:

<p>Status: {{ document.status }}</p>

Your user sees:

Status: under_review

Not great. That's an internal value leaking out to the UI.


The Solution: get_<fieldname>_display()

Whenever you define choices on a field, Django automatically generates a method called get_<fieldname>_display() on your model. You don't write it — Django creates it for free.

It looks up the stored value and returns the human-readable label instead.

document.status               # → 'under_review'
document.get_status_display() # → 'Under Review'

The naming rule is simple: get_ + your field name + _display. So if your field is called priority, the method is get_priority_display().


Using It in Templates

In Django templates, you call methods without parentheses:

<!-- Raw value — not ideal -->
<p>Status: {{ document.status }}</p>

<!-- Human-readable label — much better -->
<p>Status: {{ document.get_status_display }}</p>

The second line now shows:

Status: Under Review

This Is Not About Dropdowns

A common point of confusion: this method has nothing to do with rendering a <select> dropdown. That's the form's job.

When you use a choices field in a Django form, the form automatically renders a dropdown for input. That's separate.

get_status_display() is only for reading and displaying already-saved data — like in a document list page or a detail view.

What you want How Django handles it
Show a dropdown for input Django form renders <select> automatically
Display the saved value nicely Use get_status_display in your template

A Real Example

Here's how this fits together end to end:

models.py

class Document(models.Model):
    STATUS_CHOICES = [
        ('under_review', 'Under Review'),
        ('approved', 'Approved'),
        ('rejected', 'Rejected'),
    ]
    title = models.CharField(max_length=200)
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='under_review')

document_list.html

{% for document in documents %}
  <tr>
    <td>{{ document.title }}</td>
    <td>{{ document.get_status_display }}</td>  <!-- Shows "Under Review", not "under_review" -->
  </tr>
{% endfor %}

Clean, readable, and no extra code needed.


Summary

  • Django stores the raw value (left side of the tuple) in the database
  • The label (right side) is for humans
  • Django auto-generates get_<fieldname>_display() for any field with choices
  • Use it in templates as {{ object.get_fieldname_display }} (no parentheses)
  • It's for displaying data, not for rendering forms or dropdowns

Next time you define choices on a field, you get this for free. One less thing to build yourself.

Why enctype=\"multipart/form-data\" Matters for File Uploads

Without enctype="multipart/form-data" on your form, uploaded files never reach your Django view. request.FILES will be empty, and you'll have no idea why.


The Default: URL-Encoded Form Data

When a browser submits an HTML form, it encodes the data before sending it. By default, that encoding looks like this:

title=Report&assigned_to=3

Key-value pairs, joined by &, sent as plain text. This works perfectly for text fields, checkboxes, and dropdowns. Simple, readable, efficient.

But files are a different story.


The Problem with Files

Files are binary data — images, PDFs, Word documents. You can't represent a .png or a .pdf as a plain text string in a URL-encoded format. The data would be corrupted or lost entirely.

This is where enctype="multipart/form-data" comes in.


What enctype="multipart/form-data" Actually Does

Adding this attribute tells the browser:

"This form contains a file. Don't encode everything as a single string. Split the data into multiple parts and send each part separately."

Each field — text or file — becomes its own "part" in the HTTP request body, separated by a boundary string. The file's raw binary data is preserved and transmitted intact.


What Happens Without It

Without the attribute, a file upload fails silently:

# In your Django view:
print(request.FILES)  # → <MultiValueDict: {}>

The form submits. The view runs. No error is raised. But request.FILES is empty — the file was never included in the request at all.

With it:

print(request.FILES)  # → <MultiValueDict: {'file': [<InMemoryUploadedFile: report.pdf>]}>
uploaded_file = request.FILES['file']  # ✅ your view gets the file

The Fix: One Attribute

<!-- ❌ File upload will fail silently -->
<form method="post">
  {% csrf_token %}
  <input type="file" name="file">
  <button type="submit">Upload</button>
</form>

<!-- ✅ File upload works correctly -->
<form method="post" enctype="multipart/form-data">
  {% csrf_token %}
  <input type="file" name="file">
  <button type="submit">Upload</button>
</form>

Django's Side of the Story

Django's request.FILES is only populated when the incoming request has a Content-Type of multipart/form-data. Without that content type — which the browser sets automatically when enctype is present — Django simply has nowhere to look for file data.

This is also why Django forms with file fields require you to pass request.FILES explicitly:

form = DocumentUploadForm(request.POST, request.FILES)

Not request.POST alone. Both are needed, and both only contain the right data when the form is encoded correctly.


The Rule to Remember

Every form that uploads a file must have enctype="multipart/form-data". No exceptions.

It's easy to forget. It fails silently. And it costs you a frustrating debugging session when you do.

The Django `request` Object — A Beginner's Guide

What Is the request Object?

When someone visits a page on your Django site, Django automatically creates a request object and passes it to your view. It contains everything about that visit — who the user is, what they sent, and how they sent it.

You've already been using it every time you write this:

def my_view(request):
    return HttpResponse("Hello!")

That request sitting there as the first argument? That's it.


Fields — The Data Inside request

request.method

Tells you how the page was requested — either "GET" (just loading a page) or "POST" (submitting a form).

def my_view(request):
    if request.method == "POST":
        # user submitted the form
    else:
        # user just loaded the page

You'll write this pattern in almost every view that has a form.


request.GET

Holds any extra data passed in the URL after a ?.

/search/?q=django
query = request.GET.get("q")  # "django"

Always use .get() — it returns None instead of crashing if the key isn't there.


request.POST

Holds the data a user submitted through a form.

username = request.POST.get("username")
password = request.POST.get("password")

This is only filled when request.method == "POST". On a normal page load, it's empty.


request.user

The person currently logged in. If no one is logged in, Django gives you an AnonymousUser instead.

user = request.user
print(user.username)  # "parthi"

This is one of the most useful fields. You'll use it constantly for showing the right content to the right person.


request.user.is_authenticated

A simple True or False — is this person logged in or not?

if request.user.is_authenticated:
    # show their dashboard
else:
    # send them to the login page

Use this any time you want to protect content or personalise a page for logged-in users. It works in views and templates.

In your template:

{% if request.user.is_authenticated %}
    <p>Welcome, {{ request.user.username }}!</p>
{% else %}
    <a href="/login/">Log in</a>
{% endif %}

request.FILES

Contains any files a user uploaded through a form.

uploaded = request.FILES.get("document")

For this to work, your HTML form needs enctype="multipart/form-data":

<form method="post" enctype="multipart/form-data">

request.path

The URL path of the current page, without the domain.

request.path  # "/dashboard/"

Useful in templates to highlight the active nav link:

{% if request.path == "/dashboard/" %}
    <li class="active">Dashboard</li>
{% endif %}

request.session

A place to store small bits of data that stick around between page visits for a user.

# Save something
request.session["last_page"] = "/dashboard/"

# Read it back later
last = request.session.get("last_page")

Think of it like a sticky note Django keeps for each user.


A Real Example

Here's how these fields work together in a role-based view — similar to what you're building in your document review project:

from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required

@login_required
def dashboard(request):
    user = request.user

    if not user.is_authenticated:
        return redirect("login")

    if user.role == "reviewer":
        return redirect("reviewer_dashboard")
    elif user.role == "submitter":
        return redirect("submitter_dashboard")

    return render(request, "dashboard.html", {"user": user})
  • request.user — who is logged in
  • request.user.is_authenticated — are they actually logged in?
  • render(request, ...) — makes request available inside the template too

Quick Reference

Field What it gives you
request.method "GET" or "POST"
request.GET Data passed in the URL (?key=value)
request.POST Form data submitted by the user
request.user The logged-in user object
request.user.is_authenticated True if logged in, False if not
request.FILES Uploaded files
request.path Current URL path
request.session Persistent data for this user

Once you get comfortable with these, the request object stops feeling like magic and starts feeling like a toolbox — one you'll reach into on every view you write.


Django's Built-in Password Validation — What It Is and How It Works

A beginner-friendly guide to keeping your users' passwords safe without writing a single validation rule yourself.


The Problem: Weak Passwords Are Everywhere

If you let users pick any password they want, some will choose 123456, their own name, or just the word password. These are trivially easy to crack.

Instead of writing your own checks from scratch, Django gives you a plug-and-play system called password validation. You configure it once in settings.py, and Django enforces it everywhere — registration, password changes, the admin panel.


The Four Built-in Validators

These live in your settings.py under AUTH_PASSWORD_VALIDATORS:

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]

Let's go through each one.


1. UserAttributeSimilarityValidator

What it does: Rejects passwords that are too similar to the user's own data — their username, email, first name, or last name.

Why it matters: If someone's username is parthi123 and their password is parthi123, an attacker who knows the username can guess the password instantly.

Example: - Username: john_doe - Password: johndoe2024 ❌ — too similar, rejected - Password: BlueSky#99 ✅ — no relation to the user's info


2. MinimumLengthValidator

What it does: Rejects passwords that are too short. The default minimum is 8 characters.

Why it matters: Shorter passwords have fewer possible combinations, making them much faster to crack by brute force (trying every possible combination).

You can customise the minimum length like this:

{
    'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    'OPTIONS': {
        'min_length': 12,
    }
},

Example: - Password: abc ❌ — too short - Password: MyP@ss99 ✅ — 8 characters, passes


3. CommonPasswordValidator

What it does: Rejects passwords that appear on a list of the 20,000 most commonly used passwords.

Why it matters: Attackers use "dictionary attacks" — they try known common passwords first, because so many people use them. Django ships with this list built in, so you don't have to maintain it yourself.

Example: - Password: password ❌ — on the common list - Password: iloveyou ❌ — also on the list - Password: Tr0ub4dor&3 ✅ — not on the list


4. NumericPasswordValidator

What it does: Rejects passwords that are entirely numbers.

Why it matters: A password like 19901231 (a date of birth) looks complex, but it's just numbers — and people commonly use meaningful dates as passwords. This validator prevents that category entirely.

Example: - Password: 19901231 ❌ — all numeric - Password: 1990dec31 ✅ — has letters, passes


How Django Actually Runs These Checks

The validators don't run by magic. You have to call them manually in your view using the validate_password() function. Here's the pattern you'll use:

from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError

def register(request):
    if request.method == 'POST':
        password = request.POST.get('password')

        try:
            validate_password(password)
        except ValidationError as e:
            for error in e.messages:
                messages.error(request, error)
            return redirect('register')

        # If we reach here, the password passed all validators
        # ... create the user

Breaking it down, line by line

Code What it does
validate_password(password) Runs the password through all four validators in order
try: "Attempt the following — and be ready to handle failure"
except ValidationError as e: "If any validator fails, catch the error and call it e"
e.messages A list of human-readable error strings, one per failed rule
messages.error(request, error) Adds each error to Django's flash message system so your template can display it
return redirect('register') Sends the user back to the form to try again

If no exception is raised, the code simply falls through — the password is valid, and you can safely create the user.


What e.messages Looks Like

If a user tries to register with the password abc, e.messages might look like:

[
    "This password is too short. It must contain at least 8 characters.",
    "This password is too common."
]

Each message is shown to the user as a separate flash message. Your template just needs something like:

{% for message in messages %}
  <div class="alert alert-danger">{{ message }}</div>
{% endfor %}

A Common Mistake to Avoid

Beginners sometimes skip validate_password() and rely on Django's form or create_user() to handle validation automatically. This works in some contexts, but when you're building a custom registration view with raw POST data, you must call validate_password() yourself — it won't happen on its own.


Quick Summary

Validator Blocks
UserAttributeSimilarityValidator Passwords too similar to username/email/name
MinimumLengthValidator Passwords under 8 characters (configurable)
CommonPasswordValidator Passwords from a list of 20,000 common ones
NumericPasswordValidator Passwords made entirely of numbers

And the try/except ValidationError pattern is how you hook these validators into your own view — catching failures, surfacing the messages to the user, and redirecting them back to try again.

Debugging Gunicorn: "No module named 'django'" on a Working Virtual Environment

The Setup

I was deploying a Django app to my EC2 instance. Same stack I'd used before — Ubuntu, PostgreSQL, Gunicorn, Nginx. I'd already:

  • Created a virtual environment
  • Activated it
  • Installed Django, psycopg2, and all dependencies
  • Confirmed everything worked with python manage.py runserver

Time to test Gunicorn before wiring it to Nginx:

gunicorn docreview.wsgi:application --bind 0.0.0.0:8001

And immediately:

ModuleNotFoundError: No module named 'django'

That made no sense. Django was clearly installed — I'd just run migrations five minutes earlier.


First Instinct: Check the Virtual Environment

The most common cause of this error is a virtual environment that isn't actually active. So I checked:

which python3

It pointed inside my venv. Activated correctly. Confused, I checked what was actually installed:

pip list

Django was right there in the list. So the environment had Django. Gunicorn just couldn't see it.


Second Instinct: Check Which Gunicorn Was Running

This is the part I almost skipped, and it turned out to be the actual problem:

which gunicorn
/usr/bin/gunicorn

There it was. That's not my virtual environment — that's the system-wide gunicorn. Even though my venv was active in the terminal, the gunicorn command was resolving to a different installation entirely, one that had no idea my venv or my project's Django even existed.


Why This Happens

A virtual environment changes which python and pip your shell uses. But if a tool was installed system-wide before you created the venv — or installed in a separate step that didn't actually target the venv — your shell can still find that system version first, depending on your PATH.

In my case, I had run pip install gunicorn earlier, but for whatever reason it hadn't landed inside the venv that time. The fix was simple once I knew what to check:

pip install gunicorn
which gunicorn

The second time, it correctly pointed to:

/var/www/document-review-app/venv/bin/gunicorn

Round Two: Same Error, Different Cause

I ran gunicorn again, expecting it to work. Same error.

ModuleNotFoundError: No module named 'django'

This time the venv path was correct. So why was it still failing?

The fix that actually worked was running gunicorn through Python's module flag instead of calling it directly:

python3 -m gunicorn docreview.wsgi:application --bind 0.0.0.0:8001

This started cleanly with no errors.


Why -m Made the Difference

Calling a tool directly (gunicorn ...) relies on your shell finding the right executable on PATH and that executable's shebang line pointing to the right interpreter. Calling it as a module (python3 -m gunicorn ...) skips all of that — it tells the currently active Python interpreter to load and run the gunicorn module directly, using whatever environment that interpreter belongs to.

If there's ever ambiguity about which gunicorn or which python is being picked up, python3 -m <tool> removes the ambiguity completely. It's now my default way of running anything inside a virtual environment, not just gunicorn.


Round Three: "Address Already in Use"

After getting gunicorn running, I stopped it and made a config change, then tried to restart:

Error: Connection in use: ('0.0.0.0', 8001)

The earlier process hadn't actually been killed — it was still holding the port. The fix:

sudo lsof -ti:8001 | xargs sudo kill -9

lsof -ti:8001 finds the process ID using that port, and kill -9 forces it to stop. After that, gunicorn started cleanly.


What I'd Check First Next Time

If gunicorn (or any tool inside a venv) throws ModuleNotFoundError for a package you know is installed:

  1. Confirm the venv is active — check your terminal prompt for (venv)
  2. Check what's actually installed: pip list
  3. Check which binary is actually running: which gunicorn
  4. If it points outside your venv, that's the bug
  5. Reinstall inside the active venv if needed: pip install gunicorn
  6. Run via python3 -m <tool> instead of calling the tool directly — this sidesteps PATH issues entirely
  7. If the port is stuck, find and kill the old process: sudo lsof -ti:<port> | xargs sudo kill -9

The Takeaway

Nothing about this bug was related to Django, PostgreSQL, or my actual application code. It was entirely about which Python and which gunicorn my shell was resolving to — a reminder that in deployment, environment correctness matters just as much as code correctness. The app was never broken. The terminal was just running the wrong tool.

Deploying a Django App to AWS EC2: My Step-by-Step Setup

The Stack

Here's what's running my app in production:

Nginx        → receives requests from the internet
Gunicorn     → runs my Django app (managed by systemd)
PostgreSQL   → stores all the data
Route 53     → points my subdomain to the server
Certbot      → gives me HTTPS for free

Each piece has one job. That separation is what makes the whole thing manageable.


Step 1: Reuse the Existing Server

I already had an EC2 instance running my personal blog with Nginx, PostgreSQL, and Gunicorn installed. Instead of spinning up a new server, I deployed this app alongside it.

/var/www/personal-blog/          → existing blog
/var/www/document-review-app/    → new app, same server

Two apps, one server. Nginx would later be configured to know which domain goes to which app.


Step 2: Get the Code onto the Server

I cloned my GitHub repo directly onto the server:

cd /var/www
git clone https://github.com/<username>/document-review-app.git
cd document-review-app

Then created a virtual environment and installed dependencies — same as local setup:

python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt

Step 3: Hide the Secrets

Locally, my Django SECRET_KEY and DEBUG setting were sitting in plain code. That's fine for a laptop. Not fine for a public GitHub repo.

I installed python-decouple and moved both into a .env file that never gets committed:

SECRET_KEY=my-actual-secret-key
DEBUG=False

And in settings.py:

from decouple import config

SECRET_KEY = config('SECRET_KEY')
DEBUG = config('DEBUG', default=False, cast=bool)

DEBUG=False matters a lot here — it's what stops Django from showing detailed error pages (with file paths and code) to random visitors.


Step 4: Switch from SQLite to PostgreSQL

SQLite is great for development but isn't built for a real production app. I created a dedicated database and user:

CREATE DATABASE docreview_db;
CREATE USER docreview_user WITH PASSWORD 'strong-password';
GRANT ALL PRIVILEGES ON DATABASE docreview_db TO docreview_user;

Then pointed Django at it through the same .env pattern:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': config('DB_NAME'),
        'USER': config('DB_USER'),
        'PASSWORD': config('DB_PASSWORD'),
        'HOST': config('DB_HOST'),
        'PORT': config('DB_PORT'),
    }
}

One gotcha I hit here — newer PostgreSQL versions restrict schema permissions by default, so I had to explicitly grant access:

GRANT ALL ON SCHEMA public TO docreview_user;

Without that, migrations failed with a permissions error even though the user existed.


Step 5: Run Gunicorn as a Permanent Service

python manage.py runserver is for development only — it stops the moment you close your terminal. In production, Gunicorn runs the app, and systemd keeps Gunicorn running forever (even restarting it if the server reboots).

I created a service file:

[Unit]
Description=Gunicorn for Document Review App
After=network.target

[Service]
User=ubuntu
Group=www-data
WorkingDirectory=/var/www/document-review-app
ExecStart=/var/www/document-review-app/venv/bin/python3 -m gunicorn --workers 3 --bind unix:/var/www/document-review-app/docreview.sock docreview.wsgi:application

[Install]
WantedBy=multi-user.target

Then started it:

sudo systemctl start docreview
sudo systemctl enable docreview

enable is the important part — it means Gunicorn starts automatically even after a server reboot.


Step 6: Point Nginx at Gunicorn

Gunicorn doesn't talk to the internet directly. Nginx sits in front of it, handling incoming requests and forwarding them through a socket file:

server {
    listen 80;
    server_name docreview.parthiban.dev;

    location /static/ {
        alias /var/www/document-review-app/staticfiles/;
    }

    location /media/ {
        alias /var/www/document-review-app/media/;
    }

    location / {
        include proxy_params;
        proxy_pass http://unix:/var/www/document-review-app/docreview.sock;
    }
}

Static files (CSS, JS, admin styling) and media files (uploaded documents) are served directly by Nginx — it's faster than routing them through Django for no reason.


Step 7: Point a Subdomain at the Server

I added a new subdomain in Route 53, pointing to the same EC2 IP my blog already uses:

Record name:  docreview
Type:         A
Value:        <EC2 public IP>

This gave me docreview.parthiban.dev, fully separate from the main blog, on the same machine.


Step 8: Add HTTPS

The last step — making sure the site loads securely. Certbot handled this in one command:

sudo certbot --nginx -d docreview.parthiban.dev

It automatically updated my Nginx config to serve HTTPS and redirect any HTTP traffic to it.


What I'd Tell a Junior Dev Doing This for the First Time

  • Don't skip the .env step. Committing secrets to GitHub is an easy mistake to make once and a painful one to clean up later.
  • Switch to PostgreSQL before deploying, not after. It's a much smaller change to make early.
  • Use systemd, not a manually run process. If you SSH out and your terminal closes, a manually started server dies with it.
  • Test each layer separately. I confirmed Gunicorn worked on its own before touching Nginx. That made debugging far easier — I always knew which layer the problem was in.

The End Result

https://docreview.parthiban.dev

A Django app, planned from a written spec, built with function-based views, and now running in production with its own database, its own subdomain, and HTTPS — on the same server as my blog.