Skip to content

Extracting Structured Fields From a Membership Application Form

9 min read · updated August 11, 2026

A membership form prints every price the organisation offers and asks the applicant to pick one. A model reading it sees eight amounts and no syntactic marker of which one was chosen. That is the whole difficulty, and it is not solved by a better prompt.

The form contains two different things

Almost every membership application is two documents laid out as one. A rate card enumerates tiers and prices — reference data, identical on every copy of the form ever printed. A response section captures who the applicant is and what they chose — instance data, different on every copy. They share a page and often share a table.

MEMBERSHIP LEVELS                Annual      Monthly
  [ ] Individual                   65.00        5.95
  [x] Family                      110.00        9.95
  [ ] Sustaining                  250.00       21.95
  [ ] Benefactor                1,000.00       87.50

Billing frequency:   [ ] Annual    [x] Monthly
Amount enclosed / to be charged:  $ 9.95
Additional gift (optional):       $ 25.00

The correct extraction of that form has selected_tier: "Family", billing_frequency: "monthly", recurring_amount: "9.95" and additional_gift: "25.00". It does not have 110.00 anywhere, even though 110.00 is on the same line as the tier that was selected and is the larger, more prominent number. Nor does it have 34.95, which is what you get if you add the additional gift to the recurring amount and treat the result as a membership price.

The selection is positional, not textual

Ask a model “which membership tier did the applicant select?” and it has to solve a spatial problem through a textual interface: locate a glyph, decide whether that glyph means filled, associate it with a row by vertical proximity, and report the row label. Each of those steps is a place to go wrong, and they compound. When the tiers are laid out in two columns rather than one, proximity stops being a reliable association at all.

Worse, the failure is not random. A model that cannot find the mark does not usually say so; it picks a plausible tier, and the most plausible tier is the one whose price appears nearest the amount the applicant wrote. On a form where the applicant selected Family but wrote a custom amount, that heuristic lands on the wrong row and returns it with the same confidence as a correct read — the exact situation that makes per-field rather than per-document confidence worth the trouble.

Resolve the selection in code

Split the single question into two extractions and one join. First, extract the rate table as a table, with no reference to any selection:

{
  "rate_table": [
    { "tier": "Individual",  "annual": "65.00",   "monthly": "5.95" },
    { "tier": "Family",      "annual": "110.00",  "monthly": "9.95" },
    { "tier": "Sustaining",  "annual": "250.00",  "monthly": "21.95" },
    { "tier": "Benefactor",  "annual": "1000.00", "monthly": "87.50" }
  ]
}

Second, extract the marks as observations, each carrying the label it sits beside and nothing more:

{
  "marks": [
    { "group": "membership_levels",  "label_beside_mark": "Family",  "state": "filled" },
    { "group": "billing_frequency",  "label_beside_mark": "Monthly", "state": "filled" }
  ],
  "written_amounts": [
    { "label": "Amount enclosed / to be charged", "value": "9.95" },
    { "label": "Additional gift (optional)",      "value": "25.00" }
  ]
}

Third, join them in code: the selected tier is the rate-table row whose tier matches the label beside the filled mark in the membership_levels group. Now every step is checkable. Exactly one mark filled per group is an assertion. A label beside a mark that does not match any rate-table row is an assertion. The expected amount is a lookup rather than a read. And when something fails, the failure names which of the three stages produced it, instead of arriving as one wrong tier with no explanation. This is the practical form of the argument in choosing between a nested and a flat extraction schema: the nesting here is not stylistic, it is what makes the join expressible.

Marks that are not clean

Paper forms are filled in by people, and the mark states you will actually see are more numerous than filled and empty:

  • A tick outside the box. Drawn beside the label rather than in the square. Association by proximity still works; association by “is there ink inside this rectangle” does not.
  • Two marks, one struck through. Somebody changed their mind. The correct output is the surviving mark, and the evidence for it is a line through the other one — something a crop of a single checkbox cannot see and a crop of the whole group can.
  • A circled tier with no checkbox touched at all, which is a perfectly clear instruction to a human and invisible to a box-centric reader.
  • A faint or partial mark from a photocopy or a phone photo at an angle. This is the case for an explicit uncertain mark state routed to review rather than forced to a binary, which is what handling a field that is present but illegible is about.
  • Nothing ticked and an amount written. Common, and recoverable: if the written amount matches exactly one cell of the rate table, that cell identifies both tier and frequency. Record that you inferred it, with a distinct provenance value, so a reviewer can see the difference between a read tier and a deduced one.

The amount check that is wrong

Having built a rate table and a selection, the tempting validation is that the monthly rate times twelve equals the annual rate. On the form above:

Family, monthly:  9.95 x 12 = 119.40
Family, annual:                110.00
difference:                      9.40   (8.5% more to pay monthly)

That is not an extraction error. Organisations charge a premium for paying monthly, for the same reason every subscription business does, and a validator asserting equality between the two columns will fail on every correctly-extracted form. The check that is actually true is narrower: the amount the applicant committed to must equal the rate-table cell for the selected tier and the selected frequency.

expected = rate_table["Family"]["monthly"] = 9.95
written  = 9.95                                  -> passes

And even that check has a legitimate failure mode worth naming, because it will be the bulk of what the rule surfaces: applicants write in amounts that are not on the rate card. Somebody selects Sustaining and writes $300 because they want to give more than the minimum. The extraction is perfect and the assertion fails. Treat a written amount greater than the expected cell as an over-payment to be flagged for handling, not as a suspected misread; treat a written amount less than the expected cell as a probable extraction error or a genuinely underfunded application, and route it accordingly. Sorting the exception queue by which direction the discrepancy runs costs nothing and is the difference between a rule people act on and a rule people mute.

One last field that is easy to skip: these forms carry a date of birth, a home address, and sometimes card details written in a box at the bottom. Card numbers have no business reaching an extraction model at all. Detect and mask them during ingestion — a card number has a Luhn check digit which makes detection unusually reliable — and keep the masked form in the record.