Rohan Yeole - Homepage Rohan Yeole

How to Vet a Django Developer: 10 Questions That Reveal Real Skill

May 18, 20261 min read

The question that separates junior from senior Django developers in under five minutes: "Walk me through a migration you wrote that had to be backward-compatible with the production schema." A junior developer describes running makemigrations on a new field. A senior developer describes a specific situation — a table with millions of rows, a column type change that required a backfill, a migration that had to be reversible because the deployment could fail. If they cannot give a specific example with the constraints they faced, they have likely never maintained a Django application through a non-trivial schema change.

Here are ten questions at that same level, with what good answers sound like and why each question is diagnostic.

The Ten Questions

Why this reveals real experience: N+1 queries are the most common and most expensive performance problem in Django applications. Developers who have fixed a slow view caused by an N+1 query understand the ORM at a level that prevents the problem from recurring. Developers who haven't hit this in production often don't know the tools exist.

What a good answer sounds like: "select_related is for foreign key and one-to-one relationships — it generates a SQL JOIN, so you get the related row in the same database round-trip. prefetch_related is for many-to-many relationships and reverse foreign keys — it runs a second query with an IN clause and then joins the results in Python memory, not in the database. The SQL difference matters: select_related on Order.objects.select_related('customer') produces SELECT orders.*, customers.* FROM orders JOIN customers ON orders.customer_id = customers.id; prefetch_related on Author.objects.prefetch_related('books') produces two queries: SELECT * FROM authors followed by SELECT * FROM books WHERE author_id IN (1, 2, 3, ...). The wrong choice doesn't cause an error — it just causes extra queries you don't see unless you're checking. select_related is faster when you're accessing one related object per row; prefetch_related avoids the JOIN row multiplication problem when the related queryset is large."

Red flag answer: "I use select_related for better performance." (Correct in some cases, but shows they don't know why or when prefetch_related is the right call.)


2. "How would you add a non-nullable column to a large production table?"

Why this reveals real experience: Adding a NOT NULL column with a default to a table with millions of rows can lock the table in older PostgreSQL versions. Django's default migration behaviour — add column with default, then drop the default from the DB — does not always handle this safely. A developer who has done this in production has a specific pattern.

What a good answer sounds like: "Three migrations: first, add the column as nullable (null=True). Second, a data migration using RunPython that backfills the values in batches — 1,000–5,000 rows at a time, not all at once, because updating all rows in a single transaction takes an ACCESS EXCLUSIVE lock on the table for the duration, blocking every SELECT and INSERT while it runs. Third, alter the column to add the NOT NULL constraint. The reason PostgreSQL pre-12 was dangerous: adding NOT NULL DEFAULT required a full table rewrite — PostgreSQL needed to write the default value into every existing row's storage page. On a 50-million-row table that's 50 million row writes plus the page rewrites, all under that table lock. PostgreSQL 12+ handles constant defaults differently: it stores the default in the catalog and serves it without touching existing rows, making the operation instant. But I still split the migration regardless, because the deployment process is cleaner and a failed backfill is easier to recover from than a failed constraint addition."

Red flag answer: "I'd just add the field with default= and run migrate." (Technically works on small tables and on PostgreSQL 12+ with constant defaults; causes a table lock incident on large tables in older versions.)


Why this reveals real experience: This is the practical application of question 1. A developer who knows the tools but has never integrated them into their standard workflow will still produce N+1 queries in real code.

What a good answer sounds like: "I use select_related or prefetch_related in the queryset, depending on the relationship type. I also use Django Debug Toolbar during development — it shows the query count and execution time per request, so I catch N+1 issues before they reach code review. For DRF serialisers, the problem is subtler because the queryset is often defined in the view but the related access happens in the serialiser — I make sure the viewset's get_queryset method prefetches whatever the serialiser will access."


4. "Walk me through how you structure Django settings for multiple environments."

Why this reveals real experience: Django's default settings.py is a development settings file. Every production Django incident involving DEBUG = True, a hardcoded SECRET_KEY, or a wide ALLOWED_HOSTS started with someone copying the development settings file to production. Developers who have actually deployed Django applications in production have a deliberate settings structure.

What a good answer sounds like: "I keep a settings/ package with base.py (shared settings), local.py (development overrides), and production.py (production-specific settings). Secrets — SECRET_KEY, database credentials, API keys — come from environment variables, never from the settings file itself. I use django-environ or python-decouple for this. ALLOWED_HOSTS is set explicitly in production, DEBUG is always False in production, and I have a DJANGO_SETTINGS_MODULE environment variable that selects which settings file to use."

Red flag answer: "I have one settings.py and comment out the parts I don't need." Every production incident caused by DEBUG = True started exactly like this.


5. "How do you make a Celery task safe to retry?"

Why this reveals real experience: Celery workers crash. Redis connections drop. Servers restart mid-task. A task that is not idempotent — one that doesn't check "has this already happened?" before acting — will cause double-charges, duplicate emails, or corrupted state when it's retried. This is not a theoretical risk; it's a production incident waiting to happen.

What a good answer sounds like: "The task has to be idempotent. Before taking any action — sending an email, processing a payment, updating a record — check whether it's already been done. For things like sending a confirmation email, I store a flag on the record and check it first. For payments, I use the payment provider's idempotency key. The retry mechanism matters: Celery's default behavior is acks_early — the message is acknowledged (removed from the queue) when the worker receives the task, not when it completes it. If the worker process dies after receiving but before finishing, the task is gone. acks_late=True changes this so the acknowledgment is sent only after the task function returns — if the worker crashes, the message visibility timeout expires in Redis/RabbitMQ and the message becomes visible again for another worker to pick up. For transient failures (network timeouts, database connection drops), I use autoretry_for=(requests.Timeout, OperationalError) with retry_backoff=True so retries space out exponentially — first retry after 1s, then 2s, 4s, 8s — preventing a thundering herd of retries hammering a database that's already struggling."


6. "What's the difference between a Django model method and a manager method, and when do you use each?"

Why this reveals real experience: Developers who have maintained large Django projects know that business logic in views creates duplication and makes testing harder. Custom managers and querysets are the Django pattern for encapsulating query logic; model methods are for instance-level behaviour. Developers who don't use them write the same queryset logic in five different views.

What a good answer sounds like: "Model methods operate on a single instance — things like order.calculate_total() or user.is_premium(). Manager methods (or custom QuerySet methods) operate on the queryset level — things like Order.objects.pending() or User.objects.active_subscribers(). I prefer custom querysets over custom managers because querysets are chainable, so User.objects.active_subscribers().by_signup_date() works naturally. I use them whenever I find myself writing the same filter() conditions in more than one place."


7. "How would you debug a slow API endpoint in a Django REST Framework application?"

Why this reveals real experience: Performance debugging is a skill that only comes from having to do it in production. Developers who jump to "add a cache" without measuring first have not actually debugged a performance problem — they've guessed at one.

What a good answer sounds like: "Start by measuring, not guessing. Django Debug Toolbar shows query count and duration in development. In production, I add query logging or use Sentry's performance monitoring. Usually it's one of three things: too many queries (N+1 — solved with select_related/prefetch_related), a slow query (missing index or complex aggregation — solved by running EXPLAIN ANALYZE directly in psql), or a slow external API call (solved by moving to a Celery background task). When I see EXPLAIN ANALYZE output, I look at the Seq Scan vs Index Scan node type. A sequential scan reads every row in the table — O(n) — because PostgreSQL has no shortcut to find the matching rows. An index scan on a B-tree index traverses the tree from root to leaf in O(log n) reads; on a million-row table with 8KB pages, that's 3–4 page reads instead of ~10,000. If EXPLAIN ANALYZE shows Seq Scan on a large table for a query with a WHERE clause, that's a missing index. I add a cache only after I've identified the bottleneck, because caching a slow query hides the problem without fixing it."


8. "How do you handle file uploads securely in Django?"

Why this reveals real experience: File uploads are one of the most common sources of security vulnerabilities in Django applications — path traversal, malicious file execution, unrestricted file sizes. Developers who have built file upload features in production have a pattern for this.

What a good answer sounds like: "A few things: I never use the user-provided filename directly — I generate a UUID-based filename to prevent path traversal. I validate the file type server-side (by reading the magic bytes, not trusting the Content-Type header or file extension). I set a maximum file size limit. I store files in a separate storage backend (S3 or similar) rather than the application server, so they're not served by the same process that handles requests. And I make sure uploaded files are not executable by the web server."


Why this reveals real experience: Many-to-many relationships are where N+1 problems are hardest to see, because the additional queries are hidden behind Django's descriptor access. A developer who has diagnosed this specific problem understands it deeply.

What a good answer sounds like: "Each access to a ManyToManyField on an instance triggers a new query. If you're iterating over a queryset and accessing a M2M field on each object, you get one query per object in the loop — that's N+1. Django Debug Toolbar shows this as a very high query count for a simple-looking request. The fix is prefetch_related('the_m2m_field') on the queryset. In DRF, the prefetch needs to happen in the view's get_queryset, not in the serialiser, because the serialiser doesn't have access to the queryset-level optimisations."


10. "How do you write a test for a Django view that requires a logged-in user?"

Why this reveals real experience: Django's test client has specific patterns for authentication. Developers who skip tests, or who write tests that don't actually test the authentication behaviour, are producing a false sense of test coverage.

What a good answer sounds like: "Using Django's test client, I call self.client.force_login(user) with a user created by a factory or fixture. force_login is better than client.login for tests because it skips password hashing, making tests faster. I also write a test that does not log in and asserts a 302 redirect to the login page — that test is just as important as the logged-in test, because it verifies the view actually requires authentication and hasn't accidentally been left open. For permission-based views, I write three tests: anonymous user (302), logged-in user without permission (403), logged-in user with permission (200)."


Red Flags in Answers

"It depends" without elaboration. Every answer to every question can be prefaced with "it depends" — that is not an answer. A senior developer gives the answer for the most common case and then describes when the alternative is correct.

"I've never had to do that." For any of the above questions from a developer claiming 3+ years of Django experience, this is a sign they have worked on simple projects or have not owned the code they worked on.

"I usually Google it." Googling is fine for syntax. It is not fine for architectural decisions — select_related vs prefetch_related, migration structure, Celery idempotency. These patterns should be internalized, not looked up each time.

Correct answer but can't explain why. Some developers memorise answers to common interview questions. Ask "why" after every answer. If the explanation breaks down, the knowledge is surface-level.

What a Good Django Developer Won't Say

They won't say their code has never had a bug in production — they'll tell you about one and how they fixed it. They won't say migrations are just running makemigrations and migrate — they'll have opinions about squashing, data migrations, and zero-downtime deployments. They won't say they've never had a slow query — they'll describe how they found and fixed it.

Experience is visible in the specific problems a developer has had to solve. A developer who has only worked on small, low-traffic applications has not been forced to learn the patterns that prevent large-scale failures. That is not disqualifying — but it is something to know before you hand them a production system.

Frequently Asked Questions

How long should a Django technical interview take? 30–45 minutes covers 5–6 questions at the depth above. Going longer rarely provides more signal — you either know the candidate has production experience by question 4 or you don't. Reserve the last 10 minutes for the candidate to ask questions; how they ask reveals as much as how they answer.

Should I give a paid trial project instead of an interview? Both. The interview screens for knowledge; the trial project reveals working style, communication, and whether the code matches the interview performance. A developer who interviews well and delivers a messy trial project is telling you something important.

What if the developer answers correctly but I can't verify it? Ask for a GitHub repository and look for evidence. Check for migrations in their public projects. Look at their commit history on any Django project — do they batch changes or commit everything at once? Are there tests? Is there a requirements.txt that pins versions? Real Django experience leaves evidence.

Chat with me on WhatsApp