Forms as data. Define once in Python or JSON — validate on the backend, render on the frontend.
Traditional form libraries (Django Forms, WTForms) define forms as Python classes. A new form — or a change to an existing one — means a code change, a review and a deploy.
codeforms defines forms as data: a Pydantic model that round-trips losslessly to JSON. The same definition can be stored in a database, edited by a non-developer in a form builder, versioned per tenant, and shipped at runtime — while still giving you strict server-side validation and real Pydantic models.
┌──────────────────────┐
Python ──────────▶ │ │ ──────▶ HTML (plain / BS4 / BS5)
│ Form │ ──────▶ JSON Schema (draft-07)
JSON / DB ───────▶ │ (single source │ ──────▶ Client-side JS validation
│ of truth) │ ──────▶ Pydantic model (server-side)
Form builder ────▶ │ │ ──────▶ JSON / dict
└──────────────────────┘
| 🗃️ Forms as data | Full JSON round-trip. Store definitions in a DB, version them, ship them at runtime — no redeploy to change a form. |
| 🛡️ One source of truth | The same definition drives server validation, HTML rendering, JSON Schema and client-side JS checks. They cannot drift apart. |
| ⚡ Pydantic v2 native | FormDataValidator.create_model() builds a real Pydantic model — drops straight into FastAPI, Flask, Litestar or any typed backend. |
| 🔀 Dynamic logic built in | Conditional visibility (visible_when), dependent options and multi-step wizards — declarative, serializable, and opt-in. |
| 🌍 i18n out of the box | English and Spanish included; register any locale at runtime. Validation messages follow the active locale. |
| 🧩 Extensible by design | Register your own field types with register_field_type() — no core changes, full serialization support. |
| 🔗 Interoperable | JSON Schema draft-07 export works with React JSON Schema Form, Angular Formly and any JSON Schema validator. |
| 🪶 Tiny footprint | One dependency (pydantic[email]). Python 3.9+. MIT. 248 tests in CI. |
- Low-code / form builders — persist user-designed forms as JSON and validate submissions safely.
- BPM & workflow engines — dynamic task forms (approvals, intake, review steps) defined per process, in the spirit of Camunda form-js.
- Multi-tenant SaaS — each customer gets a different form without a branch, a build or a deploy.
- Surveys, intake and onboarding flows — multi-step wizards with conditional branching.
- Headless APIs — serve
json_schemato a React/Vue frontend and validate the same contract server-side.
| codeforms | Django Forms / WTForms | Pydantic alone | React JSON Schema Form | |
|---|---|---|---|---|
| Runtime-definable (no deploy) | ✅ | ❌ | ❌ | ✅ |
| Server-side validation | ✅ | ✅ | ✅ | ❌ |
| HTML rendering | ✅ | ✅ | ❌ | ✅ (frontend only) |
| JSON Schema export | ✅ | ❌ | — (consumes it) | |
| Conditional visibility / wizards | ✅ | ❌ | ❌ | |
| Framework-agnostic | ✅ | ❌ | ✅ | ❌ |
pip install codeformsOr with uv:
uv add codeformsRequires Python 3.9+.
Everything starts with the Form class. A form is defined with a name and a list of fields.
from codeforms import Form, TextField, EmailField, NumberField
form = Form(
name="UserRegistration",
fields=[
TextField(name="full_name", label="Full Name", required=True),
EmailField(name="email", label="Email", required=True),
NumberField(name="age", label="Age", min_value=18),
]
)from codeforms import FormDataValidator
Model = FormDataValidator.create_model(form)
user = Model.model_validate({"full_name": "Ada", "email": "ada@example.com", "age": 36})html = form.export("html_bootstrap5", submit=True)["output"]
schema = form.export("json_schema")["output"]raw = form.to_json() # persist anywhere: DB, S3, a config repo
form = Form.loads(raw) # lossless round-trip, including custom field typesThat last step is the whole point: the form is data, not code.
The Form class is the main container for your form structure.
id— Auto-generated UUID.name— Form name (used in HTML export and validation).fields— A list of field objects (e.g.TextField,EmailField). Always returns a flat list, even for grouped or stepped forms.content— The structured list of fields,FieldGroups andFormSteps.css_classes— Optional CSS classes for the<form>tag.version— Form version number.attributes— Dictionary of additional HTML attributes for the<form>tag.
Backward compatibility:
Formaccepts bothfields=andcontent=. Legacy payloads load unchanged, and.fieldsalways gives you the flat view.
All fields inherit from FormFieldBase and share these common attributes:
name— Field name (maps tonamein HTML).label— User-visible label.field_type— Field type (FieldTypeenum).required— Whether the field is mandatory.placeholder— Placeholder text inside the field.default_value— Default value.help_text— Help text displayed below the field.css_classes— CSS classes for the field element.readonly— Whether the field is read-only.attributes— Additional HTML attributes for the<input>tag.visible_when— Optional list ofVisibilityRule(see Conditional Visibility).
TextField— Generic text input (<input type="text">).minlength,maxlength: Min/max text length.pattern: Regex pattern for validation.
EmailField— Email address (<input type="email">).NumberField— Numeric value (<input type="number">).min_value,max_value: Allowed value range.step: Increment step.
DateField— Date picker (<input type="date">).min_date,max_date: Allowed date range.
SelectField— Dropdown select (<select>).options: List ofSelectOption(value="...", label="...").multiple: Enables multi-select.min_selected,max_selected: Selection count limits (multi-select only).dependent_options: See Dependent Options.
RadioField— Radio buttons (<input type="radio">).options: List ofSelectOption.inline: Display options inline.
CheckboxField— Single checkbox (<input type="checkbox">).CheckboxGroupField— Group of checkboxes.options: List ofSelectOption.inline: Display options inline.
FileField— File upload (<input type="file">).accept: Accepted file types (e.g."image/*,.pdf").multiple: Allow multiple file uploads.
HiddenField— Hidden field (<input type="hidden">).UrlField— URL input (<input type="url">).minlength,maxlength: Min/max text length.
TextareaField— Multi-line text (<textarea>).rows,minlength,maxlength.
ListField— Array of primitive values.item_type: Primitive type for each item (text,number,email,url,date).min_items,max_items: List size limits.
ObjectListField— Array of homogeneous objects validated against nested subfields.fields: List of subfields that define each object shape.min_items,max_items: List size limits.
FieldGroup— Groups fields into a titled section (<fieldset>/<legend>in HTML export).title,description,fields.collapsible,collapsed: Collapsible section metadata for your frontend.
FormStep— A wizard step; see Multi-Step Wizard Forms.
Fields inside containers are always reachable through the flat form.fields view.
Use ObjectListField when a form needs a repeatable list of structured rows, such as parallel approvers, attendees with roles, or line items.
from codeforms import Form, ObjectListField, TextField, CheckboxField
form = Form(
name="parallel_approvers",
fields=[
ObjectListField(
name="parallel_approvals",
label="Aprobadores",
required=True,
min_items=1,
max_items=5,
fields=[
TextField(name="approver_email", label="Email", required=True),
TextField(name="label", label="Etiqueta", required=True),
CheckboxField(name="required", label="Obligatorio"),
],
)
],
)Expected submitted value:
{
"parallel_approvals": [
{
"approver_email": "ana@empresa.com",
"label": "Compras",
"required": true
},
{
"approver_email": "luis@empresa.com",
"label": "Finanzas"
}
]
}Validation behavior:
- The top-level field must be a JSON array.
- Each item must be a JSON object.
- Unknown keys inside items are rejected.
- Required nested subfields are enforced.
- Validation errors include nested paths like
parallel_approvals[0].label.
Current limitation:
ObjectListFieldis fully supported in backend validation and JSON Schema export.- Rich repeatable HTML UI generation is not implemented yet. If you need an interactive editor, prefer consuming the exported
json_schemafrom your frontend.
codeforms offers multiple ways to validate user-submitted data, leveraging Pydantic's validation engine.
The most robust approach is FormDataValidator.create_model, which dynamically generates a Pydantic model from your form definition. This gives you powerful validations and detailed error messages automatically.
from codeforms import Form, FormDataValidator, TextField, SelectField, SelectOption
from pydantic import ValidationError
# 1. Define your form
form = Form(
name="MyForm",
fields=[
TextField(name="name", label="Name", required=True),
SelectField(
name="country",
label="Country",
options=[
SelectOption(value="us", label="United States"),
SelectOption(value="uk", label="United Kingdom"),
]
)
]
)
# 2. Create the validation model
ValidationModel = FormDataValidator.create_model(form)
# 3. Validate incoming data
user_data = {"name": "John", "country": "us"}
try:
validated = ValidationModel.model_validate(user_data)
print("Valid!", validated)
except ValidationError as e:
print("Validation errors:", e.errors())This approach integrates seamlessly with API backends like FastAPI or Flask, since it produces standard Pydantic models.
Two simpler alternatives exist, though FormDataValidator is preferred:
form.validate_data(data)— Built-in method on theFormclass. Less flexible; doesn't produce Pydantic models.validate_form_data(form, data)— Standalone function with basic validation logic.
Once your form is defined, you can export it to different formats.
# Export to plain HTML
html_output = form.export('html', submit=True)
print(html_output['output'])
# Export to HTML with Bootstrap 5 classes
bootstrap_output = form.export('html_bootstrap5', submit=True)
print(bootstrap_output['output'])
# Export to JSON
json_output = form.to_json()
print(json_output)
# Export to a Python dictionary
dict_output = form.to_dict()
print(dict_output)| Format | Description |
|---|---|
html |
Semantic HTML |
html_bootstrap4 |
HTML with Bootstrap 4 classes |
html_bootstrap5 |
HTML with Bootstrap 5 classes |
json_schema |
JSON Schema (draft-07) |
json |
JSON representation of the form |
dict |
Python dictionary representation |
HTML export can also generate a <script> block for basic client-side validation, derived from the same field constraints used on the server — so the two can't drift apart. All rendered values are HTML-escaped.
Generate a standard JSON Schema (draft-07) from any form. The resulting schema is compatible with tools like React JSON Schema Form, Angular Formly, and any JSON Schema validator.
import json
from codeforms import (
Form, TextField, EmailField, NumberField, SelectField, SelectOption,
CheckboxField, form_to_json_schema,
)
form = Form(
name="registration",
fields=[
TextField(name="name", label="Full Name", required=True, minlength=2, maxlength=100),
EmailField(name="email", label="Email", required=True),
NumberField(name="age", label="Age", min_value=18, max_value=120),
SelectField(
name="country",
label="Country",
required=True,
options=[
SelectOption(value="us", label="United States"),
SelectOption(value="uk", label="United Kingdom"),
],
),
CheckboxField(name="terms", label="Accept Terms", required=True),
],
)
# Option 1: Direct function call
schema = form_to_json_schema(form)
print(json.dumps(schema, indent=2))
# Option 2: Via form.export()
result = form.export("json_schema")
schema = result["output"]Output:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "registration",
"properties": {
"name": {
"type": "string",
"minLength": 2,
"maxLength": 100,
"title": "Full Name"
},
"email": {
"type": "string",
"format": "email",
"title": "Email"
},
"age": {
"type": "number",
"minimum": 18,
"maximum": 120,
"title": "Age"
},
"country": {
"type": "string",
"enum": ["us", "uk"],
"title": "Country"
},
"terms": {
"type": "boolean",
"title": "Accept Terms"
}
},
"required": ["name", "email", "country", "terms"],
"additionalProperties": false
}| codeforms Field | JSON Schema Type | Extra Keywords |
|---|---|---|
TextField |
string |
minLength, maxLength, pattern |
EmailField |
string (format: "email") |
— |
NumberField |
number |
minimum, maximum, multipleOf |
DateField |
string (format: "date") |
— |
SelectField |
string + enum |
— |
SelectField (multiple=True) |
array of enum strings |
minItems, maxItems, uniqueItems |
RadioField |
string + enum |
— |
CheckboxField |
boolean |
— |
CheckboxGroupField |
array of enum strings |
uniqueItems |
FileField |
string (contentEncoding: "base64") |
— |
FileField (multiple=True) |
array of base64 strings |
— |
HiddenField |
string |
— |
UrlField |
string (format: "uri") |
minLength, maxLength |
TextareaField |
string |
minLength, maxLength |
ListField |
array |
minItems, maxItems |
ObjectListField |
array of object |
nested properties, nested required, minItems, maxItems |
Field annotations like label, help_text, default_value, and readonly map to the JSON Schema keywords title, description, default, and readOnly respectively.
Example ObjectListField schema:
{
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"approver_email": {
"type": "string",
"title": "Email"
},
"label": {
"type": "string",
"title": "Etiqueta"
},
"required": {
"type": "boolean",
"title": "Obligatorio"
}
},
"required": ["approver_email", "label"],
"additionalProperties": false
}
}Fields inside FieldGroup and FormStep containers are flattened into the top-level properties automatically.
All validation and export messages are locale-aware. English (en) and Spanish (es) are included out of the box, and you can register any additional language at runtime via register_locale().
from codeforms import set_locale, get_locale, get_available_locales
print(get_locale()) # "en"
print(get_available_locales()) # ["en", "es"]
set_locale("es")
# All validation messages will now be in SpanishYou can add any locale at runtime. Missing keys automatically fall back to English.
from codeforms import register_locale, set_locale
register_locale("pt", {
"field.required": "Este campo é obrigatório",
"field.required_named": "O campo {name} é obrigatório",
"email.invalid": "E-mail inválido",
"number.min_value": "O valor deve ser maior ou igual a {min}",
"form.validation_success": "Dados validados com sucesso",
"form.data_validation_error": "Erro na validação dos dados",
})
set_locale("pt")The t() function translates a message key, with optional interpolation:
from codeforms import t, set_locale
set_locale("en")
print(t("field.required")) # "This field is required"
print(t("field.required_named", name="email")) # "The field email is required"
set_locale("es")
print(t("field.required")) # "Este campo es requerido"
print(t("text.minlength", min=3)) # "La longitud mínima es 3"All validation functions respect the active locale:
from codeforms import Form, TextField, validate_form_data, set_locale
form = Form(
name="example",
fields=[TextField(name="name", label="Name", required=True)]
)
set_locale("en")
result = validate_form_data(form, {})
print(result["errors"][0]["message"]) # "The field name is required"
set_locale("es")
result = validate_form_data(form, {})
print(result["errors"][0]["message"]) # "El campo name es requerido"See examples/i18n_usage.py for a full working example.
All dynamic behavior below is opt-in and additive: forms without dynamic metadata behave exactly as before, and every new JSON key is optional.
Fields can be shown or hidden based on the value of other fields using visible_when. This is metadata that your frontend can use for dynamic UI, and the backend can respect during validation.
from codeforms import Form, TextField, SelectField, SelectOption, VisibilityRule
form = Form(
name="address",
fields=[
SelectField(
name="country",
label="Country",
required=True,
options=[
SelectOption(value="US", label="United States"),
SelectOption(value="AR", label="Argentina"),
],
),
TextField(
name="state",
label="State",
required=True,
visible_when=[
VisibilityRule(field="country", operator="equals", value="US"),
],
),
TextField(
name="province",
label="Province",
required=True,
visible_when=[
VisibilityRule(field="country", operator="equals", value="AR"),
],
),
],
)Supported operators: equals, not_equals, in, not_in, gt, lt, is_empty, is_not_empty.
Use validate_form_data_dynamic() to validate only the fields that are currently visible:
from codeforms import validate_form_data_dynamic
result = validate_form_data_dynamic(
form,
{"country": "US", "state": "California"},
respect_visibility=True,
)
print(result["success"]) # True — "province" is hidden, so not requiredThe legacy validate_form_data() function is unchanged and always validates all fields regardless of visibility.
visible = form.get_visible_fields({"country": "US"})
print([f.name for f in visible]) # ["country", "state"]See examples/conditional_visibility.py for a full working example.
Use DependentOptionsConfig to define option sets that change based on another field's value:
from codeforms import SelectField, SelectOption, DependentOptionsConfig
city_field = SelectField(
name="city",
label="City",
options=[ # all possible options (for static HTML rendering)
SelectOption(value="nyc", label="New York City"),
SelectOption(value="bsas", label="Buenos Aires"),
],
dependent_options=DependentOptionsConfig(
depends_on="country",
options_map={
"US": [SelectOption(value="nyc", label="New York City")],
"AR": [SelectOption(value="bsas", label="Buenos Aires")],
},
),
)The dependent_options metadata serializes to JSON for your frontend to consume. See examples/dependent_options.py.
Use FormStep to split a form into multiple steps. Each step contains its own fields and can be validated independently.
from codeforms import Form, FormStep, TextField, EmailField, CheckboxField
form = Form(
name="registration",
content=[
FormStep(
title="Personal Information",
description="Tell us about yourself",
content=[
TextField(name="name", label="Name", required=True),
EmailField(name="email", label="Email", required=True),
],
),
FormStep(
title="Confirmation",
content=[
CheckboxField(name="terms", label="I accept the terms", required=True),
],
validation_mode="on_submit",
),
],
)# Validate a single step
result = form.validate_step(0, {"name": "John", "email": "john@example.com"})
print(result["success"]) # True
# Validate all steps at once
result = form.validate_all_steps({
"name": "John",
"email": "john@example.com",
"terms": True,
})
print(result["success"]) # Truesteps = form.get_steps() # List[FormStep]
fields = form.fields # Flat list of all fields across all stepsWizard forms export with data-wizard="true" on the <form> tag. Each step renders as a <section data-step="true"> (not <fieldset>), so you can wire up your own step navigation in JavaScript.
See examples/wizard_form.py for a full working example.
You can create your own field types by subclassing FormFieldBase and registering them with register_field_type(). Custom fields integrate seamlessly with forms, JSON serialization, validation, and HTML export.
from codeforms import FormFieldBase, register_field_type
class PhoneField(FormFieldBase):
field_type: str = "phone" # unique string identifier
country_code: str = "+1"
class RatingField(FormFieldBase):
field_type: str = "rating"
min_rating: int = 1
max_rating: int = 5
register_field_type(PhoneField)
register_field_type(RatingField)from codeforms import Form, TextField
form = Form(
name="feedback",
fields=[
TextField(name="name", label="Name", required=True),
PhoneField(name="phone", label="Phone", country_code="+54"),
RatingField(name="score", label="Score", max_rating=10),
],
)Custom fields serialize and deserialize automatically (as long as the field type is registered before deserialization):
import json
json_str = form.to_json()
restored = Form.loads(json_str)
assert isinstance(restored.fields[1], PhoneField)
assert restored.fields[1].country_code == "+54"from codeforms import get_registered_field_types
for name, classes in sorted(get_registered_field_types().items()):
print(f"{name}: {[c.__name__ for c in classes]}")See examples/custom_fields.py for a full working example.
codeforms takes backward compatibility seriously:
- Existing usage —
Form(fields=[...]),form.fields,validate_data(),validate_form_data(), current HTML output — keeps working without code changes. - New schema keys are additive and optional; previously serialized forms load unchanged.
- Dynamic behavior (visibility, dependent options, wizards) is disabled by default and enabled explicitly.
- Legacy paths get a deprecation warning for at least one minor release before removal in a major release.
| Example | Shows |
|---|---|
basic_usage.py |
Defining, validating and exporting a form |
conditional_visibility.py |
visible_when rules and dynamic validation |
dependent_options.py |
Option sets driven by another field |
wizard_form.py |
Multi-step forms and per-step validation |
custom_fields.py |
Registering your own field types |
i18n_usage.py |
Locales and custom translations |
- Internationalization (
en/es/ runtime-registered locales) - Custom field type registry
- Conditional visibility, dependent options, multi-step wizards
- JSON Schema (draft-07) export
- Frontend-friendly dict export optimized for React/Vue
- Richer HTML export:
aria-*accessibility attributes, Tailwind support - FastAPI integration (
FormDependency) - Django bridge (convert to/from a Django
Form) - Import from JSON Schema and from Camunda / form-js schemas
- PDF export
See todo/TODO.md for the detailed plan.
# Install in editable mode with dev extras
pip install -e ".[dev]"
# Run the test suite
pytest -q
# Lint and format
ruff check src/ tests/ examples/ --fix
ruff format src/ tests/ examples/Tests run on Python 3.11 and 3.12 in CI.
Issues and pull requests are welcome. Please add tests for any behavior change, and keep the compatibility guarantees above in mind.
MIT — see LICENSE.