A reusable Django admin widget for editing 2D matrices of values - add or remove rows and columns inline, persisted as JSON. Drop it on any model, no JS build step, no opinions about your storage.
- A
MatrixWidget(form widget) that renders an editable<table>with+ row/- row/+ col/- colbuttons. - A
MatrixField(model field,JSONFieldsubclass) that wires the widget by default and validates shape + dimension bounds + cell types. - Reusable validators (
RectangularMatrixValidator,MatrixBoundsValidator,NumericCellsValidator) if you want to skip the field and use the widget directly. - Vanilla JS - no npm, no bundler, no React. Configuration travels per-instance via
data-*attributes so multiple widgets on a page just work.
pip install django-matrixAdd to INSTALLED_APPS:
INSTALLED_APPS = [
# ...
"django.contrib.admin",
"django_matrix",
]# models.py
from django.db import models
from django_matrix import MatrixField
class Sheet(models.Model):
name = models.CharField(max_length=255)
grid = MatrixField(
initial_rows=3,
initial_cols=4,
min_rows=1,
max_rows=20,
min_cols=1,
max_cols=20,
cell_input_type="number",
)# admin.py
from django.contrib import admin
from .models import Sheet
@admin.register(Sheet)
class SheetAdmin(admin.ModelAdmin):
list_display = ("name",)That's it. The change form for Sheet will render an editable grid for the grid field.
[
[0, 100, 200, 300, 400],
[100, 0, 0, 0, 0 ],
[200, 0, 0, 0, 0 ]
]- Top level:
list[list[scalar | null]]. - Empty cells in numeric mode serialize as
null(rendered as a blank input). - In text mode (
cell_input_type="text"), empty cells serialize as"". - Rows are guaranteed equal-length when stored via
MatrixField(rectangular).
from django_matrix import MatrixWidget
class MySheetForm(forms.ModelForm):
grid = forms.JSONField(widget=MatrixWidget(
cell_input_type="number", # or "text"
initial_rows=2,
initial_cols=2,
min_rows=1, max_rows=None,
min_cols=1, max_cols=None,
))Override render_cell(row_idx, col_idx, value) in a subclass for selects, checkboxes, or anything else - the default returns a single <input> tag.
Same constructor kwargs as MatrixWidget plus the usual models.JSONField kwargs. formfield() returns a forms.JSONField with the widget pre-attached.
MatrixField.clean() raises ValidationError when:
- value is not a list of lists,
- rows have unequal length,
- dimensions fall outside
min_rows / max_rows / min_cols / max_cols, - cells fail to coerce to
floatandcell_input_type="number"(Noneis allowed and represents an empty cell).
The library treats every cell identically - it has no built-in "header row / header column" concept. If you want the first row and column to stand out as labels, scope a CSS rule and validate them separately in your form:
.dm-matrix tbody tr:first-child td,
.dm-matrix tbody td:first-child {
background: #f3f3f3;
font-weight: 600;
}class PriceTableForm(forms.ModelForm):
def clean_grid(self):
data = self.cleaned_data["grid"]
# row 0 = distance breakpoints, col 0 = weight breakpoints
headers_row = data[0]
if any(v is None or v < 0 for v in headers_row[1:]):
raise forms.ValidationError("Distance headers must be non-negative.")
return dataThe widget is just a 2D editor - nothing forces you to persist the grid as JSON. A common pattern is using the matrix as a bulk editor for a related model (one row per cell). For a shipping rate table where column 0 holds weight classes, row 0 holds zone names, and inner cells hold prices that should fan out into ShippingRate(zone, weight_class, price) rows:
# models.py
class ShippingRate(models.Model):
zone = models.CharField(max_length=50)
weight_class = models.CharField(max_length=50)
price = models.DecimalField(max_digits=10, decimal_places=2)
class Meta:
unique_together = ("zone", "weight_class")Keep the grid-to-model translation in two small helpers so the form stays a thin orchestrator:
# forms.py
from decimal import Decimal, InvalidOperation
from django import forms
from django.db import transaction
from django_matrix import MatrixWidget
from .models import ShippingRate
def rates_to_grid(rates):
rates = list(rates)
if not rates:
return None
zones = sorted({r.zone for r in rates})
weights = sorted({r.weight_class for r in rates})
price_at = {(r.zone, r.weight_class): str(r.price) for r in rates}
header = ["", *zones]
body = [[w, *(price_at.get((z, w), "") for z in zones)] for w in weights]
return [header, *body]
def grid_to_rates(grid):
header, *body = grid
zones = header[1:]
for weight_class, *cells in body:
for zone, cell in zip(zones, cells):
if cell:
yield ShippingRate(
zone=zone,
weight_class=weight_class,
price=Decimal(cell),
)
class ShippingRateMatrixForm(forms.Form):
grid = forms.JSONField(widget=MatrixWidget(
cell_input_type="text", # text so zone/weight labels and prices coexist
initial_rows=3, initial_cols=3,
min_rows=2, min_cols=2,
))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.is_bound:
self.initial.setdefault("grid", rates_to_grid(ShippingRate.objects.all()))
def clean_grid(self):
grid = self.cleaned_data["grid"]
try:
list(grid_to_rates(grid))
except InvalidOperation as exc:
raise forms.ValidationError("All prices must be decimal numbers.") from exc
return grid
@transaction.atomic
def save(self):
ShippingRate.objects.all().delete() # simple replace-all strategy
ShippingRate.objects.bulk_create(grid_to_rates(self.cleaned_data["grid"]))Two things to notice:
__init__seedsinitial["grid"]from existing rows so the widget shows current data on GET.rates_to_gridreturnsNonefor an empty table, letting the widget render a blankinitial_rows × initial_colsgrid.save()is your own method (this is a plainforms.Form, not aModelForm) - call it from your view afterform.is_valid(). The replace-all strategy keeps the example short; in production you'd diff against existing rows or upsert.
Hook it into a view like any other form:
def edit_shipping_rates(request):
form = ShippingRateMatrixForm(request.POST or None)
if request.method == "POST" and form.is_valid():
form.save()
return redirect("shipping-rates")
return render(request, "rates.html", {"form": form})If you don't want the field-level validation, attach the widget yourself:
class Sheet(models.Model):
grid = models.JSONField()
class SheetForm(forms.ModelForm):
class Meta:
model = Sheet
fields = ["grid"]
widgets = {"grid": MatrixWidget(initial_rows=2, initial_cols=2)}You can still reach for the shipped validators:
from django_matrix.validators import (
RectangularMatrixValidator,
MatrixBoundsValidator,
)- Python 3.10, 3.11, 3.12, 3.13
- Django 4.2 LTS, 5.2 LTS
git clone https://github.com/kmsky/django-matrix
cd django-matrix
uv sync --extra dev
uv run pytest
uv run ruff check .
uv run mypy src/See CONTRIBUTING.md for the full workflow, including the manual JS smoke checklist.
MIT - see LICENSE.
