In Django, a "plugin" is really a reusable app: a self-contained Python package of models, views, templates, static files, and management commands that you list in INSTALLED_APPS and, when you want to share it, publish to PyPI as a pip install-able distribution. Django has no separate "plugin API" — the app is the plugin, and a modern pyproject.toml is all you need to ship it.
Key takeaways
- A Django plugin = a reusable app added to
INSTALLED_APPS; there is no special plugin runtime in core Django. - Package it with a
pyproject.toml(setuptools or Hatchling) — the oldsetup.py-only flow and Python 2 are obsolete in 2026. - Use an
AppConfig(inapps.py) for the app label, default field, signals, and startup hooks. - Ship templates and static files namespaced under
app_name/so they never clash with other apps. - For genuine pluggability, expose extension points with entry points (
[project.entry-points]) and discover them viaimportlib.metadata. - Learn from battle-tested apps: django-allauth, djangorestframework, django-debug-toolbar, django-extensions, django-storages, django-filter, and Celery.
What is a Django "plugin", really?
Django ships no plugin manager, no register_plugin() call, and no hook bus in core. What people loosely call a "plugin" is just a reusable app — the same unit you create with startapp, but structured so it can live in any project. If you have ever run pip install djangorestframework and added 'rest_framework' to INSTALLED_APPS, you have already used a Django plugin.
Three things make an app genuinely reusable:
- No hard-coded project assumptions — it reads settings instead of importing your project's modules.
- Self-contained assets — its own templates, static files, migrations, and management commands.
- A distribution — packaging metadata so
pipcan install it from PyPI or a Git URL.
If you are new to building apps at all, start with our walkthrough on creating a Django app, then come back here to make it reusable.
In-project app vs reusable (pluggable) app
| Aspect | In-project app | Reusable / pluggable app |
|---|---|---|
| Lives in | One repo, one project | Its own package, many projects |
| Install | Already on the path | pip install your-app |
| Config | Imports project settings freely | Reads settings, ships sane defaults |
| Templates / static | Project-wide folders are fine | Namespaced under app_name/ |
| Packaging | None needed | pyproject.toml + build backend |
| Versioning | Tied to the project | SemVer with its own changelog |
| Distribution | n/a | PyPI, private index, or Git URL |
The practical rule: build it as an in-project app first, and only extract it into a package once a second project actually needs it.
How do you structure a reusable Django app?
A distributable app keeps everything it needs inside one importable package and namespaces its assets so nothing collides with the host project. The modern, recommended shape uses a src/ layout:
django-awesome/
|-- pyproject.toml
|-- README.md
|-- LICENSE
`-- src/
`-- awesome/
|-- __init__.py
|-- apps.py
|-- models.py
|-- views.py
|-- urls.py
|-- admin.py
|-- migrations/
| `-- __init__.py
|-- management/
| `-- commands/
| `-- sync_awesome.py
|-- templatetags/
| |-- __init__.py
| `-- awesome_tags.py
|-- templates/
| `-- awesome/
| `-- widget.html
`-- static/
`-- awesome/
`-- awesome.cssDeclare an AppConfig
Every reusable app should define an explicit AppConfig in apps.py. It sets the app label, the default auto field, and gives you a ready() hook for connecting signals — all without forcing the host project to know your internals. Since Django 3.2 you no longer set default_app_config; Django auto-discovers a single AppConfig, so installers just add "awesome" to INSTALLED_APPS.
# src/awesome/apps.py
from django.apps import AppConfig
class AwesomeConfig(AppConfig):
name = "awesome"
verbose_name = "Awesome"
default_auto_field = "django.db.models.BigAutoField"
def ready(self):
# Import signal handlers, register system checks, etc.
from . import signals # noqa: F401Namespace templates, static files, and tags
Django's loaders search every installed app's templates/ and static/ directories, so two apps that both ship widget.html will shadow each other. Always nest assets one level deeper under the app name — templates/awesome/widget.html and static/awesome/awesome.css — then reference them as {% include "awesome/widget.html" %} and {% static "awesome/awesome.css" %}.
Apply the same discipline to template tags: keep them in awesome/templatetags/, load them with {% load awesome_tags %}, and follow our guide to custom template tags and filters when you build them.
Ship management commands
Reusable apps often need a setup or sync step. Add management/commands/<name>.py and Django exposes it as python manage.py <name> for any project that installs your app. This is exactly how a sync_awesome command cleanly replaces the old, framework-specific "sync plugins" step. See our worked example of writing a custom management command for the full pattern.
# src/awesome/management/commands/sync_awesome.py
from django.core.management.base import BaseCommand
from awesome.models import Widget
class Command(BaseCommand):
help = "Synchronise Awesome widgets defined in code with the database."
def handle(self, *args, **options):
created = Widget.sync_from_registry()
self.stdout.write(self.style.SUCCESS(f"Synced {created} widgets"))How do you package a Django app with pyproject.toml?
In 2026 the standard is a single pyproject.toml. A bare setup.py is legacy, and Python 2 is long dead — target Python 3.10+ and Django 4.2 LTS or 5.x. Pick any PEP 517 build backend; Hatchling and setuptools are the two most common. Here is a complete Hatchling example:
# pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "django-awesome"
version = "1.0.0"
description = "A reusable Awesome app for Django."
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
authors = [{ name = "Your Team", email = "dev@example.com" }]
dependencies = ["Django>=4.2"]
classifiers = [
"Framework :: Django",
"Framework :: Django :: 5.0",
"Programming Language :: Python :: 3",
]
[project.urls]
Homepage = "https://github.com/your-org/django-awesome"
[tool.hatch.build.targets.wheel]
packages = ["src/awesome"]Include templates and static files in the build
Non-Python files do not ship by default. With Hatchling and a src/ layout, files inside the package directory are bundled automatically. With setuptools, set include_package_data = true and list the assets in a MANIFEST.in:
# MANIFEST.in (setuptools only)
recursive-include src/awesome/templates *
recursive-include src/awesome/static *
include LICENSE README.mdBuild and publish
Build a wheel plus a source distribution, then upload to PyPI (or a private index) with Twine:
$ python -m pip install build twine
$ python -m build # creates dist/*.whl and dist/*.tar.gz
$ python -m twine upload dist/*Anyone can now install your app from PyPI, a private index, or straight from a Git repo:
$ pip install django-awesome
# or pin it straight from a repo:
$ pip install "git+https://github.com/your-org/django-awesome.git@v1.0.0"Then add the app to INSTALLED_APPS and run migrations:
# settings.py
INSTALLED_APPS = [
# ...
"awesome",
]How do you build real plugin discovery (entry points)?
When you want third-party packages to extend your app at runtime — the classic "plugin" use case — reach for Python entry points rather than a bespoke framework. Each plugin package advertises a callable under a named group in its own pyproject.toml, and your app discovers them at startup with importlib.metadata.entry_points().
First, a plugin package registers itself:
# A third-party plugin's pyproject.toml
[project.entry-points."awesome.plugins"]
pdf_export = "awesome_pdf.exporters:PDFExporter"# src/awesome/registry.py
from importlib.metadata import entry_points
def load_plugins():
"""Discover every package that registered an 'awesome.plugins' entry point."""
plugins = {}
for ep in entry_points(group="awesome.plugins"):
plugins[ep.name] = ep.load() # import the class/callable
return pluginsThis is the same mechanism that powers pytest plugins, Flask extensions, and much of Django's ecosystem. It needs no extra dependency — importlib.metadata is in the standard library, and the entry_points(group=...) selection API shown here is stable on Python 3.10+.
Historically, third-party libraries such as django-plugins offered admin-managed plugin points backed by a database table (PluginPoint/Plugin models and a sync plugins command). In 2026 the same goals — registration, enable/disable, discovery — are met by reusable apps plus entry points with no extra dependency, which is why the reusable-app pattern is the recommended path.
Notable reusable Django apps to learn from
The fastest way to internalise good packaging is to read apps that already do it well:
| Package | What it adds | Pattern worth copying |
|---|---|---|
| djangorestframework | REST APIs | Settings namespacing, app-level defaults |
| django-allauth | Auth & social login | Pluggable providers via app config |
| django-debug-toolbar | Dev profiling panels | Registry of swappable panels |
| django-extensions | Extra mgmt commands | management/commands/ at scale |
| django-storages | File storage backends | Swappable backend classes |
| django-filter | Queryset filtering | Reusable, composable form classes |
| Celery (django) | Background tasks | Task autodiscovery across apps |
Open their pyproject.toml and apps.py side by side and you will see every convention above applied in production.
Need a hand?
Packaging, versioning, and CI for a shared Django app get fiddly once several teams depend on it. If you want help designing a reusable-app architecture or extracting tangled code into clean, installable packages, our Django development services team has shipped reusable apps and pluggable platforms since 2014, across 50+ projects for startups and enterprises.
Frequently Asked Questions
Is there an official Django plugin system?
No. Django core has no plugin manager or hook registry; the official unit of reuse is the app. You install a reusable app with pip, list it in INSTALLED_APPS, and it contributes models, views, templates, and commands. For runtime extensibility you layer Python entry points on top — there is nothing Django-specific to learn.
Do I still need setup.py to package a Django app?
No. A single pyproject.toml is the modern standard, read by both setuptools and Hatchling; you only keep a setup.py or setup.cfg for legacy edge cases. Target Python 3.10+ and Django 4.2 LTS or 5.x — any Python 2 packaging advice you find online is obsolete.
How do I make sure templates and static files are included in my package?
With Hatchling and a src/ layout, assets inside the package are bundled automatically. With setuptools, set include_package_data = true and list the folders in a MANIFEST.in. Always namespace them under templates/<app>/ and static/<app>/ so they do not collide with other installed apps.
What is the difference between django-plugins and a reusable app?
django-plugins was a third-party library that added admin-managed plugin points backed by database rows, popular in the Django 1.x era. Today the same goals — registration, enable/disable, discovery — are met with standard reusable apps plus importlib.metadata entry points, with no extra dependency and full Django 5.x support.
How do other packages extend my app at runtime?
Publish a named entry-point group (for example awesome.plugins) and document the interface plugins must implement. Third-party packages declare their plugin under that group in their own pyproject.toml, and your app loads them with entry_points(group="awesome.plugins"). This is exactly how pytest, Flask, and many Django tools handle extensions.
How should I version and distribute a reusable Django app?
Use semantic versioning, keep a changelog, and pin a supported Django range in dependencies (for example Django>=4.2). Distribute via PyPI for public apps, a private index such as self-hosted devpi or a cloud artifact registry for internal ones, or a pinned Git URL for quick sharing. Tag releases so installs stay reproducible.