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 Application object. 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, the AppConfig, 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.py still says name = 'catalog' (the template does not know that you placed the app in apps/). Listing "apps.catalog" in INSTALLED_APPS with that unchanged value fails at start-up:

    django.core.exceptions.ImproperlyConfigured: Cannot import 'catalog'.
    Check that 'apps.catalog.apps.CatalogConfig.name' is correct.

    Edit name to the full dotted path ("apps.catalog"). The reproduction above was run in the scratch project.

  • Since Django 6.0 the generated AppConfig no longer contains default_auto_field, and the generated settings.py no longer contains DEFAULT_AUTO_FIELD, because BigAutoField became the default (see What changed recently).

apps/catalog/apps.py after the edit
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

name

Full Python path of the app. Required on every subclass and unique within the project.

label

Short, unique name; defaults to the last component of name and must be a valid Python identifier. It is the app_label of the app’s models, the prefix of its table names (reviews_review), the first element of "reviews.Review" references, the name used in migration dependencies and the key of AUTH_USER_MODEL. Changing it after migrations exist breaks the project (or, for a reusable app, every existing install).

verbose_name

Human-readable name used by the admin. Defaults to label.title(). Use gettext_lazy if it is translated.

path

Filesystem path of the app. Detected automatically; only needed for namespace packages that span several directories.

default_auto_field

Primary-key type for models of this app that do not declare one. Defaults to the DEFAULT_AUTO_FIELD setting. Reusable apps that ship migrations must set it so that their migrations match their models whatever the installing project configured.

default

True or False, as described above; not set by default.

module, models_module

Read-only: the root module and the models module (None when the app has no models; the pre_migrate and post_migrate signals are only sent for apps that have one).

ready()

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:

flowchart LR A["Stage 1
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 of INSTALLED_APPS never matters.

  • Stage 2: Django imports the models module (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, and apps.ready turns True.

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:

apps/reviews/apps.py
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")
apps/reviews/signals.py
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:

apps/reviews/tests.py (excerpt)
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, including manage.py test (which would then run queries against your real database before the test database exists), migrate and check. This includes save(), delete(), manager methods and raw SQL. Django emits a RuntimeWarning: Accessing the database during app initialization is discouraged for queries issued before the apps are ready; adding Review.objects.count() to the ready() 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; use dispatch_uid (as above) or a flag on the class.

  • Do not translate at import time. gettext() needs the registry; use gettext_lazy() in module-level strings such as verbose_name, otherwise you get AppRegistryNotReady.

  • 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 a ModelChoiceField instead.

  • Calling the registry before django.setup() raises AppRegistryNotReady: Models aren’t loaded yet. (reproduced by calling apps.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: reviews may import catalog and accounts; catalog must not import reviews. A circular import between two models.py files 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 signal sender strings 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 in core.

  • 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>/ and tests directories (with APP_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.py with app_name = "catalog" so that reverse("catalog:book-detail") works. include(…​, namespace=…​) requires app_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
manage.py, asgi.py and wsgi.py (the line that changes)
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.

config/settings/base.py (abridged)
"""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:

config/settings/local.py
"""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())
config/settings/test.py
"""Test runs: fast hashers, no real services."""

from .local import *  # noqa: F403

PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
config/settings/production.py
"""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.

apps/core/models.py
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
apps/catalog/models.py
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, with manage.py defaulting to config.settings.local and wsgi.py to config.settings.production. Its base.py builds INSTALLED_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-level apps/ folder. Both are valid; the Bookshelf uses apps/ because it keeps the root readable when there are five apps and a config/.

  • Its UsersConfig has an empty ready() stub and a lazy verbose_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.toml with uv.lock is 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:

flowchart TD V["views.py (and DRF viewsets, admin actions, commands)
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.

apps/reviews/models.py
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"),
        ]
apps/reviews/services.py
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
apps/reviews/views.py
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.

apps/catalog/templates/catalog/book_detail.html
{% 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 %}
apps/reviews/tests.py (excerpt)
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:

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
pyproject.toml
[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

core

Abstract TimeStampedModel, shared validators and helpers

nothing

accounts

User(AbstractUser) with AUTH_USER_MODEL = "accounts.User", set before the first migration

core

catalog

Author, Book, list and detail pages, search

core

reviews

Review model, services.add_review, the post-save logger, moderation emails

core, catalog, accounts

api

REST serializers, viewsets and routes; no models of its own

all of the above

flowchart BT core accounts --> core catalog --> core reviews --> catalog reviews --> accounts reviews --> core api --> reviews api --> catalog api --> accounts

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 into apps/: ImproperlyConfigured: Cannot import 'catalog'.

  • Two apps with the same label: Application labels aren’t unique. Relabel one.

  • Importing models in apps.py outside ready() (or in the app’s init.py): AppRegistryNotReady.

  • Database access in ready() or at import time: a RuntimeWarning, 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_DIR computed for a flat settings.py and not corrected after moving it into a package.

  • Hard-coded SECRET_KEY, DEBUG = True or ALLOWED_HOSTS = ["*"] in a module that production loads.

  • Changing an app’s label after its migrations exist.

  • Importing the registry in an app’s init.py as apps: the name clashes with the apps submodule (the documentation’s own warning); import from django.apps import apps as django_apps there, 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_APPS at "blog.apps.BlogConfig" or set default_app_config in init.py. Since Django 3.2 an apps.py with a single AppConfig is discovered automatically, so the plain package path is enough; the default_app_config variable was removed in Django 4.1, so delete it when upgrading.

  • Books that show url() and include(…​, namespace=…​) without app_name predate Django 2.0’s app_name requirement and Django 4.0’s removal of url(); use path() with app_name in urls.py.

  • MIDDLEWARE_CLASSES (removed in 2.0), USE_L10N (removed in 5.0) and the staticfiles template library (removed in 3.0) still appear in project skeletons from those books; the 6.1 startproject template has none of them.

  • Models in those books rely on the old implicit AutoField primary key and sometimes declare no DEFAULT_AUTO_FIELD; see below for the 6.0 default.

  • A requirements/ folder with base.txt, local.txt and production.txt, Pipenv or virtualenvwrapper, and a root manage.py beside a project package named after the product are the older pattern; pyproject.toml (with pip or uv) and a config/ package are the current default of the popular templates.

  • Secrets committed in settings.py and DEBUG = True / ALLOWED_HOSTS = ["*"] for deployment appear in the books' sample projects. Do not reproduce them: read the secret from the environment and run check --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.py auto-discovery and the DEFAULT_AUTO_FIELD setting with AppConfig.default_auto_field.

  • 4.1: default_app_config removed.

  • 4.1: Python files generated by startproject, startapp and the migration commands are formatted with black when it is on the PATH.

  • 6.0: startproject and startapp create a missing target directory; DEFAULT_AUTO_FIELD defaults to BigAutoField and the generated settings and AppConfig no longer set it. Projects that still want AutoField set DEFAULT_AUTO_FIELD = "django.db.models.AutoField" or the app’s default_auto_field.

  • 6.1: the release notes mention no change to startproject, startapp, AppConfig or 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.