django-distill: exporting a Django site as static pages without leaving Django
Minimal configuration static site generator for Django
At a glance
- What is it?
- django-distill is a static site generator that works as a Django extension, letting you mark which URLs should be rendered to files and pushing the result to a bucket or a local directory. Version 4 is a ground-up rebuild with a single distill command, and the licence on the repository and in the package metadata do not agree, which is worth checking before you adopt it.
- Who is it for?
- django-distill suits a Django team that wants most of its site served as static files while keeping the CMS and the database for authoring, and it does not suit a project whose URLs depend on querystring parameters or that needs POST handling in the exported pages.
- Can I use it commercially?
- Yes. BSD-3-Clause is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 23 days ago.
- What is it written in?
- Mainly Python, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 20, 2026, and from our analysis. They are not legal advice.
Editorial analysis
Static pages for the parts of a Django site that do not need a server
The project description is a minimal configuration static site generator and publisher for Django, and the readme expands on who it is for. It suits sites such as blogs that have a mostly static front-end but you still want to use a CMS to manage the content.
That framing is the whole idea. A Django site with a database-backed admin for writing posts can publish those posts as ordinary HTML files, and serve the rest of the site dynamically. The reader gets a file, the server does nothing, and the author keeps the Django workflow.
The mechanism is that django-distill iterates over URLs in your Django project using easy to write iterable functions to yield the parameters for whatever pages you want to save as static HTML. So for each URL pattern you mark, you supply a function that yields the parameter combinations, and the generator requests each of those URLs through Django and writes the response to a file.
The output can be automatically uploaded to a bucket style remote container such as Amazon S3, Google Cloud Files or Microsoft Azure Storage, or written to a local directory as a fully working local static version of the project. And the whole thing is designed to be integrated into CI/CD workflows to auto-deploy static sites on commit.
Two features make it more usable than a full site export would be. It plugs directly into the existing Django framework without the need to write custom renderers or other more verbose code, and you can integrate it with existing dynamic sites and just generate static pages for a small subsection of pages rather than the entire site.
That second point is the one that makes the tool practical for an existing production site. You do not have to choose between a fully dynamic Django deployment and a fully static export. You pick the pages that benefit.
distill_path and the two keyword arguments that do the work
The integration is a URL routing function, and the whole configuration surface is two keyword arguments.
Assuming an existing Django project, you edit a urls.py to include the distill_path function, which replaces Django's standard path function and supports the new keyword arguments distill_func and distill_file.
The distill_func argument should be provided with a function or callable class that returns an iterable or None. The distill_file argument is entirely optional and allows you to override the URL that would otherwise be generated from the reverse of the URL regex.
Here is the shape of a setup, from the readme's example.
def get_index():
# The index URI path, '', contains no parameters, named or otherwise.
# You can simply just return nothing here.
return None
def get_all_blogposts():
# This function needs to return an iterable of dictionaries. Dictionaries
# are required as the URL this distill function is for has named parameters.
# You can just export a small subset of values here if you wish to
# limit what pages will be generated.
for post in Post.objects.all():
yield {"blog_id": post.id, "blog_title": post.title}
def get_years():
# You can also just return an iterable containing static strings if the
# URL only has one argument and you are using positional URL parameters:
return (2014, 2015)
# This is really just shorthand for ((2014,), (2015,))Three things to read there. A function returning None means the path has no parameters and nothing needs iterating, so the index page is one call with no arguments. A function yielding dictionaries is for URL patterns with named parameters, and the comment points out that you can export a subset rather than everything. And the years function returns a plain tuple of integers, which the comment explains is shorthand for a tuple of one element tuples, because that URL uses positional parameters rather than named ones.
Then the URLs are declared with the same syntax as the standard path function.
urlpatterns = (
# e.g. / the blog index
distill_path(
"",
PostIndex.as_view(),
name="blog-index",
# Note that for paths which have no paramters
# distill_func is optional
distill_func=get_index,
# '' is not a valid file name! override it to index.html
distill_file="index.html",
),The distill_file override exists because the empty string is not a valid file name, and the general purpose of the argument is renaming URLs. The readme says you can rename URLs like /example to any other name like example.html, and that as of v0.8 any URIs ending in a slash are automatically modified to end in /index.html.
You can use format string parameters in distill_file to customise the file name, with arg values from the URL substituted in, for example {} for positional args or {param_name} for named args. So a pattern like /blog/{blog_id} can become blog/{blog_id}.html without any extra code.
The limitations are stated plainly, and one of them is a design choice
The limitations section is short and honest, and the second limitation is the one that shapes how you plan your URLs.
First, django-distill generates static pages and therefore only views which allow GET requests that return an HTTP 200 status code are supported. There is no POST, and no error page becomes a file.
Second, it is assumed you are using URI parameters such as /blog/123-abc and not querystring parameters such as /blog?post_id=123&title=abc. The readme says querystring parameters do not make sense for static page generation for obvious reasons, and it is right: a static file has one name, and a query string is a request time parameter with no equivalent in the filesystem. If your existing URLs are querystring based, this is not a tool you can retrofit without changing the URL scheme, which is a routing change and a SEO consideration at the same time.
The third limitation is about static media, and it has a practical consequence for a build pipeline. Static media files such as images and stylesheets are copied from your static media directory defined in STATIC_ROOT, which means you will want to run ./manage.py collectstatic before you run ./manage.py distill generate if you have made changes to static media. django-distill doesn't chain this request by design, however you can enable it with the --collectstatic argument.
Not chaining it is the right call. A tool that silently runs collectstatic for you on every generation is a tool that occasionally rebuilds your entire static tree when you only meant to change a template. Making it an explicit flag keeps the expensive step visible.
The requirement is a 200 response code, and that has a knock on effect. Any view that redirects, or that returns a 404 for some inputs, will not produce a file, so a URL pattern whose parameters include values that are not valid will be skipped silently unless you check.
Version 4 is a rebuild, and the commands were consolidated
If you have used django-distill before, the readme has a warning box, and it is unambiguous. Version 4.0.0 and later is a ground-up rebuild, and full compatibility with 3.x.x and older is not guaranteed. The API and interfaces have been made as compatible as possible, however some method names and interfaces may have changed, and you should treat upgrading to 4.0.0 and later as a breaking change and review the documentation as well as thoroughly test your projects.
The change list gives you the specifics, and several of them matter for how you work.
Usage of the Django test framework has been replaced with internal WSGI requests. That is a change in how the generated pages are produced: instead of driving the views through Django's test client, the generator issues WSGI requests internally, which is faster and closer to how a real request behaves.
The codebase was modernized with removal of legacy Python and legacy Django support, and the project now has type hints and is linted with ruff and packaged via uv. Test coverage was improved.
The routing changed approach. The switch to using patched URLPatterns rather than custom path(...) overrides is a significant internal change, and it lines up with the requirement that the project be broadly compatible where possible with the logic and implementation of Django Distill.
Commands were consolidated into a single distill command, so the old separate commands are gone. And logging was moved to the standard logging module, with messages of rendering changed to use the standard logging module. The readme adds a practical note about that: if you don't see full logs when running distill generate or other commands, you will likely need to configure a console handler for your settings.LOGGING configuration with the level set to INFO.
The last item on the list is worth noting for anyone with a large site. Full compatibility when integrated with other contrib modules, such as humanize, sitemaps and flatpages, means the generated pages behave the way those modules expect rather than breaking in subtle ways.
The current version is 4.0.6, and the recent releases are v4.0.6 on 2026-09-30, v4.0.5 on 2026-09-21 and v4.0.4 on 2026-08-22, so the patch releases are landing regularly. The last push was on 2026-09-30.
Installing it, and the two licence answers you will get
Installation is deliberately minimal, and the readme's claim that it is a minimal configuration generator starts with two steps.
$ pip install django-distillThen add the app to your INSTALLED_APPS.
INSTALLED_APPS = [
# ... other apps here ...
"django_distill",
]That is it, and the readme says so.
The package metadata in pyproject.toml gives the shape of the dependency footprint. The project name is django-distill at version 4.0.6, it requires Python 3.10 or newer, and its two runtime dependencies are django at 5.2 or later and typing-extensions at 4.16.0 or later. There is a classifier for Development Status :: 5, Production/Stable, and the topic keywords list s3, amazon, aws, google cloud storage and azure blob storage, which reflects the publishing targets.
The cloud backends are optional extras rather than hard dependencies: amazon pulls in boto3, google pulls in google-api-python-client and google-cloud-storage, and microsoft pulls in azure-storage-blob. So a local or S3 only deployment does not drag in the other two SDKs.
There is a matching optional library for the problem that static hosting creates with caching. For static files on CDNs you can use the cache buster library django-cachekiller to allow for fast static media updates when pushing changes, and there is a complete example site that creates a static blog and uses django-distill with django-cachekiller via continuous deployment on Netlify.
And here is the wrinkle worth flagging. The licence field in the package metadata says MIT, with the classifier License :: OSI Approved :: MIT License. The licence on the repository is recorded as BSD-3-Clause. The readme's own licence section is not visible in the excerpt, and the repository carries a LICENSE file. So the two official-looking sources disagree, and for a library you are going to vendor into a commercial project that is a problem to solve before you rely on it rather than after.
The development tooling is also modern in a way that tells you something. The Makefile runs the test suite through uv.
test:
uv run manage.py testsuiteand linting and formatting both go through uvx ruff, with the build and publish steps running uv build and uv publish. The repository has a uv.lock, a .python-version file, a SECURITY.md, a CODE_OF_CONDUCT.md and a tests/ directory.
Where django-distill is the wrong tool
There are four cases where this is the wrong choice, and they come down to what a static export cannot be.
The first is a site whose URLs are querystring based. The readme states the assumption plainly, and if your existing site is built that way, adopting this means changing the URL scheme. That is a routing change, a redirect story, and a search engine consequence, and it is a far bigger project than adding a static site generator.
The second is any page that depends on the request rather than on the URL. A page whose content depends on who is logged in, on a cookie, on a referrer or on a query parameter produces a different response per request, and a static export of it produces one wrong answer that everyone then sees. The 200 status requirement does not protect you here, because a view that varies by user and always returns 200 is exactly the case that breaks.
The third is a site that is genuinely dynamic. django-distill is at its best as a hybrid, and for a live dashboard, a search page or an authenticated area, the static half adds nothing.
The fourth is a project that is not Django. This is an extension to Django, using a Django URL pattern function and Django view classes, and it renders by making requests into a Django project. There is nothing here that transfers to another framework.
On the alternative, the difference in approach against a dedicated static site generator is where the content lives. A dedicated generator takes content files, typically Markdown, in a directory and produces a site. django-distill takes a running Django application with a database and a template layer, and asks which URLs should be frozen. The consequence is that your content is already in the database, already has the admin interface, already has the permissions model, and the static output is derived from it rather than being the source.
The trade is that a static site generator gives you a build that is fast, reproducible and independent of the application, while django-distill gives you one source of truth and a much smaller conceptual footprint for a CMS driven site. For a blog with an editor, the second is less work. For a documentation site with many authors and a review workflow, the first is often better, and the readme's own example of a static blog with Netlify continuous deployment shows the pattern working.
On maintenance and the licence, the facts are as stated above. The project is at 4.0.6 with regular patch releases, the last push was on 2026-09-30, and the repository is not archived. The licence discrepancy between the package metadata and the repository field is the one thing to resolve first, and the readme's instruction to treat the 4.0.0 upgrade as a breaking change and to test thoroughly is the other.
Editorial conclusion
django-distill suits a Django team that wants most of its site served as static files while keeping the CMS and the database for authoring, and it does not suit a project whose URLs depend on querystring parameters or that needs POST handling in the exported pages. Before you upgrade from the 3.x line, read the 4.0.0 change list and treat it as a breaking change as the readme tells you to, and resolve the licence question first, because the repository is recorded under a different licence from the one the package metadata declares.
Frequently asked questions
What is django-distill?
It is a static site generator and publisher that works as a Django extension. You mark URL patterns with distill_path, supply a function yielding the parameters for each pattern, and the generator renders those URLs to files that can be published to S3, Google Cloud or Azure storage, or written locally.
How do I install django-distill?
Run pip install django-distill, then add django_distill to INSTALLED_APPS in your settings.py. The package requires Python 3.10 or newer and Django 5.2 or later, with typing-extensions as its only other runtime dependency.
What changed in django-distill 4.0.0?
It is a ground-up rebuild, and the readme says to treat the upgrade as a breaking change. Notable changes include internal WSGI requests replacing the Django test framework, a switch to patched URLPatterns instead of custom path overrides, consolidation of the commands into a single distill command, and logging moved to the standard logging module.
What are the limitations of django-distill?
Only views that allow GET requests and return HTTP 200 are supported, URLs are assumed to use path parameters such as /blog/123-abc rather than querystring parameters, and static media is copied from STATIC_ROOT, so you should run collectstatic before distill generate or pass the --collectstatic argument.
Which cloud backends does django-distill support?
Amazon S3, Google Cloud Storage and Microsoft Azure Storage, each as an optional extra dependency: boto3 for amazon, google-api-python-client and google-cloud-storage for google, and azure-storage-blob for microsoft. The output can also be written to a local directory.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/meeb-django-distill)Was this useful?