Toolmingo
Guides22 min read

Flask Tutorial for Beginners (2025): Build Python Web Apps

Complete Flask tutorial for absolute beginners. Learn Flask from scratch: routes, templates, forms, databases, authentication, REST APIs, and deploy your first Flask app. Free guide with code examples.

Flask is the most popular micro web framework for Python — lightweight, flexible, and easy to learn. Used by LinkedIn, Pinterest (early days), Netflix, and thousands of startups and side projects. Unlike Django's "batteries included" approach, Flask gives you just what you need: routing, templating, and request handling — and lets you choose your own tools for everything else. This tutorial takes you from zero to a working web app.

What you'll learn

Topic What you'll be able to do
Setup Install Flask and create your first app
Routes Map URLs to Python functions
Templates Render HTML with Jinja2
Forms Handle user input with Flask-WTF
Databases Store data with SQLAlchemy
Authentication Login, logout, sessions
REST API Build JSON APIs
Deployment Deploy to a live server

Why Flask?

Feature Flask Django FastAPI
Philosophy Micro/minimal Batteries included Async-first
ORM Third-party (SQLAlchemy) Built-in Third-party
Admin panel Flask-Admin (optional) Built-in None
Auth Flask-Login (optional) Built-in Third-party
Learning curve Low Medium Low–Medium
Flexibility Very high Medium High
Best for APIs, small-medium apps, learning Full web apps, rapid dev High-performance APIs
Used by LinkedIn (early), Lyft Instagram, Pinterest Uber, Microsoft

Flask is the right choice when you want full control, a gentle learning curve, and a minimal starting point.


Prerequisites

  • Basic Python (variables, functions, classes, lists/dicts)
  • Command line basics
  • No prior web framework experience needed

Setup

Install Flask

# Create project directory
mkdir flask-app
cd flask-app

# Create virtual environment
python -m venv venv

# Activate it
# Windows:
venv\Scripts\activate
# Mac/Linux:
source venv/bin/activate

# Install Flask
pip install flask

Your First Flask App

Create app.py:

from flask import Flask

app = Flask(__name__)

@app.route('/')
def index():
    return 'Hello, Flask!'

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

Run it:

python app.py

Open http://127.0.0.1:5000/ — you should see "Hello, Flask!"

What just happened:

  • Flask(__name__) creates the app (tells Flask where to find templates/static files)
  • @app.route('/') registers the URL route
  • debug=True enables auto-reload and helpful error pages (never use in production)

Project Structure

For a real Flask app, organize your files like this:

flask-app/
├── app.py              # Main application file
├── requirements.txt    # Dependencies
├── templates/          # Jinja2 HTML templates
│   ├── base.html
│   └── index.html
├── static/             # CSS, JS, images
│   ├── css/
│   │   └── style.css
│   └── js/
└── .env                # Environment variables (don't commit!)

For larger apps, use the Application Factory pattern (covered later).


Routes and URL Mapping

Basic Routes

from flask import Flask

app = Flask(__name__)

@app.route('/')
def index():
    return 'Home page'

@app.route('/about')
def about():
    return 'About page'

@app.route('/contact')
def contact():
    return 'Contact page'

URL Parameters

@app.route('/user/<username>')
def user_profile(username):
    return f'Profile: {username}'

@app.route('/post/<int:post_id>')
def show_post(post_id):
    return f'Post #{post_id}'

@app.route('/path/<path:subpath>')
def show_path(subpath):
    return f'Path: {subpath}'

URL converter types:

Converter Type Example
<variable> string /user/john
<int:id> integer /post/42
<float:price> float /price/9.99
<path:p> string with slashes /files/a/b/c
<uuid:id> UUID /item/abc-123

HTTP Methods

from flask import Flask, request

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

URL Building

from flask import url_for, redirect

@app.route('/old-page')
def old_page():
    return redirect(url_for('index'))  # Redirect to index()

@app.route('/go-to-user')
def go_to_user():
    return redirect(url_for('user_profile', username='alice'))

Always use url_for() instead of hardcoding URLs — it handles URL changes automatically.


Templates with Jinja2

Flask uses Jinja2 as its template engine. Templates live in the templates/ folder.

Rendering Templates

from flask import Flask, render_template

app = Flask(__name__)

@app.route('/')
def index():
    name = 'Alice'
    items = ['Python', 'Flask', 'Jinja2']
    return render_template('index.html', name=name, items=items)

templates/index.html:

<!DOCTYPE html>
<html>
<head><title>Flask App</title></head>
<body>
    <h1>Hello, {{ name }}!</h1>

    <ul>
    {% for item in items %}
        <li>{{ item }}</li>
    {% endfor %}
    </ul>

    {% if name == 'Alice' %}
        <p>Welcome back, Alice!</p>
    {% else %}
        <p>Welcome, stranger!</p>
    {% endif %}
</body>
</html>

Jinja2 Syntax

Syntax Purpose Example
{{ expr }} Output expression {{ user.name }}
{% ... %} Statement/control flow {% if %} {% endif %}
{# ... #} Comment {# this is a comment #}
{{ x | filter }} Apply filter {{ name | upper }}

Common Jinja2 Filters

{{ name | upper }}          <!-- ALICE -->
{{ name | lower }}          <!-- alice -->
{{ name | title }}          <!-- Alice Smith -->
{{ text | truncate(50) }}   <!-- Truncate to 50 chars -->
{{ items | length }}        <!-- Length of list -->
{{ price | round(2) }}      <!-- Round to 2 decimal places -->
{{ html | safe }}           <!-- Render HTML without escaping -->
{{ date | strftime('%Y-%m-%d') }}  <!-- Format date -->

Template Inheritance

Create a base template to avoid repeating HTML:

templates/base.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}My Flask 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>

    <main>
        {% block content %}{% endblock %}
    </main>

    <footer>
        <p>&copy; 2025 My Flask App</p>
    </footer>
</body>
</html>

templates/index.html:

{% extends 'base.html' %}

{% block title %}Home - My Flask App{% endblock %}

{% block content %}
    <h1>Welcome!</h1>
    <p>This is the home page.</p>
{% endblock %}

templates/about.html:

{% extends 'base.html' %}

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

{% block content %}
    <h1>About Us</h1>
    <p>We build things with Flask.</p>
{% endblock %}

Static Files

CSS, JavaScript, and images go in the static/ folder.

<!-- In templates, use url_for for static files -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="Logo">

static/css/style.css:

* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

body {
    font-family: system-ui, -apple-system, sans-serif;
    line-height: 1.6;
    padding: 20px;
    max-width: 900px;
    margin: 0 auto;
}

nav {
    display: flex;
    gap: 20px;
    padding: 10px 0;
    border-bottom: 1px solid #ddd;
    margin-bottom: 20px;
}

Request Object

Flask's request object contains everything about the incoming HTTP request:

from flask import request

@app.route('/submit', methods=['POST'])
def submit():
    # Form data (application/x-www-form-urlencoded or multipart/form-data)
    name = request.form.get('name')
    name = request.form['name']  # Raises KeyError if missing

    # Query string: /search?q=flask&page=2
    query = request.args.get('q', '')
    page = request.args.get('page', 1, type=int)

    # JSON body (Content-Type: application/json)
    data = request.get_json()
    username = data.get('username')

    # Headers
    auth = request.headers.get('Authorization')

    # Cookies
    session_id = request.cookies.get('session_id')

    # File upload
    file = request.files.get('photo')

    # Request info
    print(request.method)    # 'POST'
    print(request.url)       # Full URL
    print(request.host)      # 'localhost:5000'
    print(request.path)      # '/submit'
    print(request.remote_addr)  # Client IP

    return 'OK'

Response Object

from flask import Flask, make_response, jsonify

# Return string (200 OK)
return 'Hello'

# Return with status code
return 'Not found', 404

# Return JSON
return jsonify({'name': 'Alice', 'age': 30})

# Return JSON with status
return jsonify({'error': 'Not found'}), 404

# Custom response with headers
response = make_response('Hello')
response.headers['X-Custom-Header'] = 'value'
response.set_cookie('username', 'alice')
return response

# Redirect
from flask import redirect, url_for
return redirect(url_for('index'))
return redirect('https://example.com')

Sessions and Cookies

Flask uses signed cookies for sessions (client-side, signed with secret key):

from flask import Flask, session, redirect, url_for, request

app = Flask(__name__)
app.secret_key = 'your-secret-key-here'  # Required for sessions

@app.route('/login', methods=['POST'])
def login():
    username = request.form['username']
    # In real app: verify against database
    session['username'] = username
    session['logged_in'] = True
    return redirect(url_for('dashboard'))

@app.route('/dashboard')
def dashboard():
    if 'username' not in session:
        return redirect(url_for('login'))
    return f'Welcome, {session["username"]}!'

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

Important: Never put sensitive data in session — it's stored in the browser cookie (signed but readable). For server-side sessions, use Flask-Session.


Forms with Flask-WTF

Install:

pip install flask-wtf

Define a form:

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

class LoginForm(FlaskForm):
    email = StringField('Email', validators=[DataRequired(), Email()])
    password = PasswordField('Password', validators=[DataRequired(), Length(min=8)])
    submit = SubmitField('Login')

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

Use in a route:

@app.route('/login', methods=['GET', 'POST'])
def login():
    form = LoginForm()
    if form.validate_on_submit():
        email = form.email.data
        password = form.password.data
        # verify against DB...
        return redirect(url_for('dashboard'))
    return render_template('login.html', form=form)

Template:

{% extends 'base.html' %}
{% block content %}
<form method="POST" novalidate>
    {{ form.hidden_tag() }}  <!-- CSRF token -->

    <div>
        {{ form.email.label }}
        {{ form.email(class='form-input') }}
        {% for error in form.email.errors %}
            <span class="error">{{ error }}</span>
        {% endfor %}
    </div>

    <div>
        {{ form.password.label }}
        {{ form.password(class='form-input') }}
        {% for error in form.password.errors %}
            <span class="error">{{ error }}</span>
        {% endfor %}
    </div>

    {{ form.submit(class='btn') }}
</form>
{% endblock %}

Database with SQLAlchemy

Install:

pip install flask-sqlalchemy

Setup

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)

Define Models

from datetime import datetime

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(256), nullable=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    posts = db.relationship('Post', backref='author', lazy=True)

    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)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)

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

SQLAlchemy column types:

Type SQL Python
Integer INTEGER int
String(n) VARCHAR(n) str
Text TEXT str
Boolean BOOLEAN bool
Float FLOAT float
DateTime DATETIME datetime
JSON JSON dict/list

Create Tables and CRUD

# Create all tables
with app.app_context():
    db.create_all()

# CREATE
user = User(username='alice', email='alice@example.com', password_hash='...')
db.session.add(user)
db.session.commit()

# READ
user = User.query.get(1)                          # By primary key
user = User.query.filter_by(username='alice').first()
users = User.query.all()
users = User.query.filter(User.username.like('a%')).all()
users = User.query.order_by(User.created_at.desc()).limit(10).all()
count = User.query.count()

# UPDATE
user.email = 'new@example.com'
db.session.commit()

# DELETE
db.session.delete(user)
db.session.commit()

# Query with join
posts = Post.query.join(User).filter(User.username == 'alice').all()

Flask Blueprints (Organizing Large Apps)

Blueprints let you split your app into modules:

flask-app/
├── app/
│   ├── __init__.py       # Application factory
│   ├── models.py         # SQLAlchemy models
│   ├── auth/
│   │   ├── __init__.py
│   │   ├── routes.py
│   │   └── forms.py
│   ├── blog/
│   │   ├── __init__.py
│   │   ├── routes.py
│   │   └── forms.py
│   ├── templates/
│   │   ├── base.html
│   │   ├── auth/
│   │   └── blog/
│   └── static/
├── config.py
└── run.py

app/__init__.py (Application Factory):

from flask import Flask
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()

def create_app(config='development'):
    app = Flask(__name__)

    if config == 'development':
        app.config['DEBUG'] = True
        app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///dev.db'
    else:
        app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://...'

    app.config['SECRET_KEY'] = 'your-secret-key'
    app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

    db.init_app(app)

    # Register blueprints
    from .auth import auth as auth_bp
    app.register_blueprint(auth_bp, url_prefix='/auth')

    from .blog import blog as blog_bp
    app.register_blueprint(blog_bp)

    return app

app/auth/__init__.py:

from flask import Blueprint

auth = Blueprint('auth', __name__, template_folder='templates')

from . import routes

app/auth/routes.py:

from flask import render_template, redirect, url_for, flash
from . import auth

@auth.route('/login', methods=['GET', 'POST'])
def login():
    return render_template('auth/login.html')

@auth.route('/logout')
def logout():
    return redirect(url_for('blog.index'))

run.py:

from app import create_app

app = create_app()

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

Authentication with Flask-Login

Install:

pip install flask-login werkzeug
from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
from werkzeug.security import generate_password_hash, check_password_hash

login_manager = LoginManager()
login_manager.login_view = 'auth.login'

class User(UserMixin, 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(256))

    def set_password(self, password):
        self.password_hash = generate_password_hash(password)

    def check_password(self, password):
        return check_password_hash(self.password_hash, password)

@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))

@app.route('/login', methods=['GET', 'POST'])
def login():
    if current_user.is_authenticated:
        return redirect(url_for('index'))
    form = LoginForm()
    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data).first()
        if user and user.check_password(form.password.data):
            login_user(user, remember=form.remember.data)
            return redirect(url_for('dashboard'))
        flash('Invalid email or password', 'error')
    return render_template('login.html', form=form)

@app.route('/logout')
@login_required
def logout():
    logout_user()
    return redirect(url_for('index'))

@app.route('/dashboard')
@login_required
def dashboard():
    return render_template('dashboard.html', user=current_user)

Building a REST API

Flask is great for JSON APIs:

from flask import Flask, jsonify, request, abort
from functools import wraps

app = Flask(__name__)

# Simple in-memory store (use a database in real apps)
todos = [
    {'id': 1, 'title': 'Learn Flask', 'done': False},
    {'id': 2, 'title': 'Build an API', 'done': False},
]
next_id = 3

def find_todo(todo_id):
    return next((t for t in todos if t['id'] == todo_id), None)

# GET all todos
@app.route('/api/todos', methods=['GET'])
def get_todos():
    return jsonify({'todos': todos, 'total': len(todos)})

# GET single todo
@app.route('/api/todos/<int:todo_id>', methods=['GET'])
def get_todo(todo_id):
    todo = find_todo(todo_id)
    if not todo:
        abort(404)
    return jsonify(todo)

# CREATE todo
@app.route('/api/todos', methods=['POST'])
def create_todo():
    global next_id
    data = request.get_json()
    if not data or 'title' not in data:
        return jsonify({'error': 'Title is required'}), 400
    todo = {'id': next_id, 'title': data['title'], 'done': False}
    todos.append(todo)
    next_id += 1
    return jsonify(todo), 201

# UPDATE todo
@app.route('/api/todos/<int:todo_id>', methods=['PUT'])
def update_todo(todo_id):
    todo = find_todo(todo_id)
    if not todo:
        abort(404)
    data = request.get_json()
    todo.update({k: data[k] for k in ('title', 'done') if k in data})
    return jsonify(todo)

# DELETE todo
@app.route('/api/todos/<int:todo_id>', methods=['DELETE'])
def delete_todo(todo_id):
    todo = find_todo(todo_id)
    if not todo:
        abort(404)
    todos.remove(todo)
    return '', 204

# Error handlers
@app.errorhandler(404)
def not_found(e):
    return jsonify({'error': 'Not found'}), 404

@app.errorhandler(400)
def bad_request(e):
    return jsonify({'error': 'Bad request'}), 400

@app.errorhandler(500)
def server_error(e):
    return jsonify({'error': 'Internal server error'}), 500

Test with curl:

# Get all todos
curl http://localhost:5000/api/todos

# Create a todo
curl -X POST http://localhost:5000/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn SQLAlchemy"}'

# Update a todo
curl -X PUT http://localhost:5000/api/todos/1 \
  -H "Content-Type: application/json" \
  -d '{"done": true}'

# Delete a todo
curl -X DELETE http://localhost:5000/api/todos/1

Error Handling

from flask import Flask, render_template

app = Flask(__name__)

# Custom error pages
@app.errorhandler(404)
def page_not_found(e):
    return render_template('errors/404.html'), 404

@app.errorhandler(403)
def forbidden(e):
    return render_template('errors/403.html'), 403

@app.errorhandler(500)
def internal_error(e):
    db.session.rollback()  # Important: rollback on 500
    return render_template('errors/500.html'), 500

# abort() to trigger error handlers
from flask import abort

@app.route('/admin')
def admin():
    if not current_user.is_admin:
        abort(403)
    return render_template('admin.html')

templates/errors/404.html:

{% extends 'base.html' %}
{% block content %}
<h1>404 - Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
<a href="{{ url_for('index') }}">Go home</a>
{% endblock %}

Flash Messages

Flash messages let you show one-time notifications after redirects:

from flask import flash, get_flashed_messages

@app.route('/submit', methods=['POST'])
def submit():
    # ... process form ...
    flash('Your message was sent!', 'success')
    flash('Warning: something looks odd', 'warning')
    return redirect(url_for('index'))

In base.html:

{% 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 %}

Environment Variables and Configuration

Never hardcode secrets. Use environment variables:

# .env file (don't commit to git!)
SECRET_KEY=your-very-secret-key-here
DATABASE_URL=postgresql://user:pass@localhost/mydb
FLASK_ENV=development

config.py:

import os
from dotenv import load_dotenv

load_dotenv()  # pip install python-dotenv

class Config:
    SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-fallback-key'
    SQLALCHEMY_TRACK_MODIFICATIONS = False

class DevelopmentConfig(Config):
    DEBUG = True
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///dev.db'

class ProductionConfig(Config):
    DEBUG = False
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
    # Fail fast if required env vars are missing
    if not SQLALCHEMY_DATABASE_URI:
        raise ValueError('DATABASE_URL must be set in production')

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

Project 1: Personal Blog

A classic beginner project — a blog with posts and categories.

# app.py
from flask import Flask, render_template, redirect, url_for, request, flash
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime

app = Flask(__name__)
app.config['SECRET_KEY'] = 'dev-secret'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///blog.db'
db = SQLAlchemy(app)

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)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

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

@app.route('/')
def index():
    posts = Post.query.order_by(Post.created_at.desc()).all()
    return render_template('index.html', posts=posts)

@app.route('/post/<int:id>')
def post(id):
    post = Post.query.get_or_404(id)
    return render_template('post.html', post=post)

@app.route('/new', methods=['GET', 'POST'])
def new_post():
    if request.method == 'POST':
        title = request.form['title'].strip()
        body = request.form['body'].strip()
        if not title or not body:
            flash('Title and body are required', 'error')
        else:
            post = Post(title=title, body=body)
            db.session.add(post)
            db.session.commit()
            flash('Post created!', 'success')
            return redirect(url_for('index'))
    return render_template('new_post.html')

@app.route('/delete/<int:id>', methods=['POST'])
def delete_post(id):
    post = Post.query.get_or_404(id)
    db.session.delete(post)
    db.session.commit()
    flash('Post deleted', 'info')
    return redirect(url_for('index'))

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

templates/index.html:

{% extends 'base.html' %}
{% block content %}
<h1>My Blog</h1>
<a href="{{ url_for('new_post') }}" class="btn">New Post</a>

{% for post in posts %}
<article>
    <h2><a href="{{ url_for('post', id=post.id) }}">{{ post.title }}</a></h2>
    <time>{{ post.created_at.strftime('%B %d, %Y') }}</time>
    <p>{{ post.body[:200] }}{% if post.body|length > 200 %}...{% endif %}</p>
    <form method="POST" action="{{ url_for('delete_post', id=post.id) }}"
          onsubmit="return confirm('Delete this post?')">
        <button type="submit" class="btn-danger">Delete</button>
    </form>
</article>
{% else %}
<p>No posts yet. <a href="{{ url_for('new_post') }}">Create one!</a></p>
{% endfor %}
{% endblock %}

Project 2: REST API for a Todo App

A JSON API that could power a React/Vue frontend:

# app.py
from flask import Flask, jsonify, request
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime

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

class Todo(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(200), nullable=False)
    completed = db.Column(db.Boolean, default=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

    def to_dict(self):
        return {
            'id': self.id,
            'title': self.title,
            'completed': self.completed,
            'created_at': self.created_at.isoformat(),
        }

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

@app.route('/api/todos', methods=['GET'])
def get_todos():
    completed = request.args.get('completed')
    query = Todo.query
    if completed is not None:
        query = query.filter_by(completed=completed.lower() == 'true')
    todos = query.order_by(Todo.created_at.desc()).all()
    return jsonify([t.to_dict() for t in todos])

@app.route('/api/todos/<int:id>', methods=['GET'])
def get_todo(id):
    todo = Todo.query.get_or_404(id)
    return jsonify(todo.to_dict())

@app.route('/api/todos', methods=['POST'])
def create_todo():
    data = request.get_json()
    if not data or not data.get('title', '').strip():
        return jsonify({'error': 'Title is required'}), 400
    todo = Todo(title=data['title'].strip())
    db.session.add(todo)
    db.session.commit()
    return jsonify(todo.to_dict()), 201

@app.route('/api/todos/<int:id>', methods=['PATCH'])
def update_todo(id):
    todo = Todo.query.get_or_404(id)
    data = request.get_json()
    if 'title' in data:
        todo.title = data['title'].strip()
    if 'completed' in data:
        todo.completed = bool(data['completed'])
    db.session.commit()
    return jsonify(todo.to_dict())

@app.route('/api/todos/<int:id>', methods=['DELETE'])
def delete_todo(id):
    todo = Todo.query.get_or_404(id)
    db.session.delete(todo)
    db.session.commit()
    return '', 204

@app.errorhandler(404)
def not_found(e):
    return jsonify({'error': 'Not found'}), 404

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

Project 3: User Authentication App

A complete app with registration, login, and a protected dashboard:

# app.py
from flask import Flask, render_template, redirect, url_for, flash, request
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime

app = Flask(__name__)
app.config['SECRET_KEY'] = 'change-this-in-production'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///users.db'

db = SQLAlchemy(app)
login_manager = LoginManager(app)
login_manager.login_view = 'login'
login_manager.login_message_category = 'info'

class User(UserMixin, 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(256))
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

    def set_password(self, password):
        self.password_hash = generate_password_hash(password)

    def check_password(self, password):
        return check_password_hash(self.password_hash, password)

@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))

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

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/register', methods=['GET', 'POST'])
def register():
    if current_user.is_authenticated:
        return redirect(url_for('dashboard'))
    if request.method == 'POST':
        username = request.form['username'].strip()
        email = request.form['email'].strip()
        password = request.form['password']
        if len(password) < 8:
            flash('Password must be at least 8 characters', 'error')
        elif User.query.filter_by(email=email).first():
            flash('Email already registered', 'error')
        elif User.query.filter_by(username=username).first():
            flash('Username taken', 'error')
        else:
            user = User(username=username, email=email)
            user.set_password(password)
            db.session.add(user)
            db.session.commit()
            flash('Account created! You can now log in.', 'success')
            return redirect(url_for('login'))
    return render_template('register.html')

@app.route('/login', methods=['GET', 'POST'])
def login():
    if current_user.is_authenticated:
        return redirect(url_for('dashboard'))
    if request.method == 'POST':
        email = request.form['email'].strip()
        password = request.form['password']
        user = User.query.filter_by(email=email).first()
        if user and user.check_password(password):
            login_user(user)
            next_page = request.args.get('next')
            return redirect(next_page or url_for('dashboard'))
        flash('Invalid email or password', 'error')
    return render_template('login.html')

@app.route('/dashboard')
@login_required
def dashboard():
    return render_template('dashboard.html', user=current_user)

@app.route('/logout')
@login_required
def logout():
    logout_user()
    flash('You have been logged out', 'info')
    return redirect(url_for('index'))

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

Deployment

requirements.txt

pip freeze > requirements.txt

Or manually:

flask==3.0.3
flask-sqlalchemy==3.1.1
flask-login==0.6.3
flask-wtf==1.2.1
python-dotenv==1.0.1
gunicorn==22.0.0
psycopg2-binary==2.9.9  # For PostgreSQL in production

Production with Gunicorn

Never use Flask's development server in production:

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

Dockerfile

FROM python:3.12-slim

WORKDIR /app

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

COPY . .

ENV FLASK_ENV=production

EXPOSE 8000
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "app:app"]

Deployment Options

Platform Free tier Setup Best for
Railway Yes (limited) git push Quick deploys
Render Yes (750h/mo) git push Side projects
Fly.io Yes (3 apps) CLI Full control
Heroku No (paid) git push Teams
AWS Elastic Beanstalk Free tier CLI AWS ecosystem
Google Cloud Run Free tier Docker Serverless
DigitalOcean App Platform $5/mo git push Simple setup
VPS (Ubuntu) $4-6/mo Manual Full control, cheapest

Deploy to Render (Fastest)

  1. Push your code to GitHub
  2. Create render.yaml:
services:
  - type: web
    name: my-flask-app
    env: python
    buildCommand: pip install -r requirements.txt
    startCommand: gunicorn app:app
    envVars:
      - key: SECRET_KEY
        generateValue: true
      - key: DATABASE_URL
        fromDatabase:
          name: my-db
          property: connectionString
  1. Connect GitHub repo on render.com — deploys automatically on every push.

Common Flask Packages

Package Purpose Install
Flask-SQLAlchemy ORM for databases pip install flask-sqlalchemy
Flask-Login User authentication pip install flask-login
Flask-WTF Form handling + CSRF pip install flask-wtf
Flask-Migrate DB migrations (Alembic) pip install flask-migrate
Flask-Mail Send emails pip install flask-mail
Flask-RESTful REST API helpers pip install flask-restful
Flask-CORS Cross-origin requests pip install flask-cors
Flask-Caching Caching (Redis/Memcached) pip install flask-caching
Flask-Limiter Rate limiting pip install flask-limiter
Marshmallow Serialization/validation pip install marshmallow
python-dotenv Load .env files pip install python-dotenv
Gunicorn Production server pip install gunicorn

Flask vs Django vs FastAPI

Feature Flask Django FastAPI
Philosophy Micro/minimal Batteries included Async-first API
Learning curve Easiest Medium Easy–Medium
ORM Choose your own Built-in Choose your own
Admin panel Flask-Admin (opt) Built-in None
Auth Flask-Login (opt) Built-in JWT (opt)
Performance Good Good Excellent
Async support Limited Limited Native
Auto docs No No Yes (OpenAPI)
Type hints Optional Optional Core feature
Best for Flexibility, learning Full web apps High-perf APIs
Job market Largest Large Growing fast

When to choose Flask:

  • You want to learn web development with Python
  • You need a small-to-medium API or web app
  • You want full control over which tools to use
  • Your team is comfortable choosing and integrating packages

Learning Path

Stage Topics Time Resources
1. Basics Routes, templates, forms, sessions 1 week Flask Quickstart
2. Database SQLAlchemy, migrations, relationships 1 week Flask-SQLAlchemy docs
3. Auth Flask-Login, password hashing, sessions 1 week Flask-Login docs
4. REST API JSON responses, error handling, status codes 1 week Flask docs
5. Structure Blueprints, application factory, config 1 week Large app tutorial
6. Production Gunicorn, Docker, environment variables 1 week Deploy tutorials
7. Testing pytest, Flask test client, fixtures 1 week Flask testing docs

Common Mistakes

Mistake Problem Fix
Using debug=True in production Security/performance risk Use environment variable to control
Hardcoding SECRET_KEY Anyone can forge sessions Use os.environ.get('SECRET_KEY')
Forgetting db.session.commit() Changes not saved Always commit after add/update/delete
Using request.form['key'] without .get() KeyError crash Use request.form.get('key')
Putting business logic in routes Hard to test Move to separate functions/services
Not using Blueprints for large apps Unmaintainable single file Organize with Blueprints from the start
Forgetting CSRF protection Security vulnerability Use Flask-WTF's {{ form.hidden_tag() }}
Querying N+1 N+1 database queries Use joinedload() or subqueryload()

Flask vs Related Terms

Term What it is
Flask Python micro web framework
Jinja2 Template engine Flask uses (included with Flask)
Werkzeug WSGI toolkit Flask is built on
SQLAlchemy Python SQL toolkit and ORM (most popular with Flask)
Flask-SQLAlchemy SQLAlchemy integration for Flask
Blueprint Flask's way to organize app into modules
WSGI Web Server Gateway Interface (Python web standard)
Gunicorn Production WSGI server (replaces Flask dev server)
Flask-RESTful Extension that adds REST API helpers to Flask
WTForms Form library Flask-WTF is based on
Click CLI library Flask uses for flask command
itsdangerous Signing library Flask uses for session cookies

Frequently Asked Questions

Q: Should I learn Flask or Django first?

Flask if you want the simplest possible start and full control. Django if you want everything built in and prefer convention over configuration. Most people find Flask easier to start with. Once you understand Flask, Django's "magic" makes more sense.

Q: Can Flask handle large-scale apps?

Yes. Pinterest, Lyft, and LinkedIn have used Flask at scale. The key is architecture: use Blueprints, separate concerns, add caching (Redis), use a proper database, and deploy with Gunicorn behind Nginx. Flask itself is not the bottleneck.

Q: Flask vs FastAPI for a new API project?

If your API is mostly CRUD with a database, both work well. Choose FastAPI if you want automatic OpenAPI docs, native async, and type-safety by default. Choose Flask if your team knows it already or you need a simpler ecosystem.

Q: How do I handle file uploads?

from flask import request
import os

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

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

@app.route('/upload', methods=['POST'])
def upload():
    file = request.files.get('file')
    if not file or not allowed_file(file.filename):
        return 'Invalid file', 400
    from werkzeug.utils import secure_filename
    filename = secure_filename(file.filename)
    file.save(os.path.join(UPLOAD_FOLDER, filename))
    return 'Uploaded'

Q: How do I add database migrations?

Use Flask-Migrate (which wraps Alembic):

pip install flask-migrate
flask db init     # Create migrations/ folder
flask db migrate  # Generate migration from model changes
flask db upgrade  # Apply migrations to database

Q: How do I test a Flask app?

Flask has a built-in test client:

import pytest
from app import app, db

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

def test_index(client):
    response = client.get('/')
    assert response.status_code == 200

def test_create_todo(client):
    response = client.post('/api/todos',
        json={'title': 'Test todo'},
        content_type='application/json')
    assert response.status_code == 201
    assert response.json['title'] == 'Test todo'

Quick Reference

from flask import (
    Flask, render_template, redirect, url_for,
    request, session, flash, jsonify, abort,
    make_response, send_file, g
)

app = Flask(__name__)
app.secret_key = 'secret'

# Route with methods
@app.route('/path', methods=['GET', 'POST'])
def handler():
    # Request
    data = request.form.get('field')      # Form data
    arg = request.args.get('q')           # Query string
    body = request.get_json()             # JSON body
    file = request.files.get('upload')    # File upload

    # Response
    return render_template('page.html', var=value)  # HTML
    return jsonify({'key': 'value'})                # JSON
    return redirect(url_for('handler'))             # Redirect
    return 'text', 200                              # Text + status
    abort(404)                                      # Trigger error handler

# Before/after request hooks
@app.before_request
def before():
    g.user = get_current_user()  # Available in request context

@app.after_request
def after(response):
    response.headers['X-Frame-Options'] = 'DENY'
    return response

# Context processor (available in all templates)
@app.context_processor
def inject_globals():
    return {'site_name': 'My App', 'year': 2025}

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools