Django/Python

A complete guide to organizing settings in Django

November 11th, 2021

How do you organize your Django settings? In this article, we describe a simple approach we refined through many iterations. Our goal is not only to present you with a solution, but also to explain why it's better than the alternatives, and what those alternatives are.

Django, by default, keeps all the project settings in a single monolithic settings.py file. This is what gets created when you generate a new project using Django's command-line tools. It is simple.

Problem is, it is too simple. The monolithic approach doesn't fit well with how we develop, share and deploy our code:

  • It mixes project architecture and configuration (eg. installed apps and secret keys).
  • It should be committed to the source code repository but contains secrets.
  • Configuration differs between developers and various testing, staging, and production servers.

Those are old and known problems with the default approach. Over the years, people have come up with various improved approaches.

Throughout our work as a web development agency specializing in Django, we went through several of these approaches, adapting them as our needs and development practices changed. In this article we present a short version of that journey, to give you the context and rationale of why today's best practices are what they are.

photo by Julia Joppinen

local.py

The first improvement that many Django developers make to default is to have a local settings file. They create a local.py that gets imported at the end of settings.py, which only hosts the defaults. The local settings file is not committed to the repository, everyone has a separate copy.

This solves the difficulties with a single monolithic file:

  • Project architecture is still kept in settings.py. Configuration is now done in local.py.
  • The main settings file can be committed to the code repository without problems, as all the secrets are in the local settings.
  • Everyone has their version of local settings.

But this created a new set of problems:

  • On more complex projects, local settings contained more logic (for example, to select the correct variant of the setting for dev/prod/test) and that ended being duplicated between users, sometimes leading to slight inconsistencies
  • local.py couldn't reference default setting values from settings.py as that would create a circular import
  • It was easy to slip up and change something in local settings where the change ought to have been made in the global settings

We doubled down on this approach and created a more complex settings layout:

The settings package

This is another popular layout. Instead of a single settings.py module, have a settings package (directory with __init__.py), that contains the base settings, settings files specialized for a specific deployment type (development, testing, production), local settings file (like before), and a wrapper that just imported everything.

The structure looks like this:

settings/
    __init__.py
    base.py
    development.py
    testing.py
    production.py
    local.py

The base.py file is mostly the original settings.py. Deployment-specific settings then tweak the config as needed. For example, for development.py we might have:

from .base import *

DEBUG = True

# Other development settings go here ...

And the __init__.py module contains this:

from .base import *
from .local *

In local.py, we can then import whichever development type we want to extend and change the few extra settings.

This has the added benefit of being able to select alternative settings at invocation time. For example in development, we'd use local settings that are tweaked for our local use, but run tests with test settings so we have the same settings as on the test server:

DJANGO_SETTINGS_MODULE=settings.test python.manage.py test

Over time, this approach also showed drawbacks:

  • it's complex
  • it doesn't support the case where parts of the configuration are via environment variables (such as on Heroku, Docker, or other popular PaaS)
  • it requires a local file to be present on the filesystem that's not in the repository (again a problem with Heroku and Docker)

Settings package with env support

To solve the problem with environment variables, we adapted our approach by also having an env.py that can read variables from the environment. Our local.py then became:

from .base import *
from .dev import *

# Any additional settings here ...


from .env import *  # Override from environment vars

To avoid the local file problem on Heroku, we modified the settings/__init__.py module to contain:

from .base import *

try:
    from .local import *
except ImportError:
    pass


from .env import *

This worked well. This worked so well that we just kept adding everything to env variables and kept our local settings file minimal.

This leads us at last to the current twelve-factor approach, of having everything in the environment.

The config parsers

Juggling dozens of environment variables gets tiring pretty fast, so most systems that read from env support the .env file containing VARIABLE=value pairs. It gets loaded into the current environment at the service start.

There's a popular package python-dotenv that does just that. The only thing we have to do is to initialize the Django settings with the values obtained via dotenv.

There's only a teeny-tiny problem left: dotenv gives us all strings and sometimes we want integers, booleans, file paths, lists... We want to keep this config parsing in a single place (not spread out throughout our Django settings).

Whether we're loading environment variables manually or using python-dotenv, we still need to parse these into Python values of proper types. Several popular packages do this, such as django-environ and python-decouple.

Which you prefer is a matter of personal taste. We like the simplicity of using just dotenv with a small parser we wrote ourselves in less than 100 lines of code. It's shown in this GitHub gist - feel free to copy it if you'd like to take the same approach.

We can now go back to only having settings.py that imports the config parser, fetches the configuration, and initializes Django settings as needed.

How to handle Django settings

If you've read everything so far, congrats! You've now seen a couple of popular ways how not to handle Django settings.

And for those that skimmed here and are just joining us, here's how you want to organize your settings files in a Django project.

settings.py
.env
.env.sample

Contents of settings.py are your standard old Django settings, but instead of hardcoding values there, use functions that parse them from the environment.

Alternatively, if you have a local configuration parser (env.py) as mentioned previously, you might turn settings into a package but with only two files - base settings and the config parser:

settings/
    __init__.py
    env.py
.env
.env.sample

In both cases, the other two files are the environment file itself (.env) and the sample (or template) environment file.

The settings file would look like this (this is not a complete settings file, and this example assumes we have the local config parser):

from .env import *

# Interpret DEBUG env variable as a boolean value and use it. Default to False if not set.
DEBUG=ENV_BOOL('DEBUG', False)

...

# Static path is usually `static/` in project root, but can be overridden
STATIC_ROOT = ENV_STR("STATIC_ROOT", ABS_PATH("static"))

...

The .env file is the file holding actual configuration for the local machine and is not committed to the repository. Note that in many cases this file will be automatically parsed before the Django project is started. This is true for both Heroku and Kubernetes. If you're deploying to a VPS using systemd, you'll probably want to set these in the systemd service file.

However, being able to parse this file directly from Django makes it easy to develop with (no need to load the environment manually during development) and keeps a single source of truth in servers where there may be multiple entry points in the same environment. A typical example of this would be a VPS running Django web processes but also running cron backend jobs.

The .env.sample is an example version of .env that contains instructions for the reader on how to set the settings and provides reasonable defaults (where possible). It both serves as documentation and makes it easy to create actual environment settings files.

For example:

# Environment-based settings; copy this file to .env in project root and edit as needed

# Whether the application runs in debug mode or not
DEBUG=true

# Set a unique secret key for the project
# SECRET_KEY=

# Database URL to use
DATABASE_URL=

The env.sample file should contain all the settings that can be influenced via environment variables, so it's easy for new team members to onboard and easy to set up new deployments or test pipelines.

Further work

This setup has stood the test of time for us in dozens of projects through many years of agency and in-house work. It's simple, follows the best DevOps practices, and is compatible with many tools and platforms.

That's not to say you couldn't push it a bit further. Introducing a bit of extra logic in settings can make defaults saner and further decrease the amount of configuration needed.

For example, the settings file for a project generated with API Bakery support different defaults based on the value of DEBUG. It also makes the .env file more user-friendly by cleverly interpolating or interpreting some settings like the log level or email backend.

Here's an excerpt from the generated env.sample:

# Set a unique secret key for the project, required for running outside DEBUG mode
# SECRET_KEY=

# Log level (default is INFO, DEBUG is very verbose)
# LOG_LEVEL=INFO

# Allowed hosts (list of comma-separated hostnames, or asterisk to match all hosts), only needed if DEBUG is false
# ALLOWED_HOSTS=

# E-mail backend to use, defaults to "smtp" if DEBUG is false, and "console" if DEBUG is true
# EMAIL_BACKEND=console

The point here is not to make the configuration too magical, but to make it easy to get started with a new project.


Read next: Serving static files with Django