Skip to content

Forms

The forms package provides generic, type-safe HTML form handling. It extracts field metadata from struct tags, binds request data, validates input, and produces renderable field objects for templates — eliminating the manual wiring between Bind(), validation errors, and template re-rendering.

Use forms when you have a model-backed HTML form. For JSON APIs or standalone validation without template rendering, use Bind() and Validate() directly.

Defining a Form

The forms package works with any Go struct. There are two common patterns:

Using the model directly — works well when the form fields closely match the document. Exclude non-editable fields with form:"-" or WithExclude:

// Note is a Den document. Form tags control how it behaves in forms.
type Note struct {
    document.Base                      `form:"-"`
    Title     string    `json:"title" den:"index" form:"title" validate:"required" verbose:"Title"`
    Content   string    `json:"content" form:"content" widget:"textarea" verbose:"Content"`
    CreatedAt time.Time `json:"created_at" form:"-"`
}

Using a dedicated form struct — better when the form diverges from the document (different fields, extra validation, computed values):

// NoteForm is a dedicated form struct, separate from the Den document.
type NoteForm struct {
    Title    string `form:"title" validate:"required,max=200" verbose:"Title"`
    Content  string `form:"content" widget:"textarea" verbose:"Content"`
    Category string `form:"category" choices:"general|work|personal" verbose:"Category"`
}

With a dedicated form struct, you map between form and document in your handler after validation.

Struct Tags

Tag Purpose Example
form HTML field name for binding. Use - to exclude a field. form:"title"
validate Validation rules (validator tags) validate:"required,min=3"
verbose_name or verbose Human-readable label for templates verbose:"Title"
widget Force HTML input type widget:"textarea"
help_text Help text shown below the field help_text:"Enter a short title"
choices Static select options (pipe-separated) choices:"draft\|published"

Fields are skipped when they are unexported, anonymous (embedded), or tagged with form:"-".

Widget Types

If no widget tag is set, the type is inferred from the Go type:

Go type Default widget
string text
bool checkbox
int, float64, etc. number
time.Time date

Fields with choices tag or dynamic choices automatically become select. You can override any inferred type with the widget tag. Supported values: text, textarea, number, select, checkbox, date, email, hidden.

Creating Forms

Using a model directly

When the form struct is your Den document, use New for create pages and FromModel for edit pages. Non-editable fields like ID or CreatedAt are excluded via options:

// Create page — empty form
f := forms.New[Note](forms.WithExclude[Note]("Base", "CreatedAt"))

// Edit page — pre-populated from existing record
note, _ := repo.Get(ctx, id)
f := forms.FromModel(note, forms.WithExclude[Note]("Base", "CreatedAt"))

Reuse options across handlers

If you use the same options in multiple handlers, extract them into a helper function to avoid repetition. See the notes example app for this pattern.

After validation, f.Instance() returns the document directly — ready to pass to your repository.

Using a dedicated form struct

When the form struct is separate from the model, use New for both create and edit. For edit pages, populate the form with WithInitial:

// Create page — empty form
f := forms.New[NoteForm]()

// Edit page — populate from existing record
note, _ := repo.Get(ctx, id)
f := forms.New[NoteForm](
    forms.WithInitial[NoteForm](map[string]any{
        "title":    note.Title,
        "content":  note.Content,
        "category": note.Category,
    }),
)

After validation, f.Instance() returns a *NoteForm — map it back to your document in the handler:

form := f.Instance()
note.Title = form.Title
note.Content = form.Content
note.Category = form.Category

Options

Options are passed to New or FromModel to configure form behavior.

WithExclude

Hides fields from the form. Excluded fields won't appear in Fields() and won't be bound from the request. Use Go struct field names (not form tag names):

forms.WithExclude[Note]("ID", "UserID", "CreatedAt")

This is the typical way to keep database-managed fields out of user-facing forms when using a document directly.

WithInitial

Sets initial field values for the form. Use form tag names (not Go struct field names) as keys. Values appear in BoundField.Value before the form is submitted:

forms.WithInitial[NoteForm](map[string]any{
    "title":    "Untitled",
    "category": "general",
})

Particularly useful with dedicated form structs to populate edit forms from an existing record (see Creating Forms).

WithReadOnly

Marks fields as read-only. Read-only fields appear in Fields() but are not editable — templates should render them with the disabled attribute. On Bind(), their values are preserved from the original instance (not overwritten by the request), and validation errors for these fields are stripped automatically.

forms.WithReadOnly[Note]("CreatedAt", "UpdatedAt")

Read-only overrides form:"-" — a field tagged with form:"-" is normally hidden, but WithReadOnly forces it to appear. This is useful when the same model is used in different contexts (e.g. a user-facing form hides the field, but an admin form shows it as read-only).

WithChoices

Provides static choices for a select field. Use the Go struct field name:

forms.WithChoices[Article]("Status", []forms.Choice{
    {Value: "draft", Label: "Draft"},
    {Value: "published", Label: "Published"},
    {Value: "archived", Label: "Archived"},
})

This is an alternative to the choices struct tag when you need Choice structs with separate values and labels, or when choices are defined outside the struct.

WithChoicesFunc

Provides a function that loads choices dynamically at bind time. The function receives the request context, so it can query a database or call a service:

forms.WithChoicesFunc[Article]("CategoryID", func(ctx context.Context) ([]forms.Choice, error) {
    categories, err := repo.ListCategories(ctx)
    if err != nil {
        return nil, err
    }
    choices := make([]forms.Choice, len(categories))
    for i, c := range categories {
        choices[i] = forms.Choice{Value: strconv.Itoa(c.ID), Label: c.Name}
    }
    return choices, nil
})

See also the ChoiceProvider interface for an alternative that keeps the logic on the struct itself.

Binding and Validation

Call Bind() with the incoming request. It decodes form data, runs validation, and returns whether the form is valid:

func (a *App) Create(w http.ResponseWriter, r *http.Request) error {
    f := forms.New[Note](noteFormOpts()...)

    if !f.Bind(r) {
        // Re-render form with errors
        return burrow.Render(w, r, http.StatusUnprocessableEntity, "myapp/form", map[string]any{
            "Fields":         f.Fields(),
            "NonFieldErrors": f.NonFieldErrors(),
            "Action":         "/notes",
        })
    }

    note := f.Instance()
    // ... save note
}

Bind() performs these steps in order:

  1. Decodes the request body via burrow.Bind() (form-encoded or JSON)
  2. Runs per-field validation from validate tags
  3. Loads dynamic choices (from ChoiceProvider or WithChoicesFunc)
  4. Runs cross-field validation via Clean() (if implemented)

Accessing Results

Method Returns
f.Bind(r) bool — true if valid
f.IsValid() bool — same result after Bind()
f.Instance() *T — the bound struct with validated values
f.Errors() *burrow.ValidationError — nil if valid
f.NonFieldErrors() []string — cross-field errors from Clean()
f.Fields() []BoundField — all visible fields with values and errors
f.Field("Name") (BoundField, bool) — single field by Go struct field name

Rendering in Templates

Fields() returns a slice of BoundField objects. Each field carries everything needed to render an input:

Property Type Description
Name string Go struct field name
FormName string HTML name attribute
Label string Human-readable label (auto-translated, see below)
HelpText string Help text for the field
Type string HTML input type (text, textarea, select, subform, etc.; see Nested struct fields)
Value any Current field value
Required bool Whether the field is required
ReadOnly bool Whether the field is read-only (render as disabled)
Choices []Choice Options for select fields — Choice.Label is auto-translated too
SubFields []BoundField Populated when Type == "subform" (see Nested struct fields)
Errors []string Validation errors for this field

Translating field labels

Fields() pipes both BoundField.Label (from the verbose: / verbose_name: tag) and every Choice.Label through i18n.T before returning. Templates render {{ .Label }} and get the locale-appropriate string for free — no {{ t .Label }} wrapping needed.

Contribute translations keyed by the English Label:

# active.de.toml
Email = "E-Mail"
Username = "Benutzername"
"Display Name" = "Anzeigename"

If you build a form outside the request lifecycle (background job, CLI, goroutine), set the context explicitly so the translation has a locale to work with:

f := forms.FromModel(user, opts...).WithContext(ctx)

Bind already sets the context from r.Context(), so request-handler code needs no special wiring.

See i18n: Labels as Keys for the full convention (caveats, when structured keys remain a better fit).

Translating choice labels

Choice translations follow the same convention as BoundField.Label. For static choices defined via WithChoices or the choices:"…" struct tag, set the Label to the English source string:

forms.WithChoices[User]("Role", []forms.Choice{
    {Value: "user",  Label: "User"},
    {Value: "admin", Label: "Admin"},
}),

extractFields translates each Label as it builds the field, covering both WithChoices (static) and WithChoicesFunc (dynamic) sources.

Nested struct fields (subforms)

Struct-typed fields render as subformsFields() recurses one level into the nested struct and exposes its fields under the parent's SubFields:

type ArticleForm struct {
    Title   string `form:"title" verbose:"Title"`
    Profile struct {
        Name string `form:"name" verbose:"Name" validate:"required"`
        Bio  string `form:"bio"  verbose:"Bio"  widget:"textarea"`
    } `form:"profile" verbose:"Profile"`
}

The parent Profile BoundField has Type == "subform" and SubFields containing two BoundFields. Nested FormName values follow the parent.child convention (profile.name, profile.bio) which matches what burrow.Bind expects on submit. Validation errors with a dotted Field name (e.g. profile.name) attach to the nested BoundField via the same mechanism as flat fields.

Pointer-to-struct (*Profile) is dereferenced; a nil pointer still yields a subform with zero-value sub-fields. time.Time is excluded (keeps the "date" widget). Recursion is one level only — a struct nested inside a subform renders flat as text. form:"-" on the parent struct field skips the whole subform.

Templates render subforms by dispatching on Type == "subform" and iterating .SubFields. The Example Template below shows the pattern: factor the per-field render into a named template (myapp/field) so the subform branch can call it back on each SubField — the one-level recursion cap guarantees those inner calls always hit a non-subform branch.

Example Template

{{ define "myapp/form" -}}
<form method="post" action="{{ .Action }}">
    <input type="hidden" name="gorilla.csrf.Token" value="{{ csrfToken }}">

    {{ if .NonFieldErrors -}}
    <div class="alert alert-danger">
        {{ range .NonFieldErrors }}<p class="mb-0">{{ . }}</p>{{ end }}
    </div>
    {{- end }}

    {{ range .Fields }}{{ template "myapp/field" . }}{{ end }}

    <button type="submit" class="btn btn-primary">Save</button>
</form>
{{- end }}

{{ define "myapp/field" -}}
{{ if eq .Type "subform" -}}
<fieldset class="mb-3">
    <legend>{{ .Label }}</legend>
    {{ range .SubFields }}{{ template "myapp/field" . }}{{ end }}
</fieldset>
{{- else -}}
<div class="mb-3">
    <label for="{{ .FormName }}" class="form-label">
        {{ .Label }}{{ if .Required }} *{{ end }}
    </label>

    {{ if eq .Type "textarea" -}}
    <textarea class="form-control{{ if .Errors }} is-invalid{{ end }}"
        id="{{ .FormName }}" name="{{ .FormName }}" rows="3">{{ .Value }}</textarea>

    {{- else if eq .Type "select" -}}
    <select class="form-select{{ if .Errors }} is-invalid{{ end }}"
        id="{{ .FormName }}" name="{{ .FormName }}">
        <option value=""></option>
        {{ range .Choices -}}
        <option value="{{ .Value }}"{{ if eq .Value $.Value }} selected{{ end }}>{{ .Label }}</option>
        {{- end }}
    </select>

    {{- else if eq .Type "checkbox" -}}
    <div class="form-check">
        <input type="checkbox" class="form-check-input{{ if .Errors }} is-invalid{{ end }}"
            id="{{ .FormName }}" name="{{ .FormName }}" value="true"{{ if .Value }} checked{{ end }}>
    </div>

    {{- else -}}
    <input type="{{ .Type }}" class="form-control{{ if .Errors }} is-invalid{{ end }}"
        id="{{ .FormName }}" name="{{ .FormName }}" value="{{ .Value }}"
        {{ if .Required }}required{{ end }}>
    {{- end }}

    {{ if .HelpText -}}
    <div class="form-text">{{ .HelpText }}</div>
    {{- end }}

    {{ range .Errors -}}
    <div class="invalid-feedback">{{ . }}</div>
    {{- end }}
</div>
{{- end }}
{{- end }}

Select Fields and Choices

There are four ways to provide choices for a select field.

Static choices via struct tag

Works on both models and form structs:

type ArticleForm struct {
    Status string `form:"status" choices:"draft|published|archived" verbose:"Status"`
}

Static choices via option

When you need separate values and labels, or choices defined outside the struct:

statuses := []forms.Choice{
    {Value: "draft", Label: "Draft"},
    {Value: "published", Label: "Published"},
    {Value: "archived", Label: "Archived"},
}
f := forms.New[ArticleForm](forms.WithChoices[ArticleForm]("Status", statuses))

Dynamic choices via option function

When choices come from a database or service:

f := forms.New[ArticleForm](
    forms.WithChoicesFunc[ArticleForm]("CategoryID", func(ctx context.Context) ([]forms.Choice, error) {
        categories, err := repo.ListCategories(ctx)
        if err != nil {
            return nil, err
        }
        choices := make([]forms.Choice, len(categories))
        for i, c := range categories {
            choices[i] = forms.Choice{Value: strconv.Itoa(c.ID), Label: c.Name}
        }
        return choices, nil
    }),
)

Dynamic choices via ChoiceProvider interface

When you want the choice logic on the struct itself. The struct that implements the interface is whatever you pass as the type parameter to New or FromModel — a model or a dedicated form struct:

// ArticleForm is a dedicated form struct with dynamic choices.
type ArticleForm struct {
    Title      string `form:"title" validate:"required" verbose:"Title"`
    CategoryID int    `form:"category_id" widget:"select" verbose:"Category"`
}

func (f *ArticleForm) FieldChoices(ctx context.Context, field string) ([]forms.Choice, error) {
    if field == "CategoryID" {
        // load categories from database
    }
    return nil, nil
}

When using a document directly, the same interface works — just implement it on the document:

// Article is a Den document that also provides its own choices.
type Article struct {
    document.Base                  `form:"-"`
    Title      string `json:"title" form:"title" validate:"required" verbose:"Title"`
    CategoryID string `json:"category_id" den:"index" form:"category_id" widget:"select" verbose:"Category"`
}

func (a *Article) FieldChoices(ctx context.Context, field string) ([]forms.Choice, error) {
    if field == "CategoryID" {
        // load categories from database
    }
    return nil, nil
}

Cross-Field Validation

There are two mechanisms for cross-field validation: Clean(ctx) on the struct itself, and WithCleanFunc as a form option. Use whichever fits your needs — or both.

Clean(ctx) — struct-level validation

Implement the Cleanable interface for validation that only needs the struct's own data. The context carries request-scoped data (e.g. i18n localizer). Like ChoiceProvider, define Clean() on whatever struct you use as the form type parameter:

type EventForm struct {
    Start time.Time `form:"start" validate:"required" verbose:"Start date"`
    End   time.Time `form:"end"   validate:"required" verbose:"End date"`
}

func (f *EventForm) Clean(ctx context.Context) error {
    if !f.End.After(f.Start) {
        return &burrow.ValidationError{
            Errors: []burrow.FieldError{
                {Field: "end", Message: "End date must be after start date"},
            },
        }
    }
    return nil
}

WithCleanFunc — closure-based validation

Use WithCleanFunc when validation needs external dependencies like a database or repository. The closure captures dependencies at form creation time:

f := forms.New[UserForm](
    forms.WithCleanFunc(func(ctx context.Context, u *UserForm) error {
        if u.Role != "admin" {
            lastAdmin, _ := repo.IsLastAdmin(ctx, u.ID)
            if lastAdmin {
                return &burrow.ValidationError{
                    Errors: []burrow.FieldError{
                        {Field: "role", Message: "Cannot demote the last admin"},
                    },
                }
            }
        }
        return nil
    }),
)

How they work together

Both mechanisms run only after all per-field validations pass. Clean(ctx) runs first, then WithCleanFunc. Both can return errors, and all errors are merged:

  • Field-level errors (with a Field value) appear on the corresponding BoundField.Errors
  • Non-field errors (empty Field) are accessible via f.NonFieldErrors()
Mechanism Use when Dependencies
Clean(ctx) Logic needs only the struct's own fields None (self-contained)
WithCleanFunc Logic needs external state (DB, repo, services) Captured via closure

Complete Example

The notes example app demonstrates a full CRUD workflow with the forms package, including create/edit handlers, error re-rendering, and HTMX integration.