Login to manage your account

Please enter a valid email address.
Forgot Password?
Please enter a valid password.
OR

Don't have an account yet? Sign up

Django interviews focus on the framework's conventions and the decisions that matter in production. Expect questions on the request lifecycle and middleware ordering, the ORM and the N+1 problem, migrations on large tables, forms and validation, the authentication and permission system, and caching. Security is a recurring theme, particularly what Django protects you from by default and what remains your responsibility. The questions below cover both the framework mechanics and the operational judgement interviewers look for.

Behavioural Questions

1. Tell me about a Django project you have worked on. What was your role and what was the hardest part?

Note: Interviewers are trying to work out whether you have shipped Django or only followed a tutorial. Details about deployment, migrations, and data volume are what separate the two.

Structure it in four parts:

  • What the application did, and its scale. Number of users, size of the largest table, request volume at peak. One line is enough, but the numbers matter.
  • Your actual scope. Whether you owned models and migrations, the API layer, background tasks with Celery, or the deployment. Be precise — inflating this is easy to catch with a follow-up question.
  • The genuinely hard problem. Strong candidates: a migration on a large table that could not lock it, N+1 queries discovered under load, a permissions model that grew beyond what Django's built-in system handled, or moving slow work into a task queue.
  • The outcome and what you would redo.

2. How do you decide between using a third-party Django package and writing the functionality yourself?

Treat it as a cost decision. A Django package is not free — it constrains your upgrade path, and an abandoned package can hold you on an old Django version for years.

Use a package when: the problem is well-defined, widely solved, and dangerous to get wrong. Django REST Framework, django-allauth for social login, Celery for task queues, and django-storages for object storage all qualify — you would not want to reimplement any of them.

Write it yourself when: you need a fraction of what it does, or when the package imposes an abstraction that fights your models.

What to check before adopting:

  • Does it declare support for your Django and Python versions, and how quickly did it support the last release?
  • How many open issues, and when was the last commit?
  • Does it add migrations or middleware to your project — those are hard to back out of.

Note: Django's own contrib apps should be your first stop. Candidates regularly reach for a package for something the framework already does.

3. Describe a time you had to deploy a change to a live Django application. How did you manage the risk?

The interesting part of this question is migrations, so lead with them.

  • Separate schema changes from code changes. The safe pattern is additive first: add the new nullable column, deploy code that writes to both old and new, backfill, then deploy code that reads from the new one, and only then drop the old column. Each step is independently reversible.
  • Know what locks. Adding a nullable column is cheap on modern PostgreSQL; adding one with a default, or adding an index without CONCURRENTLY, can lock a large table for minutes.
  • Have a rollback plan for both code and schema. A migration that cannot be reversed needs to be flagged before it runs, not after.

The rest of the risk management: deploying behind a feature flag, running python manage.py check --deploy, taking a database backup immediately before, and watching error rates for the first fifteen minutes rather than walking away.

Note: If you have ever had to roll back a migration in production, tell that story. It is far more convincing than describing an ideal process.

4. How do you approach testing in a Django project, and how much testing is enough?

Give a hierarchy rather than a coverage number, because coverage percentage is a weak proxy and interviewers know it.

  • Model and business logic tests are the highest value per line. They are fast, they do not need the HTTP layer, and they catch the bugs that corrupt data.
  • View and API tests using Django's test client or DRF's APIClient, focused on permissions and status codes. Authorisation bugs are the ones that actually hurt, so test that the wrong user gets a 403.
  • Integration tests for the few flows that must never break — signup, checkout, payment.
  • End-to-end browser tests sparingly. They are slow and flaky, so reserve them for a handful of critical journeys.

On "how much": enough that you can deploy on a Friday. Concretely, that means every bug fix arrives with a regression test, anything involving money or permissions is covered, and the suite runs fast enough that people actually run it.

Note: Mention factory_boy over fixtures, and pytest-django if you use it. Both signal practical experience.

5. How do you handle working on a legacy Django codebase with old patterns or an outdated version?

Show that you can upgrade incrementally rather than proposing a rewrite, since the rewrite answer is what most interviewers are screening against.

  • Get the tests and the pipeline working first. You cannot safely change anything without a way to know you broke it. If there are no tests, add characterisation tests around the areas you must touch.
  • Upgrade one minor version at a time. Run with -W error::DeprecationWarning to surface what the next version will remove, fix those, then step forward. Jumping several versions at once turns a series of small problems into one unsolvable one.
  • Prioritise by risk, not by ugliness. An unsupported Django version with open security advisories is urgent; a fat views file is not.
  • Improve code you are already touching. Refactoring in the course of feature work is far easier to justify than a standalone cleanup ticket.

Note: Be explicit that you would document the upgrade path and its blockers before starting. Legacy work fails most often through lack of a plan, not lack of skill.

6. Tell me about a time a Django page or API became slow in production. How did you find and fix the cause?

Performance stories are common in Django interviews because they reveal whether you diagnose with data or guess. Structure the answer as symptom, investigation, fix and prevention.

  • Symptom — be concrete. For example, the order history API went from 300 ms to 6 seconds for large customers after a new ‘items’ field was added, and the support team started receiving complaints.
  • Investigation — show a method, not luck:
    • Checked APM or logs (Sentry, New Relic, or Django’s own request timing) to confirm which endpoint and which users were affected.
    • Reproduced locally with production-like data and used Django Debug Toolbar or django-silk to count queries. One request was running over 400 queries, a classic N+1 from a serializer touching a related field per row.
    • Ran EXPLAIN ANALYZE on the slowest query and found a sequential scan on a filter column with no index.
  • Fix — targeted changes: select_related and prefetch_related in the view’s get_queryset, a composite index added through a migration (created concurrently on PostgreSQL to avoid locking), and pagination on an endpoint that was returning every record.
  • Result — quantify it: 400 queries down to 4, and p95 latency back under 250 ms.
  • Prevention — added assertNumQueries tests for key endpoints, so a future change that reintroduces N+1 fails in CI, plus a slow-query alert.

What interviewers listen for: that you measured before and after, fixed the root cause rather than adding a cache to hide it, and made the regression detectable.

Note: Caching is a legitimate fix, but mention it after query optimisation. Candidates who jump straight to Redis often cache a problem that a single prefetch would have solved.

7. Describe a time you designed a Django data model and later had to change it significantly. What did you learn?

Every long-lived Django project reshapes its models, so the interviewer wants to see how you handled the change safely and what judgement you gained, not a claim that you got it right first time.

  • Original design — explain the decision and why it seemed reasonable. For example, storing a customer’s address as fields on the Order model because each customer had one address at launch.
  • What changed — the business requirement that broke it: customers needed multiple saved addresses, and invoices had to keep the address that was valid at the time of purchase.
  • How you migrated — this is the heart of the answer:
    1. Added a new Address model and a nullable foreign key, a safe, additive schema migration.
    2. Wrote a data migration with RunPython to create address rows from the old fields in batches.
    3. Deployed code that wrote to both old and new structures, then switched reads to the new one.
    4. Removed the old columns in a later release, once nothing referenced them.
  • Result — zero downtime, no data loss, and a model that supported the new feature cleanly.
  • Lessons — pick two or three genuine ones:
    • Separate data that changes over time (the current address) from historical snapshots (the invoice address).
    • Prefer additive, reversible migrations and split schema and data changes.
    • Ask ‘what happens when there are many of these?’ during design reviews.

Tone: own the original decision without being defensive. A model that fitted the requirements at the time is not a mistake; failing to migrate it carefully would have been.

Note: The expand, migrate, contract sequence is the key phrase here. It shows you understand how to change a schema while old and new code run side by side during a deploy.

8. How would you respond if a security vulnerability were reported in a Django application you maintain?

This question tests composure, prioritisation and communication under pressure. A strong answer follows a clear incident sequence and shows you would not stay silent or panic.

  1. Acknowledge and triage quickly — thank the reporter, confirm receipt, and reproduce the issue in a safe environment. Assess severity: what data or actions are exposed, whether it is being exploited, and how many users are affected. An IDOR that lets any logged-in user download another user’s invoices by changing an ID is critical.
  2. Escalate appropriately — inform your lead and, where relevant, the security or data-protection owner. Under India’s DPDP Act and CERT-In rules, some incidents carry reporting obligations and deadlines, so involve the right people early.
  3. Contain — if it is actively exploitable, apply a stopgap immediately: disable the endpoint, add a WAF rule, or rotate leaked keys and SECRET_KEY.
  4. Fix the root cause — for the IDOR example, scope the queryset to the current user in get_queryset, and search the codebase for the same pattern elsewhere. Add a regression test proving one user cannot access another’s object.
  5. Investigate impact — use access logs to determine whether anyone exploited it and which records were touched.
  6. Communicate and learn — inform affected users if required, credit the reporter, and hold a blameless post-mortem with concrete follow-ups: a permission checklist in code review, upgrading Django if the issue was in an outdated version, and manage.py check --deploy in CI.

What to emphasise: speed of containment, honesty with stakeholders, and fixing the class of bug rather than one instance.

Note: Mention that you subscribe to Django’s security release announcements and apply patch releases promptly. Many real incidents come from running a Django version that is no longer supported.

9. Tell me about a time you built an API with Django REST Framework for a frontend or mobile team. How did you agree the contract?

Interviewers ask this to see whether you design APIs collaboratively, or build endpoints in isolation and leave consumers to cope. Focus on the process of agreeing and then keeping the contract.

  • Context — for example, a React web app and an Android app both consuming a new bookings API, built by separate teams working in parallel.
  • Agreeing the contract up front:
    • Wrote an OpenAPI specification first, or generated one early with drf-spectacular, and reviewed it with both teams before implementing.
    • Agreed conventions: URL naming, pagination style, date formats in ISO 8601 with time zones, money as strings or integer paise, and a consistent error format with field-level validation messages.
    • Shared example responses and a mock server so the frontend could build against the contract before the backend was ready.
  • Implementation choices — ModelSerializers with explicit field lists, never __all__, so internal fields could not leak; separate read and write serializers where shapes differed; and query optimisation so list endpoints did not slow down as data grew.
  • Keeping the contract stable — additive changes only within a version, deprecation notices before removing fields, URL or header versioning for breaking changes, and contract tests in CI that fail if the generated schema changes unexpectedly.
  • Result — both apps launched on the same date, with very few integration bugs, and the mobile team could keep supporting older app versions still installed on users’ phones.

Point that impresses: mobile clients cannot be force-updated, so breaking an API field can break the app for users for months. Showing awareness of that proves you think beyond your own codebase.

Note: If there was a disagreement, for example the frontend wanting nested data while you preferred separate endpoints, explain how you resolved it with evidence such as payload size and query cost.

10. Describe a time you disagreed with a teammate about where to put business logic in a Django project. How was it resolved?

Where logic lives, whether in views, fat models, managers, forms, signals or a separate service layer, is a real and recurring debate in Django teams. The interviewer wants to see respectful disagreement resolved with reasoning, not seniority.

  • The disagreement — state both positions fairly. For example, a colleague wanted order-placement logic in a post_save signal so it would run automatically, while you preferred an explicit place_order() service function called from the view and the API.
  • Your reasoning — concrete, not dogmatic:
    • Signals hide control flow; a new developer reading the view cannot see that stock is reserved and an email is sent.
    • Signals do not fire for bulk_create or queryset update(), so the logic would silently be skipped in some paths.
    • A service function is easier to test and to wrap in transaction.atomic().
  • How you resolved it — you listened to their concern (avoiding duplicated logic across the view, the admin and the API), which was valid. You built a small prototype of both approaches, or discussed them in a design review with the team, and wrote the outcome down as a short architecture decision record.
  • Outcome — for example, you agreed on service functions for core workflows and signals only for loosely coupled side effects such as cache invalidation, and documented the convention for the rest of the team.

What to show: that you can be persuaded, that you address the other person’s underlying concern rather than just their proposed solution, and that the result became a team standard rather than a personal win.

Note: Avoid stories where you simply overruled someone or where the other person was obviously wrong. The best examples involve two reasonable positions and a thoughtful compromise.

Technical Questions

11. Explain Django's MTV architecture and how a request flows through it.

Django calls its pattern MTV — Model, Template, View — which maps onto MVC with the names shifted. Django's View is MVC's controller, and Django's Template is MVC's view. The framework itself plays the controller role.

The flow of a request:

  • The WSGI or ASGI server hands the request to Django, which wraps it in an HttpRequest.
  • Middleware runs top to bottom. Each layer can inspect or modify the request, or short-circuit it entirely — this is where sessions, authentication, and CSRF checks happen.
  • URL resolution matches the path against urlpatterns and extracts any captured arguments.
  • The view runs. It talks to models through the ORM, applies business logic, and returns an HttpResponse.
  • If it rendered a template, the template engine produces the HTML.
  • Middleware runs again in reverse order on the way out, then the response is returned.

Note: The reverse ordering of middleware on the response is a common follow-up. It matters because a middleware that sets a header on the way out sees the response after everything registered below it.

12. What is the Django ORM, and what is the N+1 query problem?

The ORM maps Python classes to database tables so you write Book.objects.filter(author__name='X') instead of SQL. QuerySets are lazy — nothing hits the database until you iterate, slice, or call something like len() — and they are cached once evaluated.

The N+1 problem is the most common Django performance bug. This code runs one query for the books and then one more per book:

for book in Book.objects.all():      # 1 query
    print(book.author.name)          # 1 query each time

A hundred books means a hundred and one queries.

The two fixes:

  • select_related('author') for forward ForeignKey and OneToOne relations. It performs a SQL JOIN and returns everything in one query.
  • prefetch_related('tags') for ManyToMany and reverse ForeignKey relations. It runs a second query and joins the results in Python, because a JOIN would multiply rows.

Note: Say how you would find it, not just how to fix it. django-debug-toolbar shows the query count per page, and assertNumQueries in a test stops it coming back.

Free workshop by Jobaaj Learnings

13. What are Django migrations, and how do you handle a migration that would lock a large table?

Migrations are versioned, ordered Python files describing schema changes. makemigrations compares your models to the existing migration state and generates the difference; migrate applies it and records the result in django_migrations.

The commands worth knowing: sqlmigrate shows the SQL a migration will run — always read it before applying anything to a large table. showmigrations lists what has been applied. --fake marks a migration applied without running it, for when the schema is already correct.

For a large table, the danger is the lock. The safe approach:

  • Add columns as nullable with no default. On PostgreSQL a nullable column with no default is a metadata-only change. Adding a NOT NULL column with a default rewrites the whole table on older versions.
  • Backfill in batches in a separate data migration or management command, not inside the schema migration.
  • Create indexes concurrently. Use AddIndexConcurrently from django.contrib.postgres.operations with atomic = False on the migration.
  • Split add-and-populate into separate deploys so old and new code can both run against the schema during the rollout.

Note: Setting a lock_timeout so a migration fails fast rather than queueing behind traffic is a strong detail to mention.

14. What is the difference between a Django project and an app, and how should you structure a large codebase?

A project is the deployable unit — settings, root URL configuration, WSGI and ASGI entry points. An app is a self-contained module of functionality with its own models, views, and migrations. One project contains many apps, and a well-designed app is reusable across projects.

How to draw app boundaries: by domain, not by layer. An app called orders containing its own models, views, and services is right; apps called models, views, and forms are wrong — that is just the framework's structure repeated at a larger scale.

What changes as a codebase grows:

  • Split settings into base, development, and production, with secrets from the environment rather than the repository.
  • Move logic out of views. Fat views are the most common structural problem in Django. Push business rules into model methods, managers, or a services.py so they are testable without HTTP.
  • Use custom managers and QuerySets so query logic lives in one place — Order.objects.pending() beats the same filter repeated in nine views.
  • Watch for circular imports between apps. If two apps import each other's models, the boundary is wrong.

15. How does Django's authentication system work, and what is the difference between authentication and authorisation?

Authentication establishes who you are; authorisation decides what you may do. Django provides both.

Authentication: authenticate() runs the configured backends and returns a user or None. login() puts the user's id in the session. AuthenticationMiddleware then attaches request.user on every subsequent request, falling back to AnonymousUser. Passwords are stored using PBKDF2 by default, salted and iterated, and the hasher is upgraded transparently on the next successful login.

Authorisation:

  • Permissions — Django creates add, change, delete, and view permissions per model automatically. Check them with user.has_perm('app.change_order').
  • Groups — named collections of permissions, so you assign a role rather than individual rights.
  • Enforcement — the @login_required and @permission_required decorators, or LoginRequiredMixin and PermissionRequiredMixin on class-based views.

Note: Django's model-level permissions do not cover object-level rules such as "only the author may edit this post". Say that you would implement that in the view or with a package like django-guardian — knowing the limitation matters more than knowing the API.

16. What is middleware in Django, and how would you write your own?

Middleware is a chain of hooks that wraps every request and response. Each one receives the request, may act on it, calls the next layer, and then may act on the response coming back.

class TimingMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response   # runs once at startup

    def __call__(self, request):
        start = time.monotonic()
        response = self.get_response(request)   # everything below runs here
        response['X-Duration-Ms'] = f"{(time.monotonic() - start) * 1000:.1f}"
        return response

Add the dotted path to MIDDLEWARE in settings.

The rules:

  • Order matters, and it is asymmetric. Requests pass through the list top to bottom; responses come back bottom to top.
  • Returning a response without calling get_response short-circuits everything below — which is exactly how authentication redirects and rate limiters work.
  • Placement has consequences. SessionMiddleware must come before AuthenticationMiddleware, because authentication reads the session.

Note: Good uses are cross-cutting concerns: request logging, correlation ids, timing, locale, and enforcing a maintenance mode. Business logic does not belong here, because it runs on every single request including static files and health checks.

17. What is the difference between a Django Form and a ModelForm, and how does validation work?

A Form defines its fields explicitly and is not tied to a model — a search box or a contact form. A ModelForm generates its fields from a model, and gains a save() method that creates or updates the instance.

class BookForm(forms.ModelForm):
    class Meta:
        model = Book
        fields = ['title', 'author', 'published_on']

Note: Always list fields explicitly. Using __all__ means that any field added to the model later becomes editable through this form, which is a real and frequently exploited security hole.

Validation runs in a fixed order when you call is_valid():

  • to_python converts the raw string to the right Python type.
  • validate applies field-level rules such as required and choices.
  • Validators attached to the field run next.
  • clean_<fieldname> — your per-field logic. It must return the cleaned value.
  • clean() — form-wide logic, the only place you can compare two fields, such as checking that an end date is after a start date. Raise ValidationError here for cross-field errors.

Errors collect in form.errors rather than raising, so all problems are shown to the user at once.

18. How do you handle background or long-running tasks in Django?

Never do them in the request-response cycle. Sending an email, generating a report, or calling a slow third-party API inside a view holds a worker process hostage and eventually times out for the user.

The standard answer is Celery with a broker such as Redis or RabbitMQ:

@shared_task(bind=True, max_retries=3)
def send_invoice(self, order_id):
    order = Order.objects.get(pk=order_id)
    try:
        mailer.send(order.email, render_invoice(order))
    except TransientError as exc:
        raise self.retry(exc=exc, countdown=60)

The view calls send_invoice.delay(order.id) and returns immediately.

The practices that matter:

  • Pass ids, not objects. Serialising a model instance means the worker acts on a stale copy.
  • Make tasks idempotent. A task can be delivered twice; running it twice must not send two invoices.
  • Set retries with backoff, and a time limit, so a hung task does not occupy a worker forever.
  • Dispatch after the transaction commitstransaction.on_commit(lambda: task.delay(id)) — or the worker may look up a row that has not been written yet. This is a classic race that is hard to reproduce.

Note: For simpler needs, mention django-q or a management command run by cron. Celery is powerful but it is real operational overhead.

19. How does caching work in Django, and what are the different caching levels?

Django exposes several granularities, and choosing the right one matters more than choosing the backend.

  • Per-site cachingUpdateCacheMiddleware and FetchFromCacheMiddleware cache entire pages. Simple, but wrong for anything personalised.
  • Per-view caching@cache_page(60 * 15) on a specific view. Good for public pages such as a listing or a blog post.
  • Template fragment caching{% cache 300 sidebar request.user.id %} around an expensive block, keyed by whatever makes it vary. This is often the best trade-off for a page that is mostly dynamic with one costly component.
  • The low-level APIcache.get, cache.set, and cache.get_or_set for caching a specific computation or query result. The most control and usually the right answer.

Backends: Redis or Memcached in production, local-memory for development, and dummy for tests.

The hard part is invalidation. Options are a short timeout and tolerating staleness, deleting the key in a post_save signal, or including a version marker in the cache key so a new value simply produces a new key and the old one expires on its own. The last approach avoids the race conditions the other two have.

20. What security protections does Django provide out of the box, and what must you still do yourself?

What Django gives you by default:

  • SQL injection — the ORM parameterises every query. You lose that protection the moment you use raw() or extra() with string formatting.
  • XSS — the template engine autoescapes variables. You lose it with the |safe filter or mark_safe.
  • CSRFCsrfViewMiddleware plus {% csrf_token %} in every POST form.
  • ClickjackingXFrameOptionsMiddleware sets X-Frame-Options: DENY.
  • Password storage — PBKDF2 with salting, and configurable validators.

What is still your job:

  • DEBUG = False in production, and ALLOWED_HOSTS set. Leaving DEBUG on exposes settings, environment variables, and a full traceback with source.
  • Keep SECRET_KEY out of the repository.
  • Set SECURE_SSL_REDIRECT, SESSION_COOKIE_SECURE, CSRF_COOKIE_SECURE, and HSTS.
  • Object-level authorisation. Django checks that a user may edit some order, not this order. Every candidate should say this — it is where real breaches happen.
  • Rate limit login endpoints, validate uploaded files, and keep dependencies patched.

Note: Run python manage.py check --deploy. It audits most of the settings above and is an easy, concrete thing to cite.

21. How do Prefetch objects and to_attr let you control exactly what prefetch_related loads?

Plain prefetch_related('comments') loads all related rows with a default queryset. A Prefetch object lets you customise that second query: filter it, order it, optimise it further, and store the result under a name of your choice.

from django.db.models import Prefetch

posts = Post.objects.prefetch_related(
Prefetch(
'comments',
queryset=Comment.objects.filter(is_approved=True)
.select_related('author')
.order_by('-created_at'),
to_attr='approved_comments',
)
)

for post in posts: # 2 queries in total
for c in post.approved_comments: # a plain Python list
print(c.author.username)

What each part gives you:

  • queryset= — filters and orders the related rows in the database, and can chain select_related so each comment’s author comes in the same query, avoiding a second layer of N+1.
  • to_attr= — stores the result as a list attribute instead of populating the default related manager cache. This makes it explicit that the data is filtered, and lets you prefetch the same relation twice with different filters, such as approved_comments and flagged_comments.

A common trap: calling post.comments.filter(is_approved=True) inside the loop ignores the prefetch cache completely and fires a new query per post. Any further filtering has to happen inside the Prefetch queryset, or in Python on the prefetched list.

Other useful details:

  • Nested lookups such as 'comments__likes' prefetch across several levels.
  • prefetch_related_objects(instances, 'comments') applies the same optimisation to a list of objects you already have.
  • In Django REST Framework, put these in the viewset’s get_queryset so nested serializers do not trigger N+1 queries.

Note: Prefetching sends the IDs of all parent objects in one IN clause, so on very large result sets combine it with pagination rather than prefetching for thousands of rows at once.

22. How do F expressions and Q objects work in the Django ORM, and why are F expressions important for concurrent updates?

F expressions refer to a column’s value inside the database, rather than a value loaded into Python. Q objects represent filter conditions that can be combined with OR, AND and NOT.

F for atomic updates — the classic race condition:

# Unsafe: read, modify in Python, write back
product = Product.objects.get(pk=pk)
product.stock -= 1
product.save() # two requests can both read 5 and both write 4

# Safe: the database does the arithmetic in one statement
from django.db.models import F
Product.objects.filter(pk=pk, stock__gt=0).update(stock=F('stock') - 1)

The second version compiles to UPDATE ... SET stock = stock - 1 WHERE id = ... AND stock > 0. The database applies it atomically, so concurrent requests cannot lose updates, and the stock__gt=0 condition prevents overselling. update() returns the number of rows changed, so a result of 0 means the item was out of stock.

Other uses of F:

  • Comparing two fields: Product.objects.filter(stock__lt=F('reorder_level')).
  • Arithmetic in annotations: annotate(margin=F('price') - F('cost')).
  • Ordering with null handling: order_by(F('rating').desc(nulls_last=True)).

After assigning an F expression to an instance field and saving, call refresh_from_db(); otherwise the attribute holds the expression, not the new number.

Q for complex conditions:

from django.db.models import Q

Order.objects.filter(
Q(status='pending') | Q(status='failed', retries__lt=3),
~Q(customer__is_blocked=True),
)
  • Keyword arguments to filter() are always ANDed; Q is needed for OR and NOT.
  • Q objects can be built dynamically, for example combining search terms in a loop, which is useful for filter forms.

Note: The F expression pattern is the answer interviewers look for in ‘how do you implement a like counter or wallet balance safely’ questions.

23. What is the difference between annotate() and aggregate() in Django, and how do Subquery and OuterRef work?

Both use aggregate functions such as Count, Sum and Avg, but they return very different shapes.

  • aggregate() — computes a summary over the whole queryset and returns a dictionary. It ends the query chain.
    Order.objects.filter(status='paid').aggregate(revenue=Sum('total'))
    # {'revenue': Decimal('184500.00')}
  • annotate() — adds a computed value to each row and returns a queryset you can keep filtering and ordering, which maps to SQL GROUP BY.
    Customer.objects.annotate(order_count=Count('orders'))
    .filter(order_count__gte=5)
    .order_by('-order_count')

Order matters with annotate: a filter() placed after an aggregate annotation filters on the aggregate, like SQL HAVING. A filter() placed before restricts which rows are counted. For conditional counts, use the filter argument: Count('orders', filter=Q(orders__status='paid')).

The multiple-join trap: annotating two aggregates across different relations, such as Count('orders') and Count('reviews'), joins both tables and multiplies rows, producing inflated numbers. distinct=True fixes counts but not sums; subqueries fix both.

Subquery and OuterRef embed a correlated subquery, where OuterRef refers to a field of the outer query’s current row:

from django.db.models import OuterRef, Subquery, Exists

latest = Order.objects.filter(customer=OuterRef('pk')).order_by('-created_at')

customers = Customer.objects.annotate(
last_order_at=Subquery(latest.values('created_at')[:1]),
has_open_ticket=Exists(Ticket.objects.filter(customer=OuterRef('pk'), open=True)),
)
  • The subquery must return a single column, via values(), and a single row, via [:1].
  • Exists is efficient for yes-or-no checks and can be used directly in filter().

Note: ‘Latest related record per parent’ is a very common interview and real-world task, and Subquery with OuterRef is the idiomatic ORM answer.

24. How do database transactions work in Django, including atomic, savepoints, ATOMIC_REQUESTS and on_commit?

By default Django runs in autocommit mode: every query is committed immediately. When several writes must succeed or fail together, you group them in a transaction with transaction.atomic.

from django.db import transaction

@transaction.atomic
def place_order(cart, user):
order = Order.objects.create(user=user, total=cart.total)
for item in cart.items:
OrderLine.objects.create(order=order, product=item.product, qty=item.qty)
Product.objects.filter(pk=item.product_id).update(stock=F('stock') - item.qty)
transaction.on_commit(lambda: send_confirmation.delay(order.id))
return order

If any line raises an exception, everything inside the block is rolled back, so there is never an order without its lines.

Key concepts:

  • Decorator or context managerwith transaction.atomic(): wraps just a block.
  • Nesting creates savepoints — an inner atomic() block that fails rolls back only to its savepoint, and the outer transaction can continue. This is how you handle an expected failure safely:
    with transaction.atomic():
    create_invoice(order)
    try:
    with transaction.atomic(): # savepoint
    award_loyalty_points(order)
    except IntegrityError:
    logger.warning('points already awarded')
  • Do not catch database errors inside an atomic block without a nested savepoint; the transaction is left broken and later queries fail with TransactionManagementError.
  • ATOMIC_REQUESTS = True in the database settings wraps every view in a transaction. It is simple, but it holds transactions open for the whole request, including slow external calls, so many teams prefer explicit atomic blocks.
  • transaction.on_commit(fn) — runs fn only after the outermost transaction commits, and never if it rolls back. Use it for side effects outside the database: Celery tasks, emails, cache invalidation and webhooks.
  • atomic(durable=True) — raises an error if the block is accidentally nested, guaranteeing that it really commits at its end.

Note: Keep transactions short. Never make HTTP calls to payment gateways or other services inside an atomic block, because they hold database locks while waiting on the network.

25. How do you prevent race conditions in Django, for example when two users try to book the last seat at the same time?

Race conditions appear when two requests read the same data, both decide an action is allowed, and both write. In Django, several tools prevent this, and the right one depends on the situation.

1. Pessimistic locking with select_for_update() — lock the rows while you decide:

from django.db import transaction

def book_seat(show_id, user):
with transaction.atomic():
show = Show.objects.select_for_update().get(pk=show_id)
if show.seats_left == 0:
raise SoldOut()
show.seats_left -= 1
show.save(update_fields=['seats_left'])
return Booking.objects.create(show=show, user=user)
  • It must run inside atomic(); the row lock is held until the transaction ends, so the second request waits and then sees the updated count.
  • nowait=True fails immediately instead of waiting; skip_locked=True skips locked rows, which is ideal for job-queue style processing.
  • of=('self',) limits locking when the query joins other tables.

2. Atomic conditional updates with F() — often simpler when a single counter changes: Show.objects.filter(pk=show_id, seats_left__gt=0).update(seats_left=F('seats_left') - 1). A return value of 0 means nothing was left to book.

3. Database constraints — the last line of defence. A UniqueConstraint on (show, seat_number) makes a double booking impossible, whatever the code does; catch IntegrityError and return a friendly message. get_or_create() is only race-safe when a unique constraint backs it.

4. Optimistic locking — add a version field and update with filter(pk=pk, version=old).update(..., version=old + 1). If zero rows change, someone else edited first, so ask the user to reload. It suits edit forms with low contention and avoids holding locks while users think.

Choosing: constraints always, F updates for counters, select_for_update for multi-step decisions under high contention, and optimistic locking for long-lived edits.

Note: SQLite ignores select_for_update, so these bugs rarely show up in local development. Test concurrency against PostgreSQL or MySQL.

26. How do you write a data migration with RunPython in Django, and what rules must it follow?

Schema migrations change tables; data migrations change the rows inside them, for example backfilling a new column, splitting a full name into two fields, or normalising phone numbers. You create an empty migration and add a RunPython operation.

python manage.py makemigrations customers --empty --name backfill_country
from django.db import migrations

def forwards(apps, schema_editor):
Customer = apps.get_model('customers', 'Customer')
batch = []
for c in Customer.objects.filter(country='').only('id', 'phone').iterator(chunk_size=2000):
c.country = 'IN' if c.phone.startswith('+91') else 'XX'
batch.append(c)
if len(batch) == 2000:
Customer.objects.bulk_update(batch, ['country'])
batch = []
Customer.objects.bulk_update(batch, ['country'])

class Migration(migrations.Migration):
dependencies = [('customers', '0012_customer_country')]
operations = [
migrations.RunPython(forwards, migrations.RunPython.noop),
]

Rules that matter:

  • Use apps.get_model(), never a direct import. It returns the historical model as it existed at that point in the migration history. Importing customers.models.Customer uses today’s model, which may have fields that do not exist yet, and the migration breaks for anyone running history from scratch.
  • Historical models have no custom methods and no custom save() logic, and signals do not fire. Put the logic you need inside the migration function itself.
  • Provide a reverse function — even RunPython.noop — so the migration can be rolled back.
  • Batch large updates with iterator() and bulk_update() instead of loading millions of rows or calling save() per row.
  • Keep schema and data changes in separate migrations. On PostgreSQL each migration runs in one transaction, so a long data update also holds locks from the schema change.
  • For very large tables, consider atomic = False on the migration with manual batching, or run the backfill as a management command outside the deploy.
  • Mark one-time backfills with elidable=True so they can be dropped when squashing.

Note: Test data migrations against a copy of production data. Real rows contain the nulls, blanks and odd formats that fixtures never do.

27. How do you resolve conflicting migrations from two branches, and when should you squash migrations?

Conflicts happen when two developers each create a migration from the same parent, for example two files both numbered 0015 and both depending on 0014. Django detects that the app now has two leaf nodes and refuses to migrate, with a message suggesting makemigrations --merge.

Resolving them:

  • Neither migration deployed yet — the cleanest fix is to delete your own migration, pull the other branch, and run makemigrations again, so history stays linear. Alternatively, edit your migration’s dependencies to point at the other one and renumber it.
  • One or both already applied somewhere — never rewrite applied migrations. Run python manage.py makemigrations --merge, which creates an empty merge migration depending on both leaves. That is safe when the two changes touch different fields or models.
  • Genuine conflicts, such as both branches altering the same field, need manual reconciliation. Review the merged state and add a follow-up migration if needed.
  • Always run python manage.py migrate and makemigrations --check in CI, so a missing or conflicting migration fails the build rather than a deploy.

Squashing combines many migrations into one to speed up test database creation and reduce clutter:

python manage.py squashmigrations orders 0001 0080
  • The new file lists the originals in a replaces attribute. Databases that already applied the old ones are treated as having applied the squash; fresh databases run only the squash.
  • Keep the old files until every environment has migrated past them. Then delete them, remove the replaces list, and update any migrations that depended on them.
  • RunPython operations are copied in unless marked elidable=True, and functions defined in old migration files may need moving into the squashed file.

Note: Squash on a schedule, perhaps once per major release, and never squash migrations that some environments have not applied yet.

28. How do Django signals work, and why do many teams avoid them for core business logic?

Signals implement the observer pattern: a sender dispatches a signal, and every connected receiver function is called. Django provides built-in signals such as pre_save, post_save, pre_delete, post_delete, m2m_changed, request_finished and user_logged_in, and you can define your own.

# profiles/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.conf import settings

@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def create_profile(sender, instance, created, **kwargs):
if created:
Profile.objects.create(user=instance)

# profiles/apps.py
class ProfilesConfig(AppConfig):
name = 'profiles'
def ready(self):
from . import signals # connect receivers at start-up

How they behave:

  • Receivers run synchronously, in the same thread and the same database transaction as the code that sent the signal. A slow receiver slows the request, and an exception in a receiver breaks the save.
  • Receivers must be imported for registration, which is why they are connected in AppConfig.ready().
  • dispatch_uid prevents a receiver being connected twice.

Why teams restrict them:

  • Hidden control flow — reading order.save() gives no hint that stock is reserved and an SMS is sent. Debugging becomes a search across the codebase.
  • They do not always fireQuerySet.update(), bulk_create(), bulk_update() and raw SQL bypass save(), so model save signals are skipped silently.
  • Ordering and testing — the order of multiple receivers is hard to reason about, and tests must mute or trigger signals deliberately.
  • Transaction timing — a post_save receiver that queues a Celery task can run before the transaction commits; transaction.on_commit is required.

When signals are the right tool: reacting to events from code you do not own, such as Django’s auth or third-party apps, and loosely coupled side effects such as cache invalidation or audit logs. For your own workflows, an explicit service function that performs each step is usually clearer.

Note: A good rule of thumb: if the business process would be wrong without the receiver running, it should not be a signal.

29. How do class-based views work internally in Django, and how do mixins such as LoginRequiredMixin fit in?

A URLconf needs a callable that takes a request and returns a response. View.as_view() produces that callable. On each request it creates a new instance of your class, calls setup() to store request, args and kwargs, and then calls dispatch().

dispatch() looks at the HTTP method and calls the matching method, get(), post(), put() and so on, or returns 405 through http_method_not_allowed(). Because a fresh instance is created per request, storing data on self is safe.

Generic views build on this with overridable hooks:

from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import ListView

class MyOrdersView(LoginRequiredMixin, ListView):
model = Order
template_name = 'orders/my_orders.html'
paginate_by = 20

def get_queryset(self):
return (Order.objects.filter(customer=self.request.user)
.select_related('shipping_address')
.order_by('-created_at'))
  • get_queryset() — the single most important hook. Scope data to the current user here; this is where authorisation bugs are prevented.
  • get_context_data() — add extra template variables, always calling super() first and passing its keyword arguments through, then adding your own keys to the returned dictionary.
  • get_object(), form_valid() and get_success_url() — used by DetailView, CreateView and UpdateView.

Mixins add behaviour through multiple inheritance. LoginRequiredMixin overrides dispatch() to redirect anonymous users before any handler runs; PermissionRequiredMixin and UserPassesTestMixin work the same way. Because Python resolves methods left to right along the MRO, access mixins must be listed first, to the left of the view class. Otherwise the view’s own dispatch() runs first and the check can be bypassed.

CBV or function view? Generic CBVs remove boilerplate for standard CRUD pages. For unusual flows, a function view is often easier to read than overriding five hooks, and both are equally valid Django.

Note: The site ccbv.co.uk lists every attribute and method of each generic view with its full inheritance chain, which is invaluable when working out which hook to override.

30. Why should you define a custom user model at the start of a Django project, and how do you do it?

Django’s documentation strongly recommends a custom user model for every new project, even if it starts identical to the default. The reason is that the user table is referenced by foreign keys throughout the database, and switching AUTH_USER_MODEL after the first migration is painful: it touches the auth tables, admin logs, every related model and the whole migration history.

Two starting points:

  • AbstractUser — keeps Django’s fields (username, email, names, is_staff and so on) and lets you add more. The easiest choice.
  • AbstractBaseUser with PermissionsMixin — you define the fields yourself. Choose it when the login identifier is fundamentally different, such as email or phone number with no username. It requires a custom manager.
# accounts/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models

class User(AbstractUser):
email = models.EmailField(unique=True)
phone = models.CharField(max_length=15, blank=True)

USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ['username']

# settings.py, set before the first migrate
AUTH_USER_MODEL = 'accounts.User'

Referencing the user correctly:

  • In model fields, use the setting: models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE).
  • In other code, call get_user_model() instead of importing a class directly, so reusable apps work with any user model.
  • Register the model with the admin, usually subclassing UserAdmin, and update the user creation and change forms if you changed fields.

Design advice: keep the user model lean, holding authentication and identity only. Put optional or domain-specific data, such as a candidate’s resume details or preferences, in a separate Profile model with a one-to-one link, so the core table stays small and easy to change.

Note: If you inherit a project that uses the default User, a profile model is usually a safer way to add fields than attempting a mid-project swap.

31. How do model permissions, groups and object-level permissions work in Django?

Django’s authorisation layer is built on Permission records, which can be assigned to users directly or through Groups.

Model-level permissions — for every model, Django automatically creates four permissions: add, change, delete and view, with codenames such as orders.change_order. You can add custom ones in the model’s Meta:

class Order(models.Model):
...
class Meta:
permissions = [('approve_refund', 'Can approve refunds')]

Checking permissions:

  • In code: request.user.has_perm('orders.approve_refund'). Inactive users always get False, and active superusers always get True.
  • In function views: @permission_required('orders.approve_refund', raise_exception=True).
  • In class-based views: PermissionRequiredMixin with permission_required.
  • In templates: {% if perms.orders.approve_refund %}.

Groups bundle permissions into roles, such as ‘Support’ or ‘Finance’. Assign users to groups rather than granting individual permissions, so changing a role updates everyone at once.

Object-level permissions answer ‘can this user edit this particular order?’. Django defines the API, user.has_perm('orders.change_order', obj), but the default ModelBackend always returns False for object checks. Options:

  • Scope querysets — the most common and robust approach: Order.objects.filter(owner=request.user) in get_queryset(), so other users’ objects simply return 404.
  • A custom authentication backend implementing has_perm(user_obj, perm, obj) with your rules, such as ‘owners and their team leads’.
  • django-guardian — stores per-object permission rows, suitable when rules are assigned by administrators rather than derived from data.
  • In Django REST Framework, has_object_permission() on a permission class.

Note: Permissions are cached on the user object for the request, so after granting one in the same request, re-fetch the user before checking again.

32. How do Serializer and ModelSerializer differ in Django REST Framework, and how does serializer validation work?

Serializers convert between complex data, such as model instances, and primitive types that render as JSON, and they validate incoming data in the other direction.

  • Serializer — you declare every field and implement create() and update() yourself. Use it for data that does not map to one model: a login payload, a report, or a multi-model workflow.
  • ModelSerializer — generates fields, validators such as unique constraints, and default create() and update() from the model.
class BookingSerializer(serializers.ModelSerializer):
class Meta:
model = Booking
fields = ['id', 'show', 'seats', 'status', 'created_at']
read_only_fields = ['status', 'created_at']

def validate_seats(self, value): # one field
if not 1 <= value <= 10:
raise serializers.ValidationError('Book between 1 and 10 seats.')
return value

def validate(self, attrs): # several fields together
if attrs['show'].starts_at < timezone.now():
raise serializers.ValidationError('This show has already started.')
return attrs

Validation pipeline when you call serializer.is_valid():

  1. Each field converts and validates its own raw value: type, max_length, required and so on.
  2. validate_<field>() methods run for field-specific rules.
  3. Serializer-level validators run, such as UniqueTogetherValidator.
  4. validate(attrs) runs for cross-field rules.

Errors are collected into serializer.errors, keyed by field. is_valid(raise_exception=True) turns them into a 400 response automatically. serializer.save() then calls create() or update(), and extra values can be passed in: serializer.save(user=request.user).

Good practice:

  • List fields explicitly; fields = '__all__' can expose internal or sensitive columns when the model grows.
  • Nested serializers are read-only by default; writable nested data requires your own create().
  • SerializerMethodField is convenient, but it runs per object, so make sure it uses prefetched data to avoid N+1 queries.

Note: Keep heavy business logic out of serializers. Validate and shape data there, then call a service function, so the same rules apply to the admin, management commands and Celery tasks.

33. What is the difference between APIView, generic views and ViewSets with routers in Django REST Framework?

DRF offers several layers of abstraction. Each one trades explicit control for less code.

  • APIView — the base layer. You write get(), post() and so on yourself, and DRF adds request parsing, content negotiation, authentication, permissions and throttling. Best for endpoints that are not simple CRUD, such as ‘confirm payment’ or ‘export report’.
  • Generic viewsGenericAPIView plus mixins, packaged as ready-made classes such as ListCreateAPIView and RetrieveUpdateDestroyAPIView. You set queryset and serializer_class, override hooks where needed, and wire each class to a URL yourself.
  • ViewSets — combine all the actions for one resource in a single class. Instead of HTTP method handlers, they define actions: list, create, retrieve, update, partial_update and destroy. ModelViewSet provides all six; ReadOnlyModelViewSet provides only list and retrieve.
class OrderViewSet(viewsets.ModelViewSet):
serializer_class = OrderSerializer
permission_classes = [IsAuthenticated]

def get_queryset(self):
return Order.objects.filter(customer=self.request.user).select_related('address')

def perform_create(self, serializer):
serializer.save(customer=self.request.user)

@action(detail=True, methods=['post'])
def cancel(self, request, pk=None):
order = self.get_object()
order.cancel()
return Response({'status': 'cancelled'})

router = DefaultRouter()
router.register('orders', OrderViewSet, basename='order')
urlpatterns = router.urls

Routers generate the URL patterns automatically: /orders/ for list and create, /orders/{pk}/ for retrieve, update and delete, and /orders/{pk}/cancel/ for the custom action. DefaultRouter also adds a browsable API root.

Useful hooks: get_queryset() for per-user scoping, get_serializer_class() to use different serializers for list and detail or for reads and writes, perform_create() to inject server-side values, and self.action to vary permissions per action.

Choosing: ViewSets for standard resources, generic views when you want only one or two endpoints for a resource, and APIView or function views with @api_view for workflow-style endpoints.

Note: ModelViewSet exposes delete and update by default. Use the narrower mixins or ReadOnlyModelViewSet when a resource should not support every operation.

34. How do authentication and permission classes work in Django REST Framework, and how would you add JWT authentication?

DRF separates two questions that run on every request, before your view code:

  • Authenticationwho is making this request? Each class in authentication_classes is tried in order; the first one that succeeds sets request.user and request.auth. If none succeeds, the user is anonymous.
  • Permissionsis this user allowed to do this? Every class in permission_classes must grant access. Otherwise DRF returns 401 (not authenticated) or 403 (authenticated but forbidden).

Built-in authentication classes:

  • SessionAuthentication — uses Django’s login session cookie; ideal for a same-domain web frontend. It enforces CSRF for unsafe methods.
  • TokenAuthentication — a simple, database-stored token sent as Authorization: Token ...; tokens never expire by default.
  • BasicAuthentication — mainly for testing.

JWT with djangorestframework-simplejwt — common for mobile apps and separate frontends:

# settings.py
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
}
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
}

# urls.py
path('api/token/', TokenObtainPairView.as_view()),
path('api/token/refresh/', TokenRefreshView.as_view()),

Short-lived access tokens limit the damage of a leak; refresh tokens obtain new ones. Because a JWT is valid until it expires, revocation needs the blacklist app or short lifetimes. Store tokens securely on the client, never in plain localStorage if XSS is a concern.

Permission classes: IsAuthenticated, IsAdminUser, IsAuthenticatedOrReadOnly, DjangoModelPermissions, or your own:

class IsOwner(BasePermission):
def has_object_permission(self, request, view, obj):
return obj.owner_id == request.user.id

has_object_permission runs only when the view calls get_object(), so list endpoints still need queryset filtering.

Note: Set a safe default of IsAuthenticated in settings and open endpoints explicitly. Forgetting permission_classes on one view is a common cause of data leaks.

35. How do you add pagination, filtering and throttling to a Django REST Framework API?

All three are configured globally in REST_FRAMEWORK settings and can be overridden per view.

REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 25,
'DEFAULT_FILTER_BACKENDS': [
'django_filters.rest_framework.DjangoFilterBackend',
'rest_framework.filters.SearchFilter',
'rest_framework.filters.OrderingFilter',
],
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle',
],
'DEFAULT_THROTTLE_RATES': {'anon': '60/min', 'user': '1000/hour'},
}

Pagination — never return unbounded lists.

  • PageNumberPagination?page=3; simple and familiar, but it runs a COUNT query and deep pages become slow.
  • LimitOffsetPagination?limit=25&offset=50; flexible, with the same deep-offset cost.
  • CursorPagination — an opaque cursor over a fixed ordering. Fast on huge tables and stable when new rows arrive, which makes it ideal for feeds and infinite scroll. It cannot jump to an arbitrary page.
  • Subclass a paginator to set max_page_size, so clients cannot request a million rows.

Filtering:

class JobViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Job.objects.select_related('company')
serializer_class = JobSerializer
filterset_fields = ['city', 'job_type', 'company'] # ?city=Pune
search_fields = ['title', 'company__name'] # ?search=python
ordering_fields = ['posted_at', 'salary_max'] # ?ordering=-posted_at
  • DjangoFilterBackend, from the django-filter package, gives exact and range filters; a FilterSet class handles richer rules.
  • Whitelist ordering and search fields and index the columns involved; free-text icontains searches over large tables need full-text search instead.
  • Filters apply on top of get_queryset(), so per-user scoping is never bypassed.

Throttling — rate limits per anonymous IP or per user. ScopedRateThrottle sets tighter limits on sensitive endpoints such as OTP or login. Throttle counters live in Django’s cache, so use a shared cache such as Redis when running several servers.

Note: DRF throttling is a convenience, not DDoS protection. Put rate limiting at the reverse proxy or CDN as well, for abuse that should never reach Django.

36. How should you organise Django settings across development, staging and production, and where should secrets live?

Settings must differ between environments, with DEBUG on locally and off in production, different databases and different email backends, while secrets must never be committed to version control. Two patterns are common, and many teams combine them.

1. Split settings modules

config/settings/
base.py # shared: INSTALLED_APPS, MIDDLEWARE, TEMPLATES
dev.py # from .base import *; DEBUG = True; console email
prod.py # from .base import *; security headers, real cache

DJANGO_SETTINGS_MODULE=config.settings.prod gunicorn config.wsgi

2. One settings file driven by environment variables, the twelve-factor approach:

import os

def require(name):
value = os.environ.get(name)
if not value:
raise ImproperlyConfigured(f'Missing environment variable {name}')
return value

SECRET_KEY = require('DJANGO_SECRET_KEY')
DEBUG = os.environ.get('DJANGO_DEBUG') == '1'
ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', '').split(',')
DATABASES = {'default': dj_database_url.parse(require('DATABASE_URL'))}

Libraries such as django-environ add type casting and .env file support for local development.

Where secrets belong:

  • SECRET_KEY, database passwords, API keys for payment gateways, SMS or email, and cloud credentials should come from the environment or a secrets manager such as AWS Secrets Manager, Parameter Store, GCP Secret Manager or Vault.
  • .env files are for local development only, and must be listed in .gitignore. Commit a .env.example with dummy values instead.
  • If a secret is ever committed, rotate it. Deleting the file does not remove it from git history.

Production essentials: DEBUG = False, an explicit ALLOWED_HOSTS, SECURE_SSL_REDIRECT, secure session and CSRF cookies, HSTS, and CSRF_TRUSTED_ORIGINS. Run python manage.py check --deploy in CI to catch missing ones.

Note: Fail fast. A production process that starts with a missing secret and silently falls back to a default key or SQLite is far more dangerous than one that refuses to boot.

37. What is the difference between WSGI and ASGI, and how do you deploy Django with Gunicorn and Nginx?

WSGI is the traditional synchronous interface between a Python web application and a web server: one request is handled at a time per worker thread, from start to finish. ASGI is its asynchronous successor. It supports async views, long-lived connections such as WebSockets and server-sent events, and many concurrent requests per worker. Django ships both wsgi.py and asgi.py.

Which to choose: for a conventional, mostly synchronous Django site, WSGI with Gunicorn is simple, mature and fast. Choose ASGI, with Uvicorn, Daphne or Hypercorn, when you use async views that call slow external APIs concurrently, or Django Channels for WebSockets.

A typical production stack:

Client -> CDN (optional) -> Nginx -> Gunicorn -> Django -> PostgreSQL / Redis

# WSGI
gunicorn config.wsgi:application --workers 5 --bind 127.0.0.1:8000 --timeout 30

# ASGI through Gunicorn managing Uvicorn workers (uvicorn-worker package)
gunicorn config.asgi:application -k uvicorn_worker.UvicornWorker --workers 4
  • Gunicorn — manages a pool of worker processes, restarting any that crash or hang. A common starting point is two workers per CPU core plus one, tuned by load testing. Threaded workers (--threads) help I/O-heavy sync apps. --max-requests recycles workers to contain memory leaks.
  • Nginx in front — terminates TLS, serves static and media files directly, buffers slow clients so Gunicorn workers are not tied up, sets upload size limits, and passes X-Forwarded-For and X-Forwarded-Proto headers. Tell Django to trust the proxy with SECURE_PROXY_SSL_HEADER.
  • Process management — systemd, Supervisor, or a container orchestrator such as ECS or Kubernetes keeps the processes running.

Deploy steps that belong in the pipeline: install locked dependencies, run collectstatic, run migrate once per release rather than once per container, then perform a graceful reload with kill -HUP or a rolling container update, with health checks so no requests are dropped.

Note: Never use manage.py runserver in production. It is single-process, unhardened and explicitly documented as a development server only.

38. How do async views work in Django, and what are the limitations when using the ORM from async code?

Since Django 3.1 you can write views with async def. Under an ASGI server they run on the event loop, so one worker can serve many requests that are waiting on the network at the same time.

import asyncio
import httpx

async def dashboard(request):
async with httpx.AsyncClient(timeout=5) as client:
weather, rates = await asyncio.gather(
client.get(WEATHER_URL),
client.get(FX_URL),
)
orders = [o async for o in Order.objects.filter(user=request.user)[:10]]
count = await Order.objects.filter(user=request.user).acount()
return render(request, 'dashboard.html', {
'weather': weather.json(), 'rates': rates.json(),
'orders': orders, 'count': count,
})

The two HTTP calls run concurrently, so the view takes as long as the slower one, not the sum of both. That is the main benefit: views that aggregate several slow external APIs, or long-polling and streaming responses.

The ORM in async code:

  • Django 4.1 added async query methods: aget(), acreate(), afirst(), acount(), aexists(), aupdate(), adelete(), and async for over querysets. Model instances have asave() and adelete().
  • Under the hood, most database work still runs in a thread through sync_to_async, because the database drivers are synchronous. The API is async, but individual queries are not faster.
  • Calling a synchronous ORM method directly from async code raises SynchronousOnlyOperation. That includes accessing a related field that triggers a lazy query, such as order.customer.name. Use select_related or wrap the code in sync_to_async.
  • transaction.atomic() does not work in async code; put transactional logic in a sync function and call it with sync_to_async.

Other practical points:

  • Under WSGI, async views still work but gain nothing, because each runs in its own one-off event loop.
  • Any synchronous middleware in the stack forces a thread switch per request; prefer async-capable middleware.
  • sync_to_async defaults to thread_sensitive=True, running calls in a single shared thread for safety, which can become a bottleneck.

Note: Async is not a general speed-up. For typical CRUD views backed by one database, sync Django with enough Gunicorn workers is simpler and just as fast.

39. How do you make Celery tasks in a Django project reliable, including retries, idempotency and scheduled jobs?

Celery delivers tasks at least once: a task can run twice after a worker crash, a broker reconnection or a retry. Reliable tasks are designed around that fact.

from celery import shared_task

@shared_task(
bind=True,
autoretry_for=(requests.RequestException,),
retry_backoff=True, # waits of 1s, 2s, 4s, 8s ...
retry_jitter=True,
max_retries=5,
acks_late=True,
time_limit=120,
)
def send_invoice(self, invoice_id):
invoice = Invoice.objects.get(pk=invoice_id)
if invoice.sent_at: # idempotency guard
return 'already sent'
mailer.send(invoice.to_email(), idempotency_key=f'invoice-{invoice_id}')
Invoice.objects.filter(pk=invoice_id, sent_at__isnull=True).update(sent_at=timezone.now())

Principles:

  • Pass IDs, not model instances. Arguments are serialised, and by the time the task runs the object may have changed. Re-fetch fresh data inside the task.
  • Make tasks idempotent — running twice must not charge a card twice or send two emails. Use status fields, unique constraints, or idempotency keys supported by the payment or email provider.
  • Retry only transient errors, such as timeouts and 503 responses, with exponential backoff and jitter so a recovering service is not overwhelmed. Do not retry validation errors.
  • acks_late=True acknowledges the message only after the task finishes, so a crashed worker’s task is redelivered. That is exactly why idempotency matters.
  • Dispatch after committransaction.on_commit(lambda: send_invoice.delay(invoice.id)), so the worker never looks for a row that is not yet committed.
  • Set time limits so a hung call cannot block a worker forever.
  • Route by priority — separate queues, such as emails, reports and default, so a burst of heavy reports cannot delay password-reset emails.

Scheduled jobs use Celery Beat. Define schedules in CELERY_BEAT_SCHEDULE with crontab expressions, or use django-celery-beat to manage them from the admin. Run exactly one Beat process, or tasks will be scheduled twice.

Observability: monitor queue lengths and failures with Flower, Prometheus or Sentry, and alert when a queue keeps growing. Keep the result backend disabled unless you actually read results.

Note: Interviewers love the ‘what if the task runs twice?’ follow-up. Answering with idempotency plus acks_late shows real production experience.

40. What are the differences between TestCase, TransactionTestCase and SimpleTestCase, and how do you keep a Django test suite fast?

Django’s test classes differ mainly in how they handle the database.

  • SimpleTestCase — no database access allowed. Fastest; use it for pure functions, forms without models, template filters and URL resolution.
  • TestCase — wraps each test in a transaction and rolls it back at the end, so tests are isolated and cleanup is almost free. setUpTestData() creates data once per class instead of once per test. This should be your default.
  • TransactionTestCase — really commits, then truncates all tables after each test. Much slower, but needed when you test actual transaction behaviour, such as select_for_update across connections or code that depends on real commits.
  • LiveServerTestCase — starts a real server for browser tests with Selenium or Playwright.

transaction.on_commit callbacks never fire inside TestCase, because nothing commits. Instead of switching to the slow class, use captureOnCommitCallbacks(execute=True).

class CheckoutTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = UserFactory()
cls.product = ProductFactory(stock=5)

def test_order_list_query_count(self):
OrderFactory.create_batch(10, customer=self.user)
self.client.force_login(self.user)
with self.assertNumQueries(3):
resp = self.client.get('/orders/')
self.assertEqual(resp.status_code, 200)

def test_confirmation_email_sent(self):
with self.captureOnCommitCallbacks(execute=True):
place_order(self.user, self.product)
self.assertEqual(len(mail.outbox), 1)

Keeping the suite fast:

  • Use factories (factory_boy) instead of large JSON fixtures; they are explicit and easy to maintain.
  • Configure a fast password hasher in test settings, such as MD5PasswordHasher, since real hashers are deliberately slow.
  • Run tests in parallel with --parallel, or pytest-xdist with pytest-django, and reuse the test database with --keepdb.
  • Use RequestFactory to test a view function directly without middleware when that is all you need.
  • Mock external services at the boundary, and keep a small number of slow end-to-end tests.

Note: assertNumQueries tests are cheap insurance against N+1 regressions. Add them to the endpoints that matter most.

41. What are the three types of model inheritance in Django, and when would you use each?

Django supports three inheritance styles, and they behave very differently in the database.

1. Abstract base classes — share fields and methods, with no table for the parent.

class TimeStamped(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

class Meta:
abstract = True

class Invoice(TimeStamped): # the invoice table gets both columns
amount = models.DecimalField(max_digits=10, decimal_places=2)

Each child gets its own complete table. There are no joins and no performance cost. This is the most common and usually the best choice, for timestamps, soft-delete flags, created_by and similar shared fields. You cannot query the abstract parent directly.

2. Multi-table inheritance — the parent is a concrete model with its own table.

class Place(models.Model):
name = models.CharField(max_length=100)

class Restaurant(Place): # own table plus an implicit OneToOne to Place
serves_veg = models.BooleanField(default=True)

Querying Restaurant always joins to the Place table, and saving writes two rows. You can query all places together and reach the child through place.restaurant. It is useful when you genuinely need to query the parent across types, but the hidden joins hurt at scale. Many teams prefer an explicit OneToOneField so the join is visible.

3. Proxy models — same table, different Python behaviour.

class PendingOrder(Order):
objects = PendingOrderManager() # only pending rows

class Meta:
proxy = True
ordering = ['created_at']

No new table and no migration of data. Use it to give a model a different default manager, ordering, methods or admin page, such as a separate ‘Pending orders’ section in the admin, without changing the schema. Proxies cannot add fields.

Quick guide: share columns with abstract, change behaviour with proxy, and use multi-table only when you truly need polymorphic queries over the parent.

Note: For true polymorphism, where you fetch a mixed list and get each row back as its real subclass, a third-party package such as django-polymorphic is usually needed on top of multi-table inheritance.

42. What is the difference between a custom Manager and a custom QuerySet in Django, and how does as_manager() help?

A Manager is the interface through which queries start, Model.objects. A QuerySet is the lazy, chainable object that represents a query. Both can be customised, but they solve slightly different problems.

Custom Manager methods are not chainable:

class OrderManager(models.Manager):
def paid(self):
return self.filter(status='paid')

Order.objects.paid() # works
Order.objects.filter(city='Pune').paid() # AttributeError: QuerySet has no paid()

Custom QuerySet methods are chainable, because each returns another instance of the same QuerySet class:

class OrderQuerySet(models.QuerySet):
def paid(self):
return self.filter(status='paid')

def in_city(self, city):
return self.filter(shipping_city__iexact=city)

def with_item_count(self):
return self.annotate(item_count=Count('lines'))

class Order(models.Model):
...
objects = OrderQuerySet.as_manager()

Order.objects.paid().in_city('Pune').with_item_count().order_by('-created_at')

as_manager() creates a Manager whose methods mirror the QuerySet’s, so the same methods work both at the start and in the middle of a chain. If you also need manager-only behaviour, use OrderManager.from_queryset(OrderQuerySet)().

When a Manager itself is the right place:

  • Overriding get_queryset() to change the base set of rows, for example a SoftDeleteManager that hides deleted records.
  • Methods that create objects rather than filter, such as create_user() on a user manager.

Gotchas:

  • The first manager defined becomes the default manager, used by the admin, dumpdata and some related lookups. Filtering rows out of the default manager can make records vanish unexpectedly, so keep an unfiltered all_objects manager too.
  • Keep query logic here, and business workflows in service functions.

Note: Encapsulating filters such as paid() or visible_to(user) in a QuerySet keeps business rules in one place and makes views read like plain English.

43. What is the difference between null=True and blank=True on a Django field, and how do you enforce data integrity with model constraints?

The two options look similar but apply to completely different layers:

  • null=Truedatabase level. The column allows NULL.
  • blank=Truevalidation level. Forms, the admin and full_clean() accept an empty value.
CombinationTypical use
neitherA required field, the default
blank=True onlyOptional text such as CharField or TextField, stored as an empty string
null=True, blank=TrueOptional non-text fields: dates, numbers, foreign keys
null=True onlyRare: required in forms but filled in by code later

Convention for text fields: avoid null=True on CharField, because then ‘no value’ has two representations, NULL and the empty string, and queries must check both. The exception is a unique optional field, since many rows with empty strings would violate uniqueness while multiple NULLs are allowed.

Enforcing integrity in the database — validation in forms is bypassed by update(), bulk_create(), scripts and raw SQL, so critical rules belong in constraints:

class Booking(models.Model):
show = models.ForeignKey(Show, on_delete=models.PROTECT)
seat = models.CharField(max_length=5)
amount = models.DecimalField(max_digits=10, decimal_places=2)
cancelled_at = models.DateTimeField(null=True, blank=True)

class Meta:
constraints = [
models.UniqueConstraint(
fields=['show', 'seat'],
condition=models.Q(cancelled_at__isnull=True),
name='one_active_booking_per_seat',
),
models.CheckConstraint(
condition=models.Q(amount__gte=0),
name='booking_amount_non_negative',
),
]
indexes = [models.Index(fields=['show', 'created_at'])]
  • UniqueConstraint supports conditions (partial unique indexes) and replaces the older unique_together.
  • CheckConstraint takes condition= from Django 5.1; older versions use check=.
  • Since Django 4.1, full_clean() also validates constraints, so forms show friendly errors instead of an IntegrityError.

Note: Treat the database as the final guard. Application checks give good error messages; constraints guarantee correctness even when code has bugs.

44. What do the on_delete options on a Django ForeignKey do, and how do you choose between them?

on_delete is required on every ForeignKey and OneToOneField. It decides what happens to rows that point at an object when that object is deleted.

  • CASCADE — delete the referencing rows too. Right for data that has no meaning without its parent, such as order lines when an order is deleted, or comments on a deleted post.
  • PROTECT — block the deletion by raising ProtectedError. Right for reference data that history depends on, such as a product that appears on invoices, or a customer with orders.
  • RESTRICT (Django 3.1 and later) — like PROTECT, but allows the deletion if the referencing rows are themselves being deleted through a separate CASCADE in the same operation.
  • SET_NULL — set the foreign key to NULL; the field must have null=True. Right when the child should survive, such as an article whose author account was removed.
  • SET_DEFAULT or SET(value_or_callable) — reassign to a default, such as a placeholder ‘deleted user’ account.
  • DO_NOTHING — Django does nothing; the database’s own constraint decides, which usually means an IntegrityError. Only for cases where you manage this in the database yourself.
class OrderLine(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='lines')
product = models.ForeignKey(Product, on_delete=models.PROTECT)
added_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL)

How Django applies these: traditionally Django emulates the behaviour in Python. Before deleting, it collects every related object, which can mean many queries and a large amount of memory for deep cascades, and it sends pre_delete and post_delete signals for cascaded objects.

Choosing well:

  • Be conservative with CASCADE on anything financial or legal. One accidental admin click on a customer should never wipe years of invoices.
  • For records that must be kept, prefer soft deletion, such as an is_active flag or deleted_at timestamp, combined with PROTECT.
  • The admin shows a confirmation page listing everything that will be cascade-deleted, which is worth reading.

Note: Interviewers often ask ‘what happens to orders if a user deletes their account?’. Answering with PROTECT or SET_NULL plus anonymisation shows awareness of both data integrity and privacy law.

45. How does URL routing work in Django, including path converters, include, namespaces and reverse?

When a request arrives, Django loads the module named in ROOT_URLCONF and tests the path against each entry in urlpatterns in order. The first match wins; the matching view is called with the request and any captured values.

# config/urls.py
from django.urls import include, path

urlpatterns = [
path('admin/', admin.site.urls),
path('jobs/', include('jobs.urls')),
path('api/v1/', include('api.urls')),
]

# jobs/urls.py
app_name = 'jobs'
urlpatterns = [
path('', views.job_list, name='list'),
path('<slug:slug>-<int:pk>/', views.job_detail, name='detail'),
path('city/<str:city>/', views.jobs_in_city, name='by_city'),
]

Path converters capture and convert segments: str (the default, no slashes), int, slug, uuid and path (which includes slashes). An int converter means the view receives an integer, and non-numeric input does not match, so it returns a 404 automatically. You can register custom converters with register_converter, for example a four-digit year or a PAN format. re_path() remains available for full regular expressions.

include() delegates a prefix to an app’s own URLconf, keeping apps self-contained and reusable.

Names and namespaces: naming each pattern and setting app_name lets you build URLs without hard-coding them:

reverse('jobs:detail', kwargs={'slug': 'python-developer', 'pk': 42})
# '/jobs/python-developer-42/'

{% url 'jobs:detail' slug=job.slug pk=job.pk %} # in templates
return redirect('jobs:list') # in views

Namespaces prevent collisions when two apps both have a pattern called detail. Changing a URL’s structure then requires editing only one line. Defining get_absolute_url() on a model with reverse() gives the admin and templates a canonical link.

Practical points: order specific patterns before general ones; APPEND_SLASH with CommonMiddleware redirects paths missing a trailing slash; use reverse_lazy() in class attributes, such as success_url, that are evaluated at import time; and custom handler404 and handler500 views go in the root URLconf.

Note: Hard-coded URLs in templates are a common code-review finding. Always use named routes with reverse or the url tag.

46. How do you write custom template tags and filters in Django, and what are context processors used for?

Custom filters and tags live in a templatetags package inside an app, which needs an __init__.py and must be in INSTALLED_APPS.

# jobs/templatetags/job_extras.py
from django import template

register = template.Library()

@register.filter
def inr(value):
'''Format a number in Indian style: 1250000 becomes 12,50,000.'''
s = str(int(value))
if len(s) <= 3:
return s
head, tail = s[:-3], s[-3:]
groups = []
while len(head) > 2:
groups.insert(0, head[-2:])
head = head[:-2]
if head:
groups.insert(0, head)
return ','.join(groups) + ',' + tail

@register.simple_tag(takes_context=True)
def active(context, url_name):
return 'active' if context['request'].resolver_match.url_name == url_name else ''

@register.inclusion_tag('jobs/_job_card.html')
def job_card(job):
return {'job': job}
{% load job_extras %}
Salary: Rs {{ job.salary|inr }}
<a class="{% active 'list' %}" href="{% url 'jobs:list' %}">Jobs</a>
{% job_card job %}
  • Filters transform one value, with at most one argument.
  • simple_tag takes any number of arguments and returns a string, or can assign the result to a variable with as.
  • inclusion_tag renders a small template with its own context, which is ideal for reusable components such as cards and pagination bars.

Security: output is auto-escaped. Use mark_safe or format_html only on content you have escaped yourself; returning user input through mark_safe creates an XSS hole. format_html() escapes its arguments safely.

Context processors are functions that take the request and return a dictionary merged into every template rendered with a request, registered under TEMPLATES['OPTIONS']['context_processors']. Built-in ones provide request, user, perms and messages. Custom ones suit site-wide values, such as the support phone number, feature flags or an unread notification count.

Because they run on every render, keep them cheap: avoid database queries, or make them lazy or cached. A slow context processor slows every page on the site.

Note: Keep business logic out of templates. If a filter is doing complex calculations, the value probably belongs on the model, in a service function, or in the view.

47. How do static files and user-uploaded media differ in Django, and how do you serve them securely in production?

Both are files served over HTTP, but they come from different sources and need different handling.

  • Static files — CSS, JavaScript, fonts and images that you ship with the code. They change only on deploy.
  • Media files — files users upload, such as resumes, profile photos and documents. They change constantly and cannot be trusted.

Static files:

  • STATIC_URL is the public prefix; STATICFILES_DIRS lists extra source folders; each app’s static/ folder is found automatically.
  • python manage.py collectstatic copies everything into STATIC_ROOT at deploy time.
  • Use a hashed storage, ManifestStaticFilesStorage or WhiteNoise’s compressed manifest storage, so file names include a content hash, such as app.3f9a2c.css. That allows far-future cache headers while guaranteeing browsers fetch new versions after a deploy.
  • Serve them with Nginx, a CDN, or WhiteNoise, which serves them efficiently from the Django process itself and is popular on PaaS platforms.

Media files:

def resume_path(instance, filename):
ext = Path(filename).suffix.lower()
return f'resumes/{instance.user_id}/{uuid4().hex}{ext}'

class Candidate(models.Model):
resume = models.FileField(
upload_to=resume_path,
validators=[FileExtensionValidator(['pdf', 'docx'])],
)
  • MEDIA_ROOT and MEDIA_URL configure local storage; in production, most teams use object storage such as S3 through django-storages, configured in the STORAGES setting (Django 4.2 and later).
  • Never trust the uploaded file: generate your own file names, validate type by content as well as extension, enforce size limits in Nginx and Django, and never execute or include uploaded files.
  • Serve user uploads from a separate domain or bucket, or with Content-Disposition: attachment, so an uploaded HTML or SVG file cannot run scripts on your main domain.
  • Private files, such as resumes or KYC documents, must not sit at public URLs. Serve them through a view that checks permissions, then use X-Accel-Redirect with Nginx or short-lived signed S3 URLs.

Note: Django’s static() URL helper serves media only when DEBUG is True. It is a development convenience, not a production solution.

48. Beyond select_related, how do you optimise heavy Django queries with only, values, iterator, bulk operations and explain?

Once N+1 problems are fixed, the next gains come from fetching less data, creating fewer model objects and sending fewer round trips to the database.

Fetch only what you need:

  • only('id', 'title') and defer('description') — load a subset of columns into model instances. Useful for wide tables with large text or JSON fields. The trap: touching a deferred field later triggers one extra query per object.
  • values() and values_list(..., flat=True) — return dictionaries or tuples instead of model instances, skipping object construction entirely. Ideal for exports, charts and ID lists.
  • exists() instead of if queryset:, and count() instead of len(queryset), when you do not need the rows themselves.

Handle large result sets:

for row in (Transaction.objects.filter(month='2026-08')
.values_list('id', 'amount', 'upi_ref')
.iterator(chunk_size=5000)):
writer.writerow(row)

iterator() streams rows in chunks without filling the queryset cache, so memory stays flat even for millions of rows.

Write in bulk:

Product.objects.bulk_create(products, batch_size=1000)
Product.objects.bulk_update(changed, ['price', 'stock'], batch_size=1000)
Product.objects.filter(category='old').update(is_active=False) # one UPDATE statement

These replace thousands of individual save() calls, but they skip save() methods and model signals, so any logic there will not run.

Diagnose and index:

  • print(qs.query) shows the SQL; qs.explain(analyze=True) on PostgreSQL shows the real execution plan, revealing sequential scans or bad join orders.
  • Add indexes for columns used in filter(), order_by() and joins through Meta.indexes, including composite, partial (condition=) and PostgreSQL GIN indexes for JSON and full-text search.
  • Django Debug Toolbar and django-silk show query counts and duplicate queries per request.

Other techniques: in_bulk(ids) to fetch many objects by key in one query, database functions and annotate() to compute in SQL instead of Python loops, and caching for expensive aggregates that do not need to be real-time.

Note: Measure with realistic data. A query that takes 2 ms on 500 local rows can take 20 seconds on 5 million production rows because of a missing index.

49. How does Django's CSRF protection work internally, and how do you make AJAX or single-page app requests pass it?

Cross-site request forgery tricks a logged-in user’s browser into sending a state-changing request to your site from another site, relying on the browser attaching the session cookie automatically. Django’s CsrfViewMiddleware blocks this by requiring proof that the request came from your own pages.

How it works:

  1. Django sets a csrftoken cookie containing a secret.
  2. Pages render a token with {% csrf_token %}. The token is masked with a random value on every render, which protects against compression-based attacks such as BREACH, but it always unmasks to the same secret.
  3. For unsafe methods, POST, PUT, PATCH and DELETE, the middleware compares the submitted token, from the form field csrfmiddlewaretoken or the X-CSRFToken header, against the cookie. An attacker’s site can make the browser send the cookie, but it cannot read it, so it cannot supply a matching token.
  4. Django also checks the Origin header, and the Referer header on HTTPS, against the host and CSRF_TRUSTED_ORIGINS. Since Django 4.0, those entries must include the scheme, for example https://app.example.com.

A failure returns 403 Forbidden. GET requests are never checked, which is why GET handlers must never change data.

AJAX and fetch requests:

function getCookie(name) {
const row = document.cookie.split('; ').find(r => r.startsWith(name + '='));
return row ? decodeURIComponent(row.split('=')[1]) : null;
}

fetch('/api/cart/', {
method: 'POST',
credentials: 'same-origin',
headers: {'X-CSRFToken': getCookie('csrftoken'), 'Content-Type': 'application/json'},
body: JSON.stringify({product_id: 42}),
});
  • If CSRF_COOKIE_HTTPONLY is True, JavaScript cannot read the cookie, so render the token into the page and read it from there instead.
  • For a single-page app on the same site, decorate the view that serves the app shell with @ensure_csrf_cookie so the cookie exists before the first POST.
  • Cross-subdomain setups need CSRF_TRUSTED_ORIGINS and a suitable CSRF_COOKIE_DOMAIN.

With Django REST Framework: SessionAuthentication enforces CSRF for authenticated requests, while token and JWT authentication do not need it, because the browser does not attach those credentials automatically.

Note: Use csrf_exempt only for endpoints such as payment webhooks that authenticate in another way, for example by verifying the provider’s HMAC signature. Never exempt a view just to make an error go away.

50. When is it justified to use raw SQL in Django, and how do you do it without opening a SQL injection hole?

The ORM parameterises every query it generates, which is why ordinary Django code is safe from SQL injection. Raw SQL gives up some of that protection, so it should be a deliberate choice.

Legitimate reasons to use it:

  • Complex reporting queries, such as recursive CTEs over a category tree or heavily tuned analytics, that the ORM cannot express or expresses inefficiently.
  • Database-specific features: PostgreSQL LATERAL joins, advisory locks, or vendor-specific upserts.
  • Bulk maintenance operations in migrations or management commands.

Before reaching for it, check the modern ORM: window functions, Subquery, Exists, conditional expressions, bulk_create(update_conflicts=True) and database functions cover most cases that once needed SQL.

Safe ways to run raw SQL:

# 1. Manager.raw(): returns model instances
Job.objects.raw('SELECT * FROM jobs_job WHERE city = %s AND salary_max >= %s', [city, min_salary])

# 2. A cursor, for anything else
from django.db import connection

with connection.cursor() as cur:
cur.execute(
'SELECT company_id, COUNT(*) FROM jobs_job WHERE posted_at >= %s GROUP BY company_id',
[since],
)
rows = cur.fetchall()

# 3. RawSQL inside an ORM query
Job.objects.annotate(score=RawSQL('ts_rank(search_vector, plainto_tsquery(%s))', [term]))

The rules:

  • Always pass values as parameters, the list after the SQL string. The database driver sends them separately from the query text, so they can never change its structure.
  • Never build SQL with f-strings, % formatting, .format() or concatenation using user input. f"... WHERE city = '{city}'" is exactly how injection happens.
  • Do not wrap placeholders in quotes: write %s, not '%s'. The placeholder is %s on every database backend.
  • Identifiers cannot be parameterised. Table names, column names and sort directions must come from a hard-coded whitelist, for example mapping ?sort=salary to a known column.
  • Avoid QuerySet.extra(), which is deprecated in spirit and easy to misuse.
  • Keep raw SQL in one place, such as a repository module, and cover it with tests.

Note: Interviewers often show a snippet with an f-string inside cursor.execute() and ask what is wrong. Spotting it instantly, and fixing it with parameters, is the answer they want.

Login to manage your account

Please enter a valid email address.
Forgot Password?
Please enter a valid password.
OR

Don't have an account yet? Sign up as