A form submission data model that ages well
Short answer: Store the form's answers as a structured object, keep provenance such as page and campaign in a separate metadata object, and keep ownership, status and timestamps as real columns. That keeps queries simple without requiring a migration for every new question.
The first version of every form schema is one column per field. It reads beautifully until marketing renames a question, adds a dropdown, or removes a field that three reports still reference.
The two-part split
Separate the answers from the context. Answers belong to the form and change when the form changes. Context belongs to the request and changes per visit.
{
"data": {
"name": "Ada Lovelace",
"email": "ada@example.com",
"message": "Please call me back."
},
"metadata": {
"page": "/contact",
"campaign": "spring"
}
}With this split, adding a question touches nothing structural: the new key simply appears in data. Reporting on traffic source queries metadata, which never collides with a field a form owner named identical to a system concern.
What must stay a column
- Ownership: workspace and form id, set by the server, never by the payload.
- Time: created_at, because ordering and retention depend on it.
- Workflow: status, so filtering does not require parsing arbitrary content.
- Deduplication: the idempotency key, scoped to the form.
The rules that protect the object
- 1Cap the body at 256 KB so a single submission cannot become a storage problem.
- 2Cap the number of fields and the nesting depth, so a nested object cannot become a slow query.
- 3Accept only scalar values: strings, numbers and booleans. Files are links, not payloads.
- 4Reject unknown ownership keys outright instead of ignoring them quietly.
What not to store
A form is not a vault. Card numbers, passwords, government identifiers and medical details do not belong in a contact form, whatever the schema looks like. If a question needs that data, it needs a purpose-built system with its own compliance story.
- Never store what you cannot justify keeping for a stated period.
- Never log full submission bodies to an application log; logs outlive the data policy.
- Never copy a lead into a second system without a deletion path.
Retention is part of the model
A schema without a retention decision becomes an archive. Decide how long a submission is useful, and delete on that schedule rather than on the day someone remembers to ask.
The practical test of a good model: you can add a question tomorrow without a migration, you can answer where a lead came from without joining three tables, and you can delete one submission completely without hunting through copies.