Flask Web Development Guide
This comprehensive guide will walk you through creating a complete CRUD (Create, Read, Update, Delete) application using Flask, SQLAlchemy, and Bootstrap. You'll learn modern Flask development practices, proper error handling, security considerations, and testing strategies.
Overview
This comprehensive guide demonstrates how to build a production-ready CRUD (Create, Read, Update, Delete) application using Flask, SQLAlchemy, and Bootstrap. The tutorial covers modern Flask development practices, proper error handling, security considerations, form validation, and testing strategies.
Prerequisites
Before starting this tutorial, ensure you have:
- Python 3.8+ installed on your system
- Basic understanding of Python programming
- Familiarity with HTML and CSS
- Understanding of HTTP methods (GET, POST)
- Basic knowledge of SQL databases
Setup and Installation
Create Project Structure
First, create a well-organized project directory structure:
# Create project directory
mkdir flask-crud-app
cd flask-crud-app
# Create project structure
mkdir app
mkdir app/templates
mkdir app/static
mkdir app/static/css
mkdir app/static/js
mkdir tests
mkdir migrations
Set Up Virtual Environment
Create and activate a virtual environment to isolate project dependencies:
# Create virtual environment
python -m venv venv
# Activate virtual environment
# Linux/macOS
source venv/bin/activate
# Windows
venv\Scripts\activate
Install Dependencies
Install Flask and required packages:
# Core Flask packages
pip install Flask>=2.3.0
pip install Flask-SQLAlchemy>=3.0.0
pip install Flask-Migrate>=4.0.0
pip install Flask-WTF>=1.1.0
pip install WTForms>=3.0.0
# Development dependencies
pip install pytest>=7.0.0
pip install pytest-flask>=1.2.0
# Create requirements file
pip freeze > requirements.txt
Project Structure
Your project should now look like this:
flask-crud-app/
├── app/
│ ├── __init__.py
│ ├── models.py
│ ├── routes.py
│ ├── forms.py
│ ├── static/
│ │ ├── css/
│ │ └── js/
│ └── templates/
│ ├── base.html
│ ├── index.html
│ ├── users/
│ │ ├── list.html
│ │ ├── add.html
│ │ ├── edit.html
│ │ └── delete.html
├── tests/
├── migrations/
├── config.py
├── run.py
├── requirements.txt
└── .env
Application Configuration
Configuration File
Create config.py for application configuration:
import os
from datetime import timedelta
class Config:
"""Base configuration class."""
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///app.db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
# Security settings
WTF_CSRF_ENABLED = True
WTF_CSRF_TIME_LIMIT = timedelta(hours=1)
# Pagination
USERS_PER_PAGE = 10
class DevelopmentConfig(Config):
"""Development configuration."""
DEBUG = True
SQLALCHEMY_ECHO = True
class ProductionConfig(Config):
"""Production configuration."""
DEBUG = False
class TestingConfig(Config):
"""Testing configuration."""
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
WTF_CSRF_ENABLED = False
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig,
'default': DevelopmentConfig
}
Application Factory
Create app/__init__.py to set up the application factory pattern:
from flask import Flask, render_template
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from config import config
# Initialize extensions
db = SQLAlchemy()
migrate = Migrate()
def create_app(config_name='default'):
"""Application factory function."""
app = Flask(__name__)
app.config.from_object(config[config_name])
# Initialize extensions with app
db.init_app(app)
migrate.init_app(app, db)
# Register blueprints
from app.routes import bp as main_bp
app.register_blueprint(main_bp)
# Error handlers
@app.errorhandler(404)
def not_found_error(error):
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def internal_error(error):
db.session.rollback()
return render_template('errors/500.html'), 500
return app
# Import models to register them with SQLAlchemy
from app import models
Application Entry Point
Create run.py as the main entry point:
import os
from app import create_app, db
from app.models import User
app = create_app(os.getenv('FLASK_CONFIG') or 'default')
@app.shell_context_processor
def make_shell_context():
"""Add database and models to shell context."""
return {'db': db, 'User': User}
if __name__ == '__main__':
app.run(debug=True)
Database Models
Create app/models.py to define the database schema:
from datetime import datetime, date
from app import db
class User(db.Model):
"""User model for storing user information."""
__tablename__ = 'users'
# Primary key
id = db.Column(db.Integer, primary_key=True)
# User information
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
first_name = db.Column(db.String(100), nullable=False)
last_name = db.Column(db.String(100), nullable=False)
middle_initial = db.Column(db.String(1), nullable=True)
email = db.Column(db.String(120), unique=True, nullable=False, index=True)
date_of_birth = db.Column(db.Date, nullable=False)
# Timestamps
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Database constraints
__table_args__ = (
db.CheckConstraint('length(username) >= 3', name='username_min_length'),
db.CheckConstraint("email LIKE '%@%'", name='email_format'),
)
@property
def full_name(self):
"""Return the user's full name."""
if self.middle_initial:
return f"{self.first_name} {self.middle_initial}. {self.last_name}"
return f"{self.first_name} {self.last_name}"
@property
def age(self):
"""Calculate and return the user's age."""
today = date.today()
return today.year - self.date_of_birth.year - (
(today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day)
)
def to_dict(self):
"""Convert user object to dictionary for JSON serialization."""
return {
'id': self.id,
'username': self.username,
'first_name': self.first_name,
'last_name': self.last_name,
'middle_initial': self.middle_initial,
'email': self.email,
'date_of_birth': self.date_of_birth.isoformat(),
'full_name': self.full_name,
'age': self.age,
'created_at': self.created_at.isoformat(),
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self):
return f'<User {self.username}: {self.full_name}>'
# Initialize database tables
def init_db():
"""Initialize database tables."""
db.create_all()
Forms and Validation
Create app/forms.py for form handling and validation:
from flask_wtf import FlaskForm
from wtforms import StringField, EmailField, DateField, SubmitField
from wtforms.validators import DataRequired, Length, Email, ValidationError, Optional
from wtforms.widgets import TextInput
from datetime import date, datetime
from app.models import User
class DatePickerWidget(TextInput):
"""Custom widget for date picker input."""
input_type = 'date'
class BaseUserForm(FlaskForm):
"""Base form class with common user fields."""
username = StringField('Username', validators=[
DataRequired(message='Username is required.'),
Length(min=3, max=80, message='Username must be between 3 and 80 characters.')
], render_kw={'placeholder': 'Enter username'})
first_name = StringField('First Name', validators=[
DataRequired(message='First name is required.'),
Length(min=1, max=100, message='First name must be between 1 and 100 characters.')
], render_kw={'placeholder': 'Enter first name'})
last_name = StringField('Last Name', validators=[
DataRequired(message='Last name is required.'),
Length(min=1, max=100, message='Last name must be between 1 and 100 characters.')
], render_kw={'placeholder': 'Enter last name'})
middle_initial = StringField('Middle Initial', validators=[
Optional(),
Length(max=1, message='Middle initial must be a single character.')
], render_kw={'placeholder': 'M', 'maxlength': '1'})
email = EmailField('Email', validators=[
DataRequired(message='Email is required.'),
Email(message='Please enter a valid email address.'),
Length(max=120, message='Email must be less than 120 characters.')
], render_kw={'placeholder': 'user@example.com'})
date_of_birth = DateField('Date of Birth',
validators=[DataRequired(message='Date of birth is required.')],
widget=DatePickerWidget(),
render_kw={'placeholder': 'YYYY-MM-DD'}
)
def validate_date_of_birth(self, field):
"""Validate that date of birth is reasonable."""
if field.data:
today = date.today()
age = today.year - field.data.year - ((today.month, today.day) < (field.data.month, field.data.day))
if field.data > today:
raise ValidationError('Date of birth cannot be in the future.')
if age > 150:
raise ValidationError('Date of birth seems unrealistic.')
if age < 0:
raise ValidationError('Date of birth cannot be in the future.')
class AddUserForm(BaseUserForm):
"""Form for adding a new user."""
submit = SubmitField('Add User', render_kw={'class': 'btn btn-primary'})
def validate_username(self, field):
"""Check if username is already taken."""
user = User.query.filter_by(username=field.data).first()
if user:
raise ValidationError('Username already exists. Please choose a different one.')
def validate_email(self, field):
"""Check if email is already registered."""
user = User.query.filter_by(email=field.data).first()
if user:
raise ValidationError('Email already registered. Please use a different email.')
class EditUserForm(BaseUserForm):
"""Form for editing an existing user."""
submit = SubmitField('Update User', render_kw={'class': 'btn btn-primary'})
def __init__(self, original_user, *args, **kwargs):
super(EditUserForm, self).__init__(*args, **kwargs)
self.original_user = original_user
def validate_username(self, field):
"""Check if username is already taken by another user."""
if field.data != self.original_user.username:
user = User.query.filter_by(username=field.data).first()
if user:
raise ValidationError('Username already exists. Please choose a different one.')
def validate_email(self, field):
"""Check if email is already registered by another user."""
if field.data != self.original_user.email:
user = User.query.filter_by(email=field.data).first()
if user:
raise ValidationError('Email already registered. Please use a different email.')
class DeleteUserForm(FlaskForm):
"""Form for confirming user deletion."""
submit = SubmitField('Delete User', render_kw={'class': 'btn btn-danger'})
class SearchForm(FlaskForm):
"""Form for searching users."""
query = StringField('Search Users', validators=[
Length(min=0, max=100, message='Search query must be less than 100 characters.')
], render_kw={'placeholder': 'Search by name, username, or email...'})
submit = SubmitField('Search', render_kw={'class': 'btn btn-outline-secondary'})
Database Initialization and Migrations
Initialize the database and set up migrations:
# Initialize the migration repository
flask db init
# Create initial migration
flask db migrate -m "Initial migration with User model"
# Apply migrations
flask db upgrade
# For development, you can also create tables directly
python -c "from app import create_app, db; app = create_app(); app.app_context().push(); db.create_all()"
CRUD Operations
Create app/routes.py with modern Flask routing and error handling:
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask import current_app
from app import db
from app.models import User
from app.forms import AddUserForm, EditUserForm, DeleteUserForm
from sqlalchemy.exc import SQLAlchemyError
from datetime import datetime
bp = Blueprint('main', __name__)
@bp.route('/')
def index():
"""Home page with user statistics."""
try:
total_users = User.query.count()
recent_users = User.query.order_by(User.created_at.desc()).limit(5).all()
return render_template('index.html',
total_users=total_users,
recent_users=recent_users)
except SQLAlchemyError as e:
current_app.logger.error(f'Database error in index: {e}')
flash('An error occurred while loading the page.', 'error')
return render_template('index.html', total_users=0, recent_users=[])
@bp.route('/users')
def list_users():
"""List all users with pagination and search."""
page = request.args.get('page', 1, type=int)
search_query = request.args.get('q', '', type=str)
try:
query = User.query
# Apply search filter if provided
if search_query:
query = query.filter(
db.or_(
User.first_name.contains(search_query),
User.last_name.contains(search_query),
User.username.contains(search_query),
User.email.contains(search_query)
)
)
users = query.order_by(User.last_name, User.first_name).paginate(
page=page,
per_page=current_app.config['USERS_PER_PAGE'],
error_out=False
)
return render_template('users/list.html',
users=users,
search_query=search_query)
except SQLAlchemyError as e:
current_app.logger.error(f'Database error in list_users: {e}')
flash('An error occurred while loading users.', 'error')
return render_template('users/list.html', users=None, search_query=search_query)
@bp.route('/users/add', methods=['GET', 'POST'])
def add_user():
"""Add a new user."""
form = AddUserForm()
if form.validate_on_submit():
try:
user = User(
username=form.username.data,
first_name=form.first_name.data,
last_name=form.last_name.data,
middle_initial=form.middle_initial.data or None,
email=form.email.data,
date_of_birth=form.date_of_birth.data
)
db.session.add(user)
db.session.commit()
flash(f'User {user.full_name} has been created successfully!', 'success')
return redirect(url_for('main.list_users'))
except SQLAlchemyError as e:
db.session.rollback()
current_app.logger.error(f'Database error in add_user: {e}')
flash('An error occurred while creating the user. Please try again.', 'error')
return render_template('users/add.html', form=form)
@bp.route('/users/<int:user_id>')
def view_user(user_id):
"""View user details."""
try:
user = db.session.get(User, user_id)
if user is None:
flash('User not found.', 'error')
return redirect(url_for('main.list_users'))
return render_template('users/view.html', user=user)
except SQLAlchemyError as e:
current_app.logger.error(f'Database error in view_user: {e}')
flash('An error occurred while loading the user.', 'error')
return redirect(url_for('main.list_users'))
@bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
def edit_user(user_id):
"""Edit an existing user."""
try:
user = db.session.get(User, user_id)
if user is None:
flash('User not found.', 'error')
return redirect(url_for('main.list_users'))
form = EditUserForm(original_user=user, obj=user)
if form.validate_on_submit():
user.username = form.username.data
user.first_name = form.first_name.data
user.last_name = form.last_name.data
user.middle_initial = form.middle_initial.data or None
user.email = form.email.data
user.date_of_birth = form.date_of_birth.data
user.updated_at = datetime.utcnow()
db.session.commit()
flash(f'User {user.full_name} has been updated successfully!', 'success')
return redirect(url_for('main.view_user', user_id=user.id))
return render_template('users/edit.html', form=form, user=user)
except SQLAlchemyError as e:
db.session.rollback()
current_app.logger.error(f'Database error in edit_user: {e}')
flash('An error occurred while updating the user.', 'error')
return redirect(url_for('main.list_users'))
@bp.route('/users/<int:user_id>/delete', methods=['GET', 'POST'])
def delete_user(user_id):
"""Delete a user with confirmation."""
try:
user = db.session.get(User, user_id)
if user is None:
flash('User not found.', 'error')
return redirect(url_for('main.list_users'))
form = DeleteUserForm()
if form.validate_on_submit():
username = user.username
full_name = user.full_name
db.session.delete(user)
db.session.commit()
flash(f'User {full_name} ({username}) has been deleted successfully.', 'success')
return redirect(url_for('main.list_users'))
return render_template('users/delete.html', form=form, user=user)
except SQLAlchemyError as e:
db.session.rollback()
current_app.logger.error(f'Database error in delete_user: {e}')
flash('An error occurred while deleting the user.', 'error')
return redirect(url_for('main.list_users'))
Templates and User Interface
Base Template
Create app/templates/base.html with modern Bootstrap and enhanced features:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Flask CRUD Application for User Management">
<title>{% block title %}Flask User Management{% endblock %}</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
.navbar-brand { font-weight: bold; }
.user-card { transition: transform 0.2s; }
.user-card:hover { transform: translateY(-2px); }
body { display: flex; flex-direction: column; min-height: 100vh; }
main { flex: 1; }
.footer { margin-top: auto; padding: 20px 0; background-color: #f8f9fa; }
</style>
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container">
<a class="navbar-brand" href="{{ url_for('main.index') }}">
<i class="bi bi-people-fill"></i> User Management
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.index') }}">
<i class="bi bi-house"></i> Home
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.list_users') }}">
<i class="bi bi-people"></i> Users
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.add_user') }}">
<i class="bi bi-person-plus"></i> Add User
</a>
</li>
</ul>
<!-- Search form -->
<form class="d-flex" method="GET" action="{{ url_for('main.list_users') }}">
<input class="form-control me-2" type="search" name="q" placeholder="Search users..."
value="{{ request.args.get('q', '') }}">
<button class="btn btn-outline-light" type="submit">
<i class="bi bi-search"></i>
</button>
</form>
</div>
</div>
</nav>
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="container mt-3">
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- Main Content -->
<main class="container my-4">
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer class="footer bg-light">
<div class="container text-center">
<span class="text-muted">Flask User Management System © 2025</span>
</div>
</footer>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
Key Template Features
The templates include:
- Responsive Bootstrap 5 design with modern components
- Flash message system for user feedback
- Form validation with error display
- Search functionality in the navigation
- Pagination support for large datasets
- Accessibility features with proper ARIA labels
Security Considerations
CSRF Protection
The application includes CSRF protection through Flask-WTF:
# In config.py
WTF_CSRF_ENABLED = True
WTF_CSRF_TIME_LIMIT = timedelta(hours=1)
# In templates - {{ form.hidden_tag() }} includes CSRF token
Input Validation
All user inputs are validated both client-side and server-side:
# Server-side validation in forms.py
username = StringField('Username', validators=[
DataRequired(),
Length(min=3, max=80)
])
# Custom validation methods
def validate_email(self, field):
user = User.query.filter_by(email=field.data).first()
if user:
raise ValidationError('Email already registered.')
SQL Injection Prevention
SQLAlchemy ORM protects against SQL injection by using parameterized queries:
# Safe - ORM handles parameterization
user = User.query.filter_by(username=username).first()
Environment Variables
Store sensitive configuration in environment variables:
# .env file (never commit to version control)
SECRET_KEY=your-secret-key-here
DATABASE_URL=sqlite:///app.db
FLASK_CONFIG=development
Testing
Unit Tests
Create tests/test_models.py:
import unittest
from datetime import date
from app import create_app, db
from app.models import User
class UserModelTestCase(unittest.TestCase):
def setUp(self):
self.app = create_app('testing')
self.app_context = self.app.app_context()
self.app_context.push()
db.create_all()
def tearDown(self):
db.session.remove()
db.drop_all()
self.app_context.pop()
def test_user_creation(self):
user = User(
username='testuser',
first_name='Test',
last_name='User',
email='test@example.com',
date_of_birth=date(1990, 1, 1)
)
db.session.add(user)
db.session.commit()
self.assertEqual(user.username, 'testuser')
self.assertEqual(user.full_name, 'Test User')
self.assertTrue(user.age >= 0)
if __name__ == '__main__':
unittest.main()
Integration Tests
Create tests/test_routes.py:
import unittest
from app import create_app, db
class FlaskTestCase(unittest.TestCase):
def setUp(self):
self.app = create_app('testing')
self.client = self.app.test_client()
self.app_context = self.app.app_context()
self.app_context.push()
db.create_all()
def tearDown(self):
db.session.remove()
db.drop_all()
self.app_context.pop()
def test_index_page(self):
response = self.client.get('/')
self.assertEqual(response.status_code, 200)
if __name__ == '__main__':
unittest.main()
Run Tests
# Run all tests
python -m pytest tests/
# Run with coverage
pip install pytest-cov
python -m pytest tests/ --cov=app
Best Practices Implemented
Code Organization
- Application factory pattern for flexible configuration
- Blueprints for modular application structure
- Separate configuration for different environments
- Environment variables for sensitive data
Database Best Practices
- Migrations for schema changes
- Proper indexing on frequently queried columns
- Appropriate data types (Date for dates, not strings)
- Database constraints for data integrity
Security Best Practices
- CSRF protection on all forms
- Input validation both client and server-side
- SQLAlchemy ORM to prevent SQL injection
- Environment variables for secrets
Error Handling
- Comprehensive error pages (404, 500)
- Database error handling with rollbacks
- User-friendly error messages
- Logging for debugging
Deployment Considerations
Production Configuration
# config.py - Production settings
class ProductionConfig(Config):
DEBUG = False
# Use PostgreSQL in production
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
Docker Support
Create Dockerfile:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "run:app"]
Summary
This Flask tutorial demonstrates building a modern, production-ready CRUD application with:
- Modern Flask architecture using application factory and blueprints
- Comprehensive form validation with WTForms
- Responsive user interface with Bootstrap 5
- Security best practices including CSRF protection
- Proper error handling and user feedback
- Testing framework for reliability
- Production deployment considerations
The application serves as a solid foundation for building more complex Flask applications while following industry best practices for security, maintainability, and scalability.
Next Steps
Consider extending the application with:
- User authentication and authorization
- API endpoints for mobile integration
- Email notifications
- File upload functionality
- Data export features
- Audit logging
- Performance monitoring