django-select-multiple-field
Store multiple choices in a single Django model field without using a many-to-many relationship.
Rendered using the multiselect.js plugin for jQuery. The plugin is available here: https://github.com/lou/multi-select
Denormalization Risk (Important)
This field intentionally denormalizes data by storing multiple selected choices as comma-separated text in a single column.
Use it when:
- you want a simple schema,
- you do not need relational queries (joins),
- and you mainly read/write the selected values as a list in application code.
If you need robust querying like “find all cookies with topping X,” a normalized many-to-many model is usually the better choice.
For example, if cookies can have toppings such as chocolate, dark_chocolate, and white_chocolate, a normalized design would usually use a Topping model and a Cookie model related by a ManyToManyField. That gives you joins, indexing, and exact filtering.
With this field, you filter against the stored text value instead.
# Caution: substring search can return false positives.
Cookie.objects.filter(toppings__icontains="chocolate")
That kind of lookup can produce false positives because one encoded choice can appear inside another. For example, chocolate is a substring of dark_chocolate, so a substring search can match both.
If that risk is unacceptable for your application, use a many-to-many relation instead.
Installation
Install from PyPI:
pip install django-select-multiple-field
Quick Start
Model
You must provide either
max_lengthor bothchoicesandmax_choices
Choices must be strings, not integers.
Add the select field choices normally in your model:
# models.py
from django.db import models
from select_multiple_field.models import SelectMultipleField
class Pizza(models.Model):
ANCHOVIES = 'a'
BLACK_OLIVES = 'b'
PEPPERONI = 'p'
MOZZARELLA = 'm'
TOPPING_CHOICES = (
(ANCHOVIES, 'Anchovies'),
(BLACK_OLIVES, 'Black olives'),
(PEPPERONI, 'Pepperoni'),
(MOZZARELLA, 'Mozzarella'),
)
# choices & max_choices are used to auto calc. max_length
toppings = SelectMultipleField(
max_choices=4,
choices=TOPPING_CHOICES,
)
PLAIN = 'n'
CHEDDAR = 'c'
CRUST_CHOICES = (
(PLAIN, 'Plain Crust'),
(MOZZARELLA, 'Mozzarella Stuffed Crust'),
(CHEDDAR, 'Cheddar Stuffed Crust'),
)
crust = SelectMultipleField(
max_length=6,
choices=CRUST_CHOICES,
)
Encoded Length (Choose One)
Underneath this field is a CharField which takes a maximum length for the encoded string. You can choose to provide a max_length parameter or the field will calculate it from the choices and the max_choices parameters.
max_lengthis the maximum length of the encoded string of choices. You need to add the maximum allowed choices and the delimiter character (usually a comma) that separates the choices when they are encoded.max_choicesis the maximum choices that can be encoded per field.choicescan be a mapping, iterable, or callable.
Example encoded-length calculation:
- Choice keys:
a,bb,ccc,dddd max_choices=2- Longest 2 keys are
ddddandccc, so encoded value isdddd,ccc - Required
max_lengthis8
Render the form
Use a generic view or a ModelForm as usual. In your template, use a regular form tag:
<!-- template_form.html -->
<form action="" method="post">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Submit">
</form>
This renders the following HTML:
<!-- create.html -->
<form action="" method="post">
<p>
<label for="id_toppings">Toppings:</label>
<select multiple="multiple" id="id_toppings" name="toppings" class="select-multiple-field">
<option value="a">Anchovies</option>
<option value="b">Black olives</option>
<option value="p">Pepperoni</option>
<option value="m">Mozzarella</option>
</select>
</p>
<input type="submit" value="Submit">
</form>
Null Handling
SelectMultipleField supports null=True, but it behaves differently from Django's standard CharField:
null |
Empty Selection in DB | to_python(None) |
from_db_value(None) |
get_prep_value([]) |
|---|---|---|---|---|
False (default) |
"" (empty string) |
[] |
[] |
"" |
True |
NULL |
[] |
[] |
None |
Key semantics:
null=Truechanges only database storage. Python API always returns[], neverNoneto_python(None)always returns[]regardless ofnullsettingfrom_db_value(None, ...)always returns[]regardless ofnullsettingget_prep_value(None)returnsNonewhennull=True, else""get_prep_value([])returnsNonewhennull=True, else""
Use null=True only when your database conventions require NULL over empty string for empty selections. For form-level optional fields, use blank=True instead.
Validators
SelectMultipleField replaces Django's built-in MaxLengthValidator with two custom validators:
MaxChoicesValidator
- Validates:
len(value) ≤ max_choices - Error code:
max_choices - Message:
"Ensure this value has at most %(limit_value)d choice(s)..."
MaxLengthValidator
- Validates:
len(encode_list_to_csv(value)) ≤ max_length - Error code:
max_length - Message:
"Ensure this value has at most %(limit_value)d character(s)..."
Both validators run during model validation. If you provide choices and max_choices but omit max_length, the field auto-calculates max_length from the longest possible encoded CSV (longest max_choices keys joined by delimiters). If you explicitly set max_length smaller than this calculated maximum, a RuntimeWarning is emitted at field instantiation.
Validation Error Codes
The field can raise the following validation errors:
| Error Code | Message Template | Triggered When |
|---|---|---|
invalid_type |
"Types passed as value must be string, list, tuple or None, not '%(value)s'." |
to_python receives non-list/tuple/str/None |
invalid_choice |
"Select a valid choice. %(value)s is not one of the available choices." |
Value not in choices |
blank |
"This field cannot be blank." |
blank=False and empty value |
null |
"This field cannot be null." |
null=False and None value |
max_choices |
"Ensure this value has at most %(limit_value)d choice(s)..." |
len(value) > max_choices |
max_length |
"Ensure this value has at most %(limit_value)d character(s)..." |
len(encoded) > max_length |
Displaying Stored Choices
To display your choices, decode the field contents. You can do this with a template tag:
# templatetags/pizza_tags.py
def decode_pie(ingredients):
"""Decode pizza pie toppings."""
decoder = dict(Pizza.TOPPING_CHOICES)
decoded = [decoder[t] for t in ingredients]
decoded.sort()
return ', '.join(decoded)
register.filter('decode_pie', decode_pie)
In your template, import the tag and use it:
{# details.html #}
{% load pizza_tags %}
{{ pizza.toppings|decode_pie }}
Encoding the Choices
The selected choices are stored as comma-delimited text. For example, a pizza with the following toppings:
- Pepperoni
- Mozzarella
would be stored as:
p,m
You can decode that string to a Python list using functions in the codecs module:
>>> from select_multiple_field.codecs import decode_csv_to_list
>>> encoded = 'a,b,c'
>>> decoded = decode_csv_to_list(encoded)
>>> print(decoded)
['a', 'b', 'c']
>>> print(type(decoded))
<class 'list'>
Custom Delimiters
The CSV delimiter is configurable via Django settings:
# settings.py
SELECTMULTIPLEFIELD_DELIMITER = "|" # Default: ","
Constraints:
- Must be a single character
- Multi-character delimiters are not supported
- Choice values cannot contain the delimiter (e.g., if delimiter is
,, a choice like"a,b"breaks encoding)
Both encode_list_to_csv() and decode_csv_to_list() in select_multiple_field.codecs respect this setting automatically. Changing the delimiter on an existing database requires a data migration to re-encode stored values.
The encoding method may limit your ability to search for choices.
Sample Application
This repository includes sample applications under test_projects/. You can run the Django 4.2 integration app like this:
$ cd /path/to/django-select-multiple-field
$ cd test_projects/django42
$ python manage.py migrate
$ python manage.py runserver
Development server is running at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
Versions
This code was tested with the following versions of Django and Python:
- Django 4.2 LTS
- Python 3.10
- Python 3.11
- Python 3.12
- Python 3.13
- Django 5.2 LTS
- Python 3.10
- Python 3.11
- Python 3.12
- Python 3.13
- Python 3.14
- Django 6.0
- Python 3.12
- Python 3.13
- Python 3.14
Testing
Django-select-multiple-field contains two test suites: one for the field itself and one for the Django integration apps.
To run the unit tests use hatch interactively:
hatch run tests # All tests {unit, integration}
hatch run i # Integration tests
hatch run t # Unit tests
The dependencies are managed manually in your environment.
To run all the tests with differing Python versions and Django versions, use tox:
tox # All tests for supported versions
tox -e py312-dj42 # All tests using Python 3.12 & Django 4.2 LTS
Bugs! Help!!
If you find any bugs in this software, please report them via the GitHub issue tracker or send an email to code@kelvinwong.ca. Any serious security bugs should be reported via email only.
Issue tracker: https://github.com/kelvinwong-ca/django-select-multiple-field/issues
Links
- https://pypi.org/project/django-select-multiple-field/
- https://github.com/kelvinwong-ca/django-select-multiple-field
Thank You
Thank you for taking the time to evaluate this software. I appreciate receiving feedback on your experiences using it, and I welcome code contributions and development ideas.
Release files for django-select-multiple-field 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_select_multiple_field-1.0.0.tar.gz | 12.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_select_multiple_field-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 27.4 kB
Release files / django_select_multiple_field-1.0.0.tar.gz
| Download URL | django_select_multiple_field-1.0.0.tar.gz |
|---|---|
| Size | 12.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ac368fe7dd538936818a0a536fcf645adfa5dafc6cba9257df2d2b8c60fa36ae
|
|
BLAKE2b-256 checksum How to use checksums |
32cdb10f3c5a7626e98ddd96700b94954724417e99772c8acab4125d73133659
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
Hatch/1.18.0 {"ci":null,"cpu":"arm64","distro":{"name":"macOS","version":"15.7.9"},"implementation":{"name":"CPython","version":"3.12.13"},"installer":{"name":"hatch","version":"1.18.0"},"openssl_version":"OpenSSL 3.6.3 9 Jun 2026","python":"3.12.13","system":{"name":"Darwin","release":"24.6.0"}} HTTPX2/2.12.0
|
Release files / django_select_multiple_field-1.0.0-py3-none-any.whl
| Download URL | django_select_multiple_field-1.0.0-py3-none-any.whl |
|---|---|
| Size | 14.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6c7f41a31309982b9085129454faf345143093dfdc0daca2ecdadcea25df291c
|
|
BLAKE2b-256 checksum How to use checksums |
1b7b2f8708059190634723a87980f60180629724d90a0d20b0c17455244bc497
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
Hatch/1.18.0 {"ci":null,"cpu":"arm64","distro":{"name":"macOS","version":"15.7.9"},"implementation":{"name":"CPython","version":"3.12.13"},"installer":{"name":"hatch","version":"1.18.0"},"openssl_version":"OpenSSL 3.6.3 9 Jun 2026","python":"3.12.13","system":{"name":"Darwin","release":"24.6.0"}} HTTPX2/2.12.0
|