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 routedebug=Trueenables 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>© 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)
- Push your code to GitHub
- 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
- 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}