DBMS 📂 Real Use cases · 1 of 4 35 min read

Designing the Database for a Google Forms-Style App

Build the complete database for a Google Forms-style app in MySQL. Full CREATE TABLE schemas for users, topics, forms, question types, questions, options, responses and answers. Covers how to store every answer type cleanly, two animated labelled ER diagrams, and ready-to-use SQL queries.

Section 01

What We Are Building

A Form Is Just a Container of Questions
Think of a paper survey. It has a title, a few questions, and each question has an answer box. Some questions are open text, some are tick-boxes, some are ratings. When 500 people fill the survey, you get 500 stacks of paper — each stack is one person's answers.

A Google Forms–style app is the same idea in a database. We store the blueprint of the form once, then store many answers against it. The whole design problem is: how do we store one flexible blueprint and millions of mixed-type answers cleanly?

In this tutorial we design the full database for a form builder like Google Forms. We cover users, form topics, forms, questions, question types, answer options, and how to store the actual answers. The engine is MySQL / MariaDB. We finish with two labelled, animated diagrams.

🧩
The Nine Tables

We split the design into two groups. The blueprint group defines a form: users, form_topics, forms, question_types, questions, question_options. The data group stores what people submit: responses, answers, answer_options.


Section 02

The Nine Tables at a Glance

GroupTableWhat It HoldsKey Link
BlueprintusersPeople who build forms and log in—
Blueprintform_topicsCategories: Feedback, Quiz, Survey, RSVP—
BlueprintformsOne form: title, owner, topic, statusowner → users
Blueprintquestion_typesLookup: text, choice, rating, date…—
BlueprintquestionsOne question inside a formform → forms
Blueprintquestion_optionsThe choices for choice-type questions→ questions
DataresponsesOne submission = one filled form→ forms
DataanswersOne answer to one question→ responses, questions
Dataanswer_optionsWhich options a choice answer picked→ answers, options

Section 03

Users and Form Topics

Start with the simplest tables. A user is the person who creates forms. A form_topic is a category that groups forms. Both stand alone with no foreign keys, so we build them first.

-- ============ USERS ============
CREATE TABLE users (
    id             BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    name           VARCHAR(120)      NOT NULL,
    email          VARCHAR(190)      NOT NULL,
    password_hash  VARCHAR(255)      NOT NULL,
    created_at     TIMESTAMP          NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uq_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ FORM TOPICS (categories) ============
CREATE TABLE form_topics (
    id           INT UNSIGNED  NOT NULL AUTO_INCREMENT,
    name         VARCHAR(80)  NOT NULL,
    slug         VARCHAR(80)  NOT NULL,
    description  VARCHAR(255) NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uq_topics_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
🔑
Why utf8mb4 and email length 190

utf8mb4 stores every emoji and language correctly. We cap indexed strings like email at 190 characters so a UNIQUE index stays inside MySQL's older index size limit. Both are safe defaults for any web app.


Section 04

The Form Table

A form is the blueprint. It belongs to one user (the owner) and sits in one topic. It has a status so you can keep drafts private and publish when ready. A share_slug gives each form a public link like /f/abc123.

CREATE TABLE forms (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    owner_id    BIGINT UNSIGNED NOT NULL,          -- FK -> users
    topic_id    INT UNSIGNED    NULL,              -- FK -> form_topics
    title       VARCHAR(200)     NOT NULL,
    description TEXT            NULL,
    status      ENUM('draft','published','closed') NOT NULL DEFAULT 'draft',
    share_slug  VARCHAR(40)      NULL,
    settings    JSON            NULL,              -- theme, one-response-per-user, etc.
    created_at  TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at  TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uq_forms_slug (share_slug),
    KEY ix_forms_owner (owner_id),
    KEY ix_forms_topic (topic_id),
    CONSTRAINT fk_forms_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE,
    CONSTRAINT fk_forms_topic FOREIGN KEY (topic_id) REFERENCES form_topics(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
🔧
Two Delete Rules, Two Reasons

Deleting a user removes their forms — that is ON DELETE CASCADE. Deleting a topic should not delete forms; it just clears the link — that is ON DELETE SET NULL. Pick each rule by asking: "should the child survive without the parent?"


Section 05

Question Types and Questions

Instead of hard-coding types in the app, we store them in a small lookup table. Adding a new type later (say "signature") becomes one row, not a code change. The has_options flag tells us whether a type needs rows in question_options.

✍️
Free-text types
has_options = 0
short_text, long_text, email, number, date, time. The answer is typed by the user, so there is nothing to pre-define.
☑️
Choice types
has_options = 1
single_choice, multi_choice, dropdown. The builder lists the options ahead of time in question_options.
⭐
Scale types
has_options = 0
rating and linear_scale store a number (1–5, 0–10). Range settings live in the question's JSON column.
-- ============ QUESTION TYPES (lookup) ============
CREATE TABLE question_types (
    id           TINYINT UNSIGNED NOT NULL AUTO_INCREMENT,
    type_key     VARCHAR(30)      NOT NULL,   -- 'short_text', 'single_choice'...
    label        VARCHAR(60)      NOT NULL,   -- shown in the builder UI
    has_options  TINYINT(1)      NOT NULL DEFAULT 0,
    PRIMARY KEY (id),
    UNIQUE KEY uq_qtype_key (type_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ QUESTIONS ============
CREATE TABLE questions (
    id                BIGINT UNSIGNED  NOT NULL AUTO_INCREMENT,
    form_id           BIGINT UNSIGNED  NOT NULL,     -- FK -> forms
    question_type_id  TINYINT UNSIGNED NOT NULL,     -- FK -> question_types
    title             VARCHAR(500)     NOT NULL,
    help_text         VARCHAR(500)     NULL,
    position          INT UNSIGNED     NOT NULL DEFAULT 0,  -- order on the page
    is_required       TINYINT(1)      NOT NULL DEFAULT 0,
    settings          JSON             NULL,     -- min, max, scale labels...
    PRIMARY KEY (id),
    KEY ix_questions_form (form_id, position),
    CONSTRAINT fk_q_form FOREIGN KEY (form_id) REFERENCES forms(id) ON DELETE CASCADE,
    CONSTRAINT fk_q_type FOREIGN KEY (question_type_id) REFERENCES question_types(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
📝 Seeding the question_types table
Text
short_text, long_text, email, number → has_options = 0
Choice
single_choice, multi_choice, dropdown → has_options = 1
Scale
rating, linear_scale → has_options = 0
Other
date, time, file_upload → has_options = 0

Section 06

Question Options (the Choices)

A single-choice or multi-choice question needs a list of options. We store each option as its own row. This keeps them ordered, editable, and re-usable by the answer tables. A free-text question simply has zero option rows.

CREATE TABLE question_options (
    id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    question_id  BIGINT UNSIGNED NOT NULL,       -- FK -> questions
    label        VARCHAR(255)    NOT NULL,       -- what the user sees
    value        VARCHAR(255)    NULL,           -- optional stored code
    position     INT UNSIGNED    NOT NULL DEFAULT 0,
    PRIMARY KEY (id),
    KEY ix_opt_question (question_id, position),
    CONSTRAINT fk_opt_question FOREIGN KEY (question_id)
        REFERENCES questions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
⚠️
Never Delete Options That Have Answers

If a builder edits a live form, do not hard-delete an option people already picked. Add an is_active flag and hide it instead. Deleting it would break old answers that point to it. Soft-delete keeps your history correct.


Section 07

The Core Problem — How to Store Answers

One Column Cannot Hold Every Answer Type
A text answer is a sentence. A rating is a number. A date is a date. A multi-choice answer is a list of picked options. If you try to jam all of these into one VARCHAR column, sorting, filtering, and reporting all break. So we make a clear decision about where each kind of value goes.

There are three common patterns. Here is the honest trade-off:

❌ One VARCHAR for everything
Numbers stored as text
Cannot sort or range-filter
Multi-choice becomes messy CSV
Reporting is painful
✅ Typed columns + link table
answer_text for words
answer_number for scores
answer_date for dates
answer_options for picks
🎯
Our Choice: Typed Columns

We give the answers table a few nullable, typed columns. Each answer fills the one column that matches its question type. Choice answers store nothing in those columns; they store picked options in a separate link table. This keeps every value in its correct data type, which makes queries fast and reports simple.

Where Each Answer Type Lands

Question typeColumn used in answersUses answer_options?
short_text, long_text, emailanswer_textNo
number, rating, linear_scaleanswer_numberNo
date, timeanswer_dateNo
single_choice, dropdownnoneYes — 1 row
multi_choicenoneYes — many rows
file_uploadanswer_text (file URL)No

Section 08

The Data Tables — Responses, Answers, Answer Options

A response is one submission of the whole form. Each answer belongs to that response and points to the question it answers. Choice picks go into answer_options, which links an answer to the option rows it selected.

-- ============ RESPONSES (one submission) ============
CREATE TABLE responses (
    id                 BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    form_id            BIGINT UNSIGNED NOT NULL,     -- FK -> forms
    respondent_user_id BIGINT UNSIGNED NULL,         -- FK -> users (if logged in)
    respondent_email   VARCHAR(190)    NULL,         -- if collected anonymously
    submitted_at       TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    ip_address         VARBINARY(16)   NULL,         -- INET6_ATON()
    PRIMARY KEY (id),
    KEY ix_resp_form (form_id, submitted_at),
    CONSTRAINT fk_resp_form FOREIGN KEY (form_id) REFERENCES forms(id) ON DELETE CASCADE,
    CONSTRAINT fk_resp_user FOREIGN KEY (respondent_user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ ANSWERS (one per question per response) ============
CREATE TABLE answers (
    id             BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    response_id    BIGINT UNSIGNED NOT NULL,      -- FK -> responses
    question_id    BIGINT UNSIGNED NOT NULL,      -- FK -> questions
    answer_text    TEXT            NULL,          -- text, email, file URL
    answer_number  DECIMAL(14,4)   NULL,          -- number, rating, scale
    answer_date    DATETIME        NULL,          -- date, time
    PRIMARY KEY (id),
    UNIQUE KEY uq_answer (response_id, question_id),  -- one answer per question
    KEY ix_answer_question (question_id),
    CONSTRAINT fk_ans_response FOREIGN KEY (response_id) REFERENCES responses(id) ON DELETE CASCADE,
    CONSTRAINT fk_ans_question FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ============ ANSWER OPTIONS (picked choices) ============
CREATE TABLE answer_options (
    id                  BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    answer_id           BIGINT UNSIGNED NOT NULL,   -- FK -> answers
    question_option_id  BIGINT UNSIGNED NOT NULL,   -- FK -> question_options
    PRIMARY KEY (id),
    UNIQUE KEY uq_answer_option (answer_id, question_option_id),
    KEY ix_ao_option (question_option_id),
    CONSTRAINT fk_ao_answer FOREIGN KEY (answer_id) REFERENCES answers(id) ON DELETE CASCADE,
    CONSTRAINT fk_ao_option FOREIGN KEY (question_option_id) REFERENCES question_options(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
🎁
The Two UNIQUE Keys Do Real Work

uq_answer (response_id, question_id) stops a person answering the same question twice in one submission. uq_answer_option (answer_id, question_option_id) stops the same option being recorded twice. These two lines prevent whole classes of duplicate-data bugs.


Section 09

Animated ER Diagram — The Blueprint

The six blueprint tables and their links. Each line reads one-to-many (1 → N) from parent to child. Lines flow in the direction data depends.

📊 ER Diagram — Form Blueprint
1→N 1→N 1→N 1→N 1→N users PK id name email (unique) form_topics PK id name slug (unique) forms PK id FK owner_id FK topic_id title status question_types PK id type_key has_options questions PK id FK form_id FK question_type_id title is_required question_options PK id FK question_id label position

Section 10

Animated Diagram — How One Answer Is Stored

A submission creates one response, then one answer per question. Choice picks add rows in answer_options. Faded boxes are blueprint tables the data points back to.

📤 Data Flow — Storing a Submission
1→N 1→N 1→N 1→N 1→N forms blueprint (defined earlier) responses PK id FK form_id submitted_at questions blueprint (defined earlier) answers FK response_id FK question_id answer_text / _number question_options blueprint (defined earlier) answer_options FK answer_id FK question_option_id
01
User clicks Submit
Insert one row into responses with the form_id and a timestamp. This gives you a new response_id.
02
Loop over answered questions
For each question, insert one row into answers with the response_id and question_id.
03
Fill the right column
Text goes to answer_text, numbers to answer_number, dates to answer_date.
04
Save the picks
For choice questions, insert one row per selected option into answer_options. Multi-choice makes several rows.

Section 11

Putting It to Work — Example Queries

Save a submission (inside one transaction)

START TRANSACTION;

-- 1) the response
INSERT INTO responses (form_id, respondent_email)
VALUES (10, 'asha@example.com');
SET @response_id = LAST_INSERT_ID();

-- 2) a text answer (question 101)
INSERT INTO answers (response_id, question_id, answer_text)
VALUES (@response_id, 101, 'The service was quick and friendly.');

-- 3) a rating answer (question 102)
INSERT INTO answers (response_id, question_id, answer_number)
VALUES (@response_id, 102, 5);

-- 4) a multi-choice answer (question 103) -> picks 2 options
INSERT INTO answers (response_id, question_id) VALUES (@response_id, 103);
SET @answer_id = LAST_INSERT_ID();
INSERT INTO answer_options (answer_id, question_option_id)
VALUES (@answer_id, 5001), (@answer_id, 5004);

COMMIT;
🔒
Always Wrap a Submission in a Transaction

A submission writes to three tables. If the server crashes halfway, you must not keep a half-saved response. START TRANSACTION … COMMIT makes the whole submission all-or-nothing.

Read back one full submission

SELECT q.title                       AS question,
       COALESCE(
           a.answer_text,
           CAST(a.answer_number AS CHAR),
           GROUP_CONCAT(o.label ORDER BY o.position SEPARATOR ', ')
       )                              AS answer
FROM   answers a
JOIN   questions q          ON q.id = a.question_id
LEFT JOIN answer_options ao  ON ao.answer_id = a.id
LEFT JOIN question_options o ON o.id = ao.question_option_id
WHERE  a.response_id = 7
GROUP BY a.id, q.title, a.answer_text, a.answer_number
ORDER BY q.position;

Count votes for a single-choice question

SELECT o.label, COUNT(*) AS votes
FROM   answer_options ao
JOIN   question_options o ON o.id = ao.question_option_id
JOIN   answers a          ON a.id = ao.answer_id
WHERE  a.question_id = 103
GROUP BY o.id, o.label
ORDER BY votes DESC;
OUTPUT — poll results
label votes Email support 412 Live chat 388 Phone 145 Help centre 97

Section 12

Golden Rules

🧩 Form Database — Non-Negotiable Rules
1
Separate the blueprint from the data. Six tables define the form; three tables store what people submit. Never mix the two roles in one table.
2
Store answers in typed columns. Text, numbers, and dates each get their own column. This keeps sorting, filtering, and reports correct and fast.
3
Keep question types in a lookup table. Adding a new type is one row, not a code change. Use has_options to know if it needs option rows.
4
Use UNIQUE keys to block duplicates. One answer per question per response, and one row per picked option. Let the database enforce it, not the app.
5
Choose delete rules on purpose. CASCADE when the child cannot live without the parent (a form's questions). SET NULL when it can (a form's topic).
6
Soft-delete options that already have answers. Hard-deleting them breaks old submissions. Hide with a flag instead.
7
Save every submission in a transaction. A submission touches three tables. All rows commit together, or none do.