Project Structure & Apps
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1 on PyPI; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and
PostgreSQL 15+, checked on 2026-10-03; where Django 5.2 LTS behaves differently the text says so. The code listings
were run in a scratch project on Python 3.13.16 and Django 6.1.1 with SQLite: manage.py check, check --deploy,
migrate, the test suite (four tests, run with both manage.py test and pytest-django) and a curl against
runserver all pass. The cookiecutter-django layout was generated from release 2026.10.1 (published
2026-10-02) the same day.
Django does not enforce a folder layout, but it does have two precise ideas that shape every layout: the project
(one settings module and the glue around it) and applications (Python packages that Django knows about through
INSTALLED_APPS). This page explains those two ideas, what Django does with an application at start-up, and then
makes the design decisions the rest of this section relies on: a config/ package with split settings, a core
app for shared building blocks, small single-purpose apps under apps/, and a models / services / views layering.
It ends with the layout of the running example, the Bookshelf. The architectural reasoning behind such splits is on
the Backend Architecture pages; this page only shows how Django expresses it.
Projects versus apps
The official documentation defines the two terms in the application reference. A project is a whole Django web
application: a Python package defined mainly by a settings module, usually together with the URL configuration and
the ASGI and WSGI entry points. An application (app) is a Python package that provides some set of features — models, views, templates, template tags, static files, URLs, middleware — and that can be reused in several
projects. The project root (the directory that contains manage.py) is the usual container for all of the apps
that are not installed from PyPI.
Two consequences are worth stating before any layout advice:
-
There is no
Applicationobject. An app is "a set of code that interacts with various parts of the framework". Django only keeps a small metadata object for each installed app, theAppConfig, described below. -
Nothing stops the project package from also being an app with models; the project and app roles are conventions that Django does not police.
django-admin startproject creates the project; the argument . puts manage.py in the current directory
instead of inside an extra folder with the project’s name:
django-admin startproject config .
manage.py
config/
__init__.py
asgi.py
settings.py
urls.py
wsgi.py
The inner package is named config here, not after the product. The generated name (mysite) is only a
placeholder, and a neutral name keeps the settings and URL entry points at the same, predictable path in every
project. startproject and startapp also accept --template to start from your own template (a directory,
archive or URL); templates are used as-is, so the official documentation tells you to inspect third-party ones before use.
Creating apps with startapp
django-admin startapp name [directory] creates the app package. The optional destination is created if it does not
exist (Django 6.0 added that automatic creation; before 6.0 you had to run mkdir first).
mkdir apps && touch apps/__init__.py
for app in core accounts catalog reviews api; do
django-admin startapp "$app" "apps/$app"
done
Each app gets init.py, admin.py, apps.py, models.py, tests.py, views.py and a migrations/ package.
Two details matter once the apps live in a package such as apps/:
-
The generated
apps.pystill saysname = 'catalog'(the template does not know that you placed the app inapps/). Listing"apps.catalog"inINSTALLED_APPSwith that unchanged value fails at start-up:django.core.exceptions.ImproperlyConfigured: Cannot import 'catalog'. Check that 'apps.catalog.apps.CatalogConfig.name' is correct.Edit
nameto the full dotted path ("apps.catalog"). The reproduction above was run in the scratch project. -
Since Django 6.0 the generated
AppConfigno longer containsdefault_auto_field, and the generatedsettings.pyno longer containsDEFAULT_AUTO_FIELD, becauseBigAutoFieldbecame the default (see What changed recently).
from django.apps import AppConfig
class CatalogConfig(AppConfig):
name = "apps.catalog" # full dotted path: apps/ is a package
label = "catalog" # short name used in tables, migrations and "app_label.Model"
AppConfig: name, label and the rest
AppConfig subclasses live in apps.py by convention. The convention is more than style: when INSTALLED_APPS
contains the dotted path of an app package, Django looks in its apps.py submodule. If it finds exactly one
AppConfig subclass it uses it automatically; with several, it uses the one whose default attribute is True;
with none, it falls back to the plain AppConfig. Setting default = False stops a lone subclass from being
picked, which is useful for optional alternative configurations in reusable apps. INSTALLED_APPS can also
contain the dotted path of the configuration class itself ("apps.catalog.apps.CatalogConfig"), which is how a
project selects a non-default configuration shipped by a third-party app.
| Attribute | Purpose and rules |
|---|---|
|
Full Python path of the app. Required on every subclass and unique within the project. |
|
Short, unique name; defaults to the last component of |
|
Human-readable name used by the admin. Defaults to |
|
Filesystem path of the app. Detected automatically; only needed for namespace packages that span several directories. |
|
Primary-key type for models of this app that do not declare one. Defaults to the |
|
|
|
Read-only: the root module and the |
|
Initialisation hook, described in the next section. |
Two apps with the same label cannot coexist. If apps.catalog were relabelled reviews, manage.py check
would stop with Application labels aren’t unique, duplicates: reviews (reproduced in the scratch project). The
usual cause is two third-party apps with the same last path component, and label is the intended fix.
The registry is available as django.apps.apps:
from django.apps import apps
for config in apps.get_app_configs():
print(config.label, config.name, len(list(config.get_models())))
apps.get_app_config("reviews") # AppConfig; LookupError if unknown
apps.is_installed("apps.reviews") # takes the full name, returns a bool
apps.get_model("reviews.Review") # or get_model("reviews", "Review")
Running that loop in manage.py shell of the scratch project prints one line per installed app, for example
core apps.core 0, accounts apps.accounts 1, catalog apps.catalog 2 and reviews apps.reviews 1.
How apps are loaded and what ready() is for
django.setup() populates the registry. It is called for you by the ASGI and WSGI handlers and by every management
command; a plain Python script must call it explicitly. It loads the settings, configures logging, sets the URL
script prefix and initialises the registry in three stages, each processing the apps in the order of
INSTALLED_APPS:
import each INSTALLED_APPS entry
apps.py loaded, AppConfig created"] --> B["Stage 2
import each app's models module
apps.get_model() works"] B --> C["Stage 3
call each AppConfig.ready()
apps.ready becomes True"] C --> D["URLconf, views, templates
(first request or command)"]
-
Stage 1: Django imports each entry. For a configuration class it imports the root package of the app; for a package it finds the configuration in
apps.py. Neither the app’s root package nor the module that defines the configuration class should import models, even indirectly. Django would technically allow it once the configuration is loaded, but the documentation strongly recommends against it so that the order ofINSTALLED_APPSnever matters. -
Stage 2: Django imports the
modelsmodule (or package) of each app. Every model must be defined or imported there, otherwise the registry may be incomplete and the ORM misbehaves. -
Stage 3: Django calls
ready()on every configuration. Models can be imported here, the registry is complete, andapps.readyturnsTrue.
The classic use of ready() is connecting signal receivers. The Bookshelf logs every new review from a receiver in
signals.py; ReviewsConfig.ready() imports that module, and the import is what runs the @receiver decorator:
import logging
from django.apps import AppConfig
logger = logging.getLogger(__name__)
class ReviewsConfig(AppConfig):
name = "apps.reviews"
label = "reviews"
def ready(self):
# Importing the module is what connects its @receiver functions.
# Models may be imported here; database queries may not.
from . import signals # noqa: F401
logger.debug("reviews app ready")
import logging
from django.db.models.signals import post_save
from django.dispatch import receiver
logger = logging.getLogger(__name__)
# A string sender ("app_label.ModelName") avoids importing the model class.
@receiver(post_save, sender="reviews.Review", dispatch_uid="reviews.log_new_review")
def log_new_review(sender, instance, created, **kwargs):
if created:
logger.info("new review %s for book %s", instance.pk, instance.book_id)
The test below proves that ready() connected the receiver, without calling any code of the signals module directly:
def test_signal_is_connected_by_ready(self):
with self.assertLogs("apps.reviews.signals", level="INFO") as logs:
Review.objects.create(book=self.book, author=self.user, rating=5)
self.assertIn("new review", logs.output[0])
Rules for ready(), all from the official reference:
-
Do not touch the database.
ready()runs during the start-up of every management command, includingmanage.py test(which would then run queries against your real database before the test database exists),migrateandcheck. This includessave(),delete(), manager methods and raw SQL. Django emits aRuntimeWarning: Accessing the database during app initialization is discouragedfor queries issued before the apps are ready; addingReview.objects.count()to theready()above reproduces it in the scratch project. -
Be idempotent. Normally
ready()runs once, but tests that fiddle with installed apps can run it more than once; usedispatch_uid(as above) or a flag on the class. -
Do not translate at import time.
gettext()needs the registry; usegettext_lazy()in module-level strings such asverbose_name, otherwise you getAppRegistryNotReady. -
Do not query at import time either. A form field such as
choices=[c.name for c in Country.objects.all()]runs the query during module import; use aModelChoiceFieldinstead. -
Calling the registry before
django.setup()raisesAppRegistryNotReady: Models aren’t loaded yet.(reproduced by callingapps.get_model()in a bare Python script).
Designing apps
Django’s own guidance defines the unit loosely, so most teams adopt a rule of thumb. The one this section uses is the widely taught one from Two Scoops of Django: an app should do one thing well and be describable in a sentence without "and". The rest are common conventions rather than Django rules.
-
One purpose per app.
catalog(books and authors),reviews,accounts: each one can be explained in a sentence. An app that needs a paragraph is usually two apps. -
Name it with a plural noun for the thing it manages, or a short noun for the capability (
reviews,accounts). Avoid names that collide with the standard library or common packages (email,logging,test); a collision is a confusing import error, not a Django error. -
Direction of dependencies. Draw the arrows and keep them acyclic:
reviewsmay importcatalogandaccounts;catalogmust not importreviews. A circular import between twomodels.pyfiles is the most common start-up failure ("ImportError: cannot import name") and a sign that the boundary is wrong. -
Reference across apps by string where a cycle is unavoidable.
ForeignKey("catalog.Book")and signalsenderstrings avoid importing the class. -
Do not treat apps as folders. An app earns its place by owning models, migrations, templates or commands. A folder of helpers is a plain package (
utils), or better, lives incore. -
Split when two parts change for different reasons or are reused by different projects; merge when two apps always change together. Premature splitting multiplies migrations and circular references.
-
Keep everything the app needs inside the app. Per-app
templates/<app>/,static/<app>/andtestsdirectories (withAPP_DIRS = True) keep an app movable; project-wide assets (base.html, site CSS) live at the project level. -
Namespace URLs. Each app has a
urls.pywithapp_name = "catalog"so thatreverse("catalog:book-detail")works.include(…, namespace=…)requiresapp_name(Django 2.0).
The config package: split settings
startproject creates a single settings.py. The Bookshelf turns it into a package: shared settings in base.py and one
small module per environment, selected through DJANGO_SETTINGS_MODULE. The project then has three entry points
that each name the module they want:
config/
__init__.py
asgi.py # DJANGO_SETTINGS_MODULE default: config.settings.production
wsgi.py # DJANGO_SETTINGS_MODULE default: config.settings.production
urls.py
settings/
__init__.py # empty
base.py # everything shared
local.py # developer machine
test.py # test runs
production.py # deployment, everything sensitive from the environment
manage.py # DJANGO_SETTINGS_MODULE default: config.settings.local
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") # manage.py
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") # asgi.py, wsgi.py
manage.py defaults to the developer settings and the servers default to production: forgetting an environment
variable on a server then fails safely instead of running with DEBUG = True. setdefault means an exported
DJANGO_SETTINGS_MODULE always wins, which is how the test run below picks config.settings.test.
"""Settings shared by every environment."""
from pathlib import Path
# config/settings/base.py -> project root is three levels up
BASE_DIR = Path(__file__).resolve().parent.parent.parent
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# Bookshelf apps, ordered from the most basic to the most dependent
"apps.core",
"apps.accounts",
"apps.catalog",
"apps.reviews",
"apps.api",
]
AUTH_USER_MODEL = "accounts.User"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"
ASGI_APPLICATION = "config.asgi.application"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"], # project-level templates
"APP_DIRS": True, # plus templates/ folders inside each app
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
STATICFILES_DIRS = [BASE_DIR / "static"]
LOCALE_PATHS = [BASE_DIR / "locale"]
# No DEFAULT_AUTO_FIELD line: BigAutoField is the default since Django 6.0.
Because the file moved one directory deeper, BASE_DIR needs a third .parent; forgetting that is the usual reason
for "TemplateDoesNotExist" right after converting to a package. The environment modules import everything from base
and override only what differs:
"""Developer machine: readable errors, throw-away secret key."""
import os
from django.core.management.utils import get_random_secret_key
from .base import * # noqa: F403
DEBUG = True
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]"]
# A fresh key per process is fine for development (sessions reset on restart).
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", get_random_secret_key())
"""Test runs: fast hashers, no real services."""
from .local import * # noqa: F403
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
"""Production: everything sensitive comes from the environment."""
import os
from .base import * # noqa: F403
DEBUG = False
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] # KeyError at start-up if missing
ALLOWED_HOSTS = os.environ["DJANGO_ALLOWED_HOSTS"].split(",")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
Running the three entry points in the scratch project:
python manage.py check
# System check identified no issues (0 silenced).
DJANGO_SETTINGS_MODULE=config.settings.test python manage.py test
# Ran 4 tests in 0.014s ... OK
DJANGO_SECRET_KEY="$(python -c 'from django.core.management.utils import get_random_secret_key as g; print(g())')" \
DJANGO_ALLOWED_HOSTS=bookshelf.example.com \
DJANGO_SETTINGS_MODULE=config.settings.production python manage.py check --deploy
# System check identified no issues (0 silenced).
DJANGO_SETTINGS_MODULE=config.settings.production python manage.py check
# KeyError: 'DJANGO_SECRET_KEY'
The last command is the point of reading the secret from os.environ[…]: a missing secret stops the process at
start-up instead of silently using a default. No secret appears in the repository; get_random_secret_key() is
Django’s own helper, and the same call generates the production value.
Split modules are one answer, not the only one: many teams keep a single settings.py that reads everything from
environment variables (for example with django-environ) and use from .base import * only for a handful of
differences. Both are covered, with their trade-offs, in
Settings & Configuration. from .base import * is the one place
where a star import is idiomatic, and linters need the noqa.
The core app
Almost every project grows code that belongs to no feature: an abstract base model, a few validators, a template
tag, a base management command. The Bookshelf collects it in apps/core, with one rule: core imports nothing from
any other Bookshelf app. Every other app may depend on core; core depends on none of them. That one-way arrow
is what keeps it from becoming a dumping ground that ties the whole project together.
from django.db import models
class TimeStampedModel(models.Model):
"""Abstract base: created/modified timestamps for every model that wants them."""
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
from django.db import models
from django.urls import reverse
from apps.core.models import TimeStampedModel
class Author(TimeStampedModel):
name = models.CharField(max_length=200)
def __str__(self):
return self.name
class Book(TimeStampedModel):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, on_delete=models.PROTECT, related_name="books")
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("catalog:book-detail", args=[self.pk])
An abstract model has no table and no migration of its own: makemigrations creates migrations for catalog and
reviews (their models carry the two timestamp columns) but none for core, and showmigrations reports core as
(no migrations). Good candidates for core: abstract models and mixins, project-wide validators and template tags,
a base management command, small pure helpers. Poor candidates: anything that knows about books or reviews
(that belongs in the owning app) and anything that does I/O to a third party (give it its own app or a service module).
cookiecutter-django
Cookiecutter Django is a widely used project template, generated with
the cookiecutter tool, that produces a production-minded project. Generating it with default answers (release 2026.10.1,
generated on 2026-10-03 with cookiecutter 2.7.1, project_slug=bookshelf) gives this shape:
pip install cookiecutter
cookiecutter https://github.com/cookiecutter/cookiecutter-django
bookshelf/
manage.py
pyproject.toml # dependencies and tooling; uv.lock is generated
.python-version # 3.14
config/
settings/{base,local,production,test}.py
urls.py wsgi.py
bookshelf/ # the "project slug" package holds the apps
users/ # apps.py, models.py, views.py, admin.py, forms.py, tests/
contrib/sites/ # migrations override for django.contrib.sites
templates/ static/
conftest.py
tests/ locale/ docs/ utility/ .envs/ .pre-commit-config.yaml README.md LICENSE
Notes from the generated project (checked on 2026-10-03):
-
It uses the same split-settings idea as this page:
config/settings/{base,local,test,production}.py, withmanage.pydefaulting toconfig.settings.localandwsgi.pytoconfig.settings.production. Itsbase.pybuildsINSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS, a three-way grouping that keeps Django’s apps, third-party apps and your own apps apart. -
Apps live inside the project-slug package (
bookshelf.users) rather than in a top-levelapps/folder. Both are valid; the Bookshelf usesapps/because it keeps the root readable when there are five apps and aconfig/. -
Its
UsersConfighas an emptyready()stub and a lazyverbose_name, which is the pattern described above. -
Defaults are opinionated. The default answers generate a project pinned to Django 6.0.8 and Python 3.14 (not yet Django 6.1), with django-allauth, crispy forms, Argon2 hashing, Redis and Anymail already wired in; Docker, Celery, Webpack/Gulp and cloud-storage choices are generation options (see its "Project Generation Options" page). Pinning everything in
pyproject.tomlwithuv.lockis a reproducible-builds choice. -
When to use it: when you want an opinionated, deployable starting point and accept its dependencies. When not to: when you are learning (the amount of generated code hides the Django concepts), or when your stack differs from its assumptions; the generated project must be understood and trimmed, because you own all of it afterwards. Reading it is worthwhile even if you do not use it: it is a compact catalogue of current practice.
Layering: models, services and views
Django’s own architecture is MTV, and Django is deliberately silent about where business rules go. The default answer in tutorials is "in the view" or "in the model"; both stop scaling. The Bookshelf uses three layers inside each app, which is the Django spelling of the layered structure described in Structuring the code and Layering backend and frontend:
HTTP in, HTTP out: parse, call a service, choose the response"] S["services.py
business rules and transactions, plain functions with keyword arguments"] M["models.py (+ managers/querysets)
fields, constraints, queries, small invariants"] V --> S --> M
-
Models own persistence: fields, constraints (the database enforces the rules that must never be broken, such as one review per user and book), custom managers and querysets for reusable queries, and small methods that concern one row.
-
Services (
services.py) own business operations that span rows or models, run inside a transaction and can raise domain exceptions. They are plain functions, take explicit keyword arguments and know nothing about HTTP. -
Views translate HTTP to a service call and the result to a response. Anything else that triggers the same operation — a DRF viewset, a management command, an admin action, a task — calls the same service.
from django.conf import settings
from django.db import models
from apps.catalog.models import Book
from apps.core.models import TimeStampedModel
class Review(TimeStampedModel):
book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="reviews")
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
rating = models.PositiveSmallIntegerField()
body = models.TextField(blank=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=["book", "author"], name="one_review_per_user_and_book"),
models.CheckConstraint(condition=models.Q(rating__gte=1, rating__lte=5), name="rating_between_1_and_5"),
]
from django.db import IntegrityError, transaction
from .models import Review
class ReviewError(Exception):
"""Raised when a review cannot be added; views translate it to HTTP."""
def add_review(*, book, user, rating, body=""):
"""Business rule: one review per user and book, rating from 1 to 5."""
if not 1 <= rating <= 5:
raise ReviewError("rating must be between 1 and 5")
try:
with transaction.atomic():
return Review.objects.create(book=book, author=user, rating=rating, body=body)
except IntegrityError as exc:
raise ReviewError("you already reviewed this book") from exc
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect
from django.views.decorators.http import require_POST
from apps.catalog.models import Book
from .services import ReviewError, add_review
@login_required
@require_POST
def create_review(request, book_id):
book = get_object_or_404(Book, pk=book_id)
try:
add_review(
book=book,
user=request.user,
rating=int(request.POST.get("rating", 0)),
body=request.POST.get("body", ""),
)
except ReviewError as exc:
return HttpResponseBadRequest(str(exc))
return redirect(book)
The state-changing endpoint accepts POST only (require_POST), requires a login, and its form in the template
carries the CSRF token. The service raises one domain exception and has no import from django.http, so a DRF
view or a task can reuse it unchanged. The wrapping transaction.atomic() is needed because the IntegrityError is
caught inside a request that may itself be running in a transaction; transactions are covered in
Transactions, Raw SQL & Multiple Databases.
The raw int(request.POST.get(…)) is deliberate shorthand for this page: user input should go through a form
(see Forms), which also handles the ValueError that a non-numeric rating raises here.
{% extends "base.html" %}
{% block content %}
<h1>{{ book.title }}</h1>
<form method="post" action="{% url 'reviews:create' book.pk %}">
{% csrf_token %}
<input type="number" name="rating" min="1" max="5" required>
<textarea name="body"></textarea>
<button type="submit">Add review</button>
</form>
{% endblock %}
def test_post_creates_review_then_rejects_duplicate(self):
self.client.force_login(self.user)
url = reverse("reviews:create", args=[self.book.pk])
first = self.client.post(url, {"rating": 4, "body": "Great"})
self.assertRedirects(first, self.book.get_absolute_url(), fetch_redirect_response=False)
self.assertEqual(Review.objects.count(), 1)
self.assertEqual(self.client.post(url, {"rating": 3}).status_code, 400)
self.assertEqual(Review.objects.count(), 1)
def test_get_is_not_allowed_and_anonymous_is_redirected(self):
url = reverse("reviews:create", args=[self.book.pk])
self.assertEqual(self.client.post(url, {"rating": 4}).status_code, 302) # to login
self.client.force_login(self.user)
self.assertEqual(self.client.get(url).status_code, 405)
Checked against a running server in the scratch project: a POST without the CSRF cookie answers 403, and an
anonymous GET answers 302 (to the login page, because login_required is the outer decorator and runs before
require_POST). The Django test client does not enforce CSRF unless Client(enforce_csrf_checks=True) is used, so the
tests above prove the logic, not the CSRF protection.
Pitfalls of the layering:
-
Do not create a service for a one-line
Model.objects.create(). Services exist for rules and transactions. Reusable queries belong in a custom queryset (see Managers & Custom QuerySets). -
Do not put business rules in signals. A signal is the right tool for a reaction that other code should not have to know about (the review logger above); a rule that must run is an explicit call.
-
Put the arrows in one direction.
views→services→models; models never import services or views. -
The same discipline applies across apps:
reviewsimportscatalog.models;catalognever importsreviews. For a deeper model of what services and repositories mean, see Repositories, factories and domain services; the same split in other stacks is shown in Project structure and layered architecture (FastAPI) and Project structure and workspaces (NestJS). Whether the whole thing stays one deployable or splits is a separate decision: Choosing monolith or microservices and the monolith to modular monolith path describe why a Django project with disciplined apps is already a modular monolith. For the choice of framework in the first place, see the "Backend framework" table of Starting a new project.
The final Bookshelf layout
This is the layout the later pages build towards. The apps shown as empty here receive their models, views and routes in the pages named in the table; everything above them already exists and was run for this page.
bookshelf/
manage.py
pyproject.toml
config/
settings/{base,local,test,production}.py
urls.py asgi.py wsgi.py
apps/
core/ # abstract models, shared helpers (imports nothing from the other apps)
accounts/ # custom User (email login), sign-up, password reset
catalog/ # Author, Book: public pages, search, pagination
reviews/ # Review: services.py, signals.py, moderation tasks and emails
api/ # versioned REST API (DRF, with a Django Ninja variant)
templates/ # project-level: base.html, registration/
static/ # project-level assets
locale/ # translations (English, Spanish)
tests/ # cross-app and end-to-end tests
[project]
name = "bookshelf"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"django>=6.1,<6.2",
"psycopg[binary]>=3.3",
"django-environ>=0.14",
]
[dependency-groups]
dev = [
"pytest-django>=4.14",
"factory-boy>=3.3",
]
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.settings.test"
python_files = ["tests.py", "test_*.py"]
The TOML file parses with tomllib, and pytest collects and passes the same four tests as manage.py test. A single
pyproject.toml (installed with pip or with uv) replaces the requirements/base.txt,
local.txt and production.txt trio and the Pipenv or virtualenvwrapper setup that older Django books use. Package
versions are a snapshot of PyPI on 2026-10-03; pytest-django 4.14 and factory_boy 3.3 declare support up to Django 6.0
and 5.1 respectively in their classifiers, and the scratch project ran them against Django 6.1.1 without problems.
| App | Responsibility | Depends on |
|---|---|---|
|
Abstract |
nothing |
|
|
|
|
|
|
|
|
|
|
REST serializers, viewsets and routes; no models of its own |
all of the above |
The AUTH_USER_MODEL choice must be made before the first migration of a project; it is explained in
Custom User Model & Backends. Why the custom user lives in
accounts and not in core follows from the arrows above: reviews needs the user (the catalog only depends on
core), and core must not know about any of them.
Pitfalls checklist
-
name = "catalog"left unchanged after moving a generated app intoapps/:ImproperlyConfigured: Cannot import 'catalog'. -
Two apps with the same
label:Application labels aren’t unique. Relabel one. -
Importing models in
apps.pyoutsideready()(or in the app’sinit.py):AppRegistryNotReady. -
Database access in
ready()or at import time: aRuntimeWarning, slower start-up of every command, possibly stale cached data, and a failure when migrations are pending. -
A signals module that nothing imports: the receiver is never connected. Import it in
ready(). -
BASE_DIRcomputed for a flatsettings.pyand not corrected after moving it into a package. -
Hard-coded
SECRET_KEY,DEBUG = TrueorALLOWED_HOSTS = ["*"]in a module that production loads. -
Changing an app’s
labelafter its migrations exist. -
Importing the registry in an app’s
init.pyasapps: the name clashes with theappssubmodule (the documentation’s own warning); importfrom django.apps import apps as django_appsthere, or move the code to a submodule.
Where the books are outdated
The four reference books target Django 1.11 to 2.1, and several structure-related practices have changed since:
-
Pre-3.2 books point
INSTALLED_APPSat"blog.apps.BlogConfig"or setdefault_app_configininit.py. Since Django 3.2 anapps.pywith a singleAppConfigis discovered automatically, so the plain package path is enough; thedefault_app_configvariable was removed in Django 4.1, so delete it when upgrading. -
Books that show
url()andinclude(…, namespace=…)withoutapp_namepredate Django 2.0’sapp_namerequirement and Django 4.0’s removal ofurl(); usepath()withapp_nameinurls.py. -
MIDDLEWARE_CLASSES(removed in 2.0),USE_L10N(removed in 5.0) and thestaticfilestemplate library (removed in 3.0) still appear in project skeletons from those books; the 6.1startprojecttemplate has none of them. -
Models in those books rely on the old implicit
AutoFieldprimary key and sometimes declare noDEFAULT_AUTO_FIELD; see below for the 6.0 default. -
A
requirements/folder withbase.txt,local.txtandproduction.txt, Pipenv or virtualenvwrapper, and a rootmanage.pybeside a project package named after the product are the older pattern;pyproject.toml(with pip or uv) and aconfig/package are the current default of the popular templates. -
Secrets committed in
settings.pyandDEBUG = True/ALLOWED_HOSTS = ["*"]for deployment appear in the books' sample projects. Do not reproduce them: read the secret from the environment and runcheck --deploy. -
Where the books keep business logic in "fat views" or "fat models", most teams now add an explicit service layer for operations that cross models, as shown above.
What changed recently
-
3.2:
apps.pyauto-discovery and theDEFAULT_AUTO_FIELDsetting withAppConfig.default_auto_field. -
4.1:
default_app_configremoved. -
4.1: Python files generated by
startproject,startappand the migration commands are formatted withblackwhen it is on thePATH. -
6.0:
startprojectandstartappcreate a missing target directory;DEFAULT_AUTO_FIELDdefaults toBigAutoFieldand the generated settings andAppConfigno longer set it. Projects that still wantAutoFieldsetDEFAULT_AUTO_FIELD = "django.db.models.AutoField"or the app’sdefault_auto_field. -
6.1: the release notes mention no change to
startproject,startapp,AppConfigor the app registry; the announced rename of the deprecation warnings to calendar versions (RemovedInDjango2028Warning, shipping in a 6.1 patch after 6.1.1) does not affect project structure. -
5.2 LTS: everything on this page also applies to Django 5.2 (supported until April 2028), except the 6.0 items above.