Prepare for Django developer interviews with questions grouped by experience level, from the ORM to production security and scaling.
Junior (0-2 years)
Django is a high-level Python web framework that comes with most of what a web application needs already built in. an ORM, an admin interface, user authentication, form handling. The pitch behind it has always been getting from an idea to a working application fast, without hunting down and wiring together a dozen separate libraries yourself.
MVT stands for Model, View, Template. The Model handles data, the Template handles what the user sees, and the View sits in between, pulling data from the Model and passing it to the Template. It maps closely to MVC, but Django's View plays the role a traditional Controller would, and the Template plays the role of the View in classic MVC naming. The concepts are basically the same, just labeled differently.
A new Django project folder containing manage.py, a settings module, a root urls.py, and a WSGI (or ASGI) entry point for deployment. This is the skeleton every Django site is built on top of. Individual apps get added inside this project structure afterward.
A project is the whole website, the overall configuration and settings tying everything together. An app is a self-contained module handling one specific piece of functionality, like a blog or a user profile system. One project can, and usually does, contain several apps, and a well-designed app can even be reused across different projects.
It's the central configuration file for a Django project. database connection details, installed apps, middleware, static file locations, secret keys. Nearly every major configuration decision in a Django project traces back to a setting defined here.
It's a command-line utility for interacting with a Django project. running the development server, creating migrations, opening a Python shell preloaded with your project's models. python manage.py runserver is usually the very first command anyone types when starting a new Django project.
A Python class that maps to a database table. Each attribute on the class becomes a column, and Django's ORM translates operations on that class, creating, querying, updating, into the actual SQL needed to talk to the database. You write Python, and Django handles the SQL underneath.
Migrations are Django's way of tracking and applying changes to your database schema over time. Every time you add a field or a new model, you generate a migration file, and running it applies the corresponding SQL to the actual database. Without migrations, keeping a database schema in sync with your code across a team, or across dev, staging, and production, would be a manual and error-prone mess.
makemigrations looks at changes in your models and generates migration files describing them, but doesn't touch the database at all. migrate actually applies those migration files, running the real SQL against the database. Forgetting to run migrate after makemigrations is a classic beginner mistake, since the code looks right but the database hasn't caught up yet.
CharField is meant for shorter text and requires a max_length. TextField has no length limit and is meant for larger blocks of content, like a blog post body. Using CharField for something that could realistically run long, or TextField for something that's always short, like a name, works technically but goes against how each field is meant to be used.
Add a ForeignKey field pointing to the related model, specifying an on_delete behavior, like models.CASCADE, which controls what happens to this row if the referenced row gets deleted. author = models.ForeignKey(Author, on_delete=models.CASCADE) is a typical example, linking a Book model to an Author model.
CASCADE deletes the referencing row automatically when the referenced row is deleted, so deleting an Author would delete all their Books too. SET_NULL instead sets the foreign key field to null on the referencing row, keeping it around but disconnected, which requires that field to allow null values in the first place.
Model.objects.all() returns every row in the table as a queryset. Model.objects.filter(field=value) returns only the rows matching that condition. Both return a queryset, a lazy, chainable object that doesn't actually hit the database until you iterate over it, slice it, or otherwise force it to evaluate.
A Python function (or class) that takes a web request and returns a web response. It's where the actual logic of handling a request lives, pulling data from models, running calculations, and deciding what template or response to send back.
A function-based view is a plain Python function taking a request and returning a response, straightforward and explicit. A class-based view organizes that same logic into a class, with separate methods for different HTTP verbs (get, post) and built-in generic views for common patterns like listing objects or handling a form. Function-based views tend to be clearer for simple, one-off logic, while class-based views cut down on repetition once you're handling the same CRUD pattern across many models.
urls.py maps URL patterns to views using a list of path() entries. When a request comes in, Django checks each pattern in order and calls the view attached to the first one that matches. Each app usually has its own urls.py, included into the project's root urls.py, keeping routing organized rather than one giant file.
path() uses simple, readable converters like <int:id> for common cases, string, integer, UUID. re_path() accepts full regular expressions for anything more complex that path() converters can't express directly. Most modern Django routing uses path(), reaching for re_path() only when a pattern genuinely needs regex-level flexibility.
Capture it in the URL pattern, like path('books/<int:book_id>/', views.book_detail), and Django passes it as an argument to the view function automatically. def book_detail(request, book_id): then has direct access to that captured value without parsing the URL manually.
HttpResponse returns a plain response with whatever content you give it directly, useful for a quick string or JSON payload. render() combines a template with a context dictionary, processes it, and wraps the result in an HttpResponse for you, which is what you'd use for any view returning an actual HTML page built from a template.
A simple templating syntax embedded in HTML files, using {{ variable }} to output a value and {% tag %} for logic like loops and conditionals. It's intentionally limited compared to writing raw Python inside a template, which keeps presentation logic separate from business logic on purpose.
Build a context dictionary in the view and pass it to render(), like render(request, 'book_list.html', {'books': books}). Inside the template, {{ books }} or a loop like {% for book in books %} then has access to that data.
A base template defines the overall page structure, header, navigation, footer, with named blocks marking sections that child templates can override. A child template extends the base with {% extends 'base.html' %} and fills in only the blocks it needs to change. This avoids repeating the same header and footer HTML across every single page.
A Form class defines fields similarly to a Model, but its job is validating and processing user input rather than mapping to a database table. It handles rendering the HTML input elements, validating submitted data against each field's rules, and giving you cleaned, type-correct data once validation passes.
A plain Form is built from scratch, field by field, independent of any model. A ModelForm generates its fields automatically from a specified model, and its save() method can create or update a model instance directly. If a form's fields map closely to an existing model, ModelForm saves a lot of repetitive field definitions.
Override the clean() method (for validation involving multiple fields together) or a specific clean_fieldname() method (for validation on just that one field) on the Form or ModelForm subclass. Raising a ValidationError inside either surfaces an error back to the user tied to the relevant field.
A built-in, auto-generated interface for managing your data, letting staff users create, edit, and delete records through a web UI without you writing any of that CRUD logic by hand. Registering a model with admin.site.register(YourModel) in admin.py is enough to get a basic version of it working.
Create a subclass of admin.ModelAdmin, set attributes like list_display (which columns show in the list view), search_fields, and list_filter, then register the model with that custom admin class instead of the default. This turns a bare, generic admin page into something actually usable for whoever's managing the data day to day.
STATIC_URL is the URL prefix used to reference static files in templates, like /static/. STATIC_ROOT is the actual filesystem directory where the collectstatic command gathers every app's static files into one place for deployment. STATIC_URL matters during development, STATIC_ROOT matters when you're preparing to serve the site in production.
It copies static files (CSS, JavaScript, images) from each individual app's static folder into one central directory, STATIC_ROOT, so a production web server can serve them efficiently and directly, rather than Django serving them itself, which it isn't really built to do at scale.
Media files are configured separately, with MEDIA_URL and MEDIA_ROOT, since they're user-generated content rather than part of the application's own codebase. In production, they're commonly stored in something like AWS S3 rather than on the application server's local disk, since local storage doesn't survive a server being replaced or scaled horizontally.
User registration, login and logout views, password hashing and reset flows, and permission checks, all without you writing that logic from scratch. It's genuinely one of the strongest reasons to reach for Django over a more minimal framework when a project needs real user accounts.
Add the @login_required decorator above a function-based view, or use the LoginRequiredMixin for a class-based view. Either one redirects an unauthenticated user to the login page automatically instead of letting the view execute.
Authentication confirms who the user actually is, handled by the login process itself. Authorization decides what that now-identified user is allowed to do, handled through Django's permissions and groups system, or a custom check inside the view.
Hashed, never in plain text, using a configurable hashing algorithm (PBKDF2 by default, with support for others like Argon2). Even someone with direct database access can't read an actual password, only its hash, which is exactly the point.
A Group is a named collection of permissions that can be assigned to many users at once, instead of assigning the same set of individual permissions to each user one by one. Adding a user to an Editor group, for example, instantly grants them every permission tied to that group.
Mid-Level (3-6 years)
A QuerySet represents a database query, but it doesn't actually run against the database until you do something that forces evaluation, iterating over it, slicing it, calling len() on it. This laziness lets you chain filters together, Model.objects.filter(a=1).filter(b=2).exclude(c=3), and Django combines them into one efficient SQL query instead of running three separate ones.
It happens when fetching a list of objects triggers one query for the list, plus one additional query per object to access a related field, N+1 queries total instead of one efficient query. select_related() fixes this for foreign key and one-to-one relationships by using a SQL join. prefetch_related() fixes it for many-to-many and reverse foreign key relationships using a separate, batched query instead of a join.
select_related() follows a foreign key or one-to-one relationship using a SQL JOIN, pulling the related data in the same query. prefetch_related() handles many-to-many and reverse foreign key relationships, which can't be joined the same way, by running a second query and joining the results in Python instead. Picking the wrong one either fails outright or just doesn't give you the performance benefit you were expecting.
Use the aggregate() method with a function from django.db.models, like Book.objects.aggregate(Avg('price')) to get the average price across all books. For per-group aggregation, like counting books per author, annotate() applied to a queryset attaches that computed value to each object in the result instead of collapsing everything into one number.
values() returns each row as a dictionary, with field names as keys. values_list() returns each row as a plain tuple instead. Passing flat=True to values_list() when selecting a single field returns a flat list of just those values, which is a common pattern for pulling out a simple list of IDs or names without any extra structure around them.
Django ships with pre-built class-based views for common patterns, ListView, DetailView, CreateView, UpdateView, DeleteView, that handle the standard CRUD flow with minimal code. Instead of writing near-identical function-based views for every model that needs basic list-and-detail pages, you subclass the generic view and just point it at your model.
Override the get_queryset() method on the view, returning whatever filtered or ordered queryset you actually want, instead of the default which returns all objects for the specified model. This is the standard hook for adding filtering logic to an otherwise generic view.
A mixin is a class providing a specific piece of reusable behavior meant to be combined with a view through multiple inheritance, rather than used on its own. LoginRequiredMixin is a common example, added alongside a generic view class to require authentication without duplicating that check across every view that needs it.
CreateView already handles the standard flow: rendering the form on a GET request and validating plus saving it on a POST request. You'd typically customize behavior by overriding form_valid() (to add logic that runs after successful validation, before redirecting) rather than reimplementing the whole request-handling flow yourself.
Middleware are components that process a request on its way in and a response on its way out, sitting between Django's URL routing and the actual view logic. They're used for cross-cutting concerns like authentication, session handling, and security headers, applied globally rather than repeated in every view.
Define a class with an __init__ method (receiving get_response) and a __call__ method that runs your logic before calling get_response(request), then runs any logic you need after, before returning the response. Registering it in the MIDDLEWARE list in settings.py adds it to the chain, and order in that list matters, since middleware runs in sequence.
Signals let one part of an application notify other parts when something happens, like a model being saved, without those parts needing to import or directly reference each other. post_save is a common one, letting you run logic (sending a welcome email, creating a related profile object) automatically whenever a specific model is saved.
Signals can make an application's control flow genuinely hard to follow, since saving one model might silently trigger several unrelated pieces of logic scattered across different files, none of which are visible just by reading the code that does the saving. For logic that's tightly coupled to a specific action, calling a method directly is often clearer than relying on a signal to connect the pieces implicitly.
pre_save fires just before a model instance is actually saved to the database, useful for modifying data before it's written. post_save fires just after the save completes, useful for triggering follow-up actions that depend on the object already having a database ID.
DRF is a toolkit built on top of Django specifically for building web APIs, handling serialization, authentication, and browsable API documentation out of the box. Building the equivalent by hand with plain views means reimplementing a lot of well-established patterns DRF already provides, consistently, across a whole team.
A serializer converts complex data, like a Django model instance, into a format that's easy to render as JSON, and does the reverse too, parsing incoming JSON into validated Python data. It plays a similar role to a Django Form, but is built specifically for API input and output rather than HTML forms.
A plain Serializer is defined field by field, independent of any model, similar to a plain Form. A ModelSerializer generates its fields automatically from a specified model, the same relationship ModelForm has to Form, saving you from redeclaring fields DRF can already infer directly from the model.
A ViewSet groups related view logic, list, create, retrieve, update, delete, into a single class instead of separate view classes for each action. A router then automatically generates the URL patterns for all of a ViewSet's actions, which is why a fully working CRUD API in DRF can take only a few lines once the serializer and model are already in place.
DRF ships with TokenAuthentication built in, issuing a token per user that gets sent in an Authorization header on subsequent requests. For more involved needs, JWT-based authentication through a package like djangorestframework-simplejwt is a common upgrade, since it supports token expiry and refresh without needing a database lookup on every single request.
Django's own TestCase class, built on Python's unittest, along with a test client that simulates requests against your views without running a real server. It also provides fixtures and a separate test database that gets created and destroyed automatically, so tests never touch real production data.
Use the Django test client to simulate a request, self.client.get('/books/'), then assert on the response, its status code, the context data it returned, or specific content in the rendered HTML. This tests the view's actual behavior without needing a browser or a running server.
TestCase wraps each test in a database transaction that gets rolled back at the end, which is fast but means certain database behaviors, like transaction commit signals, won't actually fire during the test. TransactionTestCase truly commits and resets the database between tests instead, which is slower but necessary when you're specifically testing something transaction-related.
Create an instance of the model directly in the test (or via a fixture), call the method or access the property, and assert the result matches what you expect. Model logic like this is usually the easiest thing in a Django app to unit test, since it doesn't depend on requests, views, or templates at all.
Senior (6-8 years)
django-debug-toolbar is usually the first stop, showing exactly how many database queries a view triggered and how long each one took. More often than not, a slow view traces back to an N+1 query problem or a missing index rather than the Python logic itself being genuinely slow.
Django supports several caching backends: in-memory (fine for development, not for multi-process production), file-based, and Redis or Memcached for production use. Caching can be applied at different levels, the whole site, a specific view with the cache_page decorator, or a specific piece of a template with the {% cache %} template tag, depending on how much of the page actually needs to be cached.
Page-level caching (cache_page) caches an entire rendered response, simple to apply but all-or-nothing for that view. The low-level cache API, cache.set() and cache.get(), lets you cache specific pieces of data, like a single expensive query result, and reuse it across multiple views, which is more flexible when only part of a page's data is actually expensive to compute.
Add db_index=True to a model field, or define a Meta.indexes list for indexes spanning multiple columns, then generate and run a migration to actually apply it to the database. Indexes speed up queries that filter or order by that field, at the cost of slightly slower writes and additional storage.
Connection pooling reuses a set of open database connections instead of opening and closing a new one for every single request. Django's default behavior opens a new connection per request unless CONN_MAX_AGE is configured to keep connections alive between requests, and for true pooling at scale, many production setups add a dedicated pooler like PgBouncer in front of PostgreSQL.
list_select_related and list_prefetch_related on the ModelAdmin class apply the same select_related and prefetch_related optimizations to the admin's own queryset, which by default doesn't optimize related-field lookups at all. Without this, an admin list page showing a foreign key's related field can trigger the exact same N+1 problem a regular view would.
Denormalization trades some data duplication for read performance, storing a computed or copied value directly rather than joining to calculate it every time. It's worth it for a value that's read constantly and expensive to compute on the fly, like a running total, as long as you also handle keeping that duplicated value in sync when the source data changes. For most fields, staying normalized keeps the data model simpler and avoids that sync problem entirely.
A view that takes several seconds to respond because it's sending an email or processing a file makes the user wait for something they don't need to wait for. Celery offloads that work to a background worker process, letting the view return an immediate response while the actual task runs separately, sometimes finishing seconds or minutes later.
A broker, commonly Redis or RabbitMQ, is what queues up tasks and hands them off to available Celery workers. Celery itself doesn't store or manage the task queue directly. It relies on the broker for that, which is also what lets you run multiple worker processes, even on different machines, all pulling from the same queue.
Celery Beat is the scheduler component, running alongside your regular Celery workers, that triggers tasks on a fixed schedule (like a cron expression or a simple interval) by pushing them onto the queue at the right time. It's how you'd implement something like a nightly report generation job without a separate cron setup outside of Django.
By default, a failed task just fails silently unless you're actively monitoring it. Celery supports automatic retries with a configurable backoff, and pairing that with a monitoring tool like Flower or your own logging gives visibility into failures instead of them going unnoticed until someone asks why an email never arrived.
If it's fast and the user genuinely needs the result before the page can render, like a database lookup for the page's own content, it stays synchronous. If it's slow, involves an external service that might be unreliable, or the user doesn't need to wait for the outcome, sending an email, generating a report, it's a strong candidate for a background task instead.
Flower gives a real-time web dashboard of active, pending, and failed tasks, which covers a lot of day-to-day visibility. For production alerting, I'd also want queue length tracked over time, since a queue that keeps growing means workers aren't keeping up, a leading indicator worth catching well before tasks start timing out or piling up for hours.
Lead (8-10 years)
Django's CsrfViewMiddleware requires a valid, per-session CSRF token on any state-changing request (POST, PUT, DELETE), embedded automatically in forms rendered with the {% csrf_token %} template tag. A request missing or presenting the wrong token gets rejected before it ever reaches the view logic.
The ORM parameterizes queries automatically, so values are never directly concatenated into raw SQL strings. The risk reappears specifically when you drop down to raw SQL yourself, using .raw() or a direct cursor, without using parameterized placeholders there too. Sticking with the ORM, or parameterizing manual queries carefully, is what actually closes this gap.
Django's template engine automatically escapes variables by default, so a user-submitted string containing HTML or a script tag gets rendered as harmless text instead of executed. Explicitly marking content as safe with the |safe filter or mark_safe() disables that protection for that specific piece of content, so it should only be used when you're certain the content is genuinely trustworthy.
Clickjacking tricks a user into clicking something on your site by hiding it inside an invisible iframe on an attacker's page. XFrameOptionsMiddleware sets a response header telling browsers whether your site can be embedded in a frame at all, blocking that attack by default unless a view explicitly opts out.
Never commit them to source control. Load them from environment variables (often via a package like django-environ) at runtime instead, keeping actual secrets out of settings.py entirely. In production, those environment variables usually come from a secrets manager or the hosting platform's own configuration, not a checked-in file.
DEBUG=True shows detailed error pages, including stack traces, local variable values, and settings, which is invaluable during development but a serious information leak if left on in production. Running with DEBUG=False in production is one of the most basic and most frequently forgotten security requirements in a Django deployment.
It's a list of host or domain names the Django site is allowed to serve, and requests with a Host header not matching anything on that list get rejected outright. It protects against HTTP Host header attacks, where a malicious request tries to trick the application into generating links or emails pointing at an attacker-controlled domain instead of the real one.
Horizontally, running multiple application server instances behind a load balancer, since a single Django process only handles so much concurrency on its own. Beyond that, moving sessions and caching to a shared store like Redis (rather than per-instance memory) keeps behavior consistent across instances, and database read replicas take pressure off a single primary database as read traffic grows.
A read replica is a copy of the primary database kept in sync, used to handle read queries so the primary is freed up mostly for writes. Django supports this through database routers, custom classes that decide, per query, which database alias to use, letting you send reads to a replica and writes to the primary automatically.
Serve them from a shared location rather than each server's own local disk, since local storage isn't shared across instances and doesn't survive an instance being replaced. A CDN in front of static files, and object storage like AWS S3 for user-uploaded media, is the standard pattern once you're running more than one application server.
It happens when the number of open database connections across all your application instances exceeds what the database can actually handle, causing new connections to fail or queue up. It becomes a real risk once you're running many application instances or workers each opening their own connections, which is exactly why a connection pooler like PgBouncer becomes necessary at that scale rather than optional.
The three common approaches are a shared database with a tenant ID column on every table, separate schemas within one database per tenant, or fully separate databases per tenant. Shared database with a tenant column is simplest to build and scales well for a large number of small tenants. Separate databases give the strongest isolation but add real operational overhead once you have more than a handful of tenants to manage.
Run the new code alongside the old briefly during rollout, and write migrations that are backward-compatible with the currently-running old code, adding a new column as nullable first rather than a single migration that renames or drops something the old code still depends on. A migration that isn't backward-compatible with whatever's still running during the rollout window is one of the most common causes of a deployment outage.
Vertical scaling means a bigger server. Horizontal scaling means more servers running the same application behind a load balancer. Most production Django deployments favor horizontal scaling once they outgrow a single machine, since it also gives redundancy, one instance going down doesn't take the whole site with it, which a single larger server can't offer on its own.
Staff (10+ years)
I'd weigh it by ownership and deployment independence rather than defaulting to microservices as automatically better. If a separate team owns it, it needs to scale or deploy independently, or it has meaningfully different reliability requirements, that argues for a separate service. Otherwise, a well-organized Django app inside the existing project usually ships faster and costs less to operate.
Run it incrementally. Get the codebase passing under both the old and new version where deprecation warnings allow it, lean on existing test coverage to catch regressions early, and roll the upgrade out in stages rather than a single big-bang cutover. A full stop-everything migration is rarely something the business will actually tolerate.
I look at whether it's solving the real problem or just its symptom, whether the data model's grain and relationships are clearly thought through, and whether it's consistent with patterns already established elsewhere in the codebase. An inconsistent one-off pattern becomes a maintenance burden the whole team inherits later, so I'd rather ask pointed questions that surface the team's own blind spots than hand them a prescribed answer.
Automate what can be automated: linting, formatting, and a shared set of custom checks (like flagging a queryset that's clearly going to N+1) enforced in CI, so standards aren't a matter of opinion in code review. For architectural conventions that resist automation, I'd document the handful of decisions that actually matter, with the reasoning behind them, rather than a long style guide nobody reads end to end.
I'd look at where the actual pain is coming from. slow deploys because unrelated teams keep colliding in the same codebase, a specific component needing to scale independently, or genuine team-ownership conflicts, rather than assuming a monolith is inherently a problem just because it's gotten large. Splitting it apart is worth the real cost of that migration only once a monolith is actively causing one of those specific, named problems.
First rule out environmental differences, connection pool sizing, data volume, and whether staging traffic actually resembles production concurrency. Then I'd want query-level monitoring (like django-silk or APM tooling) in production itself, since 'intermittent under load' is very often a query that's fast on a small staging dataset but slow once the production table has millions of rows, something staging alone would never surface.
A basic health endpoint checking the process is alive is the floor, not the ceiling. I'd extend it with checks for what actually matters, database connectivity, cache availability, and a Celery worker heartbeat if background tasks are part of the critical path, beyond simply whether the web process itself is responding.
Treat changes to that model as a contract. Adding a nullable field is generally safe. Renaming or removing an existing field needs a deprecation period, adding the new field first, migrating usage over, and only removing the old one once nothing references it anymore, rather than a single migration that breaks anything still expecting the old shape.
Mitigation before root-causing. Roll back a recent deploy, or disable a specific feature flag if that stops the bleeding, even before fully understanding why it broke. I'd also make sure one person is clearly driving the incident and communicating status, since incidents usually drag on longer because of diffuse ownership, not a lack of technical skill in the room.
Start from actual load testing at realistic traffic shapes rather than linear extrapolation, since the real bottleneck, the database, a slow third-party API call, a specific unoptimized view, rarely scales linearly and often shows up well before the target load. I'd identify the actual constraint first, then decide whether the fix is more instances, caching, a database read replica, or fixing the specific slow query causing the problem.
This is a judgment question interviewers use to see how you reason under uncertainty, not to test a specific fact. A strong answer names the actual constraint that forced the decision, the realistic options that were genuinely on the table, why you picked one knowing it wasn't guaranteed to be right, and what you'd do differently with what you know now.
I'd pair on one of their actual views, pull up django-debug-toolbar together, and show them concretely how many queries it's actually running and why, rather than just telling them to use select_related in the abstract. Watching their own view go from thirty queries down to two tends to change how they write the next queryset far more than a general rule ever does.
I wouldn't push a wholesale rewrite. I'd let new code follow whichever pattern actually fits the specific case better, since both are legitimate, and demonstrate the difference concretely on a real example rather than arguing the merits abstractly. Forcing a consistent style across old code that already works rarely justifies the churn on its own.
I'd bring the actual query patterns and performance data behind my position, real slow query logs, expected read-versus-write ratios, rather than a general preference for one modeling approach over another. Most disagreements like this resolve once both sides are looking at the same concrete numbers instead of arguing from differing assumptions about how the data actually gets used.
I'd translate the technical need into numbers leadership already tracks: a specific incident that's already happened because of a database bottleneck, hours of engineering time spent firefighting the same class of problem repeatedly, or a growth projection showing the current setup running out of headroom by a specific date. Framed as risk and cost avoidance rather than a technical upgrade for its own sake, it competes far better for budget.




