Home Python Flask Web Framework Basics: Build Your First Python Web App

Flask Web Framework Basics: Build Your First Python Web App

In Plain English 🔥
Imagine you own a restaurant. When a customer walks in and asks for 'Table 4, pasta please', a waiter takes that request, goes to the kitchen, gets the right dish, and brings it back. Flask is that waiter for the internet — it listens for requests from browsers, figures out what page or data was asked for, grabs the right response, and sends it back. You just have to tell it which requests to listen for and what to do with them.
⚡ Quick Answer
Imagine you own a restaurant. When a customer walks in and asks for 'Table 4, pasta please', a waiter takes that request, goes to the kitchen, gets the right dish, and brings it back. Flask is that waiter for the internet — it listens for requests from browsers, figures out what page or data was asked for, grabs the right response, and sends it back. You just have to tell it which requests to listen for and what to do with them.

Every time you load a webpage, something on a server had to receive your browser's request, run some logic, and send back HTML, JSON, or a file. For Python developers, Flask is one of the most popular tools to build that server-side logic — and it powers everything from internal company dashboards to public APIs used by millions. Companies like Pinterest and LinkedIn have used Flask in production. It's not a toy framework; it's a deliberate, minimal one.

The problem Flask solves is dead simple: Python has no built-in way to speak HTTP. You'd need to manually parse URLs, handle headers, route requests to different functions, and construct valid HTTP responses — all before writing a single line of your actual app logic. Flask wraps all that plumbing in a clean interface so you can focus on your product, not the protocol. It's called a 'micro' framework because it gives you the essentials without forcing a project structure, an ORM, or an admin panel on you.

By the end of this article you'll be able to build a multi-route Flask app with dynamic URLs, handle GET and POST requests, render HTML templates with real data, and understand the request/response lifecycle well enough to debug it when things go wrong. You'll also know the mistakes that trip up even experienced developers on their first Flask project.

How Flask Routes Requests: The Core Mental Model

Every Flask app is built around one central idea: a URL maps to a Python function. That mapping is called a route, and it's registered using the @app.route() decorator. When a browser hits your server at /products, Flask looks up which function is registered for that URL and calls it. Whatever that function returns becomes the HTTP response.

This is fundamentally different from frameworks like Django, which uses a separate urls.py file for routing. In Flask, the route lives right above the function it controls. That co-location makes small apps extremely readable — you see the URL and the logic in one glance.

Under the hood, Flask uses the Werkzeug library to parse incoming HTTP requests and the Jinja2 library to render HTML templates. You don't need to import these directly — Flask wraps them — but knowing they exist helps when you read error messages that mention them.

The app object you create with Flask(__name__) is the heart of everything. It holds the route map, the configuration, and the request context. The __name__ argument tells Flask where to find your templates and static files relative to your code.

basic_flask_app.py · PYTHON
123456789101112131415161718192021222324252627282930
from flask import Flask

# Create the Flask application instance.
# __name__ tells Flask where this file lives so it can find
# templates and static files relative to this directory.
app = Flask(__name__)

# The @app.route decorator registers this URL with the app.
# GET is the default method — browser address bar requests are always GET.
@app.route('/')
def home():
    # Whatever you return here becomes the HTTP response body.
    # Flask automatically sets Content-Type to text/html.
    return '<h1>Welcome to TheCodeForge Store</h1><p>Browse our products below.</p>'

@app.route('/about')
def about():
    return '<h1>About Us</h1><p>We build tools for developers.</p>'

@app.route('/status')
def status():
    # Returning a plain string — useful for health-check endpoints.
    return 'Server is running. All systems operational.'

# This block ensures the dev server only starts when you
# run this file directly, not when it's imported as a module.
if __name__ == '__main__':
    # debug=True enables auto-reload on file save and shows
    # detailed error pages in the browser. NEVER use in production.
    app.run(debug=True)
▶ Output
* Running on http://127.0.0.1:5000
* Debug mode: on
* Restarting with stat
* Debugger is active!

[When you visit http://127.0.0.1:5000/]
Welcome to TheCodeForge Store
Browse our products below.

[When you visit http://127.0.0.1:5000/status]
Server is running. All systems operational.
⚠️
Watch Out: debug=True in ProductionFlask's debug mode exposes an interactive Python shell in the browser when an error occurs. Anyone who triggers an error can run arbitrary Python on your server. Always set debug=False in production and control it via an environment variable: app.run(debug=os.getenv('FLASK_DEBUG', 'false').lower() == 'true')

Dynamic Routes and the Request Object: Handling Real User Input

Static routes like /about are fine for fixed pages, but real apps need dynamic URLs. A product catalogue needs /products/42 and /products/107 to show different items without you hand-coding a route for every ID. Flask handles this with URL converters inside angle brackets in the route string.

The converter tells Flask both how to match the URL segment AND what Python type to give your function. Using means Flask will only match that route if the segment is a valid integer — a request to /products/abc simply won't match, and Flask returns a 404 automatically. That's free input validation.

For form submissions and API calls, input arrives not in the URL but in the request body or query string. Flask exposes all of this through the request object, imported from flask. It's a context-local object, meaning it's automatically populated with the current request's data for each incoming call — you don't pass it around manually.

The distinction between request.args (query string: /search?term=flask) and request.form (POST body from an HTML form) trips people up constantly. Knowing which one to reach for depends entirely on the HTTP method and how the client sent the data.

dynamic_routes_app.py · PYTHON
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
from flask import Flask, request, jsonify

app = Flask(__name__)

# Simulated product database — in a real app this would be a database query.
PRODUCTS = {
    1: {'name': 'Python Handbook', 'price': 29.99, 'category': 'books'},
    2: {'name': 'Mechanical Keyboard', 'price': 149.99, 'category': 'hardware'},
    3: {'name': 'Dark Theme Pack', 'price': 4.99, 'category': 'software'},
}

# <int:product_id> does two things:
# 1. Captures the URL segment as a variable named product_id
# 2. Enforces that it must be a valid integer (non-integers get a 404)
@app.route('/products/<int:product_id>')
def get_product(product_id):
    product = PRODUCTS.get(product_id)

    if product is None:
        # jsonify creates a proper JSON response with correct Content-Type header.
        # The second return value sets the HTTP status code.
        return jsonify({'error': f'Product {product_id} not found'}), 404

    return jsonify({'id': product_id, **product})

# Query string parameters: /search?category=books&max_price=50
@app.route('/search')
def search_products():
    # request.args is an ImmutableMultiDict — access it like a dict.
    # The second argument to .get() is the default if the key is missing.
    category_filter = request.args.get('category', None)
    max_price_str = request.args.get('max_price', None)

    results = list(PRODUCTS.values())

    if category_filter:
        results = [p for p in results if p['category'] == category_filter]

    if max_price_str:
        # Query string values are always strings — convert before comparing.
        max_price = float(max_price_str)
        results = [p for p in results if p['price'] <= max_price]

    return jsonify({'count': len(results), 'results': results})

if __name__ == '__main__':
    app.run(debug=True)
▶ Output
[GET /products/1]
{
"category": "books",
"id": 1,
"name": "Python Handbook",
"price": 29.99
}

[GET /products/99]
{
"error": "Product 99 not found"
}
(HTTP 404)

[GET /search?category=books&max_price=50]
{
"count": 1,
"results": [
{"name": "Python Handbook", "price": 29.99, "category": "books"}
]
}
⚠️
Pro Tip: Type Converters Save You Defensive CodeFlask's built-in URL converters are (default), , , (allows slashes), and . Using instead of means you never write 'if not id.isdigit()' — Flask rejects non-integers before your function even runs. Choose your converter deliberately.

Jinja2 Templates: Separating Logic From Presentation

Returning HTML strings from Python functions works for trivial examples, but it breaks down fast. You end up with escaped quotes, unreadable code, and zero separation between your logic and your presentation. That's exactly why Flask ships with Jinja2 templating built in.

Jinja2 lets you write real HTML files with special {{ variable }} placeholders and {% control flow %} blocks. Flask's render_template() function takes the filename of a template, fills in the placeholders with data you pass as keyword arguments, and returns the completed HTML string as the response.

The magic is in the separation: your Python function handles data fetching and business logic, the template handles layout and display. This maps directly to the Model-View-Controller pattern — your route function is the controller, the template is the view.

Templates must live in a folder called templates/ in the same directory as your app file. Flask looks there by default. This isn't arbitrary — it's a convention that prevents templates from being accidentally served as static files. You can change the path, but unless you have a compelling reason, don't fight the convention.

template_app.py · PYTHON
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
# File structure required:
# project/
# ├── template_app.py
# └── templates/
#     ├── base.html
#     └── product_list.html

from flask import Flask, render_template, abort

app = Flask(__name__)

PRODUCTS = [
    {'id': 1, 'name': 'Python Handbook', 'price': 29.99, 'in_stock': True},
    {'id': 2, 'name': 'Mechanical Keyboard', 'price': 149.99, 'in_stock': False},
    {'id': 3, 'name': 'Dark Theme Pack', 'price': 4.99, 'in_stock': True},
]

@app.route('/products')
def product_list():
    # Pass data to the template as keyword arguments.
    # 'products' becomes available as a variable inside the template.
    in_stock_only = [p for p in PRODUCTS if p['in_stock']]
    return render_template(
        'product_list.html',
        products=in_stock_only,
        page_title='Available Products'
    )

if __name__ == '__main__':
    app.run(debug=True)


# ── templates/base.html ──────────────────────────────────────────
'''
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <!-- block allows child templates to override this section -->
    <title>{% block title %}TheCodeForge{% endblock %}</title>
</head>
<body>
    <nav><a href="/">Home</a> | <a href="/products">Products</a></nav>
    <!-- Child template content is injected here -->
    {% block content %}{% endblock %}
</body>
</html>
'''

# ── templates/product_list.html ──────────────────────────────────
'''
{% extends "base.html" %}

{% block title %}{{ page_title }} — TheCodeForge{% endblock %}

{% block content %}
  <h1>{{ page_title }}</h1>

  <!-- Jinja2 for loop iterates over the list passed from Python -->
  {% for product in products %}
    <div class="product-card">
      <h2>{{ product.name }}</h2>
      <!-- Jinja2 filters: 'round' rounds the float, format styles it -->
      <p>Price: ${{ "%.2f" | format(product.price) }}</p>
    </div>
  {% else %}
    <!-- 'else' on a for loop runs when the list is empty -->
    <p>No products are currently in stock.</p>
  {% endfor %}

  <p>Showing {{ products | length }} item(s).</p>
{% endblock %}
'''
▶ Output
[Browser at /products renders:]

<nav>Home | Products</nav>
<h1>Available Products</h1>
<div class="product-card">
<h2>Python Handbook</h2>
<p>Price: $29.99</p>
</div>
<div class="product-card">
<h2>Dark Theme Pack</h2>
<p>Price: $4.99</p>
</div>
<p>Showing 2 item(s).</p>
🔥
Interview Gold: Auto-EscapingJinja2 auto-escapes HTML in .html templates by default. That means if a user stores '' in your database and you render it in a template, it displays as text, not executable script. This is your first line of defense against XSS attacks. You can opt out per-variable with {{ user_content | safe }} — but only do that when you're 100% certain the content is trusted.

Handling POST Requests: Processing Form Submissions

Reading data is half the job. The other half is accepting data from users — registrations, search forms, order submissions. HTTP POST requests carry data in the request body, not the URL, which is why you don't see form passwords in the address bar.

In Flask, you explicitly declare which HTTP methods a route accepts using the methods parameter on @app.route(). If you hit a route with a method it doesn't accept, Flask returns a 405 Method Not Allowed response automatically.

A common pattern is to handle both GET and POST on the same URL: GET renders the empty form, POST processes the submitted data. This keeps your URLs clean — /register is always the registration page, whether you're viewing or submitting it. After a successful POST, you should always redirect rather than returning HTML directly. This prevents the 'resubmit form?' browser dialog when the user refreshes the page — a pattern called POST-Redirect-GET.

Flask's redirect() and url_for() functions handle this pair. url_for() generates the correct URL for a given function name, which means your redirect doesn't break if you later change the route string.

form_handling_app.py · PYTHON
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
from flask import Flask, request, render_template, redirect, url_for, session

app = Flask(__name__)
# session uses a cryptographic signature to prevent tampering.
# This key must be secret and stable — in production, load from environment variable.
app.secret_key = 'dev-secret-change-in-production'

# In-memory store for demo. Replace with a real database in production.
registered_users = []

# methods=['GET', 'POST'] means this route accepts both.
# Flask raises 405 for any other method (PUT, DELETE, etc.)
@app.route('/register', methods=['GET', 'POST'])
def register():
    error_message = None

    if request.method == 'POST':
        # request.form is populated only for POST requests with form data.
        username = request.form.get('username', '').strip()
        email = request.form.get('email', '').strip()
        password = request.form.get('password', '')

        # --- Basic validation ---
        if not username or not email or not password:
            error_message = 'All fields are required.'
        elif any(u['email'] == email for u in registered_users):
            error_message = 'An account with that email already exists.'
        elif len(password) < 8:
            error_message = 'Password must be at least 8 characters.'
        else:
            # Store the user (never store plain-text passwords in real apps — use bcrypt).
            registered_users.append({'username': username, 'email': email})

            # POST-Redirect-GET pattern: redirect after successful POST.
            # url_for('success') generates '/success' from the function name.
            # This prevents duplicate submissions on browser refresh.
            return redirect(url_for('registration_success', username=username))

    # For GET requests (or POST with validation errors), render the form.
    return render_template('register.html', error=error_message)

@app.route('/success')
def registration_success():
    username = request.args.get('username', 'Developer')
    return f'<h1>Welcome aboard, {username}!</h1><p>Your account has been created.</p>'

if __name__ == '__main__':
    app.run(debug=True)

# ── templates/register.html ──────────────────────────────────────
'''
<!DOCTYPE html>
<html>
<body>
  <h1>Create an Account</h1>

  <!-- Show error message only if one exists -->
  {% if error %}
    <p style="color: red;">{{ error }}</p>
  {% endif %}

  <!-- action="" posts to the current URL (/register) -->
  <form method="POST" action="">
    <label>Username: <input type="text" name="username"></label><br>
    <label>Email: <input type="email" name="email"></label><br>
    <label>Password: <input type="password" name="password"></label><br>
    <button type="submit">Register</button>
  </form>
</body>
</html>
'''
▶ Output
[GET /register]
Renders the empty registration form.

[POST /register with empty fields]
Renders form again with error:
"All fields are required."

[POST /register with valid data: username=alice, email=alice@example.com, password=secure123]
Redirects to: /success?username=alice

[GET /success?username=alice]
Welcome aboard, alice!
Your account has been created.
⚠️
Watch Out: The Missing Redirect After POSTIf you return render_template() directly after a successful POST, the browser still thinks it's on a POST request. When the user hits F5 to refresh, the browser asks 'Resubmit form data?' — and if they click yes, you get a duplicate registration, order, or payment. Always redirect after a successful POST. This is the POST-Redirect-GET (PRG) pattern and it's non-negotiable in production apps.
AspectFlask (Micro)Django (Full-Stack)
Setup time for Hello World~5 lines, zero config~10 files, project scaffold required
Routing styleDecorators above functionsSeparate urls.py file with regex/path()
ORM includedNo — you choose (SQLAlchemy, Peewee, etc.)Yes — Django ORM built in
Admin panelNo — add Flask-Admin extension if neededYes — auto-generated from models
Template engineJinja2 (built in)Django Template Language (built in)
Best suited forAPIs, microservices, small-to-medium appsContent sites, rapid full-stack prototypes
Learning curveLow — build from zero, understand each partHigher — magic happens in many places
Request/Response controlFull control, low abstractionMore abstracted, class-based views available

🎯 Key Takeaways

  • Flask routes work by mapping URL patterns to Python functions via the @app.route() decorator — the function's return value becomes the HTTP response body verbatim.
  • URL converters like give you free type validation at the routing layer — non-matching types return a 404 before your function is ever called.
  • Always redirect after a successful POST request (POST-Redirect-GET pattern) to prevent duplicate form submissions when a user refreshes the browser.
  • Jinja2 auto-escapes HTML in templates by default, giving you XSS protection out of the box — only use the | safe filter when you explicitly trust the content source.

⚠ Common Mistakes to Avoid

  • Mistake 1: Running Flask with debug=True in production — Symptom: Anyone who triggers a 500 error gets an interactive Python shell in their browser and can execute arbitrary code on your server — Fix: Set debug=False in production and use an environment variable: app.run(debug=os.getenv('FLASK_ENV') == 'development'). Better yet, use a production WSGI server like Gunicorn and never call app.run() in production at all.
  • Mistake 2: Forgetting to specify methods=['GET', 'POST'] on form-handling routes — Symptom: Submitting an HTML form returns '405 Method Not Allowed' even though the route URL is correct — Fix: Add methods=['GET', 'POST'] to the @app.route() decorator for any route that needs to accept form submissions. Flask defaults to GET-only for every route.
  • Mistake 3: Accessing request.form data outside of the request context — Symptom: 'RuntimeError: Working outside of request context' when trying to access request.form or request.args in a helper function called from a route — Fix: Pass the data you need as regular function arguments instead of accessing the request object deep inside utility functions. Keep request access at the route level: data = request.form.get('email'); result = process_registration(data). The request object is only valid inside an active request context.

Interview Questions on This Topic

  • QWhat is the difference between request.args and request.form in Flask, and when would you use each one?
  • QExplain the POST-Redirect-GET pattern. Why is it important and how does Flask's url_for() function support it?
  • QFlask is described as a 'micro' framework — what does that actually mean, and what are the trade-offs of that design philosophy compared to a batteries-included framework like Django?

Frequently Asked Questions

Do I need to install anything besides Flask to build a web app?

Flask itself (pip install flask) pulls in Werkzeug for request handling and Jinja2 for templating — both are installed automatically. For development that's sufficient. For production you'll also want a WSGI server like Gunicorn (pip install gunicorn) because Flask's built-in server is single-threaded and not designed for real traffic.

What is the Flask application context and why does it matter?

Flask has two contexts: the application context (current_app, g) and the request context (request, session). The request context is pushed automatically for every incoming HTTP request and torn down when the response is sent. This is why you can access 'request' as a global-looking import but it's actually scoped to the current request — each simultaneous user gets their own. Accessing it outside a request (e.g., in a background thread) raises RuntimeError: Working outside of request context.

When should I use Flask instead of Django for a new Python project?

Reach for Flask when you're building a REST API, a microservice, or an app where you want to choose your own database layer and project structure. Choose Django when you need a relational database with an ORM, an admin interface, and authentication all working out of the box — and you're comfortable with its conventions. Flask's minimal footprint also makes it easier to understand exactly what's happening, which matters a lot when debugging production issues.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousRequests Library in PythonNext →Django Web Framework Basics
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged