Designing a Schema for a Form With Optional and Repeating Checkbox Groups
10 min read · updated August 11, 2026
Two groups of boxes on the same page can look identical and mean entirely different things. One is a set the respondent chooses from freely; the other is a single choice rendered as several boxes. Get the modelling wrong and you will not merely store the answer badly — you will lose the ability to see that the form was filled in wrongly.
Two shapes, two schemas
A check-all-that-apply group is a set. Zero, one or many options can be selected, the options are independent, and the natural representation is an array of stable option ids with uniqueItems set true. A pick-one group is a single value with several possible states, and the natural representation is an enum. Both render as squares on paper and neither is discoverable from the pixels: the distinction lives in the form’s instructions, so it belongs in the template definition rather than in the extraction.
// check all that apply
"contact_preferences": {
"type": ["array", "null"],
"items": { "enum": ["email", "phone", "sms", "post"] },
"uniqueItems": true
}
// pick one
"employment_status": {
"enum": ["employed", "self_employed", "retired", "student",
"not_working", null]
}Use stable string ids for options, never positions. “Option 3” changes meaning the day somebody inserts a new option second, and every historical record silently acquires a different answer. If you also need the printed order — and for survey analysis you do, because order affects selection — store it as a separate property of the option in the template, not as the option’s identity.
Empty is not absent
The array type above is nullable, and that is the load-bearing detail. An empty array and a null mean different things, and a schema that collapses them throws away the one distinction that scanned forms make expensive to recover.
- Empty array. The group was present on the form, all of its boxes were located and measured, and none of them was marked. This is a real answer: the respondent declined to select anything.
- Null. No claim is being made. The group was not reached because of a skip pattern, or the page was missing, or the boxes could not be located — the
not_observedstate from the detection layer propagating upward.
Carry a reason alongside the null rather than relying on the null alone. “Skipped by routing rule”, “page not present” and “boxes not located” look the same in the data model and are completely different operationally: one is correct, one is a scanning failure, one is an extraction failure. Counting nulls without reasons tells you a number and nothing about what to fix.
The same distinction applies to a partially observed group, which is the case people forget. If three of five boxes were located and one of those three is marked, the array is not the answer — you know one selection and you do not know about two options. Model the group as observed-complete, observed-partial or unobserved, and refuse to emit a bare array for the middle case.
Represent the states the form forbids
Here is the mistake worth building the whole schema around. If you declare an exclusive group as an enum and hand that schema to a model with strict structured output, the model must return one value. When the respondent has ticked two boxes, it will return one of them. Nothing in the output records that a second box was marked, no confidence score is lowered, and the contradiction is now unrecoverable without going back to the image.
The extraction schema and the validated schema are therefore not the same schema. Extraction must be able to represent anything the paper can physically show, including states the form’s rules forbid. Validation then applies the rules and produces findings. Compressing these into one step is how contradictions disappear.
// extraction: what the paper shows
"employment_status_marks": {
"type": "array",
"items": { "enum": ["employed", "self_employed", "retired",
"student", "not_working"] }
}
// validation, applied afterwards
// length 1 -> value
// length 0 -> unanswered
// length 2+ -> finding: multiple_marked_in_exclusive_group
// value stays null; the marks are preservedKeep the marks on the record permanently, not just until validation passes. When somebody disputes a decision made from this form, the answer is that two boxes were marked and which two — and a validated enum plus an error flag cannot tell them which two.
The none-of-the-above option
Many check-all-that-apply groups contain one option that is exclusive against the rest: “none of the above”, “prefer not to say”, “not applicable”. Selecting it alongside anything else is a contradiction, and it is a common one on paper because nothing stops the respondent’s pen.
Note first that “none of the above” selected is not the same as the empty array, even though both mean nothing was chosen. The explicit option is a positive statement by the respondent; the empty array is the absence of one, which might equally be inattention. Preserve the difference — it is precisely the difference between a respondent who answered and one who skipped.
The constraint itself is expressible in the validation schema if you want it enforced declaratively: either the array contains the exclusive option and has exactly one element, or it does not contain it at all.
"oneOf": [
{ "type": "array", "maxItems": 1,
"contains": { "const": "prefer_not_to_say" } },
{ "type": "array",
"items": { "not": { "const": "prefer_not_to_say" } } }
]Applied to extraction output rather than to the extraction itself, for the reason above: you want the violation reported, not prevented.
Repeating groups and sparse rows
A repeating group is the same set of boxes offered once per row — per dependant, per vehicle, per property, per week of a timesheet. It is an array of objects, and it has two problems of its own.
The first is sparse rows. A form with six dependant rows, of which rows one and three are filled, must not become an array of two objects, because that renumbers row three to position two and any cross-reference to “dependant 3” elsewhere on the form now points at the wrong person. Give every row an explicit row_index taken from the printed row, and emit blank rows explicitly with a flag rather than omitting them — a blank row in the middle is a fact, and it is sometimes a sign that a page was filled in out of order.
The second is that rows are not always positionally aligned across columns. On a scanned form, the row a mark belongs to is decided by its vertical position, and a respondent’s tick drifting upward into the row above is common. Store the bounding box of each mark and the row band it was assigned to, so that a row assignment can be reviewed. When a row’s marks straddle a band boundary, that is a review case, not a rounding decision.
Optional groups gated by an earlier answer are the third shape. “If yes, tick all that apply” means the group is null when the gate says no — not an empty array, since it was never asked. And a gated group with marks in it despite a gate answered no is a genuine contradiction worth surfacing, which you can only detect if you extracted the marks instead of trusting the routing rule to make the group irrelevant.
When the form gains an option
Forms are revised, and the commonest revision is an added option. Three rules keep the archive coherent.
- Never reuse an option id for a different meaning. Retire ids; do not recycle them. This is the same discipline a codebook needs and the same reason.
- Version the template, and store the version on every record. An answer is only interpretable against the option set that was on the paper, and a form filled in on an old revision keeps arriving for years after the revision changes.
- Widen the enum, never narrow it. Removing an option from the schema invalidates historical records that legitimately contain it. Mark options as retired with an effective date instead, so validation of an old record uses the old set — the same approach as any schema evolution problem where old data must stay readable.
Analysis inherits all of this. A check-all-that-apply group produces percentages that sum past one hundred, and the denominator has to be stated — of respondents, or of selections. It is the same denominator question a multi-label codebook creates, and a reader who is not told which one you used will assume the other.