Django Interview Questions Answered — ORM, Views, Middleware & More
Django powers some of the most-visited sites on the planet — Instagram started on it, Pinterest scaled with it, and thousands of startups ship with it every year. When a company posts a Django backend role, they're not looking for someone who memorised the docs. They want engineers who understand the framework's design decisions deeply enough to bend them when business requirements get weird. That's what separates a candidate who gets an offer from one who gets a polite rejection email.
The problem with most Django interview prep is that it's surface-level. Lists of questions with one-line answers that don't explain why Django works the way it does. That leaves you vulnerable the moment an interviewer asks a follow-up — 'okay, but why would you choose that approach?' — and you freeze. Real interviews dig into trade-offs, failure modes, and architectural judgment, not just API recall.
By the end of this article you'll be able to explain Django's MVT architecture, the ORM query lifecycle, middleware execution order, signals vs direct calls, and caching strategies — each with the real-world context that makes your answers land. You'll also know the three mistakes that silently kill otherwise good Django interviews.
Django's MVT Architecture — Why It's Not Quite MVC
Almost every Django interview starts here, and almost every candidate fumbles it by saying 'Django uses MVC.' It doesn't — not exactly. Django uses MVT: Model, View, Template. The naming shift isn't cosmetic; it reflects a genuine architectural difference worth understanding.
In classic MVC, the Controller handles HTTP requests, decides what data to fetch, and tells the View what to render. In Django, that controller logic lives in the View function or class. Django's Template is purely the presentation layer — it has no business logic, and the framework enforces that through the deliberately limited template language. The 'Controller' in the traditional sense is Django's URL dispatcher itself, which routes the incoming request to the right view.
Why does this matter in an interview? Because understanding MVT shows you understand Django's philosophy: keep business logic in Python (views and models), keep presentation in templates, and trust the framework to wire them together. When you understand that philosophy, you make better decisions — like knowing that fat models and thin views is a Django best practice, not just a style preference.
Interviewers love asking 'where would you put business logic in a Django app?' The wrong answer is 'in the template.' The right answer is 'in the model or a service layer, with the view acting as a thin coordinator.'
# ─── models.py ─────────────────────────────────────────────────────────────── # The Model owns the data AND the business rules around that data. # Notice: the discount calculation lives HERE, not in the view or template. from django.db import models class Product(models.Model): name = models.CharField(max_length=200) base_price = models.DecimalField(max_digits=8, decimal_places=2) is_on_sale = models.BooleanField(default=False) def discounted_price(self): """Business logic belongs in the model — fat models, thin views.""" if self.is_on_sale: return self.base_price * 0.80 # 20% discount return self.base_price def __str__(self): return f"{self.name} (£{self.discounted_price():.2f})" # ─── views.py ──────────────────────────────────────────────────────────────── # The View is a thin coordinator: fetch data, pass to template. Nothing more. from django.shortcuts import render from .models import Product def product_list(request): # QuerySet is lazy — no DB hit yet, just a description of the query on_sale_products = Product.objects.filter(is_on_sale=True) # DB hit happens HERE when Django evaluates the QuerySet for the template context = { 'products': on_sale_products, # passed to the template by name } return render(request, 'shop/product_list.html', context) # ─── shop/product_list.html (Template) ─────────────────────────────────────── # Templates are dumb on purpose — only presentation, no business logic. # {% for product in products %} # <p>{{ product.name }} — {{ product.discounted_price }}</p> # {% empty %} # <p>No sales on right now.</p> # {% endfor %}
# When a browser hits /products/, Django:
# 1. Matches URL → calls product_list(request)
# 2. Builds QuerySet for on-sale products
# 3. Evaluates QuerySet → SQL: SELECT * FROM shop_product WHERE is_on_sale=True
# 4. Renders template with product data
# 5. Returns HTTP 200 with HTML body to the browser
Django ORM Deep Dive — QuerySets, Lazy Evaluation & the N+1 Problem
The ORM is where most Django interviews separate candidates. Everyone knows .filter() and .all(). The real question is whether you understand when the database is actually hit — because getting that wrong kills performance in production.
Django QuerySets are lazy. When you write Product.objects.filter(is_on_sale=True), nothing touches the database. Django builds a description of the query in memory. The database is only hit when you iterate, slice, call list(), or access len() on the QuerySet. This design lets you chain filters efficiently without redundant round-trips.
The N+1 problem is the most common ORM performance trap — and interviewers know it. It happens when you load a list of objects and then access a related object on each one inside a loop. That's one query to get the list, then N more queries for each related record. On a list of 500 orders, you've just fired 501 database queries instead of 2.
The fix is select_related() for ForeignKey/OneToOne relationships (SQL JOIN) and prefetch_related() for ManyToMany or reverse ForeignKey relationships (separate optimised query). Knowing which one to use — and why they work differently under the hood — is what makes you stand out.
from django.db import models # ─── models.py ─────────────────────────────────────────────────────────────── class Author(models.Model): name = models.CharField(max_length=100) def __str__(self): return self.name class Book(models.Model): title = models.CharField(max_length=200) author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name='books') published_year = models.IntegerField() def __str__(self): return self.title # ─── views.py ──────────────────────────────────────────────────────────────── from django.shortcuts import render from .models import Book # ❌ THE N+1 PROBLEM — fires 1 + N database queries def book_list_slow(request): books = Book.objects.all() # Query 1: SELECT * FROM book for book in books: # Each access to book.author fires a NEW query: # Query 2: SELECT * FROM author WHERE id=1 # Query 3: SELECT * FROM author WHERE id=2 ... and so on print(book.author.name) # 💥 N additional queries return render(request, 'books/list.html', {'books': books}) # ✅ THE FIX — select_related() for ForeignKey (SQL JOIN, 1 query total) def book_list_fast(request): # Django generates: SELECT book.*, author.* FROM book # INNER JOIN author ON book.author_id = author.id books = Book.objects.select_related('author').all() for book in books: # author data is already in memory — ZERO extra queries here print(book.author.name) # ✅ No additional DB hit return render(request, 'books/list.html', {'books': books}) # ✅ prefetch_related() for reverse FK / ManyToMany (2 queries, not N+1) def author_list_with_books(request): # Query 1: SELECT * FROM author # Query 2: SELECT * FROM book WHERE author_id IN (1, 2, 3, ...) # Django then stitches the results together in Python memory authors = Author.objects.prefetch_related('books').all() for author in authors: # author.books.all() uses the prefetched cache — no extra query book_titles = [book.title for book in author.books.all()] print(f"{author.name}: {book_titles}") return render(request, 'books/authors.html', {'authors': authors}) # ─── LAZY EVALUATION DEMO ──────────────────────────────────────────────────── def queryset_laziness_demo(): # Step 1: No DB hit — just builds a query object recent_books = Book.objects.filter(published_year__gte=2020) # Step 2: Still no DB hit — chaining is free recent_books = recent_books.order_by('-published_year') # Step 3: DB hit happens HERE — list() forces evaluation book_list = list(recent_books) # SQL fired: SELECT * FROM book WHERE published_year >= 2020 # ORDER BY published_year DESC print(f"Fetched {len(book_list)} books with a single query") return book_list
# Fired 101 SQL queries — 1 for books, 100 for authors
# book_list_fast() with 100 books:
# Fired 1 SQL query — JOIN fetches everything at once
# author_list_with_books() with 20 authors, 100 books:
# Fired 2 SQL queries total — always 2, regardless of row count
# J.K. Rowling: ['Harry Potter', 'The Ickabog']
# George Orwell: ['1984', 'Animal Farm']
# queryset_laziness_demo():
# Fetched 34 books with a single query
Middleware — Django's Request/Response Pipeline Explained
Middleware is one of those Django concepts that interviewers love because it reveals whether you understand the framework's internals or just its surface API. Most candidates know middleware exists for authentication and CSRF. Fewer can explain the execution order, and almost none can write custom middleware correctly on the spot.
Think of middleware as a stack of airport security layers. Your request passes through each layer on the way in, reaches the view (the gate), and then passes back through each layer in reverse on the way out. Django's MIDDLEWARE setting in settings.py defines this stack — top to bottom for requests, bottom to top for responses.
This bidirectional flow matters. SecurityMiddleware sits at the top intentionally — it enforces HTTPS redirects before any other processing happens. SessionMiddleware must come before AuthenticationMiddleware because auth needs the session to be set up first. Swap them and you get a cryptic AttributeError at runtime.
Custom middleware is powerful for cross-cutting concerns: request timing, API key validation, per-request feature flags, or audit logging. The modern Django middleware pattern uses a callable class — understanding both the old process_request/process_response style and the new callable style shows real depth.
# ─── middleware.py ─────────────────────────────────────────────────────────── import time import logging from django.http import JsonResponse logger = logging.getLogger(__name__) class RequestTimingMiddleware: """ Logs how long each request takes and adds an X-Response-Time header. This is the MODERN Django middleware pattern (callable class). """ def __init__(self, get_response): # Called ONCE when the server starts — good place for one-time setup self.get_response = get_response logger.info("RequestTimingMiddleware initialised") def __call__(self, request): # ── INBOUND: code here runs BEFORE the view ────────────────────────── request_start_time = time.monotonic() # monotonic = immune to clock drift # Hand off to the next middleware / view in the chain response = self.get_response(request) # ── OUTBOUND: code here runs AFTER the view ────────────────────────── elapsed_ms = (time.monotonic() - request_start_time) * 1000 # Add timing info to every response header — useful for API clients response['X-Response-Time'] = f"{elapsed_ms:.2f}ms" logger.info( f"{request.method} {request.path} completed in {elapsed_ms:.2f}ms" ) return response class ApiKeyMiddleware: """ Validates API key for /api/ routes only. Demonstrates short-circuiting — returning early WITHOUT calling the view. """ VALID_API_KEYS = {'prod-key-abc123', 'staging-key-xyz789'} # Use DB in real life def __init__(self, get_response): self.get_response = get_response def __call__(self, request): # Only enforce on /api/ routes — leave admin and frontend alone if request.path.startswith('/api/'): api_key = request.headers.get('X-API-Key', '') if api_key not in self.VALID_API_KEYS: # Short-circuit: return 401 WITHOUT calling the view at all return JsonResponse( {'error': 'Invalid or missing API key'}, status=401 ) # Key is valid (or not an API route) — proceed normally response = self.get_response(request) return response # ─── settings.py ───────────────────────────────────────────────────────────── MIDDLEWARE = [ # Execution order: top → bottom on request, bottom → top on response 'django.middleware.security.SecurityMiddleware', # Layer 1 in/out 'django.contrib.sessions.middleware.SessionMiddleware', # Layer 2 in/out 'django.middleware.common.CommonMiddleware', # Layer 3 in/out 'django.middleware.csrf.CsrfViewMiddleware', # Layer 4 in/out 'django.contrib.auth.middleware.AuthenticationMiddleware', # Layer 5 in/out 'myapp.middleware.RequestTimingMiddleware', # Layer 6 in/out (custom) 'myapp.middleware.ApiKeyMiddleware', # Layer 7 in/out (custom) 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ]
# INFO RequestTimingMiddleware initialised ← (once, on server start)
# INFO POST /api/products/ completed in 42.18ms
# Response header: X-Response-Time: 42.18ms
# Server logs after a GET to /api/orders/ with NO key:
# HTTP 401 returned immediately — view function never called
# Response body: {"error": "Invalid or missing API key"}
Django Signals vs Direct Calls — When Decoupling Costs You
Signals are one of Django's most misunderstood features, and they're a favourite interview topic because they expose how a candidate thinks about system design trade-offs — not just Django syntax.
Django signals implement the Observer pattern. When something happens — a model is saved, a user logs in, a request finishes — Django broadcasts a signal. Any code that has 'subscribed' to that signal runs automatically. The appeal is decoupling: the model that fires post_save doesn't need to know about the email sender, the audit logger, or the cache invalidator. They all subscribe independently.
But here's where interviewers trip people up: signals are not free. They make code flow implicit and hard to trace. When a User is saved and five signal handlers fire across three different apps, a new team member reading the save code has no idea any of that happens. That's a maintenance burden. Signals also make unit testing harder — you have to remember to disconnect them or use @override_settings.
The honest answer on when to use signals: use them for genuinely cross-app concerns where you don't want the sender to know about the receiver — like a third-party app hooking into your User model's save lifecycle. For same-app logic, a direct method call or a service layer function is almost always clearer and easier to test.
# ─── models.py ─────────────────────────────────────────────────────────────── from django.db import models from django.contrib.auth.models import User class UserProfile(models.Model): """Extended user data — classic use case for post_save signal.""" user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') bio = models.TextField(blank=True) avatar_url = models.URLField(blank=True) def __str__(self): return f"Profile({self.user.username})" # ─── signals.py ────────────────────────────────────────────────────────────── # ✅ GOOD signal use case: UserProfile lives in a different app to User. # We can't (shouldn't) modify Django's built-in User.save() directly. # A signal lets us react to User creation without touching the User model. from django.db.models.signals import post_save from django.dispatch import receiver from .models import UserProfile @receiver(post_save, sender=User) def create_user_profile_on_registration(sender, instance, created, **kwargs): """ Fires automatically after EVERY User.save(). 'created' is True only when a NEW row was inserted — not on updates. """ if created: # Only create the profile once, when the User is first created UserProfile.objects.create(user=instance) print(f"✅ Profile auto-created for new user: {instance.username}") # ─── apps.py ───────────────────────────────────────────────────────────────── # CRITICAL: signals must be imported so Django discovers the @receiver decorator from django.apps import AppConfig class AccountsConfig(AppConfig): name = 'accounts' def ready(self): # This import triggers the @receiver decorators to register themselves import accounts.signals # noqa: F401 — import for side effects # ─── CONTRAST: direct call (better for same-app logic) ─────────────────────── # ❌ DON'T use a signal for this — it's all within the same app # and the logic is tightly coupled to the view anyway. # BAD: signal-based order confirmation email (same app, obscure flow) # @receiver(post_save, sender=Order) # def send_order_email(sender, instance, created, **kwargs): # if created: # send_email(instance.customer_email, 'Your order is confirmed') # ✅ GOOD: explicit call in the view — the flow is readable and testable from django.shortcuts import redirect from .models import Order from .email_service import send_order_confirmation_email def checkout_complete(request): order = Order.objects.create( customer=request.user, total=request.session.get('cart_total', 0) ) # Explicit call — any developer reading this immediately knows what happens send_order_confirmation_email(order) return redirect('order_detail', pk=order.pk)
# ✅ Profile auto-created for new user: sarah_jones
# When an existing user updates their email (User.save() with UPDATE):
# (no output — the 'if created' guard prevents duplicate profile creation)
# Django shell demo:
# >>> from django.contrib.auth.models import User
# >>> u = User.objects.create_user('test_user', password='secret123')
# ✅ Profile auto-created for new user: test_user
# >>> u.profile
# <UserProfile: Profile(test_user)>
| Aspect | select_related() | prefetch_related() |
|---|---|---|
| Relationship type | ForeignKey, OneToOneField | ManyToManyField, reverse ForeignKey |
| SQL strategy | Single JOIN query | Two separate queries + Python join |
| Number of queries | Always 1 | Always 2 (regardless of row count) |
| Best for | Accessing a parent object from a child | Accessing many children from a parent |
| Memory usage | Lower — one result set | Higher — two result sets merged in Python |
| Can be chained with filter() | Yes — filters apply to JOIN | Yes — with Prefetch() object for fine control |
| Works across database shards | No — JOIN requires same DB | Yes — queries can hit different DBs |
🎯 Key Takeaways
- Django's MVT puts the 'Controller' role in the URL dispatcher — Views are coordinators, not controllers. Business logic belongs in fat models or service layers, never in templates.
- QuerySets are lazy — the database is only hit when you iterate, slice, or call list(). Chain filters freely; use select_related() for FK joins and prefetch_related() for M2M/reverse FK to kill N+1 queries.
- Middleware executes top-to-bottom on requests and bottom-to-top on responses. Order in MIDDLEWARE matters — SessionMiddleware must always precede AuthenticationMiddleware or you'll get an AttributeError.
- Use signals for genuine cross-app decoupling (like hooking into Django's built-in User model). For same-app logic, a direct function call is clearer, easier to test, and easier for future developers to trace.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Calling QuerySet methods inside a template tag loop — Symptom: Django Debug Toolbar shows hundreds of identical SQL queries for a single page load — Fix: Move all ORM calls into the view, use select_related()/prefetch_related(), and pass fully evaluated data to the template. Templates should never trigger database queries.
- ✕Mistake 2: Forgetting to import signals in apps.py ready() — Symptom: Signal handlers silently never fire — no error, no traceback, just missing behaviour in production — Fix: Add
import yourapp.signalsinside theready()method of your AppConfig class. The import itself registers the @receiver decorators as a side effect. - ✕Mistake 3: Putting business logic in templates using custom template tags — Symptom: Price calculations, discount logic, or access control checks scattered across .html files, impossible to unit test — Fix: Move all logic to model methods or service functions, call them in the view, and pass simple values to the template. Ask yourself: 'Could I test this without a browser?' If no, it's in the wrong place.
Interview Questions on This Topic
- QExplain what happens internally — step by step — from the moment a browser sends a GET request to your Django app until HTML is returned. Include middleware, URL resolution, the view, and template rendering.
- QYou've deployed a Django app and the product listing page is loading slowly. Django Debug Toolbar shows 347 SQL queries for a single page load. Walk me through how you'd diagnose and fix this.
- QA colleague suggests using Django signals to send a confirmation email every time an Order is saved. You disagree. Make the case for and against signals here, and explain what you'd do instead.
Frequently Asked Questions
What is the difference between select_related and prefetch_related in Django?
select_related() uses a SQL JOIN to fetch related ForeignKey or OneToOneField data in a single query — best when accessing a parent from a child object. prefetch_related() fires two separate queries and joins them in Python memory — required for ManyToMany and reverse ForeignKey relationships. Using the wrong one doesn't raise an error; it silently falls back to N+1 behaviour.
How does Django middleware work and what order does it execute in?
Django middleware forms a stack defined in the MIDDLEWARE setting in settings.py. On an incoming request, middleware runs top-to-bottom. After the view produces a response, middleware runs bottom-to-top. Each middleware layer wraps the next using the get_response callable, forming a chain. Order matters critically — for example, AuthenticationMiddleware must come after SessionMiddleware because it reads from the session.
When should I use Django signals and when should I avoid them?
Use signals when you need to react to an event in one app from code in a separate, decoupled app — the canonical example is automatically creating a UserProfile when Django's built-in User is saved. Avoid signals for same-app logic because they make code flow implicit and hard to test. A direct function call or service layer method is almost always the right choice within a single app.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.