Python Flask: Lightweight Web Framework

Flask calls itself a 'micro' framework, but don't mistake that for limited. The 'micro' refers to Flask's philosophy: keep the core simple and let developers choose their own tools for databases,...

Key Insights

  • Flask’s minimalist core gives you complete control over your architecture—you add only the components you need, making it ideal for APIs, microservices, and applications where Django feels like overkill.
  • The decorator-based routing system and Jinja2 templating engine provide an intuitive developer experience that gets you from idea to working prototype in minutes, not hours.
  • Flask scales from single-file prototypes to production applications through Blueprints and extensions, letting you start simple and add complexity only when your application demands it.

Introduction to Flask

Flask calls itself a “micro” framework, but don’t mistake that for limited. The “micro” refers to Flask’s philosophy: keep the core simple and let developers choose their own tools for databases, form validation, authentication, and other concerns. Unlike Django’s “batteries included” approach, Flask gives you a lightweight foundation and trusts you to make architectural decisions.

This makes Flask perfect for REST APIs, microservices, small-to-medium web applications, and situations where you need fine-grained control over dependencies. Choose Flask when you want to avoid framework lock-in or when your project doesn’t need Django’s admin panel, ORM, and built-in authentication system.

Here’s the canonical Flask “Hello World”:

from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello():
    return 'Hello, World!'

Run it with flask run, and you have a working web server. That’s the Flask philosophy in action.

Setting Up Your First Flask Application

Start with a virtual environment to isolate dependencies:

python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate
pip install flask python-dotenv

For any real application, structure matters. Here’s a production-ready layout:

myapp/
├── app/
│   ├── __init__.py
│   ├── routes.py
│   ├── models.py
│   └── templates/
├── config.py
├── requirements.txt
└── run.py

Your config.py should handle different environments:

import os

class Config:
    SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key'
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///app.db'
    SQLALCHEMY_TRACK_MODIFICATIONS = False

class DevelopmentConfig(Config):
    DEBUG = True

class ProductionConfig(Config):
    DEBUG = False

config = {
    'development': DevelopmentConfig,
    'production': ProductionConfig,
    'default': DevelopmentConfig
}

Initialize your application in app/__init__.py:

from flask import Flask
from config import config

def create_app(config_name='default'):
    app = Flask(__name__)
    app.config.from_object(config[config_name])
    
    from app import routes
    app.register_blueprint(routes.bp)
    
    return app

This factory pattern enables testing with different configurations and keeps your application modular.

Routing and Request Handling

Flask’s routing uses decorators that map URLs to Python functions. The system is intuitive and powerful:

from flask import Blueprint, request, jsonify

bp = Blueprint('api', __name__, url_prefix='/api')

@bp.route('/tasks', methods=['GET'])
def get_tasks():
    # Retrieve all tasks
    return jsonify({'tasks': []})

@bp.route('/tasks/<int:task_id>', methods=['GET'])
def get_task(task_id):
    # Retrieve specific task
    return jsonify({'id': task_id, 'title': 'Example Task'})

@bp.route('/tasks', methods=['POST'])
def create_task():
    data = request.get_json()
    title = data.get('title')
    
    if not title:
        return jsonify({'error': 'Title required'}), 400
    
    # Create task in database
    return jsonify({'id': 1, 'title': title}), 201

@bp.route('/tasks/<int:task_id>', methods=['PUT'])
def update_task(task_id):
    data = request.get_json()
    # Update task logic
    return jsonify({'id': task_id, 'updated': True})

@bp.route('/tasks/<int:task_id>', methods=['DELETE'])
def delete_task(task_id):
    # Delete task logic
    return '', 204

The request object provides access to form data, JSON payloads, query parameters, and headers. For query strings, use request.args.get('param'). For form data, use request.form.get('field').

Templates and Static Files

Flask uses Jinja2 for templating. Create a base template for consistency:

<!-- templates/base.html -->
<!DOCTYPE html>
<html>
<head>
    <title>{% block title %}My App{% endblock %}</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
    <nav>
        <a href="{{ url_for('index') }}">Home</a>
        <a href="{{ url_for('about') }}">About</a>
    </nav>
    
    {% with messages = get_flashed_messages(with_categories=true) %}
        {% if messages %}
            {% for category, message in messages %}
                <div class="alert alert-{{ category }}">{{ message }}</div>
            {% endfor %}
        {% endif %}
    {% endwith %}
    
    <main>
        {% block content %}{% endblock %}
    </main>
</body>
</html>

Child templates extend the base:

<!-- templates/tasks.html -->
{% extends "base.html" %}

{% block title %}Tasks{% endblock %}

{% block content %}
    <h1>My Tasks</h1>
    <ul>
    {% for task in tasks %}
        <li>{{ task.title }} - {{ task.status }}</li>
    {% else %}
        <li>No tasks yet!</li>
    {% endfor %}
    </ul>
{% endblock %}

Render templates from your routes:

from flask import render_template

@app.route('/tasks')
def tasks():
    tasks = get_tasks_from_db()
    return render_template('tasks.html', tasks=tasks)

Working with Forms and Databases

Install Flask-WTF and SQLAlchemy:

pip install flask-wtf flask-sqlalchemy flask-migrate

Define a model:

from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)
    email = db.Column(db.String(120), unique=True, nullable=False)
    password_hash = db.Column(db.String(128))
    
    def __repr__(self):
        return f'<User {self.username}>'

Create a form with validation:

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, Length

class RegistrationForm(FlaskForm):
    username = StringField('Username', validators=[
        DataRequired(), Length(min=3, max=80)
    ])
    email = StringField('Email', validators=[DataRequired(), Email()])
    password = PasswordField('Password', validators=[
        DataRequired(), Length(min=8)
    ])
    submit = SubmitField('Register')

Handle the form in your route:

from flask import flash, redirect, url_for
from werkzeug.security import generate_password_hash

@bp.route('/register', methods=['GET', 'POST'])
def register():
    form = RegistrationForm()
    
    if form.validate_on_submit():
        user = User(
            username=form.username.data,
            email=form.email.data,
            password_hash=generate_password_hash(form.password.data)
        )
        db.session.add(user)
        db.session.commit()
        
        flash('Registration successful!', 'success')
        return redirect(url_for('login'))
    
    return render_template('register.html', form=form)

Blueprints and Application Scaling

As applications grow, Blueprints organize code into modules:

# app/auth/__init__.py
from flask import Blueprint

bp = Blueprint('auth', __name__, url_prefix='/auth')

from app.auth import routes

# app/auth/routes.py
from app.auth import bp

@bp.route('/login', methods=['GET', 'POST'])
def login():
    # Login logic
    pass

@bp.route('/logout')
def logout():
    # Logout logic
    pass

Register blueprints in your application factory:

def create_app(config_name='default'):
    app = Flask(__name__)
    app.config.from_object(config[config_name])
    
    db.init_app(app)
    
    from app.auth import bp as auth_bp
    from app.api import bp as api_bp
    from app.main import bp as main_bp
    
    app.register_blueprint(auth_bp)
    app.register_blueprint(api_bp)
    app.register_blueprint(main_bp)
    
    return app

This modular structure keeps related functionality together and makes testing easier.

Deployment and Production Considerations

Never run Flask’s development server in production. Use a WSGI server like Gunicorn:

pip install gunicorn
gunicorn -w 4 -b 0.0.0.0:8000 "app:create_app()"

Create a production configuration with environment variables:

# .env
SECRET_KEY=your-production-secret-key
DATABASE_URL=postgresql://user:pass@localhost/dbname
FLASK_ENV=production

Here’s a Dockerfile for containerized deployment:

FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

ENV FLASK_APP=run.py
ENV FLASK_ENV=production

EXPOSE 8000

CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "app:create_app()"]

Add security headers to protect your application:

@app.after_request
def set_security_headers(response):
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['X-Frame-Options'] = 'DENY'
    response.headers['X-XSS-Protection'] = '1; mode=block'
    return response

Flask’s simplicity is its strength. You build exactly what you need, no more, no less. Start with the basics, add extensions as requirements emerge, and scale your architecture as your application grows. The framework stays out of your way while providing the tools to build production-ready web applications.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.