Build a Survey Builder
Surveys are everywhere, feedback forms, market research, course evaluations, and behind every one is a structured engine: question types, validation, conditional branching, and analysis. This project builds that engine from scratch: a set of Python classes that model different question types (multiple choice, rating scales, open text), a runner that sequences questions with branching logic, and a pandas pipeline that converts raw responses into frequency charts and cross-tabulations.
This assumes Python basics including classes, lists, and dicts, and comfort with input(), nothing beyond. It’s optional and ungraded; see Real-World Projects for the full, growing list.
🎯 What you’ll do
- Model different question types (multiple choice, rating scale, open text) as Python classes with shared validation and branching behavior.
- Build a
Surveyrunner that sequences questions, applies branch rules, and collects responses. - Simulate realistic response data when
input()isn’t practical for automated runs. - Convert responses to a pandas DataFrame and compute frequency counts, averages, and cross-tabulations.
- Generate bar charts and heatmaps that make survey results easy to interpret at a glance.
Where to run this
This project runs almost anywhere, pandas and matplotlib are pure Python, and the only interactive piece is input(), which works in any terminal.
JupyterLite playground works well, paste the cells directly into a notebook. You’ll need to !pip install pandas matplotlib in a cell first. Note that input() works differently in a notebook than in a terminal, the simulate_responses() function in Step 3 exists partly because of this.
Google Colab works out of the box, both libraries are pre-installed, and input() works natively in notebooks.
Locally with uv is the recommended path for running the real interactive survey loop (Step 2) where input() prompts you question by question, follow the Setup section below.
- Run it in your browser. An interactive companion notebook is ready, open it in Colab, Kaggle, or Binder and follow along top-to-bottom.
Setup
Everything you need before writing a survey question.
Install uv
uv is a single tool that replaces the usual “install Python, then install pip, then install a virtual environment tool, then install packages” chain, it can install and manage Python versions itself, alongside your project’s dependencies.
macOS / Linux (terminal):
curl -LsSf https://astral.sh/uv/install.sh | sh
Windows (PowerShell):
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
Close and reopen your terminal, then confirm it installed:
uv --version
Set up the project
uv init survey-builder
cd survey-builder
uv add pandas matplotlib
pandas turns raw responses into a structured DataFrame for statistics and cross-tabulation; matplotlib produces the charts. Both are pure Python (with NumPy underneath), so they install cleanly with uv.
✅ Checklist
- ✅
uv --versionprints a version number. - ✅
survey-builder/exists with apyproject.toml, andpandasandmatplotlibare installed. - ✅
uv run python -c "import pandas, matplotlib; print('all good')"printsall good.
Step 1: Define question types
A survey isn’t one form, it’s a series of different questions, each with its own input format, validation rules, and branching behavior. Modeling each type as a class lets you share the common parts (display, validation, branching) in a base class while customizing the details per type.
1.1 Create the base Question class and two subclasses
from dataclasses import dataclass, field
from typing import Any, Optional
@dataclass
class Question:
text: str
required: bool = True
branch_rules: dict[str, str] = field(default_factory=dict)
def display(self) -> None:
print(f"\n {self.text}")
def validate(self, answer: Any) -> bool:
return True
def next_question_id(self, answer: Any) -> Optional[str]:
return self.branch_rules.get(str(answer))
@dataclass
class MultipleChoice(Question):
options: list[str] = field(default_factory=list)
def display(self) -> None:
print(f"\n {self.text}")
for i, opt in enumerate(self.options, 1):
print(f" {i}. {opt}")
def validate(self, answer: str) -> bool:
return answer in [str(i) for i in range(1, len(self.options) + 1)]
@dataclass
class RatingScale(Question):
low_label: str = "Poor"
high_label: str = "Excellent"
scale_min: int = 1
scale_max: int = 5
def display(self) -> None:
print(f"\n {self.text}")
print(f" {self.scale_min} ({self.low_label}) — {self.scale_max} ({self.high_label})")
def validate(self, answer: str) -> bool:
try:
return self.scale_min <= int(answer) <= self.scale_max
except ValueError:
return False
mc = MultipleChoice("How often do you exercise?", options=["Daily", "Weekly", "Monthly", "Rarely"])
rating = RatingScale("How satisfied are you?", low_label="Not at all", high_label="Very")
mc.display()
rating.display()
print(f"MC valid '2': {mc.validate('2')} | Rating valid '6': {rating.validate('6')}")
👟 Starter hint: The base Question class does only what’s common to every question: store the text, validate (trivially, True), and look up a branch rule. MultipleChoice and RatingScale subclass it and override display() and validate(), exactly the inheritance pattern that lets a Survey runner treat every question the same way. Notice branch_rules is a dict mapping an answer to the id of the next question.
🎯 Expected output:
How often do you exercise?
1. Daily
2. Weekly
3. Monthly
4. Rarely
How satisfied are you?
1 (Not at all) — 5 (Very)
MC valid '2': True | Rating valid '6': False
🩹 If it’s off: If MultipleChoice.validate('2') returns False, check that options has at least 2 entries, the validation builds range(1, len(options)+1). If RatingScale.validate('6') returns True, the scale_max isn’t applied, confirm the int(answer) conversion and the <= comparison order.
1.2 Verify the question classes
✅ Checklist
- ✅
MultipleChoice.validate("2")isTrue,validate("5")isFalsefor a 4-option question. - ✅
RatingScale.validate("3")isTrue,validate("0")andvalidate("6")are bothFalse. - ✅ You can explain why
MultipleChoicestores options as a list of strings rather than ints.
🤔 Socratic Question(s)
- The
RatingScaleclass storesscale_minandscale_maxas class attributes with defaults. If you wanted a 1–10 scale, what would you override at instantiation, and wouldvalidateneed to change? MultipleChoice.validatebuilds the valid answer list fromlen(self.options). What would happen if you had 10 options, would the validation code need to change, or does it scale automatically? Why?
Step 2: Build the survey runner
A Question in isolation is inert. The Survey class sequences them, applies branching rules to decide which question comes next, and collects answers into a single SurveyResponse object you can analyze later.
2.1 Create the Survey and SurveyResponse classes
from dataclasses import dataclass
from typing import Any, Optional
@dataclass
class SurveyResponse:
survey_title: str
answers: dict[str, Any]
timestamp: str = ""
def __post_init__(self):
if not self.timestamp:
from datetime import datetime
self.timestamp = datetime.now().isoformat()
class Survey:
def __init__(self, title: str):
self.title = title
self.questions: dict[str, Question] = {}
self.order: list[str] = []
def add_question(self, q_id: str, question: Question) -> None:
self.questions[q_id] = question
self.order.append(q_id)
def run(self) -> SurveyResponse:
print(f"\n Survey: {self.title}")
answers: dict[str, Any] = {}
idx = 0
while idx < len(self.order):
q_id = self.order[idx]
question = self.questions[q_id]
question.display()
while True:
answer = input(" Your answer: ").strip()
if not question.required and answer == "":
break
if question.validate(answer):
answers[q_id] = answer
break
print(" Invalid answer, please try again.")
branch = question.next_question_id(answer)
idx = self.order.index(branch) if branch and branch in self.questions else idx + 1
print(" Thank you for completing the survey!")
return SurveyResponse(survey_title=self.title, answers=answers)
👟 Starter hint: The while idx < len(self.order) loop walks through questions in order. After each answer, it looks up next_question_id(answer), if the branch rules say “answer 2 jumps to question ‘followup’”, the index jumps there; otherwise it advances by one. SurveyResponse.__post_init__ stamps a timestamp when one isn’t provided, dataclasses run __post_init__ right after __init__, which is the idiomatic place for default-dependent logic.
🎯 Expected output: Running Survey("Health Survey").run() prompts question by question and returns a SurveyResponse with the collected answers and a timestamp.
🩹 If it’s off: If the runner gets stuck in an infinite loop, branch is likely pointing to a question id that isn’t in self.questions, the else idx + 1 fallback only runs when the branch is None or not found, so a mistyped id in branch_rules causes the loop to repeat the same question. If input() immediately errors in a notebook, you’re in a non-interactive cell, use the simulation approach from Step 3 instead.
2.2 Verify the survey runner
✅ Checklist
- ✅
Survey.run()completes and returns aSurveyResponsewith keys matching your question ids. - ✅ A required question that receives an invalid answer reprompts, never accepting the bad input.
- ✅ An optional question (
required=False) accepts an empty answer.
🤔 Socratic Question(s)
- The runner uses a
while idx < len(self.order)loop, not aforloop overself.order. Why is awhileloop necessary when a branch instruction can jump the index forward or backward? - If two questions had the same display text but different ids, how would the survey record distinguish them? What does this suggest about why question ids must be unique?
Step 3: Collect responses, live and simulated
Real surveys need many responses, but running input() 30 times in a terminal is impractical. This step builds both paths: a loop that calls survey.run() for live collection, and a simulate_responses() function that generates realistic random responses so analysis doesn’t depend on someone sitting at a keyboard.
3.1 Collect live responses
def collect_responses(survey: Survey, count: int) -> list[SurveyResponse]:
responses = []
for i in range(count):
print(f"\n--- Participant {i + 1} of {count} ---")
responses.append(survey.run())
return responses
3.2 Simulate responses for automated analysis
def simulate_responses() -> list[dict]:
import random
return [{
"exercise_freq": str(random.choice([1, 2, 3, 4])),
"satisfaction": str(random.randint(1, 5)),
"recommend": str(random.choice([1, 2, 3, 4, 5])),
"feedback": random.choice(["Great service", "Needs improvement", "Excellent", "Could be better"]),
} for _ in range(30)]
responses = simulate_responses()
print(f"Collected {len(responses)} simulated responses")
👟 Starter hint: collect_responses is the live path, call it with a Survey and a count and it runs the survey that many times, each producing a SurveyResponse. simulate_responses is the automated path, it uses random to generate 30 plausible responses with the same keys your survey questions would produce. The keys must match your question ids exactly, or the pandas step below won’t find the right columns.
🎯 Expected output:
Collected 30 simulated responses
🩹 If it’s off: If random.choice([1, 2, 3, 4]) returns a numpy int that breaks downstream code, the values are stored as strings (str(...)), that’s deliberate. If you see a KeyError when building the DataFrame later, a simulated response is missing a key that your survey questions produce, check that the simulate_responses dict keys match your question ids.
3.3 Verify response collection
✅ Checklist
- ✅
simulate_responses()returns a list of 30 dicts, each with the same four keys. - ✅ Each response’s values are strings (not ints), matching what
Survey.run()would produce frominput(). - ✅
collect_responsesproduces a list ofSurveyResponseobjects when given a realSurvey.
🤔 Socratic Question(s)
- Responses are stored as strings (
"2","4") even though they represent numbers. Why does that match reality, what doesinput()return, and how does storing it raw preserve information? simulate_responsesusesrandom.choicefor everything. What would the distribution look like if you usedrandom.randint(1, 4)instead ofrandom.choice([1, 2, 3, 4])? How would that change the analysis?
Step 4: Analyze results with pandas
The data is collected, now it needs to become insight. This step converts the response list to a DataFrame, maps raw numeric answers to readable labels, and computes frequency counts and averages that answer questions like “how often do people exercise?” and “how satisfied are they, on average?”
4.1 Convert to a DataFrame and compute statistics
import pandas as pd
df = pd.DataFrame(responses)
freq_map = {"1": "Daily", "2": "Weekly", "3": "Monthly", "4": "Rarely"}
df["exercise_label"] = df["exercise_freq"].map(freq_map)
exercise_counts = df["exercise_label"].value_counts()
satisfaction_counts = df["satisfaction"].value_counts().sort_index()
print("Exercise Frequency:")
print(exercise_counts)
print(f"\nAverage Satisfaction: {df['satisfaction'].astype(int).mean():.2f}")
👟 Starter hint: pd.DataFrame(responses) turns a list of dicts into rows and columns automatically. .map(freq_map) converts the raw "1" to the readable "Daily" string, this is the classic lookup/recode step in survey analysis. value_counts() counts how often each value appears, and df[...].astype(int).mean() computes the numeric average by converting the string column to integers first.
🎯 Expected output:
Exercise Frequency:
Daily <count>
Weekly <count>
Monthly <count>
Rarely <count>
Name: exercise_label, dtype: int64
Average Satisfaction: <number between 1.0 and 5.0>
🩹 If it’s off: A KeyError: 'exercise_freq' means responses doesn’t have that column, check that simulate_responses’ keys match exercise_freq, satisfaction, etc. exactly. If exercise_counts is empty, value_counts() found only NaN values, check whether df["exercise_freq"] is None or NaN in some rows. If .astype(int) fails, a response value isn’t a clean integer string, check for whitespace or extra characters.
4.2 Build a cross-tabulation
cross_tab = pd.crosstab(df["exercise_label"], df["satisfaction"])
print("\nExercise Frequency vs Satisfaction:")
print(cross_tab)
🎯 Expected output: A 4-row by 5-column table where each cell is the count of respondents with that exercise frequency and that satisfaction score.
🩹 If it’s off: If pd.crosstab returns an error about duplicate indices, you may have duplicate exercise labels, unlikely with a clean map, but check for typos in freq_map. If the table has NaN cells, pd.crosstab handles empty combinations as 0 by default, verify you’re not looking at missing data instead of an actual zero-count.
4.3 Verify the analysis
✅ Checklist
- ✅
pd.DataFrame(responses)creates a DataFrame with the same keys as columns. - ✅
exercise_countsshows a frequency distribution across all four labels, no label is ever missing when represented in the data. - ✅
pd.crosstab(df["exercise_label"], df["satisfaction"])produces a non-trivial table (rows > 1).
🤔 Socratic Question(s)
value_counts()drops missing values by default, whilecrosstabtreats a non-appearing combination as 0. When does that distinction matter, can you think of a case where you’d want a missing row to stay missing rather than become 0?- If you changed the satisfaction scale from 1–5 to 1–10, what code would break? The
crosstabwill automatically show 10 columns, would any other step need changes?
Step 5: Visualize the results
Numbers in a DataFrame are precise but slow to absorb. Two bar charts, one for exercise frequency, one for satisfaction distribution, turn the counts into an at-a-glance picture.
5.1 Build the charts
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
colors_ex = ["#2ecc71", "#3498db", "#f39c12", "#e74c3c"]
exercise_counts.plot(kind="bar", ax=axes[0], color=colors_ex)
axes[0].set_title("Exercise Frequency")
axes[0].set_ylabel("Responses")
axes[0].tick_params(axis="x", rotation=45)
colors_sat = ["#e74c3c", "#e67e22", "#f1c40f", "#2ecc71", "#27ae60"]
satisfaction_counts.plot(kind="bar", ax=axes[1], color=colors_sat[:len(satisfaction_counts)])
axes[1].set_title("Satisfaction Ratings")
axes[1].set_ylabel("Responses")
plt.tight_layout()
plt.savefig("survey_results.png", dpi=150)
plt.show()
👟 Starter hint: plt.subplots(1, 2, figsize=(12, 5)) creates a single figure with two side-by-side axes. Each Series.plot(kind="bar", ax=axes[n]) draws onto a specific subplot; the color arrays are ordered so that low satisfaction is red and high satisfaction is green, an intentional visual choice that matches intuitive “red = bad, green = good” associations. color=colors_sat[:len(satisfaction_counts)] slices the palette down to the actual number of rating values present, so a survey where nobody chose 5 doesn’t show an empty bar.
🎯 Expected output: Two bar charts in one figure: exercise frequency on the left (4 colored bars), satisfaction ratings on the right (up to 5 colored bars). The figure saves to survey_results.png in your project folder and displays on screen.
🩹 If it’s off: A blank figure (no bars) means the Series you’re plotting is empty, check that exercise_counts and satisfaction_counts have data. If the satisfaction chart shows only 3 colors but 5 ratings, satisfaction_counts has fewer than 5 unique values, that’s data, not a bug, and the slice is what keeps the palette aligned. If plt.show() shows nothing in a headless environment, the savefig still wrote the file, check that one.
5.2 Verify the visualization
✅ Checklist
- ✅
survey_results.pngexists in your project folder (or the notebook outputs the figure). - ✅ Both subplots have titles (
Exercise Frequency,Satisfaction Ratings) and y-axis labels. - ✅ The satisfaction bars use color ordering that visually communicates low-to-high satisfaction.
🤔 Socratic Question(s)
- The color arrays are hardcoded with five hex codes. What would happen if you ran the survey with a 10-point scale, would the colors still map sensibly, or would you need to generate them programmatically?
plt.savefig("survey_results.png")writes to the current directory. What would break if you ran this script from a different working directory, and what doesPath(__file__).parentgive you instead?
⚠️ Common pitfalls
- Question ids don’t match response keys.
Survey.run()stores answers under the question ids you pass toadd_question, andsimulate_responses()returns dicts with hardcoded keys. If the id inadd_questionis"exercise_freq_x"but the simulation uses"exercise_freq", your DataFrame will be missing a column. Keep the two in sync, or better, drive the simulation keys from the survey itself. - String vs int confusion.
input()returns strings, so comments like “6isn’t in range” are string comparisons.df["satisfaction"].astype(int).mean()converts before averaging; a stray non-numeric string (like a blank from an optional question) makes.astype(int)throw. Filter or fill NaN before converting. - The branch loop can spin forever. If
branch_rulesmaps an answer to a question id that isn’tin self.questions, theelse idx + 1doesn’t fire and the runner re-asks the same question. Mistyped ids are the classic cause, keep a single source of truth for question ids. value_countsdrops missing vscrosstabcounting-0 difference. A respondent who skips an optional question vanishes fromvalue_counts()but appears as a 0 count incrosstabonly if the category exists elsewhere. Know which behavior your analysis needs before interpreting the chart.
What you just built
A complete survey platform: type-safe question classes with validation, a branching survey runner, live and simulated response collection, and a pandas-driven analysis pipeline that turns raw answers into frequency counts, averages, cross-tabulations, and side-by-side charts. You also learned the core pattern of any data-analysis workflow: raw → structured → statistics → visualization.
Run a fuller version without any local setup
examples/survey-builder/ in the course repo is a fuller version with a heatmap cross-tabulation, response-filtering functions (show all Daily exercisers and compute their average satisfaction), and multi-level branching. Clone it, or open the whole repo in a GitHub Codespace and run it from there.
Where to go from here
- Add a cross-tabulation heatmap: use
pd.crosstab+matplotlib.imshow(or Seaborn’sheatmap) to visualize exercise frequency vs. satisfaction as a color grid instead of a number table. - Add response filtering: write a function that returns only the respondents who chose a specific answer (e.g., all Daily exercisers) and compute their average satisfaction, the filter reveals subgroup insight the aggregate misses.
- Extend branching to multi-level: when answer A on question 1 jumps to question X, and answer B on question X jumps to question Y, your
next_question_idandorder.index(branch)logic needs to handle chains of branches, not just single jumps.
Share your project with the class
Built something you’re proud of? examples/student-projects/ is a gallery of projects other students have submitted, and its README has a full, beginner-friendly walkthrough for adding yours via a pull request, even if you’ve never used git before: forking the repo, making a branch, committing your files, and opening the PR, one step at a time. No prior git experience assumed.
Welcome to writing Python outside the browser. 🎓
Finish every step, then mark this project complete to claim its XP.