The Pain of Hardcoded Forms
Every Django developer knows the ritual. You define a model. Then you write a ModelForm. Then you tweak the widgets. Then you add a crispy layout. Then the client says "add a field" and you touch four files. Then they say "make that field conditional" and you add JavaScript. Then they say "same form, but in a modal" and you duplicate a template.
Multiply this by 20 models and you are maintaining a small army of form classes, each slightly different, each hardcoded, each quietly drifting out of sync with the model it represents.
I hit this wall on two very different projects - an industrial manufacturing quality control system and a financial trading back office - and arrived at the same solution both times: stop writing form classes and start writing form configurations.
The Core Idea: Forms Are Data
A Django form field is really just a bag of properties: a name, a type, a widget, a label, whether it is required, what choices it has. There is nothing in that description that requires a Python class definition. It is data, and data belongs in a data structure.
The insight is simple: define fields as dictionaries, store them in a schema, and let a single generic form class build itself from that schema at runtime.
# Instead of this:
class InspectionForm(forms.Form):
diameter = forms.FloatField(
label="Inner diameter",
widget=forms.NumberInput(attrs={"class": "form-control"}),
)
status = forms.ChoiceField(
choices=[("pass", "Pass"), ("fail", "Fail")],
widget=forms.Select(attrs={"class": "form-select"}),
)
# Write this:
INSPECTION_SCHEMA = {
"fields": [
{
"name": "diameter",
"type": "FloatField",
"widget": "NumberInput",
"label": "Inner diameter",
"widget_attrs": {"class": "form-control"},
},
{
"name": "status",
"type": "ChoiceField",
"widget": "Select",
"label": "Status",
"choices": [("pass", "Pass"), ("fail", "Fail")],
"widget_attrs": {"class": "form-select"},
},
]
}
The DynamicForm Engine
The entire system rests on a single class. It maps string identifiers to Django field classes and widget classes, then iterates through the schema to build itself:
class DynamicForm(forms.Form):
FIELD_MAP = {
"CharField": forms.CharField,
"ChoiceField": forms.ChoiceField,
"FloatField": forms.FloatField,
"IntegerField": forms.IntegerField,
"BooleanField": forms.BooleanField,
"DateField": forms.DateField,
"DecimalField": forms.DecimalField,
"EmailField": forms.EmailField,
# ... every Django field type
}
WIDGET_MAP = {
"TextInput": forms.TextInput,
"Select": forms.Select,
"NumberInput": forms.NumberInput,
"Textarea": forms.Textarea,
"CheckboxInput": forms.CheckboxInput,
"DateInput": forms.DateInput,
# ... every Django widget type
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_cfg in self.scheme["fields"]:
field_class = self.FIELD_MAP[field_cfg["type"]]
widget_class = self.WIDGET_MAP[field_cfg["widget"]]
self.fields[field_cfg["name"]] = field_class(
label=field_cfg["label"],
widget=widget_class(
attrs=field_cfg.get("widget_attrs", {})
),
required=field_cfg.get("required", True),
choices=field_cfg.get("choices", []),
)
That is the entire engine. Every Django field type, every widget type, mapped by string lookup. A new form is a new dictionary, not a new class.
Case Study 1: Manufacturing Quality Control
The first project was a production data system for an industrial manufacturer. Quality inspectors follow Standard Operating Procedures (SOPs) that require measuring specific dimensions, checking surface treatments, and recording pass/fail results for each production stage.
The challenge: 20+ different inspection forms, each with unique fields specific to the workpiece type and SOP step. An end plate vertical lathe inspection measures different things than a bipolar plate milling inspection.
With hardcoded forms, that is 20+ form classes, each with its own field definitions, its own validation, its own template quirks. With the blueprint approach, it is 20+ dictionaries and one form class.
# Each QC inspection is just a schema definition
QC_ENDPLATE_LATHE_SCHEMA = {
"fields": [
{
"name": "inner_diameter",
"widget": "TextInput",
"label": _("Inner diameter: 1846mm"),
},
{
"name": "outer_diameter",
"widget": "TextInput",
"label": _("Outer diameter: 1876-1876.1mm"),
},
{
"name": "surface_qualified",
"type": "ChoiceField",
"widget": "Select",
"label": _("Surface qualification"),
"choices": [
("qualified", _("Qualified")),
("unqualified", _("Unqualified")),
],
},
]
}
# The form class is one line
class EndPlateLathQCForm(DynamicForm, QCValidationMixin):
scheme = QC_ENDPLATE_LATHE_SCHEMA
The collected data is stored as JSON in a JSONField, preserving both the field labels and values. This means the inspection record is self-describing - you can read it without knowing which schema generated it.
# Data stored as self-describing JSON
{
"inner_diameter": {
"label": "Inner diameter: 1846mm",
"value": "1846.02"
},
"surface_qualified": {
"label": "Surface qualification",
"value": "qualified"
}
}
Case Study 2: Financial Trading Back Office
The second project pushed the pattern further. A cryptocurrency trading back office needed forms for accounts, commissions, risk profiles, compliance status, tariff plans - each with different fields, different permissions, different submission methods (POST for create, PATCH for bulk updates), and different rendering contexts (full page, modal, inline).
Here the blueprint grew beyond field definitions into a complete form specification:
ACCOUNT_CREATE_BLUEPRINT: FormBlueprint = {
"name": "account_create",
"title": "Create Account",
"column_count": 2,
"modal_size": "xl",
"method": "POST",
"api_config": "account:accounts_v1",
"success_message": "Account created successfully",
"fields": [
{
"name": "currency",
"type": "ChoiceField",
"label": "Currency",
"permission": "ACCOUNT_CURRENCY_UPDATE",
"api_choices": "common:unified_search",
"api_choices_args": ["assets-v1"],
"api_choices_autocomplete": True,
"row_class": "col-md-6",
},
{
"name": "commission_rate",
"type": "FloatField",
"label": "Commission Rate (%)",
"permission": "ACCOUNT_COMMISSION_UPDATE",
"widget_attrs": {"step": "0.01", "min": "0"},
"row_class": "col-md-6",
},
],
}
Notice what is encoded in that single dictionary: the HTTP method, the API endpoint, the modal size, which fields require which permissions, where to load dropdown choices from, the Bootstrap grid layout, and the success message. That is an entire form experience in pure data.
Permission-Driven Field Visibility
The financial platform added a powerful feature: per-field permission control. Each field in the blueprint can declare a permission string. The form renderer checks the current user's permissions and degrades gracefully:
class FormRenderer:
def _apply_field_permissions(self, fields, user):
user_perms = get_user_section_permissions(user)
for field in fields:
required_perm = field.get("permission")
if required_perm and required_perm not in user_perms:
field.setdefault("widget_attrs", {})
field["widget_attrs"]["disabled"] = True
field["widget_attrs"]["readonly"] = True
Users without the ACCOUNT_COMMISSION_UPDATE permission see the commission rate field - but it is read-only and greyed out. No separate template, no {% if perms.x %} blocks, no conditional rendering logic. The blueprint declares the rule, the renderer enforces it.
API-Loaded Choices and Autocomplete
Static choice lists are fine for "Qualified/Unqualified" dropdowns, but a currency selector needs to pull from a live API. The blueprint handles this with two properties:
api_choices- a Django URL name that returns choicesapi_choices_autocomplete- enables Select2 with server-side search
The form engine resolves the URL name, fetches choices at render time, and wires up Select2 autocomplete if configured. The blueprint author never writes JavaScript - they just declare where the data lives.
Why This Is Better Than Hardcoded Forms
After building two large applications with this pattern, I can articulate exactly what it gives you:
- Adding a form is adding data, not code. A new QC inspection or a new account type is a new dictionary. No new class, no new template, no new URL. The system discovers and renders it.
- Changing a form is changing one place. Field label wrong? Change the dictionary. Need a new dropdown option? Update the choices list. Need to make a field optional? Set
"required": False. One file, one change, done. - Forms become portable. A blueprint is just a dictionary. You can serialize it to JSON, store it in a database, load it from a config file, or send it over an API. The form engine does not care where the schema came from.
- Consistency is automatic. Every form goes through the same rendering pipeline. Widget attrs, CSS classes, layout behaviour - they are all standardised by the engine, not copy-pasted between form classes.
- Self-describing data storage. When form data is saved as JSON with labels and values, the stored record is human-readable without referencing the schema. Reports and exports become trivial.
- Validation mixins compose cleanly. The dynamic form is still a Django form. You can add mixins for domain-specific validation (workpiece existence checks, permission validation) without touching the schema.
When to Reach for This Pattern
Dynamic forms are not always the right choice. For a login form with two fields, a ModelForm is simpler and clearer. But the pattern shines when:
- You have many similar forms with different field sets (QC inspections, onboarding steps, survey pages)
- Forms need to be configurable without code changes - by admins, by clients, or by external systems
- You need permission-driven field control across many forms without duplicating template logic
- Form data needs to be stored as self-describing JSON rather than in fixed database columns
- You are building a platform with multiple tenants who each need slightly different forms
The upfront cost is one DynamicForm class (about 50 lines) and the discipline to define schemas instead of form classes. The payoff is a system where adding a 21st form takes the same effort as adding the 2nd.
A hardcoded form is a snapshot of today's requirements. A form schema is a contract that evolves. Write the contract.