Django Postgres enums

By

Intro

When handling select boxes / dropdowns, in Django we use choices feature. Most times it is choices attached to CharField or any of integer fields. This manages the standard char/text/int column in the database and we see the easily rendered select boxes. It works for most cases and the reason why it’s done this way is the Django support for multiple different database engines. In other words, a cost of universality.

Multiple right choices to handle choices

For a quick reference, we talk about multiple possible approaches, for instance:

class Request(models.Model):

    GET = 'GET'
    POST = 'POST'
    DELETE = 'DELETE'
    PUT = 'PUT'

    REQUEST_TYPE_CHOICES = {
        GET: 'Get',
        POST: 'Post',
        DELETE: 'Delete',
        PUT: 'Put'
    }

    ...
    request_type = models.CharField(max_length=8, choices=REQUEST_TYPE_CHOICES, default=GET)

This works, it is solid, time-proven approach. If we put the choices out of the model, we can easily reuse across models etc. However, it is a bit rigid. Django added a different approach, using enums (actually, there were cool libraries like django-choices before this was implemented in Django). This gives us a bit more control.

class RequestTypeChoices(models.IntegerChoices):

    GET = 0, 'Get'
    POST = 1, 'Post'
    DELETE = 2, 'Delete'
    PUT = 3, 'Put'


class Request(models.Model):

    ...
    request_type = models.PositiveSmallIntegerField(choices=RequestTypeChoices, default=RequestTypeChoices.GET)

There are many more features, which can be found in the official documentation Enumeration types. Please check out the docs for more details.

Where native types make more sense

For the rest of this text, we will not take into account differences between charfields/varchars/text fields, just for simplicity. That’s more a DBA topic.

A Django project is usually a homogenous environment, where the Django deployment operates on top of its database. But in real world, we might have multiple independent apps accessing the same database. Now imagine, we used the IntegerChoices, our database would look like

id request_type
1 150 0
2 175 1
3 175 2

On the other end, we would need to manage the number enumeration again, because we don’t want to work with values 0, 1, 2 hardcoded all over the place. Suddenly, we have to manage two different choice approaches. And especially, without Django, we have no real constraints on the column, so we could write there value 123 which the application would accept while it was not meant to be valid (if we assume Django was the source of truth). And having multiple apps most likely means having multiple devs working with the data. Having to maintain this context across different applications.

It is a bit easier with the strings like:

id request_type
1 150 GET
2 175 POST
3 175 DELETE

Because it is human-readable. We however repeat a lot of text and have a low cardinality column (dang, really wanted to avoid this). Anyways, it is still vulnerable to human error, because even though Django can manage the input constraints, from DBA point of view, it is just a text column and we can write there anything. So we could end up like

id request_type
1 150 GET
2 175 POST
3 175 DELETE
4 150 ~o_l

This is, where native database enums can save us (CHECK constraint would do as well, for completeness).

Postgres enums

Postgres Enumerated Types or enums are native types, which we can define like

CREATE TYPE request_type AS ENUM ('GET', 'POST', 'DELETE', 'PUT')

-- and then we use it like

CREATE TABLE service_request (
    id SERIAL PRIMARY KEY,
    ...
    request_type request_type DEFAULT 'GET'::request_type
);

Alright, this seems easy. But we suddenly manage the enum from outside the Django migrations. And we don’t know, how to use this column in Django models yet.

Without complex workarounds, migratins enums is not straightforward, we will begin with a manual migration.

Sample project

mkdir /where/ever/we/need/to
cd /where/ever/we/need/to
uv init
uv add django psycopg
uv run django-admin startproject enumtest

We will need a postgres database, so we created a db called enumtest and edited enumtest/enumtest/settings.py adding the db credentials.

cd enumtest
uv run ./manage.py startapp services

We then add the services to INSTALLED_APPS in the settings.py file.

Now, let’s see what we need to add to services/models.py:

from django.db import models

class PgEnumField(models.Field):

    def __init__(self, enum_name=None, choices=None, default=models.NOT_PROVIDED, **kwargs):
        self.enum_name = enum_name
        self.choices = choices
        self.default = default
        super().__init__(choices=choices, default=default, **kwargs)

    def db_type(self, connection):
        if self.default is not None:
            return f"{self.enum_name} DEFAULT '{self.default}'"
        return self.enum_name


class RequestTypeChoices(models.TextChoices):

    GET = 'Get'
    POST = 'Post'
    DELETE = 'Delete'
    PUT = 'Put'


class Request(models.Model):

    millis = models.PositiveIntegerField(default=150)
    request_type = PgEnumField(enum_name='request_type', choices=RequestTypeChoices, default=RequestTypeChoices.GET)

To maintain the Django expectations, we will first create empty migration:

uv run ./manage.py makemigrations services --empty
Migrations for 'services':
  services/migrations/0001_initial.py

We edit the migration file, adding:

from django.db import migrations


class Migration(migrations.Migration):

    dependencies = [
    ]

    operations = [
        migrations.RunSQL(
            sql=[
                "CREATE TYPE request_type AS ENUM ('GET', 'POST', 'DELETE', 'PUT');"
            ],
            reverse_sql=[
                "DROP TYPE request_type;"
            ]
        )
    ]

Now, we create migration for the model:

uv run ./manage.py makemigrations services

We finally migrate everything:

uv run ./manage.py migrate

Now, what’s left is admin:

from django.contrib import admin

from .models import Request

@admin.register(Request)
class RequestAdmin(admin.ModelAdmin):

    pass

Let’s verify that it works:

Django Add Service Form

And the saved record:

Database output

Outro

This is not very common issue to be solved. It is useful if we access the same database from different apps or we build Django app on top of existing database and don’t want to drastically rewrite everything.

Tags