← Back to DevBytes

Flask from Scratch: The Ultimate Guide to

Introduction: What is Flask?

Flask is a lightweight WSGI web application framework for Python. Unlike heavyweight frameworks like Django, Flask gives you the bare essentials — routing, templating, and request/response handling — and lets you add exactly what you need. Created by Armin Ronacher in 2010, Flask has grown into one of the most popular Python web frameworks, powering everything from tiny APIs to massive production systems.

At its core, Flask is a microframework. The term "micro" doesn't mean it's only for small applications — it means Flask keeps the core small but extensible. You bolt on exactly the libraries you need: an ORM, form validation, authentication, or anything else. This philosophy gives you full control over your application architecture without fighting the framework's opinions.

Why Flask Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Flask matters because it sits at the sweet spot between simplicity and power. Here's why developers choose it:

Setting Up Your Environment

Start by creating a dedicated project directory and a virtual environment. This isolates dependencies and prevents version conflicts across projects.

mkdir flask_tutorial && cd flask_tutorial
python -m venv venv
source venv/bin/activate   # On Windows: venv\Scripts\activate
pip install flask

Once Flask is installed, verify it works by checking the version:

python -c "import flask; print(flask.__version__)"

Now create the classic "Hello, World!" application in a file called app.py:

from flask import Flask

app = Flask(__name__)

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

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

Run it with python app.py and visit http://localhost:5000. You'll see "Hello, World!" rendered in your browser. The debug=True flag enables automatic reloading when you change code and shows detailed error pages — essential during development, but never use it in production.

Understanding the WSGI Layer

Flask sits on top of Werkzeug, a comprehensive WSGI toolkit. When you call app.run(), Flask starts Werkzeug's development server. In production, you'll point a production-grade WSGI server like Gunicorn or uWSGI at your Flask app object. This separation means your application code never changes between development and production — only the server layer does.

Routing: Mapping URLs to Python Functions

Routes are the backbone of any web application. They map URL patterns to view functions that return responses. Flask's routing system is simple yet remarkably powerful.

Basic Route Patterns

@app.route('/users')
def user_list():
    return "List of all users"

@app.route('/users/<int:user_id>')
def user_detail(user_id):
    return f"User profile for ID: {user_id}"

The angle bracket syntax captures variable parts of the URL and passes them as arguments to your view function. Flask supports several built-in converters:

HTTP Methods and Multiple Routes

By default, routes only respond to GET requests. Specify additional methods with the methods parameter:

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form['username']
        password = request.form['password']
        # Authenticate user...
        return f"Logged in as {username}"
    return '''
        <form method="post">
            <input name="username" placeholder="Username">
            <input name="password" type="password" placeholder="Password">
            <button type="submit">Log In</button>
        </form>
    '''

URL Building with url_for

Never hardcode URLs in your application. Use url_for() to generate URLs from route function names. This keeps links valid even if you restructure URL patterns later.

from flask import url_for

@app.route('/dashboard')
def dashboard():
    user_profile_url = url_for('user_detail', user_id=42)
    return f'<a href="{user_profile_url}">View Profile 42</a>'

url_for() accepts the endpoint name (the function name) and any keyword arguments needed by the route's variables. It works in both Python code and Jinja templates.

Templates: Rendering Dynamic HTML with Jinja2

Returning raw strings works for APIs, but web applications need HTML. Flask ships with Jinja2, a fast, feature-rich templating engine. Templates live in a templates/ directory by default.

Basic Template Rendering

Create templates/home.html:

<!DOCTYPE html>
<html>
<head>
    <title>{{ page_title }}</title>
</head>
<body>
    <h1>Welcome, {{ username }}!</h1>
    <ul>
    {% for item in todo_list %}
        <li>{{ item }}</li>
    {% endfor %}
    </ul>
</body>
</html>

Render it from your view function:

from flask import render_template

@app.route('/home')
def home():
    return render_template('home.html',
                           page_title='Dashboard',
                           username='Alice',
                           todo_list=['Buy groceries', 'Walk the dog', 'Write code'])

Template Inheritance

Jinja2 supports template inheritance, which lets you define a base layout once and extend it across many pages. Create templates/base.html:

<!DOCTYPE html>
<html>
<head>
    <title>{% block title %}My App{% endblock %}</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
    <nav>
        <a href="{{ url_for('home') }}">Home</a>
        <a href="{{ url_for('about') }}">About</a>
    </nav>
    <main>
        {% block content %}{% endblock %}
    </main>
    <footer>&copy; 2024 My App</footer>
</body>
</html>

Child templates extend this base and fill in the blocks:

{% extends "base.html" %}

{% block title %}About Us{% endblock %}

{% block content %}
    <h1>About Our Company</h1>
    <p>We build amazing software.</p>
{% endblock %}

Escaping and Security

Jinja2 auto-escapes all variables rendered with {{ }} to prevent XSS attacks. If you need to render raw HTML (carefully), use the |safe filter:

{{ user_generated_html|safe }}

Only use safe on content you absolutely trust. For user-submitted content, consider using Markdown or a sanitization library instead.

Static Files: Serving CSS, JavaScript, and Images

Static assets live in a static/ directory. Flask serves them automatically at the /static/ URL path. Use url_for('static', filename='...') to generate proper URLs:

<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="Logo">

Organize your static directory with subfolders — css/, js/, images/, uploads/ — to keep things manageable as the project grows.

Handling Forms and Request Data

Web applications collect data through forms, query parameters, JSON payloads, and file uploads. Flask provides a unified request object that exposes all of these.

Accessing Form Data

from flask import request

@app.route('/submit', methods=['POST'])
def submit():
    name = request.form.get('name', 'Anonymous')
    email = request.form['email']  # Raises KeyError if missing
    return f"Received submission from {name} ({email})"

Use .get() with a default for optional fields and direct key access for required fields (the KeyError gives immediate feedback about missing data).

Query String Parameters

@app.route('/search')
def search():
    query = request.args.get('q', '')
    page = request.args.get('page', 1, type=int)
    results = perform_search(query, page)
    return render_template('results.html', results=results, query=query)

The type parameter on .get() automatically converts the string value to the specified type, saving you from manual parsing.

JSON Request Bodies

@app.route('/api/users', methods=['POST'])
def create_user():
    data = request.get_json()
    if not data:
        return {"error": "Invalid JSON"}, 400
    username = data.get('username')
    email = data.get('email')
    # Validate and create user...
    return {"id": 42, "username": username}, 201

File Uploads

import os
from werkzeug.utils import secure_filename

UPLOAD_FOLDER = 'uploads'
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}

def allowed_file(filename):
    return '.' in filename and \
           filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

@app.route('/upload', methods=['GET', 'POST'])
def upload_file():
    if request.method == 'POST':
        file = request.files.get('photo')
        if file and allowed_file(file.filename):
            filename = secure_filename(file.filename)
            file.save(os.path.join(UPLOAD_FOLDER, filename))
            return f"Uploaded {filename}"
    return '''
        <form method="post" enctype="multipart/form-data">
            <input type="file" name="photo">
            <button type="submit">Upload</button>
        </form>
    '''

Always use secure_filename() on user-supplied filenames. It strips dangerous characters that could enable path traversal attacks (like ../../../etc/passwd).

Database Integration with Flask-SQLAlchemy

Most applications need persistent storage. Flask-SQLAlchemy wraps SQLAlchemy, the most popular Python ORM, into a Flask-friendly extension.

Install it first:

pip install flask-sqlalchemy

Configuration and Models

from flask import Flask
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)

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)
    created_at = db.Column(db.DateTime, server_default=db.func.now())
    posts = db.relationship('Post', backref='author', lazy='dynamic')

    def __repr__(self):
        return f'<User {self.username}>'

class Post(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(200), nullable=False)
    body = db.Column(db.Text, nullable=False)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)

    def __repr__(self):
        return f'<Post {self.title}>'

Creating the Database

with app.app_context():
    db.create_all()

The app_context() is required because Flask needs an active application context to know which database URI to use. In a Flask shell or view function, the context is already active.

CRUD Operations in Views

@app.route('/users', methods=['GET', 'POST'])
def users():
    if request.method == 'POST':
        username = request.form['username']
        email = request.form['email']
        user = User(username=username, email=email)
        db.session.add(user)
        db.session.commit()
        return redirect(url_for('users'))
    
    all_users = User.query.order_by(User.created_at.desc()).all()
    return render_template('users.html', users=all_users)

@app.route('/users/<int:user_id>/delete', methods=['POST'])
def delete_user(user_id):
    user = User.query.get_or_404(user_id)
    db.session.delete(user)
    db.session.commit()
    return redirect(url_for('users'))

get_or_404() is a Flask-SQLAlchemy convenience that returns a 404 response if the record doesn't exist, rather than raising an exception. Use it for all "fetch by ID" operations.

Database Migrations with Alembic

As your application evolves, you'll need to modify the database schema without losing data. Flask-Migrate wraps Alembic for this purpose:

pip install flask-migrate
from flask_migrate import Migrate

migrate = Migrate(app, db)

Then use the CLI:

flask db init          # One-time setup, creates migrations directory
flask db migrate -m "Add user avatar column"  # Auto-generate migration
flask db upgrade       # Apply migration to database
flask db downgrade     # Roll back the last migration

Building REST APIs with Flask

Flask excels at building APIs. Its JSON handling is built-in, and the routing system maps cleanly to RESTful resources.

Returning JSON Responses

@app.route('/api/users')
def api_users():
    users = User.query.all()
    return {
        "users": [
            {"id": u.id, "username": u.username, "email": u.email}
            for u in users
        ],
        "count": len(users)
    }

Flask automatically serializes dictionaries, lists, and primitive types to JSON and sets the Content-Type: application/json header. For complex objects, use the jsonify function:

from flask import jsonify

@app.route('/api/users/<int:user_id>')
def api_user_detail(user_id):
    user = User.query.get_or_404(user_id)
    return jsonify({
        "id": user.id,
        "username": user.username,
        "email": user.email,
        "post_count": user.posts.count()
    })

HTTP Status Codes and Error Handling

@app.route('/api/users', methods=['POST'])
def api_create_user():
    data = request.get_json()
    if not data or 'username' not in data or 'email' not in data:
        return {"error": "Missing required fields"}, 400
    
    existing = User.query.filter_by(username=data['username']).first()
    if existing:
        return {"error": "Username already taken"}, 409
    
    user = User(username=data['username'], email=data['email'])
    db.session.add(user)
    db.session.commit()
    return jsonify({"id": user.id, "username": user.username}), 201

Return a tuple of (response_body, status_code) to control the HTTP status. Use standard codes: 200 for success, 201 for creation, 204 for deletion, 400 for bad requests, 404 for not found, 409 for conflicts.

Centralized Error Handlers

@app.errorhandler(404)
def not_found(error):
    return jsonify({"error": "Resource not found"}), 404

@app.errorhandler(500)
def internal_error(error):
    db.session.rollback()
    return jsonify({"error": "Internal server error"}), 500

Blueprints: Organizing Large Applications

When your application grows beyond a single file, Blueprints let you split it into modular components. Each blueprint is a self-contained unit with its own routes, templates, and static files.

Creating a Blueprint

Create auth/__init__.py:

from flask import Blueprint

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

from . import routes  # Import routes after blueprint creation

Then auth/routes.py:

from . import auth_bp
from flask import render_template, request, redirect, url_for, session

@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        # Authenticate...
        session['user_id'] = 42
        return redirect(url_for('dashboard'))
    return render_template('auth/login.html')

@auth_bp.route('/logout')
def logout():
    session.pop('user_id', None)
    return redirect(url_for('home'))

Registering Blueprints

Back in your main app.py:

from flask import Flask
from auth import auth_bp
from blog import blog_bp

app = Flask(__name__)
app.register_blueprint(auth_bp)
app.register_blueprint(blog_bp, url_prefix='/blog')

The url_prefix on registration adds a prefix to all routes in the blueprint. If auth_bp already has /auth as its prefix, all auth routes start with /auth/. Adding another prefix on registration would stack them — avoid double-prefixing.

Blueprint Template and Static Folders

Blueprints can have their own templates and static files. By default, templates are looked up in the blueprint's directory under a templates/ subfolder. For explicit control:

auth_bp = Blueprint('auth', __name__, url_prefix='/auth',
                    template_folder='templates',
                    static_folder='static',
                    static_url_path='/auth/static')

User Sessions and Authentication

Flask provides cryptographically signed session cookies. Data stored in session is serialized, signed with the app's secret key, and sent as a cookie to the browser. The client cannot read or modify the data without the secret key.

Setting Up Sessions

app.config['SECRET_KEY'] = 'your-secret-key-here-change-in-production'

Generate a proper secret key in production using Python's secrets module:

import secrets
app.config['SECRET_KEY'] = secrets.token_hex(32)

Login and Logout Flow

from flask import session

@app.route('/login', methods=['POST'])
def login():
    username = request.form['username']
    password = request.form['password']
    user = User.query.filter_by(username=username).first()
    
    if user and check_password_hash(user.password_hash, password):
        session['user_id'] = user.id
        session['username'] = user.username
        return redirect(url_for('dashboard'))
    
    return render_template('login.html', error='Invalid credentials')

@app.route('/logout')
def logout():
    session.clear()
    return redirect(url_for('home'))

Decorator for Protected Routes

from functools import wraps
from flask import session, redirect, url_for

def login_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'user_id' not in session:
            return redirect(url_for('login'))
        return f(*args, **kwargs)
    return decorated_function

@app.route('/dashboard')
@login_required
def dashboard():
    user = User.query.get(session['user_id'])
    return render_template('dashboard.html', user=user)

This pattern keeps authentication logic DRY. Stack multiple decorators for additional checks like admin-only routes.

Configuration Management

Hardcoding configuration is a recipe for disaster. Flask supports multiple configuration patterns. The best approach combines a base configuration class with environment-specific overrides.

Configuration Classes

Create config.py:

import os

class Config:
    SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-key-change-me')
    SQLALCHEMY_TRACK_MODIFICATIONS = False

class DevelopmentConfig(Config):
    DEBUG = True
    SQLALCHEMY_DATABASE_URI = 'sqlite:///dev.db'

class TestingConfig(Config):
    TESTING = True
    SQLALCHEMY_DATABASE_URI = 'sqlite:///test.db'

class ProductionConfig(Config):
    DEBUG = False
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
    SESSION_COOKIE_SECURE = True

Load the appropriate config when creating the app:

import config

app = Flask(__name__)
env = os.environ.get('FLASK_ENV', 'development')
app.config.from_object(getattr(config, f'{env.capitalize()}Config'))

Testing Flask Applications

Flask provides a test client that simulates HTTP requests without a running server. Combined with pytest, you can build a comprehensive test suite.

Basic Test Structure

import pytest
from app import app, db, User

@pytest.fixture
def client():
    app.config['TESTING'] = True
    app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///test.db'
    
    with app.test_client() as client:
        with app.app_context():
            db.create_all()
            yield client
            db.drop_all()

def test_home_page(client):
    response = client.get('/')
    assert response.status_code == 200
    assert b'Welcome' in response.data

def test_create_user(client):
    response = client.post('/api/users', json={
        'username': 'testuser',
        'email': 'test@example.com'
    })
    assert response.status_code == 201
    data = response.get_json()
    assert data['username'] == 'testuser'

def test_login_required_redirect(client):
    response = client.get('/dashboard')
    assert response.status_code == 302
    assert '/login' in response.location

Run tests with pytest -v. The test client handles cookies, sessions, and redirects automatically, making integration tests straightforward.

Best Practices for Flask Projects

1. Project Structure

Aim for this structure as your application grows:

project/
├── app.py              # Application factory and entry point
├── config.py           # Configuration classes
├── requirements.txt    # Pinned dependencies
├── migrations/         # Alembic migration files
├── auth/               # Auth blueprint
│   ├── __init__.py
│   ├── routes.py
│   └── templates/
│       └── auth/
│           ├── login.html
│           └── register.html
├── blog/               # Blog blueprint
│   ├── __init__.py
│   ├── routes.py
│   ├── models.py
│   └── templates/
│       └── blog/
│           ├── list.html
│           └── detail.html
├── static/
│   ├── css/
│   ├── js/
│   └── images/
├── templates/
│   └── base.html
└── tests/
    ├── conftest.py
    ├── test_auth.py
    └── test_blog.py

2. Application Factory Pattern

Instead of a global app object, use an application factory. This lets you create multiple app instances (crucial for testing) and defer configuration:

def create_app(config_name='development'):
    app = Flask(__name__)
    app.config.from_object(config[config_name])
    
    db.init_app(app)
    migrate.init_app(app, db)
    
    app.register_blueprint(auth_bp)
    app.register_blueprint(blog_bp)
    
    return app

The factory pattern also makes it trivial to spin up different configurations for development, testing, and production from the same codebase.

3. Use Environment Variables

Never store secrets, API keys, or database URLs in source code. Use environment variables and a library like python-dotenv to load them during development:

pip install python-dotenv

Create a .env file (never commit this to version control):

SECRET_KEY=actual-secret-key
DATABASE_URL=postgresql://user:pass@localhost/dbname

Load it early in your application:

from dotenv import load_dotenv
load_dotenv()

4. Logging and Error Tracking

Flask uses Python's built-in logging module. Configure it properly:

import logging

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s %(levelname)s %(name)s: %(message)s'
)

# In production, log to a file or external service
handler = logging.FileHandler('app.log')
handler.setLevel(logging.WARNING)
app.logger.addHandler(handler)

For production applications, integrate with services like Sentry or Rollbar to capture and aggregate errors.

5. Avoid the Development Server in Production

app.run() uses Werkzeug's development server, which is single-threaded, single-process, and not designed for production loads. Use Gunicorn instead:

pip install gunicorn
gunicorn -w 4 -b 0.0.0.0:8000 app:app

The -w 4 flag runs four worker processes. Adjust based on your server's CPU cores. For async workloads, use gevent workers: gunicorn -k gevent -w 4 app:app.

6. Request Validation with Marshmallow

For complex API input validation, Marshmallow serializes and deserializes data with strict schemas:

pip install marshmallow
from marshmallow import Schema, fields, validate, ValidationError

class UserSchema(Schema):
    username = fields.String(required=True, validate=validate.Length(min=3, max=80))
    email = fields.Email(required=True)

@app.route('/api/users', methods=['POST'])
def create_user():
    schema = UserSchema()
    try:
        data = schema.load(request.get_json())
    except ValidationError as err:
        return {"errors": err.messages}, 400
    
    user = User(**data)
    db.session.add(user)
    db.session.commit()
    return jsonify(schema.dump({"id": user.id, "username": user.username, "email": user.email})), 201

7. Caching Responses

Flask-Caching adds Redis or Memcached-backed caching with minimal effort:

pip install flask-caching
from flask_caching import Cache

cache = Cache(app, config={'CACHE_TYPE': 'redis'})

@app.route('/api/stats')
@cache.cached(timeout=300)  # Cache for 5 minutes
def stats():
    # Expensive database query...
    return jsonify(compute_stats())

8. CSRF Protection

Flask-WTF provides CSRF protection for forms. Even for APIs, consider CSRF if you use cookie-based sessions:

pip install flask-wtf
from flask_wtf.csrf import CSRFProtect

csrf = CSRFProtect(app)

For JavaScript frontends, set up the CSRF token in a cookie and have your JS read it and send it back in a header. Flask-WTF handles this automatically when configured correctly.

Deployment Checklist

Before pushing your Flask application to production, verify these items:

Conclusion

Flask's brilliance lies in its restraint. It doesn't impose a rigid project structure, a particular ORM, or a prescribed way to handle authentication. Instead, it gives you the fundamentals — routing, templating, a request/response cycle, and a session system — and gets out of your way. This means you build exactly the application you need, understanding every component because you chose it deliberately.

Starting from a single file with a handful of routes, you can grow organically into blueprints, database models, REST APIs, and a full production deployment, all while retaining complete control. The patterns covered here — the application factory, blueprint organization, environment-based configuration, proper testing, and production deployment with Gunicorn — form the foundation of every serious Flask project. Master these fundamentals, and you'll be equipped to build web applications that are both elegant and robust, from prototypes to production systems serving millions of requests.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles