Software Journal
Web Development Updated Sep 2, 2026 12 min read

Flask Quiz Master Explained: Building a Dynamic Quiz App with Flask and QuizAPI.io

A complete deep-dive into Flask Quiz Master — a dynamic Python Flask quiz app that fetches live questions from QuizAPI.io, manages sessions, randomizes questions, and renders a responsive Bootstrap UI with score tracking and review.

Portrait of Benmalek Zohir

Benmalek Zohir

Contributor

Share
Flask Quiz Master Explained: Building a Dynamic Quiz App with Flask and QuizAPI.io

Flask Quiz Master Explained: Building a Dynamic Quiz App with Flask and QuizAPI.io

Repository: azario0/flask-quiz-master | API: QuizAPI.io | Framework: Flask

A dynamic web-based quiz application that pulls real questions from an external API, randomizes them, tracks your score in a server-side session, and gives you a full answer review at the end. Simple idea, but it stitches together almost every core concept of modern Python web development.

This article breaks down every file, every method, and how they work together.


1. At a Glance: What Does It Do?

  1. User lands on a welcome page and clicks Start Quiz
  2. App calls QuizAPI.io for N questions (default 5)
  3. Questions are shuffled, stored in Flask Session, and the user is redirected to /quiz
  4. One question is shown at a time. User selects an answer -> POST to same route -> scored -> next question
  5. After the last question -> /score shows score / total, a progress bar, and a detailed review with explanations

Features that come out-of-the-box from the README.md:

  • 🎯 Dynamic fetching from QuizAPI.io
  • 🔀 random.shuffle() for randomized order
  • 📊 Real-time score tracking via session['score']
  • 📝 Answer review with correct/incorrect highlighting
  • 🎨 Responsive UI with Bootstrap 4.5 + custom CSS
  • 🔄 Restart functionality (session.clear())
  • ⚡ Server-side session state

2. Tech Stack & Languages Used

LayerTechnologyPurposeLink
Backend LanguagePython 3.7+All logic, routing, API callspython.org
Web FrameworkFlaskLightweight WSGI framework, routing, templating, sessionsflask.palletsprojects.com
TemplatingJinja2 (bundled with Flask){% extends %}, {% block %}, {{ variable }} logic in HTMLjinja.palletsprojects.com
HTTP ClientRequestsrequests.get() to call QuizAPIrequests.readthedocs.io
FrontendHTML5 + Jinja2layout.html, index.html, question.html, score.htmldeveloper.mozilla.org/en-US/docs/Web/HTML
StylingCSS3 + Bootstrap 4.5Responsive grid, cards, badges, progress barsgetbootstrap.com
AdditionalGoogle Fonts (Poppins)Typographyfonts.google.com
Frontend JSjQuery 3.5.1 + Popper.js + Bootstrap JSOnly for Bootstrap components (no custom JS)jquery.com
APIQuizAPI.ioSource of 1000+ trivia/dev questions with answers + correct_answersquizapi.io/docs
Data ExplorationJupyter Notebook (notebook.ipynb)Prototype/test API call before integrating into Flaskjupyter.org

No database. Everything lives in the signed cookie session (app.secret_key = os.urandom(24)). Great for demo/portability, not for persistence.


3. Project Structure

From quiz_app/README structure:

flask-quiz-master/
├── quiz_app/
│   ├── app.py              # Main Flask application (170 lines)
│   ├── templates/
│   │   ├── layout.html     # Base template - navbar, footer, Bootstrap imports
│   │   ├── index.html      # Homepage: Welcome + Start button + error display
│   │   ├── question.html   # Single question view: radio buttons + metadata
│   │   └── score.html      # Results: score, progress bar, review loop
│   └── static/
│       └── css/
│           └── style.css   # Custom styles (130 lines) - gradients, cards, hover
├── notebook.ipynb          # API prototyping - shows raw JSON response
├── README.md
└── LICENSE (MIT)

4. Core File Deep Dive

4.1 quiz_app/app.py — The Brain (170 lines)

This is the entire backend. Source: quiz_app/app.py

A. Initialization quiz_app/app.py:1-13

from flask import Flask, render_template, request, redirect, url_for, session
import requests, os, random
app = Flask(__name__)
app.secret_key = os.urandom(24) # Generates a random secret key each restart
API_KEY = "YOUR_API_KEY"
QUIZ_API_URL = "https://quizapi.io/api/v1/questions"
NUMBER_OF_QUESTIONS = 5
  • Flask(__name__) creates the WSGI app.
  • secret_key is required for Flask sessions — they are cryptographically signed cookies. Using os.urandom(24) means sessions invalidate on restart (in production use a fixed env var).
  • NUMBER_OF_QUESTIONS is the single config knob to change quiz length.

B. Helper: fetch_questions() quiz_app/app.py:16-47

The most important function. Handles all external communication.

def fetch_questions(limit=NUMBER_OF_QUESTIONS, category=None, difficulty=None, tags=None):
    params = {"apiKey": API_KEY, "limit": limit}
    if category: params["category"] = category
    if difficulty: params["difficulty"] = difficulty # Easy/Medium/Hard
    if tags: params["tags"] = tags # e.g. "JavaScript,PHP"
    try:
        response = requests.get(QUIZ_API_URL, params=params)
        response.raise_for_status()
        questions_data = response.json()
        valid_questions = []
        for q in questions_data:
            if q.get("question") and isinstance(q.get("answers"), dict) ...
                if any(val is not None for val in q["answers"].values()):
                    valid_questions.append(q)
        return valid_questions
    except requests.exceptions.RequestException as e: ...
    except ValueError as e: ...
  • What it does: Builds query params, does GET https://quizapi.io/api/v1/questions?apiKey=xxx&limit=5, validates JSON.
  • Validation is critical: QuizAPI sometimes returns malformed entries. It checks question exists, answers/correct_answers are dicts, and at least one answer is not null.
  • Error handling: raise_for_status() catches 401 (bad key), 429 (rate limit). Returns None on failure so the route can show an error.
  • Extensibility: category, difficulty, tags are already wired — you just need a form to expose them (see Potential Enhancements in README).

C. Helper: get_correct_answer_key() quiz_app/app.py:49-58

Quirk of QuizAPI’s schema:

"correct_answers": {
  "answer_a_correct": "true",
  "answer_b_correct": "false",
  ...
}
def get_correct_answer_key(question_data):
    for key, is_correct in question_data.get("correct_answers", {}).items():
        if is_correct == "true":
            return key.replace("_correct", "") # 'answer_a_correct' -> 'answer_a'
    return None
  • Converts answer_a_correct → answer_a to compare directly with request.form.get('answer') (which is answer_a, answer_b, etc.).

D. Route: index() — GET / quiz_app/app.py:61-65

@app.route('/')
def index():
    session.clear() # Wipes previous quiz
    return render_template('index.html', num_questions=NUMBER_OF_QUESTIONS)
  • Resets state every time you visit home. Prevents stale score from leaking into a new game.

E. Route: start_quiz() — POST /start_quiz quiz_app/app.py:67-95

Triggered by the Start Quiz button in index.html.

@app.route('/start_quiz', methods=['POST'])
def start_quiz():
    questions = fetch_questions(limit=NUMBER_OF_QUESTIONS)
    if not questions:
        return render_template('index.html', error="Could not fetch...", num_questions=...)
    random.shuffle(questions)
    session['questions'] = questions
    session['current_question_index'] = 0
    session['score'] = 0
    session['user_answers'] = []
    return redirect(url_for('show_question'))
  • Fetches → validates length → shuffles (so order is different each playthrough) → seeds session → redirects to /quiz.
  • Note duplicate check if not questions (lines 79 and 83) — minor redundancy but harmless.

F. Route: show_question() — GET & POST /quiz quiz_app/app.py:97-146

This route does double duty — display + evaluate.

GET path (display):

current_question_data = questions[current_index]
answers_for_template = {k:v for k,v in current_question_data['answers'].items() if v is not None}
return render_template('question.html', question_data=..., question_number=current_index+1, ...)
  • Filters out null answers (QuizAPI sends answer_e: null etc. when only 4 options exist) so UI doesn’t show empty radio buttons.

POST path (evaluate):

if request.method == 'POST':
    user_answer_key = request.form.get('answer') # e.g. 'answer_a'
    correct_answer_key = get_correct_answer_key(current_question_data)
    is_correct = (user_answer_key == correct_answer_key)
    if is_correct: session['score'] += 1
    session['user_answers'].append({
        "question_text": ...,
        "user_answer": current_question_data["answers"].get(user_answer_key, "Not answered"),
        "correct_answer_text": current_question_data["answers"].get(correct_answer_key, "N/A"),
        "is_correct": is_correct,
        "explanation": current_question_data.get("explanation")
    })
    session.modified = True # MUST for mutable list
    session['current_question_index'] += 1
    return redirect(url_for('show_question'))
  • session.modified = True is essential — Flask doesn’t detect in-place mutation of lists/dicts. Without it, user_answers wouldn’t persist.
  • Stores both texts (user_answer and correct_answer_text) for the review page, not just keys — so score.html can render without re-accessing API.
  • Uses Post/Redirect/Get pattern (redirect after POST) to prevent double-submit on refresh.

Guard clauses:

if 'questions' not in session: return redirect(url_for('index'))
if current_index >= len(questions): return redirect(url_for('show_score'))

G. Route: show_score() — GET /score quiz_app/app.py:148-167

@app.route('/score')
def show_score():
    if 'score' not in session: return redirect(url_for('index'))
    score = session.get('score', 0)
    total_questions = len(session.get('questions', []))
    user_answers_review = session.get('user_answers', [])
    return render_template('score.html', score=..., total_questions=..., user_answers_review=...)
  • Calculates percentage in template: {% set percentage = (score / total_questions * 100) %}.
  • Session is not cleared here (code commented out) — allows hitting back. Could clear to force fresh start.

H. Runner quiz_app/app.py:169-170

if __name__ == '__main__':
    app.run(debug=True)
  • debug=True enables auto-reload + debugger. Never use in production.

4.2 Templates — Jinja2 Inheritance

All templates extend layout.html — classic DRY.

layout.html quiz_app/templates/layout.html:1-42 — The Master Shell

  • Imports Bootstrap 4.5 CSS, style.css via url_for('static', filename='css/style.css'), and Poppins font.
  • Defines {% block title %} and {% block content %}.
  • Navbar with SVG graduation cap icon linking to url_for('index').
  • Footer: Powered by QuizAPI.io with target="_blank".
  • Loads jQuery + Popper.js + Bootstrap JS at bottom.

index.html quiz_app/templates/index.html:1-25 — Landing

  • Shows {{ num_questions }} dynamically (so changing config auto-updates UI).
  • {% if error %} alert block displays API failures from start_quiz().
  • Form POST to url_for('start_quiz') with big yellow .btn-start-quiz.

question.html quiz_app/templates/question.html:1-47 — Quiz Engine UI

  • Header: Question {{ question_number }} of {{ total_questions }} + {{ question_data.category }} badge.
  • Body: {{ question_data.question }} + optional {{ question_data.description }}.
  • Loop: {% for key, text in answers.items() %} → <input type="radio" name="answer" value="{{ key }}" required> → ensures one choice, HTML5 validation.
  • Footer: Difficulty: {{ question_data.difficulty | capitalize }} + {% for tag in question_data.tags %} badges.

score.html quiz_app/templates/score.html:1-54 — Results & Review

  • {{ score }} / {{ total_questions }} with large display-3.
  • Progress bar color logic:
    {% if percentage >= 75 %}bg-success{% elif percentage >= 50 %}bg-warning{% else %}bg-danger{% endif %}
  • Play Again → url_for('index') (which clears session).
  • Review loop: {% for item in user_answers_review %} → card border border-success vs border-danger, badge colors, shows Correct answer only if not item.is_correct, and Explanation: if present.

4.3 static/css/style.css quiz_app/static/css/style.css:1-130 — Custom Polish

Not just Bootstrap defaults:

  • body { font-family: 'Poppins'; background: #f0f2f5; display:flex; flex-direction:column; min-height:100vh } → sticky footer layout + .content-area { flex:1 }.
  • .welcome-container { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) } → gradient hero.
  • .btn-start-quiz:hover { transform: translateY(-2px); box-shadow: ... } → subtle lift.
  • .answer-options .list-group-item:hover { background:#e9ecef; border-color:#007bff } → hover feedback.
  • .progress, .progress-bar { border-radius:20px } → rounded bar.
  • .review-section { max-width:700px; margin:2rem auto } → centered, readable review.

4.4 notebook.ipynb — API Playground

Source: notebook.ipynb

A single cell that does what fetch_questions() does but standalone:

import requests, json
api_key = "Your_API_KEY"
url = "https://quizapi.io/api/v1/questions"
params = {"apiKey": api_key}
response = requests.get(url, params=params)
data = response.json()
print(json.dumps(data, indent=2))
print(f"Question: {data[0].get('question')}")
  • Output in the notebook shows 20-question payload with fields: id, question, answers: {answer_a..f}, correct_answers: {answer_a_correct: "true"/"false"}, explanation, tags, category, difficulty.
  • Purpose: Test your key and inspect shape before wiring into Flask. Also reveals the exposed key 0Bh4rrJ... in output — rotate it if yours.

5. How It All Works Together — Request Lifecycle

Browser                     Flask (app.py)                  QuizAPI.io
  |                             |                               |
  | GET /                       |                               |
  |---------------------------->| session.clear()                |
  |<-- index.html (Start btn) --|                               |
  |                             |                               |
  | POST /start_quiz            |                               |
  |---------------------------->| fetch_questions()             |
  |                             |-------- GET /questions?apiKey &limit=5 --->|
  |                             |<------- JSON[5 questions] -----------------|
  |                             | random.shuffle()              |
  |                             | session['questions']=...      |
  |<-- 302 Redirect /quiz ------|                               |
  |                             |                               |
  | GET /quiz                   |                               |
  |---------------------------->| answers_for_template filter    |
  |<-- question.html Q1 --------|                               |
  | POST /quiz (answer_a)       |                               |
  |---------------------------->| get_correct_answer_key()      |
  |                             | score +=1 if correct          |
  |                             | user_answers.append(...)      |
  |                             | current_index +=1             |
  |<-- 302 Redirect /quiz ------|                               |
  | ... repeat Q2..Q5 ...       |                               |
  | GET /quiz (index==len)      |                               |
  |---------------------------->| redirect /score                |
  | GET /score                  |                               |
  |---------------------------->| render score.html              |
  |<-- score.html + review -----|                               |

Key design choices:

  • Stateless server, stateful client cookie: No DB, no server memory bloat. But cookie size limit (~4KB) means you can’t scale to 50 questions with full JSON. For larger quizzes, store in server-side cache (Redis) or DB.
  • Synchronous fetch: App blocks on QuizAPI at start. If API is slow, user waits. Could add loading spinner or async fetch.
  • Shuffle on server: Ensures every user gets different order even with same API response.

6. Installation & Configuration (from README)

git clone https://github.com/azario0/flask-quiz-master.git
cd flask-quiz-master
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install flask requests

Get key from QuizAPI.io (free tier) and edit quiz_app/app.py:11:

API_KEY = "your_actual_api_key_here"

Optional tweak quiz_app/app.py:13:

NUMBER_OF_QUESTIONS = 5 # try 10
# and in fetch_questions(): category="Linux", difficulty="Easy", tags="Python"

Run:

python quiz_app/app.py
# → http://localhost:5000

7. Strengths & Limitations

Strengths

  • Minimal dependencies (flask, requests) — easy to run anywhere.
  • Clean separation: fetch_questions() is pure, routes are thin, templates handle presentation.
  • Good validation + error UX (shows error on index if API fails).
  • Nice UI polish beyond default Bootstrap.

Limitations / Gotchas

  • os.urandom(24) on each start invalidates all sessions — use os.environ.get('SECRET_KEY') in prod.
  • No handling for multiple_correct_answers == "true" — currently only checks first "true", would mis-score multi-select questions. QuizAPI sends that flag; you could filter multiple_correct_answers == "false" or implement checkbox logic.
  • Stores full question JSON in cookie — could hit CookieTooLarge. Safer to store IDs or server-side.
  • Exposed API key in notebook output — should be gitignored / env var.

8. Potential Enhancements (from README)

  • User auth + persistent scoring (Flask-Login + SQLite/Postgres)
  • Category/Difficulty selectors on index.html → pass to fetch_questions(category=...)
  • Timer per question (JS setInterval + session['start_time'])
  • Leaderboard (/leaderboard route)
  • Question bookmarking
  • Social sharing (score.html meta tags)

9. Conclusion

Flask Quiz Master is a textbook example of glue code done right: ~170 lines of Python stitching Flask, Requests, Jinja2, Bootstrap, and QuizAPI.io into a complete, shippable product. You learn:

  • How to consume a third-party JSON API and sanitize it
  • How Flask sessions + Post/Redirect/Get make a multi-step flow feel seamless
  • How template inheritance keeps HTML DRY
  • How a tiny CSS file transforms a generic Bootstrap app into something delightful

Clone it, swap the API key, change NUMBER_OF_QUESTIONS, and you have a quiz for any topic in under 5 minutes.

Links to explore further:

⭐ If you found this breakdown helpful, star the original repo and try building your own category filter!


Generated from source review of azario0/flask-quiz-master on 2026-09-02. All file references verified locally.

Share
Portrait of Benmalek Zohir

Written by

Benmalek Zohir

Founder, AI Engineer & Full Stack Developer

Benmalek Zohir is an AI Engineer, Full Stack Developer, and technology enthusiast focused on artificial intelligence, software development, and emerging technologies. He is the founder of SoftwareJournal.blog, where he shares practical insights, software discoveries, AI tools, and the latest developments in technology.

The Software Journal Dispatch

One excellent engineering read, every week.

A concise digest of our best new essays on architecture, tooling, databases, and the craft of software. No spam, no noise — unsubscribe anytime.