In this guide
On this page ⌄
- Key takeaways
- Step 1: Find the project package and the settings
- Step 2: Read INSTALLEDAPPS as the table of contents
- Step 3: Walk the URLconfs
- Step 4: Read the models first
- Step 5: Read views and how they are wired
- Step 6: Check middleware, signals and startup code
- Step 7: Find work outside the request cycle
- Step 8: Trace one request end to end
- A first-day checklist
Django is one of the more readable frameworks to inherit, because it has strong conventions: every project has settings, a root URLconf, a list of installed apps, and each app tends to have models.py, views.py, urls.py and admin.py. If you know where to look, a Django codebase tells you a lot about itself before you read any business logic.
The catch is that Django also offers several ways to run code at a distance. Middleware touches every request, signals fire when models are saved, AppConfig.ready() runs at startup, custom managers change what Model.objects returns, and management commands and Celery tasks run outside the request cycle entirely. This guide gives you an order for reading a Django project that surfaces those first, along with the manage.py commands that answer structural questions faster than reading files.
Key takeaways
- Find the settings module first.
DJANGO_SETTINGS_MODULEdecides which settings are live, and many projects split settings across several files. INSTALLED_APPSis the table of contents. Separate Django's own apps, third-party apps and the project's apps.- Read URLconfs from
ROOT_URLCONFdown through everyinclude(). That gives you every public endpoint. - Models are the center of a Django project. Read them before views, and look at custom managers,
save()overrides andMetaoptions. - Check middleware order, signal receivers and
AppConfig.ready()early. They run code you will not see by reading views. - Use
manage.py check,showmigrations,diffsettingsandshellto confirm what you think you read.
Step 1: Find the project package and the settings
The root of a Django repository usually has manage.py and a project package with the same name as the project. Open manage.py; it sets the default settings module:
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.local')
Also check wsgi.py, asgi.py, Dockerfiles, .env files and deployment configs, because production often overrides that default. Common layouts include a single settings.py, a settings/ package with base.py, local.py and production.py, or settings driven by environment variables via django-environ or pydantic-settings.
To see what is actually different from Django's defaults in the environment you are running, use:
python manage.py diffsettings
It prints every setting that differs from the defaults, which is a much shorter read than the settings files themselves. In settings, pay attention to INSTALLED_APPS, MIDDLEWARE, ROOT_URLCONF, DATABASES, AUTH_USER_MODEL, CACHES, CELERY_* and anything under REST_FRAMEWORK.
Confirm the project is in a sane state before you go further:
python manage.py check
python manage.py check --deploy # production-readiness warnings
Step 2: Read INSTALLED_APPS as the table of contents
Split INSTALLED_APPS into three groups. Django's contrib apps (django.contrib.admin, auth, contenttypes, sessions) tell you which built-ins are used. Third-party apps tell you the major dependencies: rest_framework for an API, django_filters, corsheaders, allauth, celery integrations, django_extensions. The project's own apps are where the code you care about lives.
For each project app, write one line describing its responsibility. A healthy project has apps with clear names like billing, accounts, catalog. A project with one giant core app holding forty models is also common; in that case, the module structure inside core is your real map.
Note AUTH_USER_MODEL. If it is set to something like accounts.User, the project has a custom user model and every foreign key to users points there.
Step 3: Walk the URLconfs
Start at the module named in ROOT_URLCONF and follow every include():
# config/urls.py
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('api.urls')),
path('accounts/', include('accounts.urls')),
path('', include('catalog.urls')),
]
Patterns are tried in order and the first match wins, so a broad pattern near the top can shadow later ones. Older code uses url() with regexes or re_path(); newer code uses path() with converters like <int:pk> and <slug:slug>.
Reading URLconfs by hand works, but listing them is faster. The django-extensions package provides:
python manage.py show_urls
which prints every URL pattern with its view and name. If the project uses Django REST Framework, look for routers. A DefaultRouter with router.register('orders', OrderViewSet) generates list, create, retrieve, update and delete endpoints that do not appear as separate path() lines, so show_urls is especially useful there.
Step 4: Read the models first
In Django, the models are the domain. Read every project app's models.py (or models/ package) before reading views. For each model, note:
- Fields and relationships:
ForeignKey,OneToOneField,ManyToManyField, and theon_deletebehavior for each. Metaoptions:ordering(which silently affects every query),unique_togetherorconstraints,indexes,abstract = Truefor base classes,proxy = Truefor proxy models.- Custom managers:
objects = ActiveManager()meansModel.objectsmay not return all rows. save()anddelete()overrides, which run logic on every write.- Properties and methods that encode business rules.
A quick way to see the model graph is django-extensions again:
python manage.py graph_models -a -g -o models.png # needs pygraphviz or pydot
Or explore interactively. python manage.py shell (or shell_plus from django-extensions, which auto-imports every model) lets you check assumptions directly:
>>> from billing.models import Invoice
>>> [f.name for f in Invoice._meta.get_fields()]
>>> Invoice.objects.filter(status='open').query # print the SQL
Then look at migrations. python manage.py showmigrations lists every migration and whether it has been applied. Data migrations (RunPython operations) often contain one-off business logic and history worth knowing about.
Step 5: Read views and how they are wired
Views come in a few styles, and a project often mixes them:
| Style | What to look for |
|---|---|
| Function-based views | Decorators like @login_required, @require_POST, @transaction.atomic |
| Class-based views | The base class (ListView, UpdateView), get_queryset(), get_context_data(), mixins like LoginRequiredMixin |
| DRF APIView / ViewSet | permission_classes, authentication_classes, serializer_class, get_queryset(), perform_create() |
With class-based views and DRF, the behavior is spread across the class hierarchy. When a view seems to do nothing, it is usually inheriting everything. Check the method resolution order to see where a method comes from:
>>> from orders.views import OrderViewSet
>>> [c.__name__ for c in OrderViewSet.__mro__]
In DRF projects, serializers carry a lot of logic: validation in validate_<field>() and validate(), nested writes in create() and update(), and computed fields via SerializerMethodField. Treat serializers.py as part of the view layer.
Also read forms.py for HTML views, since clean() methods hold validation, and admin.py, because the Django admin is often the internal back office and can contain custom actions that change data.
Step 6: Check middleware, signals and startup code
These three are where Django code runs without being called from where you are reading.
Middleware. MIDDLEWARE in settings is an ordered list. On the way in, requests pass through it top to bottom; on the way out, responses pass bottom to top. Project middleware commonly sets the current tenant, enforces auth on certain paths, logs requests or adds headers. If a request behaves differently from what the view says, read the custom middleware.
Signals. Find every receiver:
grep -rn "@receiver\|\.connect(" --include=*.py . | grep -v migrations
post_save, pre_save, post_delete and m2m_changed receivers run whenever a model is written, from anywhere, including the admin and management commands. They are a frequent source of surprises: sending emails, updating denormalized counters, creating related objects. Note that QuerySet.update() and bulk_create() do not fire save signals, which is itself a common bug.
AppConfig.ready(). Signals usually get registered by importing a signals module inside the app's AppConfig.ready() in apps.py. Anything else in ready() runs at startup for every process: workers, web servers, tests and management commands alike.
Step 7: Find work outside the request cycle
Not all of a Django project runs in response to HTTP.
Management commands live at <app>/management/commands/<name>.py and are invoked as python manage.py <name>. List them:
python manage.py help # commands grouped by app
find . -path '*/management/commands/*.py' -not -name '__init__.py'
Each one defines a Command(BaseCommand) class with a handle() method. These are often cron jobs, imports, backfills and maintenance scripts, and they frequently encode important operational knowledge.
Background tasks. Look for tasks.py files and @shared_task or @app.task decorators (Celery), or django-rq, Dramatiq or Huey equivalents. Then find where tasks are enqueued with .delay() or .apply_async(). Celery beat schedules, in settings as CELERY_BEAT_SCHEDULE or in the database via django-celery-beat, tell you what runs periodically.
Cron and deployment. Check Procfile, Kubernetes CronJobs, or a crontab file for anything that calls manage.py.
Step 8: Trace one request end to end
With the map in place, pick one important endpoint, such as creating an order, and trace it: URL pattern, view or viewset, permission checks, serializer validation, model save(), signal receivers that fire, tasks enqueued, and the response. Running it with the debug toolbar (django-debug-toolbar) or logging SQL with django.db.backends set to DEBUG shows every query the request makes, which is often where you discover N+1 patterns and missing select_related() or prefetch_related() calls. The general approach is in tracing a request through a web app.
A first-day checklist
- Find
DJANGO_SETTINGS_MODULEfor each environment and rundiffsettings. - Run
checkandshowmigrations. - Group
INSTALLED_APPSand describe each project app in one line. - List URLs with
show_urlsand note DRF routers. - Read every model, including managers,
Metaandsave()overrides. - Read custom middleware in order.
- List signal receivers and read each app's
AppConfig.ready(). - List management commands and Celery tasks, and find their schedules.
- Trace one write request with SQL logging on.
If the repository is a monorepo with several Django services or shared packages, navigating a Python monorepo covers how import paths and package boundaries work there. For a visual overview of which apps import which, Codebase Knowledge by OpenVisio can index the project in your browser and show the app-level dependency graph in Explore, which is a quick way to spot an app that everything else depends on.

