Table of Contents

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 &copy; 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