Django Postgres enums
By Jozef Sukovský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:
And the saved record:
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.