Lesson 1 of 5
Django's Architecture
"The web framework for perfectionists with deadlines." — the Django project's own tagline, djangoproject.com
Part I built the machine: a message, a server interface, a router, request and response objects, and a middleware chain. Django is a set of decisions layered on top of that machine, and this chapter is about the decisions rather than the API surface.
Three of them shape everything else. Django splits your code into a project and apps, which is a packaging decision with consequences for import order. It resolves configuration from a module named by an environment variable, which means settings are imported code rather than a config file — powerful, and the reason a settings import can have side effects. And it populates an app registry in phases, which is the piece almost nobody can explain and the one that answers a whole family of otherwise-mysterious errors: why ForeignKey takes a string, why you cannot import a model at the top of some files, and what AppConfig.ready() is for.
None of that requires Django installed to understand. It requires building it, which is what we will do.
Django refuses to let you import a model at the top of some modules, wants foreign keys written as strings, and provides a ready() hook whose entire purpose is "later than you think". Those look like three unrelated quirks. What single design decision produces all of them — and what does it buy that a simpler arrangement would not?
Project and app: a packaging decision, not a hierarchy
Click Run to execute the code.
The project is the deployable unit: settings, the root URLconf, the WSGI and ASGI entry points. There is one. The app is a reusable unit of functionality: models, views, its own URLs, its own migrations. There are many, and the intent is that one could be lifted into another project unchanged.
That intent is the whole justification for the split, and it is why an app declares its routes relative to itself (Part I chapter three) and never imports the project. An app that reads chika.settings directly instead of django.conf.settings has quietly stopped being reusable.
The migrations/__init__.py check is not pedantry. A migrations directory without it is not a package, Django does not find it, and the symptom is migrations that silently never run — which is a genuinely confusing first hour.
Think of a hospital rather than a filing cabinet. The project is the building: one address, one power supply, one set of policies, one reception. The apps are departments — radiology, pharmacy, records — each with its own staff, its own equipment and its own procedures, and each in principle transplantable into a different hospital. What makes that transplant possible is that radiology never refers to "the third floor of St Mary's"; it refers to "the building's power supply" through a standard socket. An app importing django.conf.settings rather than chika.settings is using the socket. And the reason departments cannot simply open on day one is the same reason Django's registry has phases: pharmacy needs to know records exists before it can send anything there, so the building opens every department's doors first and only then lets them start talking to each other.
Settings are a module, named by an environment variable
Click Run to execute the code.
Two rules with real consequences. Only upper-case names are settings, which is why a typo like Debug = True silently does nothing. And settings are merged over global_settings, so every setting always has a value and settings.SOMETHING almost never raises AttributeError — it returns Django's default, which is usually the safe one.
The important structural point is that this is imported Python, not parsed configuration. It can compute values, read the environment, branch on a hostname — and it can also perform side effects, which is why a settings module that opens a connection or calls an API does so in every process that imports it, including manage.py shell and every test run. Volume 14's import chapter is the whole explanation.
The settings object is lazy, and it raises for a reason
Click Run to execute the code.
The laziness matters more than it looks. from django.conf import settings at the top of a module must not read anything, because that module may be imported by a tool that has not configured Django yet. So the object defers until first attribute access — the same descriptor-and-caching idea as the request object in Part I chapter four.
ImproperlyConfigured with that exact wording is one of the most-seen Django errors, and it now decodes: something touched a setting before DJANGO_SETTINGS_MODULE was set. Usually that means a script run directly instead of through manage.py, which is the file whose entire job is to set that variable.
The app registry is two-phase, and that explains everything
Click Run to execute the code.
Read the log. Every model is registered before any reference is resolved, and ready() runs after both. That ordering is the entire design, and it exists because apps genuinely need to point at each other: orders has a customer, customers has a latest order, and if either imported the other at module level you would have Volume 14's circular import.
This is why ForeignKey("customers.Customer") accepts a string. The string is a promise to resolve later, deferred until phase two, which is exactly the "defer the lookup" fix the import chapter recommended — institutionalised by the framework so you do not have to think about it.
And it is why apps.get_model() raises AppRegistryNotReady before populate finishes: the registry is telling you, accurately, that the answer does not exist yet.
ready() exists because of timing, not tidiness
Click Run to execute the code.
ready() is the one place where you are guaranteed that settings are loaded, every model is registered and every app is importable. That makes it the right home for signal registration, system checks and any wiring that needs to see the whole application.
It is also a place people put things that do not belong. ready() runs in every process that starts Django — every management command, every worker, every test run — and it runs before migrations have been applied, so a database query there breaks migrate on a fresh database. That is a genuinely common bug in Django projects, and the symptom is that a new developer cannot set the project up at all.
Django was written at the Lawrence Journal-World newspaper in 2003 by Adrian Holovaty and Simon Willison, under the deadline pressure that produced the tagline above, and open-sourced in July 2005 named after the guitarist Django Reinhardt. The app registry you just built is much newer than that, and it arrived to fix real pain: for a decade Django loaded models by importing every models.py and keeping them in a fragile structure called the "app cache", which produced import-order bugs that were notoriously hard to reason about — the folklore rule "never import models at the top of anything" comes from that era. Django 1.7 (2014) replaced it with the explicit apps registry, AppConfig and ready(), in the same release that introduced built-in migrations; the two are related, because a reliable migration system needs a reliable answer to "what models exist?". Since then default_auto_field and app-level configuration have moved into AppConfig, and Django 3.2 made apps.py the default in startapp output rather than an optional extra.
MTV is MVC with the names moved
Click Run to execute the code.
MTV is not a different architecture. Django's view is what MVC calls a controller — it receives a request, decides what data is needed and what to do, and returns a response. Django's template is what MVC calls a view. The model is the model in both.
The framework's own explanation is that the "controller" in a web framework is the framework itself — the routing and dispatch layer you built in Part I — so naming a user-written function "controller" would be misleading. Whether that argument convinces you matters less than knowing which word means what, because every Django discussion assumes it.
The practical guidance that comes with it is fat models, thin views: business rules belong on the model or in a service module, not in the view, because a view is bound to HTTP and a model is not. A rule living in a view cannot be reused by a management command, a Celery task or a test without going through a request.
What makes an app reusable
Click Run to execute the code.
The last item is the one that hurts most in practice. A query at module level — CHOICES = list(Category.objects.all()) at the top of models.py — runs at import, which happens before migrate has created the table. The project works for everyone who already has a database and fails for every new developer and every CI run from scratch.
The general principle behind all six: an app should depend on interfaces, not on a particular project. django.conf.settings rather than chika.settings, reverse("order-detail") rather than /orders/42, get_user_model() rather than importing User.
Run python manage.py diffsettings on a project you work on. It prints only the settings that differ from Django's defaults, which is usually a much shorter and more honest description of the project than settings.py itself — and the entries you cannot explain are worth a conversation. Then python manage.py shell -c "from django.apps import apps; print([a.label for a in apps.get_app_configs()])" shows the registry as a list, and apps.get_models() shows what phase two resolved. The experiment worth doing once: put print("importing", __name__) at the top of two models.py files and run manage.py check — watching the import order happen, and seeing it complete before any ready() fires, makes the whole registry concrete in a way reading about it does not. And grep your apps for from chika import settings or from myproject.settings import; every hit is an app that has quietly stopped being reusable.
The registry's phases show up directly in Django's most-searched error messages. AppRegistryNotReady: Apps aren't loaded yet means something touched a model before populate() finished — classically a module-level from myapp.models import Order inside apps.py, which is why the documented pattern is to import models inside ready(). ImproperlyConfigured: Requested setting X, but settings are not configured means a script ran outside manage.py without setting DJANGO_SETTINGS_MODULE. And the string form of relations is what makes swappable models possible at all: settings.AUTH_USER_MODEL is a string precisely so your ForeignKey can point at a user model the framework has not loaded yet, which is why the documentation insists on get_user_model() rather than importing User. On the settings side, the near-universal production pattern is a base module plus environment-specific modules that from .base import *, with secrets read from the environment — the arrangement Volume 14's final chapter argued for, arriving here as a file layout.
Common Mistakes
- Lower-casing a setting name. Only upper-case names are read, so
Debug = Trueis silently ignored and Django's default applies. - Importing the project's settings module directly.
from chika.settings import Xcouples the app to one project;from django.conf import settingsdoes not. - Doing work at settings-module level. It is imported code, so a connection or an API call there happens in every process — including tests and every management command.
- Importing models at the top of
apps.py. That runs before the registry is populated, producingAppRegistryNotReady. Import insideready(). - Querying the database in
ready()or at module level. It runs before migrations, so the project works for everyone who already has a database and fails for every fresh setup. - Forgetting
migrations/__init__.py. The directory is not a package, Django never finds it, and migrations silently never run. - Importing
Userdirectly. Useget_user_model()or theAUTH_USER_MODELstring, or a project that swaps the user model cannot use your app. - Putting business rules in views. A view is bound to HTTP, so the rule cannot be reused by a management command, a task or a test without faking a request.
- Confusing Django's "view" with MVC's view. Django's view is MVC's controller; Django's template is MVC's view.
- Running scripts directly instead of through
manage.py.manage.pyexists to setDJANGO_SETTINGS_MODULE; skipping it producesImproperlyConfigured.
Interview Corner
include(), and why it imports django.conf.settings rather than the project's settings module. Settings themselves are just a Python module, found through the DJANGO_SETTINGS_MODULE environment variable, which is what manage.py sets — that is why running a script directly rather than through manage.py gives you ImproperlyConfigured. And only upper-case names in that module count as settings, so a lower-cased one is silently ignored and Django's default applies instead. On naming: Django's "view" is what MVC calls a controller, and Django's "template" is what MVC calls a view, which catches everyone once.ForeignKey accept a string like "customers.Customer", and what is ready() for?ready() is the third phase, and it is the one place where settings are loaded, every model is registered and every app is importable — so signal registration, system checks and wiring that needs to see the whole application belong there rather than at module level. The two traps are that importing models at the top of apps.py runs before the registry is populated and gives you AppRegistryNotReady, so you import inside ready(); and that ready() runs in every process and before migrations, so a database query there breaks migrate on a fresh database and the project only works for people who already have one.base.py with everything shared, then dev.py, prod.py and test.py that each from .base import * and override, with DJANGO_SETTINGS_MODULE selecting one per environment. Secrets and anything varying per deployment come from environment variables read in base, never committed — and I validate the required ones at import so a misconfigured deploy dies immediately with the variable named, rather than at the first request that touches it. What I keep out of settings entirely is work: it is imported code, so a connection or an API call there runs in every management command and every test process. On app boundaries, I split by domain rather than by layer — an orders app owning its models, views and migrations, not a project-wide models.py — and I hold the line that apps depend on interfaces rather than on each other's internals: django.conf.settings, get_user_model(), reverse() by name, and string references for cross-app foreign keys. That is what keeps the dependency graph acyclic, which is the same layering argument from Volume 14's project-structure chapter, and it is checkable in CI. The one Django-specific thing I would set early is AUTH_USER_MODEL to a custom user model even if it is initially empty, because changing it after the first migration is genuinely painful and costs nothing on day one.Build the registry that makes Django's import rules make sense. load_settings must merge over defaults and take only upper-case names, LazySettings must refuse to load without the environment variable, and Registry.populate must run three phases in order so that two apps referencing each other both resolve. The mutual-reference check is the one that matters — it can only pass if every model is registered before any reference is resolved. The checklist reports each requirement as PASS, FAIL or ERR; run it before writing anything and watch the lines flip.
Click Run to execute the code.
Summary
- A project is the deployable unit; an app is a reusable one. The split only pays off if apps depend on interfaces —
django.conf.settings,get_user_model(),reverse()— rather than on a particular project. - Settings are an imported module named by
DJANGO_SETTINGS_MODULE. Only upper-case names count, values merge over Django's defaults, and side effects there run in every process. - The settings object is lazy so importing it is safe.
ImproperlyConfiguredmeans something read a setting before the environment variable was set — usually a script run outsidemanage.py. - The app registry populates in phases: import every app's models, then resolve cross-app references, then call
ready(). - That is why
ForeignKeytakes a string. The reference resolves in phase two, which defers the lookup and dissolves what would otherwise be a circular import. ready()is the first moment the whole application exists. Signals, checks and wiring belong there; database queries do not, because it runs before migrations and in every process.AppRegistryNotReadyis accurate, not obstructive. Something asked for a model before phase one finished — classically a model import at the top ofapps.py.- MTV is MVC renamed. Django's view is MVC's controller; Django's template is MVC's view. Business rules belong on models or services, not in views bound to HTTP.
- Module-level database queries break fresh setups. They run at import, before
migratehas created the table, so the project works only for people who already have a database. migrations/__init__.pyis load-bearing. Without it the directory is not a package, Django never finds it, and migrations silently never run.
In the next chapter, Models & the ORM, we go from architecture to data: how a class becomes a table, what a field declaration actually generates, why Meta exists, and the mapping between Python attributes and SQL columns that the next three chapters all depend on.
The app registry is a good example of a framework absorbing a problem rather than documenting it. Volume 14's import chapter established that circular imports are usually a layering failure and that the fixes are to restructure, defer the lookup, or import inside a function. Django's models genuinely cannot be layered — orders and customers really do point at each other, and neither is "below" the other — so restructuring is not available. Rather than telling every user to work around it, Django built the deferral into the field declaration: you write a string, and the framework promises to resolve it once everything exists. What you get is a rule that looks arbitrary until you know the mechanism and is obvious afterwards. That is worth recognising as a pattern, because most framework "quirks" are this: a general problem solved once, in a place you did not see, leaving behind a convention whose reason has been optimised away.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.