Django Interview Questions · 2026

Django Interview Questions (2026): Most Asked, With Answers

Django open-sourced in July 2005, so it turns 21 this month. It's still the default answer when a Python team needs a batteries-included web framework, but the 2025 Stack Overflow Developer Survey puts it at 11.7 percent of professional developers, behind Flask's 13.2 percent and FastAPI's 15.1 percent (Stack Overflow, 2025). That's a share of every developer surveyed, Python or otherwise, so the raw number understates Django's actual footprint inside Python-specific shops. Still, it means Django interview questions in 2026 increasingly come with a follow-up attached: why Django and not FastAPI for this particular service.

Here's an opinion I'll defend even though it might be wrong: most Django prep spends its energy on ORM syntax, filter chains, Q objects, that kind of thing, and the syntax rarely trips anyone up past a junior screen. The questions that actually separate a mid-level candidate from a senior one are about what a QuerySet does when nobody's watching: when it actually hits the database, why a for loop over related objects turns into 40 queries instead of 2, why a migration that looks like a one-line diff locks a production table for six minutes. Those are judgment questions, and most study guides barely touch them.

This page covers Django interview questions across nine areas: the MTV architecture pattern (everyone calls it MVT, we'll get to why), models and the ORM, migrations, QuerySets and the N+1 problem, views and URL routing, templates and forms, middleware, Django REST Framework, and signals, sessions, and security settings. Examples run against Django 5.x and Python 3.12. DRF gets its own section because by 2026 most Django roles that touch an API expect at least conversational fluency in it, even from candidates applying to server-rendered, template-heavy teams.

52Questions
ORM & N+1 QueriesCore Topic
Python & Django ShellFormat
Django 5.x + DRFStack

Django's MTV architecture, and why everyone still says MVT

Every loop opens here, even for a candidate with eight years of Django on their resume. It's a warm-up, but a sloppy answer sets a bad tone early.

Easy questions

15

Model handles data and business logic, wrapping a database table. Template handles presentation, the HTML that actually reaches the browser. View handles the request and response logic: deciding what data to fetch and which template to hand it to, which is the job MVC gives to a controller.

Django's own documentation actually calls this the MTV pattern, not MVT, reordering the letters because the framework itself acts as the controller through the URL dispatcher, and what Django calls the "view" is doing controller work, not the V in the traditional sense (Django FAQ). Nobody in an interview room is going to mark you down for saying MVT instead of MTV. Just be ready to explain why Django's naming doesn't map cleanly onto the acronym everyone already knows.

The ORM maps Python classes to database tables and generates the SQL for you, portable across Postgres, MySQL, SQLite, and Oracle without rewriting queries per backend. What you're giving up is fine-grained control over complex SQL: window functions, recursive CTEs, and some multi-table aggregations get awkward or verbose to express through the ORM's query API.

Django doesn't lock you out entirely. Model.objects.raw() and connection.cursor() both drop to real SQL when the ORM genuinely can't express what you need, though reaching for either should be the exception, not the default.

makemigrations introspects models.py, diffs it against the migration history already on disk, and writes new migration files describing the change. It never touches the database. migrate is what actually applies (or reverses) those migration operations against the real schema, and Django tracks what's already been applied in a django_migrations table so it knows exactly where a given database stands.

Building a queryset with filter(), exclude(), order_by(), or annotate() doesn't touch the database at all, Django just accumulates the query as a Python object. It only runs SQL once something forces evaluation: iterating over it, slicing it with a step, calling len(), list(), or bool() on it, or printing it in a shell, which evaluates and caches the first 21 rows since REPR_OUTPUT_SIZE defaults to 20 plus one extra row to know whether there's more.

That laziness is exactly why chaining five.filter() calls in a row costs nothing until something actually forces the query to run.

A function-based view is one function per URL and verb combination. The logic is explicit and easy to trace top to bottom, but you end up rewriting the same list, filter, and paginate boilerplate in every view that needs it. A class-based view gets generic ListView, DetailView, and CreateView classes, plus mixins like LoginRequiredMixin, for free, at the cost of reading through as_view(), dispatch(), and a chain of base classes to figure out what actually runs for a given request.

My honest read: function-based views age better for small teams and one-off endpoints. Class-based views pay off once you've got a dozen nearly identical CRUD views and you actually want them to stay identical over time instead of drifting apart.

{% extends "base.html" %} combined with {% block content %} lets a child template fill in named regions of a shared base instead of duplicating the surrounding HTML, header, nav, footer, in every single file. A context processor is a function that runs on every request and injects extra variables into every template's context automatically, request.user or a site-wide setting, say, without every view having to pass it in by hand.

Middleware is a chain of classes wrapping every request and response, each one gets a chance to touch the request before it reaches a view, and the response before it leaves. Requests flow top to bottom through the MIDDLEWARE list in settings.py; responses flow back bottom to top through that same list, so whichever middleware sits first in the list is also the last one to touch the outgoing response.

That ordering is why SessionMiddleware has to sit above AuthenticationMiddleware in the list: AuthenticationMiddleware reads request.session to attach request.user, and that session object has to already exist by the time it runs.

Serialization and deserialization between Python model instances and JSON, with validation built in, a browsable API for free during development, and a consistent authentication, permission, and throttling layer that plugs into every view the same way instead of every team reinventing it per endpoint. None of that is impossible in plain Django. DRF just gives it a shared, predictable shape (DRF docs, serializers).

The default backend is database-backed sessions, writing to the django_session table, one row per active session keyed by the session id in the user's cookie. Alternatives: cache (fast, but a session disappears if the cache gets flushed or evicts under memory pressure), cached_db (cache-first with a database fallback, where most production setups running Redis land), and signed_cookies, which skips server-side storage entirely and keeps the session data client-side, signed but not encrypted with SECRET_KEY, tamper-evident but still readable by the user, so nothing genuinely sensitive belongs in a session on that backend.

A project is the top-level container, the settings module, root URLconf, and WSGI/ASGI entry points, everything created by django-admin startproject. An app is a self-contained unit of functionality, created with python manage.py startapp, that owns its own models, views, templates, and migrations. One project usually holds several apps, and a well-written app can even be reused across different projects if you keep it decoupled from project-specific settings.

The confusion trips up juniors because both look like directories with similar files, but conceptually the project is the site itself and the app is a feature area, like accounts, billing, or blog.

INSTALLED_APPS tells Django which app configs to load at startup, and that's how the app registry gets built. Without an app listed there, Django won't discover its models, so there's no migration, no table, and no admin registration for anything defined inside it. Its management commands won't be found either, its templates directory won't be searched under the app_directories loader, and any signals wired up in that app's AppConfig.ready() never connect.

A common gotcha: you write a new model, forget to register the app, run makemigrations, and get an empty "No changes detected" because Django genuinely has no idea the app exists.

With DEBUG=True, an unhandled exception renders Django's full traceback page to whoever triggered it, dumping local variables, request data, cookies, settings values, and file paths. That's a direct way to leak SECRET_KEY, database credentials pulled from environment variables, or session data to anyone who can force a 500.

DEBUG also changes how static files and ALLOWED_HOSTS get handled. Django enforces the Host header strictly only when DEBUG is False, so leaving it on quietly weakens one of your host-header protections too, and static file serving behavior differs enough between the two modes that "it worked locally but CSS is missing after we fixed DEBUG in prod" is one of the most common first-deploy bugs.

null=True is a database-level setting, it lets the column store NULL instead of requiring a value. blank=True is a validation-level setting, it tells forms and model validation that the field is allowed to be empty on submission. You can have one without the other.

For CharField and TextField, Django's convention is blank=True with null=False, since an empty string already represents "no value," and having both an empty string and NULL as possible representations of nothing makes querying a mess. For fields like DateField or ForeignKey, where there's no empty-string equivalent, you need null=True as well if the field is genuinely optional.

get() expects exactly one match. It raises Model.DoesNotExist if there are zero and Model.MultipleObjectsReturned if there's more than one. filter() always returns a QuerySet, empty or not, and never raises for zero matches.

first() runs off filter() (or an existing queryset), applies an implicit ordering if none is set, and returns None if nothing matches, without ever raising MultipleObjectsReturned even if hundreds of rows qualify. In practice, get() is right for a unique lookup like a primary key, filter() is right when you expect a set, and first() is the safe choice when you want "one if it exists" without the exception noise of get().

A full CRUD interface with almost no code, a paginated list view, an add and change form auto-generated from the model's fields, delete confirmation, and permission checks tied into Django's auth system so only staff users with the right permissions can touch it.

Underneath, register() ties the model to a ModelAdmin class, the default one if you don't supply your own, which is what actually controls list_display, search_fields, list_filter, and readonly_fields. Most teams start with the bare registration and quickly write a custom ModelAdmin, because the defaults show every field as an editable box, don't filter foreign key dropdowns, and can get painfully slow on tables with tens of thousands of rows without list_select_related or raw_id_fields.

Medium questions

25

The framework itself, mostly. The URL resolver matches an incoming request to a view function or class, and that dispatch step is the closest thing Django has to an explicit controller layer, though it's mostly invisible to app code. You write urls.py and Django handles the routing decision; you never write a controller class that "calls" your view the way a hand-rolled MVC setup might.

A manager is the interface a model's QuerySets get created through, Model.objects being the default one Django adds automatically. Overriding get_queryset() on a custom manager lets you scope a model's default queries, only returning non-deleted rows on a soft-delete model, say, and adding manager methods gives reusable query logic a real home instead of copy-pasting the same filter() chain into every view that touches that model.

One catch worth knowing cold: if a custom manager is the first one defined on a model, it becomes the default, and Django's admin and related-object lookups will use it too, so a soft-delete manager that filters out deleted rows can quietly hide them from the admin unless a second, unfiltered manager gets defined alongside it.

Iterating over a queryset of parent objects and touching a related field on each row issues one query for the parent list, plus a separate query for every single related lookup on top of it.

python
posts = Post.objects.all()
for post in posts:
  print(post.author.name) # one extra query per iteration

That's 1 query for the posts plus N queries for the authors, N+1 total. On a page rendering 50 posts, that's 51 queries where 2 would do, and it stays invisible in local dev against a table with 8 rows until real traffic hands you an actual N.

select_related does a SQL JOIN and pulls the related row back in the same query, and it only works for forward ForeignKey and OneToOneField relations, single-valued ones, because a JOIN only makes sense when there's exactly one related row per row. prefetch_related runs a second, separate query for the related objects and stitches everything together in Python, which is the only option for ManyToMany fields and reverse ForeignKey relations, where a JOIN would multiply the parent rows instead of just extending them (Django docs, database access optimization).

python
# 2 queries total instead of 1 + N
posts = Post.objects.select_related("author").prefetch_related("tags")
for post in posts:
  print(post.author.name, [t.name for t in post.tags.all()])

path() uses simple converters like <int:id> and <slug:slug> for the common cases and reads clean. re_path() drops down to a full regular expression when a converter can't express what you need, an arbitrary pattern with multiple optional segments, for instance.

Naming a URL and using reverse() in Python code or {% url %} in a template, instead of typing "/blog/5/edit/" directly, means renaming a URL path later doesn't turn into a grep-and-replace across the whole codebase. It's usually the first thing that breaks in a new Django hire's code review.

ModelForm generates fields, and their validators, straight from a model and knows how to.save() directly to the database. A plain Form declares every field by hand and leaves saving entirely to your view code.

clean_<fieldname>() validates one field in isolation. clean() runs after every individual field has already passed its own validation, so it's the only place to validate a relationship between two fields, confirming password matches confirm_password, or that start_date falls before end_date, checks a single field never has enough context to make on its own.

CsrfViewMiddleware rejects any unsafe request, POST, PUT, PATCH, DELETE, that doesn't carry a valid CSRF token matching the one tied to the current session. That's what stops a malicious site from silently submitting a form on your site using a logged-in user's own browser session, a cross-site request forgery.

Forget the tag and the form still submits fine on your own machine with DEBUG=True showing a full traceback the instant something's wrong. In production it throws a plain 403 Forbidden for anyone hitting it for real, which is one of the more common "why does this only break for actual users" bugs a new Django developer runs into.

Serializer means declaring every field by hand and writing create() and update() yourself. ModelSerializer introspects a model and auto-generates its fields, and their validators, from it, with default create() and update() implementations you're still free to override for anything custom.

is_valid() runs validation on either one and populates validated_data. Calling.save() before checking is_valid() is a genuinely common interview trap, save() assumes validation already ran and will throw an unhelpful error, or worse, save partially bad data, if it didn't.

APIView is the closest thing to Django's plain View, one method per HTTP verb, get, post, put, delete, that you write out yourself. A ViewSet groups related actions, list, create, retrieve, update, destroy, as methods on one class, and a Router (DefaultRouter, SimpleRouter) auto-generates the URL patterns for all of them from one router.register() call instead of five separate path() entries written by hand.

python
class PostViewSet(viewsets.ModelViewSet):
  queryset = Post.objects.select_related("author")
  serializer_class = PostSerializer
  permission_classes = [IsAuthenticatedOrReadOnly]

router = DefaultRouter()
router.register("posts", PostViewSet)

ModelViewSet layers the default CRUD implementations on top, the same idea as ModelSerializer defaulting create() and update(), so a working CRUD API for one model can be a handful of lines.

Signals, post_save, pre_delete, m2m_changed, and others, let code in one app react to something happening in a model without the model itself importing that app. Sending a welcome email after a user gets created, say, without User's own save() method knowing anything about email.

The gotcha: signals only fire through a model's actual.save() and.delete() methods. QuerySet.update(), bulk_create(), bulk_update(), and a queryset-level.delete() all skip the model's save() and delete() entirely and go straight to SQL, so a post_save-triggered side effect just silently doesn't run for anything created or updated in bulk. That's rarely caught until a bulk import job quietly skips sending 4,000 emails nobody meant to skip. My honest opinion: signals get reached for by reflex more than they should. Overriding save() directly, or moving the logic into a service function both a view and a management command can call, is usually easier to trace than a signal handler quietly registered in some other app's apps.py.

unique_together enforces uniqueness across a combination of fields at the database level, but it's a plain tuple with no way to attach a condition, a custom name, or an expression. UniqueConstraint does the same job through the newer constraints API and adds support for a condition argument for partial unique constraints, deferrable behavior, and a name you control instead of Django's autogenerated one.

unique_together still works but is effectively legacy. If you need a constraint that only applies to some rows, say a unique email per is_deleted=False, UniqueConstraint with a condition is the only way to express that without hand-writing raw SQL in a migration.

You need it whenever multiple writes have to succeed or fail together, transferring a balance between two rows, creating a parent object and its children, anything where a partial write leaves data invalid. Inside atomic(), if any exception propagates out of the block, Django rolls back every write to the savepoint (or the whole transaction if it's the outermost block) before re-raising.

One thing that trips people up: atomic() rolls back database writes, not Python side effects. If you already sent an email or called an external API before the exception, that already happened and won't be undone. That's exactly why transaction.on_commit() exists, to defer side effects until the transaction has actually committed.

Both bypass the ORM's usual per-instance path, generating one or a handful of INSERT or UPDATE statements instead of one query per row, which is the whole point when writing thousands of rows at once. The cost is they skip save(), so any custom save() override, auto-populating a slug or updating a timestamp, never runs.

They also don't send pre_save or post_save signals by default, so anything listening for post_save to update a search index or fire a notification silently does nothing. bulk_create() can also fail to return primary keys on databases without RETURNING support unless you pass extra flags, which matters if you need the new IDs right after the call.

only() tells the ORM to fetch just the listed fields in the initial query, deferring everything else. defer() is the inverse, fetch everything except the listed fields. Both help when a model has a huge TextField or JSONField you don't need for a given view, a list page showing titles and dates shouldn't pull the full article body for every row.

The backfire case is touching a deferred field later. Django issues a separate query per instance to fetch it lazily, so if you deferred a field and then loop over 500 objects accessing it anyway, you've recreated the N+1 problem you were trying to avoid, except it's harder to spot because the first query looked lean.

aggregate() collapses a queryset into a single dictionary, a total count, sum, or average across the whole set. annotate() attaches a computed value to each row instead, so you get the same number of rows back, each carrying an extra field.

Book.objects.annotate(num_reviews=Count('review')) gives every book with a review count attached, while Book.objects.aggregate(total=Count('review')) gives one number, the total across all books. People also get bitten combining annotate() with filter() in the wrong order, filtering before annotating restricts which rows get counted, and joining multiple annotate() calls with multiple related tables can silently multiply counts because of how Django flattens joins into one query.

A plain product.stock -= 1; product.save() reads the current value into Python, decrements it in memory, then writes it back. If two requests do this concurrently, both read stock=10, both compute 9, both write 9, and you've silently lost a decrement even though two units actually sold.

Using product.stock = F('stock') - 1 pushes the arithmetic into the SQL itself, so the database does the read and write as one atomic statement, and concurrent requests correctly land on 8.

python
from django.db.models import F

product.stock = F('stock') - 1
product.save()
product.refresh_from_db()

After save(), the object's stock attribute is still the F() expression reference, not the new integer, so you need refresh_from_db() if you need the actual updated value in the same request.

You register a Library instance in a templatetags module inside an app, then decorate a function with @register.simple_tag for something that takes arguments and returns a value, or @register.filter for something that transforms a single value piped in with the pipe operator.

Filters are the wrong tool once you need more than one argument or access to the template context, since a filter takes at most one argument beyond the value being filtered. If you need looping, branching, or rendering a sub-template based on logic, you want an inclusion_tag, or a full custom tag with @register.tag if you need to control parsing of the block contents. A common mistake is stuffing business logic into a filter because it feels lightweight, when that logic really belongs on a model method or in the view.

Per-site caching, via UpdateCacheMiddleware and FetchFromCacheMiddleware, caches every GET response site-wide, which is rarely what you want since it caches per URL including query strings and can serve stale personalized content without careful Vary headers. Per-view caching with the cache_page decorator scopes the same mechanism to a single view, which is far more controllable.

Template fragment caching with the cache tag lets you cache just an expensive chunk of a page, a sidebar of trending posts, while the rest stays dynamic. The low-level cache API, cache.get() and cache.set() directly, is for everything else, caching an expensive computed value or an external API response under a key you construct yourself with an explicit timeout. Most production Django apps barely touch per-site caching and live mostly in the low-level API plus fragment caching.

TestCase wraps every test method in a database transaction and rolls it back at the end, which is why Django tests run fast, nothing ever actually commits to disk. That's also why, by default, your tests can't observe transaction-related behavior, if you're testing a transaction.on_commit() callback, it never fires under plain TestCase because the outer transaction never actually commits.

TransactionTestCase actually commits and truncates the tables afterward, which is slower but necessary for testing on_commit hooks, raw SQL depending on committed state visible to another connection, or constraints that only fire at commit time. The rule of thumb is default to TestCase for speed, and reach for TransactionTestCase only when testing something that depends on an actual commit having happened.

Static files are assets that ship with your code, CSS, JS, images bundled with the app, version-controlled and unchanging at runtime. Media files are user-uploaded content, profile pictures or attachments, created after deployment through a FileField or ImageField. They use entirely separate settings, STATIC_URL and STATIC_ROOT for one, MEDIA_URL and MEDIA_ROOT for the other, and in production they typically live in different places, static files often go to a CDN at build time, media files go to persistent storage like S3 so they survive redeploys.

collectstatic walks every app's static directory plus STATICFILES_DIRS and copies everything into one STATIC_ROOT folder, the folder your web server or CDN actually serves from in production. runserver serves static files directly in dev without needing collectstatic at all, which is exactly why "CSS is missing after the first prod deploy" is such a common bug.

A ModelForm handles one instance of one model. Formsets let you manage a variable number of forms for the same model on one page, adding three line items to an invoice or editing five existing tags at once, without manually looping to construct and validate each form yourself.

Django tracks how many forms were submitted through hidden management form fields, which is also the part people break most often, rendering the formset without its management form causes validation to fail with a confusing error about missing management data. inlineformset_factory is the variant worth knowing specifically, it wires up a formset scoped to a parent object's foreign key automatically, which is what backs the classic edit-order-and-its-line-items-on-one-page pattern.

Paginator(object_list, per_page) wraps a queryset or list and gives you page(number) to slice out a Page object with object_list, has_next(), has_previous(), and page range info. Calling page() on a queryset triggers a COUNT query for the total, then slices with LIMIT and OFFSET for the requested page, both real database queries, so paginating a huge table isn't free even though it feels like it should be.

Requesting an out-of-range page raises EmptyPage, requesting something that isn't an integer raises PageNotAnInteger, and both are things you're expected to catch yourself. Paginator doesn't silently clamp to a valid range, which is a common source of 500 errors on a list view when someone edits the page query parameter by hand.

It's a thin wrapper that runs get() and catches DoesNotExist for you, turning it into an actual Http404 that renders your 404 template instead of leaking an unhandled exception as a 500. That saves the boilerplate of a try and except block in every detail view.

It falls short on MultipleObjectsReturned. If the lookup isn't actually unique, get_object_or_404 doesn't catch that, you still get a 500 for a query that matches more than one row. get_list_or_404 also exists as the equivalent for querysets, raising 404 instead of returning an empty list, which is a genuinely different UX decision, an empty list usually should render as "no results," not a 404 page.

AbstractUser keeps all of Django's default user fields, username, email, first_name, is_staff, and lets you add more on top, the right choice if the default field set is basically fine. AbstractBaseUser gives you almost nothing, just the password and last_login machinery, and expects you to define every field yourself plus a manager with create_user() and create_superuser(), the right choice if you want email as the actual login field with no username at all.

The reason this decision needs to happen up front is that swapping AUTH_USER_MODEL after migrations already ran against the built-in User model leaves every ForeignKey to User elsewhere in the app, plus Django's own permission and admin tables, pointing at the wrong table. There's no clean supported migration path, the practical fix on a live project is usually a painful data migration or starting the database over, which is why this belongs in day one of a project, not month six.

related_name controls the attribute name used to access the reverse side of a relationship, from the model being pointed to, back to the model with the foreign key. Without it, Django defaults to modelname_set, so a Comment.post foreign key gives you post.comment_set.all() by default.

The real problem shows up when two different fields on the same model point at the same target, say a Match model has both home_team and away_team as foreign keys to Team. Django refuses to run makemigrations and throws a clashing reverse accessor error, because both would default to match_set on Team, which can't exist twice. You have to set related_name on at least one of them, typically both for clarity, something like related_name='home_matches' and related_name='away_matches'.

Hard questions

12

An abstract base class has no database table of its own. Its fields get copied straight into each child model's table, more like a mixin than real inheritance at the database level, and there's no way to query across parent and children together as one set.

Multi-table inheritance creates a real separate table for every model in the chain, including the parent, and Django links a child row back to its parent through an implicit OneToOneField. Fetching a child model's parent fields means Django is doing a JOIN beneath that whether or not that's obvious from the code, which is a genuinely common source of an accidental N+1 for teams that inherited a multi-table setup and never noticed. Proxy models share the exact same table as the model they proxy and change only Python-level behavior, a different default ordering, an extra method, nothing about the schema moves at all.

Django can't add a NOT NULL column to a table with existing rows and leave those rows without a value, so it stops and asks you directly: either give it a one-off default right there in the terminal for the existing rows, or set a default= on the field so both new and existing rows have something to fall back on.

Behind that prompt sits a real production concern. On Postgres 11 and later, adding a column with a static, non-volatile default is a fast metadata-only change. A default that has to be computed per row, or an index added at the same time, can still take a full table lock depending on table size and Postgres version, and on MySQL or SQLite some column changes still mean copying the entire table under an exclusive lock. I've watched a migration that read like a one-line diff take down writes on a mid-size table for several minutes because nobody checked which operation actually locked what before running it against production.

Turn on Django Debug Toolbar, or just log connection.queries, against data shaped like production, not a table with 8 seed rows. A run of near-identical queries repeated dozens of times is the signature of N+1, and it's usually obvious the moment you actually look.

Add select_related or prefetch_related for whatever relation is getting hit inside a loop, use.only() or.defer() to stop pulling columns nobody reads if the table is wide, and if the page is still slow after that, check for a missing index. An N+1 problem and a missing index look identical from the outside, slow page, high query count, until you actually check the query plan and see which one you're staring at.

Python resolves methods left to right through the method resolution order. LoginRequiredMixin needs to intercept dispatch() before ListView's own dispatch() runs the actual view logic, so it has to sit earlier in the base class list to get first crack at the call.

Flip the order to class MyView(ListView, LoginRequiredMixin) and the login check quietly stops doing anything: ListView's dispatch() runs first, the view logic executes, and the mixin never gets a chance to redirect an anonymous user before that happens. This is a real bug candidates ship, not a trick-question invention.

python
class RequestTimingMiddleware:
  def __init__(self, get_response):
    self.get_response = get_response

  def __call__(self, request):
    start = time.monotonic()
    response = self.get_response(request)
    elapsed_ms = int((time.monotonic() - start) * 1000)
    response["X-Response-Time-Ms"] = str(elapsed_ms)
    return response

__call__ only wraps the whole request and response cycle: code before self.get_response(request) runs on the way in, code after runs on the way out. process_view runs right before Django calls the matched view, with access to the view function and its arguments, useful for something like feature-flag gating a specific view without touching every request. process_exception fires only if the view raised an unhandled exception, which is where custom error logging or a Sentry-style capture belongs instead of wrapping every individual view in its own try/except.

SessionAuthentication rides on Django's existing cookie-based session and needs a CSRF token on unsafe requests, fine for a browsable API or a same-site frontend hitting the same Django app, awkward for a separate mobile client or a third-party API consumer with no cookie jar to speak of. TokenAuthentication, built into DRF, issues one static token per user with no built-in expiry, simple to wire up, weak if a token leaks since it stays valid until someone manually revokes it.

djangorestframework-simplejwt, a third-party package rather than core DRF, gives short-lived access tokens plus a longer-lived refresh token, which is the closer-to-standard answer for a public API or mobile client in 2026, at the cost of more moving parts, token blacklisting, refresh rotation, than the built-in token auth. My take: reach for simplejwt by default for anything public-facing, and keep plain token auth for internal service-to-service calls where rotation genuinely isn't part of the threat model.

DEBUG=False first, and it's non-negotiable, DEBUG=True in production leaks full stack traces, including settings values, to anyone who can trigger a 500. ALLOWED_HOSTS has to list the real domain once DEBUG is off, or Django refuses every request with a plain 400. SECRET_KEY needs to come from an environment variable, never hardcoded in settings.py inside version control, since it signs both sessions and the CSRF token.

SECURE_SSL_REDIRECT, SESSION_COOKIE_SECURE, and CSRF_COOKIE_SECURE keep cookies off plain HTTP entirely. manage.py check --deploy walks through most of this list automatically and is worth actually running before a first deploy rather than trusting memory, I still run it on every new project even after years of doing this by hand.

select_for_update() adds a SELECT with FOR UPDATE, which takes a row-level lock held until the enclosing transaction commits or rolls back, blocking any other transaction trying to take the same lock until the first one releases it. It has to run inside transaction.atomic(), Django raises an error otherwise since there's no transaction to hold the lock for.

The deadlock case happens when two transactions lock the same two rows in opposite order, transaction A locks row 1 then waits on row 2, transaction B already holds row 2 and waits on row 1. Neither can proceed and the database eventually kills one with a deadlock error your code needs to catch and retry. The practical fix is always locking rows in the same consistent order across every code path, typically by ordering on primary key before the select_for_update() call, which removes the possibility of two transactions approaching the same pair of rows from opposite directions.

GenericForeignKey, backed by Django's contenttypes framework, lets a model point at a row in any other model instead of one fixed model, by storing a content type plus an object ID instead of a fixed foreign key column. That's genuinely useful for something like a Comment or Tag model that needs to attach to posts, photos, and products without three separate nullable foreign keys or three separate join tables.

What you give up is real referential integrity. The database enforces no constraint that the object ID actually points at an existing row, so orphaned generic relations after a delete are a real failure mode unless you handle it yourself with signals or cascading logic. You also lose the ability to do a normal SQL join for querying, "give me every comment on posts by author X" can't be expressed as a simple join, it needs two separate queries or awkward raw SQL, and select_related doesn't work across a GenericForeignKey at all.

A naive RunPython migration that calls update() on the whole queryset, or iterates every row in one transaction, holds locks and consumes memory for however long that takes, which on a huge table can be minutes to hours, an unacceptable blocking window in production. The standard approach is batching, iterating in chunks by ID range, committing between batches so you're not holding one giant transaction the whole time, and so a failure partway through doesn't lose all progress.

For schema changes specifically, modern Postgres handles adding a column with a constant default without a full table rewrite, but adding a NOT NULL constraint or an index can still lock the table. You handle the index case with a concurrent index build, which requires setting atomic = False on that migration since concurrent index creation can't run inside a transaction.

python
from django.contrib.postgres.operations import AddIndexConcurrently

class Migration(migrations.Migration):
  atomic = False

  operations = [
    AddIndexConcurrently(
      model_name='order',
      index=models.Index(fields=['status'], name='order_status_idx'),
    ),
  ]

The general pattern is add the column nullable, backfill in batches with a management command, then add the NOT NULL constraint as a separate fast migration once the backfill is confirmed complete.

Calling task.delay() directly inside a view that's still inside an open database transaction creates a real race condition. The Celery worker is a separate process with its own database connection that can't see uncommitted writes from yours, so it can pick up the task and try to read a row you just created before your transaction has actually committed.

Depending on timing, the task fails with a DoesNotExist error that's maddening to debug, it works fine in local dev with faster processing and fails intermittently in production once queue latency and database latency both matter. transaction.on_commit(lambda: task.delay(order_id)) defers the dispatch until Django confirms the enclosing transaction has actually committed, guaranteeing the worker sees the data. It's safe to use everywhere as a default, since it runs the callback immediately when there's no open transaction, but the failure mode it prevents only shows up in the wrapped-in-a-transaction case, which is exactly why it's easy to miss in code review.

The classic Django-specific culprit is query logging: with DEBUG=True, every query gets appended to django.db.connection.queries for the life of the connection, which in a process that never restarts grows without bound. This is one of several reasons DEBUG=True in a worker is its own kind of production bug even when it's not user-facing.

The second common cause is holding a reference to a large queryset across loop iterations. Django querysets cache their results after first evaluation, so building one outside a loop and accumulating results into an external list across many iterations grows memory that never gets released until the process dies. The third thing to check is connection handling, workers that never recycle the database connection or call close_old_connections() can accumulate connection-level state.

The actual debugging move is to run the process with a memory profiler attached, take snapshots at intervals, and diff them. That tells you concretely which objects are accumulating rather than guessing from symptoms, since "it's slow and grows over time" describes at least four different plausible root causes, and guessing wastes a full deploy cycle each time you're wrong.

How to prepare for a Django interview in 2026

Skip another slide deck on the MTV diagram and just build something with real seed data instead of 8 rows in a table. Spin up a small Django and DRF project, turn on Django Debug Toolbar, and watch the query count climb the moment you remove a select_related call you added earlier. Add a required field to a model that already has rows, run makemigrations, and read what it actually asks you instead of guessing. Roll a migration forward, then back with migrate app_name 0003, and watch what a reverse migration can and can't undo. None of that takes more than an evening, and it teaches the mental model faster than reading about it does.

Across mock interviews run through LastRoundAI tagged Python or backend, the select_related versus prefetch_related question trips up more candidates than migrations does, even though migrations get asked about more often in raw count. My guess is that candidates memorize "use select_related for foreign keys" as a rule without understanding why, and freeze the moment an interviewer asks about a reverse many-to-many relation instead of a straightforward forward one. We don't have a clean percentage to put on that pattern, only that it comes up often enough in review to flag here.

Defend these answers before an interviewer stress-tests them

Reading an answer is not the same as defending it once an interviewer changes one detail on you, swaps a ForeignKey for a ManyToMany, doubles the row count, asks what happens if the bulk import skips your signal. LastRoundAI's mock interview mode runs backend and Python rounds with follow-up questions that adapt to what you actually said instead of a fixed script, and the free plan includes 15 credits a month that reset monthly rather than piling up. Starter is $19/mo if fifteen sessions isn't enough runway some months.

If the harder part of the job hunt right now is finding enough backend or Python roles that actually mention Django, rather than passing the interview once you land one, Auto-Apply queues tailored applications for your review, 10 a month on the free plan, up to 400 a month on the Ultimate plan, and nothing goes out until you approve it.

Questions about either product go to contact@lastroundai.com. That's the only inbox we check.

How this list was built

Worth being straight about where these questions come from, because plenty of pages in this category are not. The set was compiled from a research pass across official documentation, vendor release notes, published engineering writing and public discussion of hiring processes, then cross-checked against the current version of each technology so nothing here describes behaviour that has since changed.

What that means in practice: these are the questions the material supports as reasonable and current for this role, not a transcript of any one company's loop. We have not sat in on your interview and we are not going to claim we have. Treat the list as well-sourced preparation rather than a leaked question bank, and expect your panel to phrase things their own way.

If you spot something out of date, tell us at contact@lastroundai.com and we will fix it.

Frequently asked questions

How long does it take to prepare for a Django interview?

If you already work with Django day to day, a focused week on the areas you avoid in practice is usually enough. Coming in cold, expect three to four weeks. The gap is rarely knowledge; it is being able to explain something you normally just use.

What Django topics come up most often?

Interviewers concentrate on the parts that cause production incidents rather than the parts that are pleasant to learn. Expect the fundamentals to be assumed and the follow-up questions to sit one layer below what a tutorial covers.

Do I need hands-on Django experience to pass?

It shows quickly either way. Textbook answers hold up until the interviewer asks what you did when it broke, and that is usually the question that separates candidates. A small real project you can discuss honestly beats a longer list of familiarity claims.

Is Django still worth learning in 2026?

For interview purposes the question is really whether the teams you are targeting use it, which is worth checking against their actual job postings rather than general popularity rankings. Where it is in use it tends to be deeply embedded and slow to replace.

Leave a Reply

Your email address will not be published. Required fields are marked *