Building a Simple CRUD Web Application with Flask and MySQL

Building a Simple CRUD Web Application with Flask and MySQL

12/16/2025 Python By Tech Writers
FlaskPythonMySQLCRUDWeb DevelopmentAPIMicroservicesDatabase

Introduction: Flask for Beginners

Hello fellow developers! In this article, we’ll learn together how to build a simple CRUD (Create, Read, Update, Delete) web application using the Flask framework and MySQL database. Flask is one of the most popular Python web frameworks due to its simplicity while remaining powerful.

We’ll start from scratch: environment setup, installing dependencies, creating database, implementing CRUD operations, up to advanced features like authentication, testing, and deployment. Perfect for those just starting with Flask or wanting to refresh their knowledge about web development with Python.

Table of Contents

Why Flask + MySQL?

Flask is a lightweight yet extensible micro-framework for Python web development. Unlike Django which comes with “batteries included”, Flask gives you complete freedom to choose the tools you need. This makes Flask very suitable for:

  • Microservices architecture: Lightweight and easy to scale
  • API development: Easy integration with modern frontends
  • Prototyping: Fast development without boilerplate code
  • Learning purposes: Gentle learning curve for beginners

MySQL as the database choice because:

  • Reliable: Proven in production environments
  • Fast: Excellent query performance
  • Widely used: Many tools and community support
  • ACID compliant: Data integrity guaranteed

Environment Preparation

Install Python

Make sure Python 3.8+ is installed on your system. Check with:

python --version
# or
python3 --version

If not available, download from python.org or use your system’s package manager.

Virtual Environment

Always use virtual environment for each Python project. This prevents dependency conflicts between projects.

# Create project folder
mkdir flask-crud-app
cd flask-crud-app

# Create virtual environment
python -m venv venv

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

Install Dependencies

Install Flask and supporting libraries:

pip install Flask flask-sqlalchemy pymysql python-dotenv pytest flask-testing

We’ll install additional libraries as development progresses. For SQLAlchemy with MySQL, we use PyMySQL as the driver.

MySQL Database Setup

Install MySQL Server

# Ubuntu/Debian
sudo apt update
sudo apt install mysql-server

# CentOS/RHEL
sudo yum install mysql-server

# macOS with Homebrew
brew install mysql

Start MySQL service and setup:

sudo systemctl start mysql  # Linux
sudo systemctl enable mysql

# Setup secure installation
sudo mysql_secure_installation

Create Database and Tables

Login to MySQL as root:

mysql -u root -p

Create database and tables for our CRUD example:

-- Create database
CREATE DATABASE flask_crud_app;

-- Use database
USE flask_crud_app;

-- Create users table (for authentication later)
CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- Create posts table (for CRUD operations)
CREATE TABLE posts (
    id INT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    content TEXT,
    author_id INT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE
);

-- Insert sample data
INSERT INTO users (username, email, password_hash) VALUES 
('admin', '[email protected]', 'hashed_password_here'),
('user1', '[email protected]', 'hashed_password_here');

Basic CRUD Implementation

Application Structure

Create folder structure like this:

flask-crud-app/
├── app/
│   ├── __init__.py
│   ├── models.py
│   ├── routes.py
│   ├── auth.py
│   ├── forms.py
│   └── templates/
│       ├── base.html
│       ├── index.html
│       ├── login.html
│       └── post_form.html
├── config.py
├── requirements.txt
└── run.py

Configuration File

Create config.py:

import os
from dotenv import load_dotenv

load_dotenv()

class Config:
    SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key')
    
    # SQLAlchemy configuration
    SQLALCHEMY_DATABASE_URI = os.getenv(
        'DATABASE_URL',
        'mysql+pymysql://root:@localhost/flask_crud_app'
    )
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    SQLALCHEMY_ECHO = False  # Set True to debug SQL queries
    
    # Pagination
    POSTS_PER_PAGE = 10
    
    # Upload settings
    UPLOAD_FOLDER = 'app/static/uploads'
    MAX_CONTENT_LENGTH = 16 * 1024 * 1024  # 16MB

class DevelopmentConfig(Config):
    DEBUG = True

class ProductionConfig(Config):
    DEBUG = False

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

Models

Create app/models.py:

from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime

db = SQLAlchemy()

class User(db.Model):
    __tablename__ = 'users'
    
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(50), unique=True, nullable=False)
    email = db.Column(db.String(100), unique=True, nullable=False)
    password_hash = db.Column(db.String(255), nullable=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
    
    # Relationship
    posts = db.relationship('Post', backref='author', lazy='dynamic', cascade='all, delete-orphan')
    
    def __repr__(self):
        return f'<User {self.username}>'
    
    @staticmethod
    def get_by_id(user_id):
        return User.query.get(user_id)
    
    @staticmethod
    def get_by_username(username):
        return User.query.filter_by(username=username).first()
    
    @staticmethod
    def get_by_email(email):
        return User.query.filter_by(email=email).first()
    
    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)
    
    def save(self):
        db.session.add(self)
        db.session.commit()
    
    def delete(self):
        db.session.delete(self)
        db.session.commit()

class Post(db.Model):
    __tablename__ = 'posts'
    
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(200), nullable=False)
    content = db.Column(db.Text)
    author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
    
    def __repr__(self):
        return f'<Post {self.title}>'
    
    @staticmethod
    def get_all(page=1, per_page=10):
        return Post.query.order_by(Post.created_at.desc()).paginate(
            page=page, per_page=per_page, error_out=False
        )
    
    @staticmethod
    def get_by_id(post_id):
        return Post.query.get(post_id)
    
    @staticmethod
    def get_by_author(author_id, page=1, per_page=10):
        return Post.query.filter_by(author_id=author_id).order_by(
            Post.created_at.desc()
        ).paginate(page=page, per_page=per_page, error_out=False)
    
    @staticmethod
    def search(keyword, page=1, per_page=10):
        return Post.query.filter(
            (Post.title.contains(keyword)) | (Post.content.contains(keyword))
        ).order_by(Post.created_at.desc()).paginate(
            page=page, per_page=per_page, error_out=False
        )
    
    def save(self):
        db.session.add(self)
        db.session.commit()
    
    def delete(self):
        db.session.delete(self)
        db.session.commit()

Benefits of using SQLAlchemy:

  • Type-safe: Column types are clearly defined
  • Relationships: Foreign keys and relationships are built-in
  • Query builder: More readable and maintainable
  • Migration support: Easy database schema changes
  • Protection: Built-in SQL injection protection
  • Pagination: Built-in pagination support

Initialize Database

Create app/__init__.py:

from flask import Flask
from config import config
from .models import db

def create_app(config_name='default'):
    app = Flask(__name__)
    app.config.from_object(config[config_name])
    
    # Initialize extensions
    db.init_app(app)
    
    # Register blueprints
    from .routes import main
    from .auth import auth
    app.register_blueprint(main)
    app.register_blueprint(auth)
    
    # Create tables
    with app.app_context():
        db.create_all()
    
    return app

CRUD Routes

Create app/routes.py:

from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from .models import Post
from .forms import PostForm
from functools import wraps
import math

main = Blueprint('main', __name__)

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

@main.route('/')
def index():
    page = request.args.get('page', 1, type=int)
    pagination = Post.get_all(page=page)
    posts = pagination.items
    
    return render_template('index.html', 
                         posts=posts, 
                         pagination=pagination)

@main.route('/post/new', methods=['GET', 'POST'])
@login_required
def new_post():
    form = PostForm()
    if form.validate_on_submit():
        post = Post(
            title=form.title.data,
            content=form.content.data,
            author_id=session['user_id']
        )
        post.save()
        flash('Post created successfully!', 'success')
        return redirect(url_for('main.index'))
    return render_template('post_form.html', form=form, title='New Post')

@main.route('/post/<int:post_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_post(post_id):
    post = Post.get_by_id(post_id)
    if not post:
        flash('Post not found', 'error')
        return redirect(url_for('main.index'))
    
    # Check if user owns the post
    if post.author_id != session['user_id']:
        flash('You can only edit your own posts', 'error')
        return redirect(url_for('main.index'))
    
    form = PostForm(obj=post)
    if form.validate_on_submit():
        post.title = form.title.data
        post.content = form.content.data
        post.save()
        flash('Post updated successfully!', 'success')
        return redirect(url_for('main.index'))
    
    return render_template('post_form.html', form=form, title='Edit Post')

@main.route('/post/<int:post_id>/delete', methods=['POST'])
@login_required
def delete_post(post_id):
    post = Post.get_by_id(post_id)
    if post and post.author_id == session['user_id']:
        post.delete()
        flash('Post deleted successfully!', 'success')
    else:
        flash('Post not found or you do not have permission', 'error')
    return redirect(url_for('main.index'))

Good Folder Structure

Good folder organization helps with application maintainability:

flask-crud-app/
├── app/
│   ├── api/           # API blueprints
│   ├── auth/          # Authentication logic
│   ├── models/        # Database models
│   ├── routes/        # Route handlers
│   ├── static/        # CSS, JS, images
│   ├── templates/     # Jinja2 templates
│   ├── utils/         # Helper functions
│   └── __init__.py
├── migrations/        # Database migrations
├── tests/            # Unit and integration tests
├── docs/             # Documentation
├── .env              # Environment variables
├── config.py         # Configuration
├── requirements.txt  # Dependencies
└── run.py           # Application entry point

Database Migration with Flask-Migrate

When using SQLAlchemy, you need a tool to manage database schema changes. Flask-Migrate (wrapper for Alembic) is perfect for this.

Install Flask-Migrate

pip install Flask-Migrate

Setup Migration

Update app/__init__.py:

from flask import Flask
from flask_migrate import Migrate
from config import config
from .models import db

migrate = Migrate()

def create_app(config_name='default'):
    app = Flask(__name__)
    app.config.from_object(config[config_name])
    
    # Initialize extensions
    db.init_app(app)
    migrate.init_app(app, db)
    
    # Register blueprints
    from .routes import main
    from .auth import auth
    app.register_blueprint(main)
    app.register_blueprint(auth)
    
    return app

Initialize Migration Repository

# Initialize migration folder
flask db init

# Create first migration
flask db migrate -m "Initial migration"

# Apply migration
flask db upgrade

Migration Workflow

Every time you change a model:

# 1. Edit model in models.py
# 2. Generate migration script
flask db migrate -m "Add column email_verified to users"

# 3. Review migration file in migrations/versions/
# 4. Apply migration
flask db upgrade

To rollback:

# Rollback one version
flask db downgrade

# Rollback to specific version
flask db downgrade <revision_id>

Migration Tips

  • Always review migration scripts before applying
  • Backup database before running migrations in production
  • Test migrations in development environment first
  • Commit migration files to version control

Authentication and Authorization

Implement login/logout with sessions:

# app/auth.py
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from .models import User
from .forms import LoginForm, RegisterForm
from werkzeug.security import generate_password_hash

auth = Blueprint('auth', __name__)

@auth.route('/login', methods=['GET', 'POST'])
def login():
    form = LoginForm()
    if form.validate_on_submit():
        user = User.get_by_username(form.username.data)
        if user and user.check_password(form.password.data):
            session['user_id'] = user.id
            session['username'] = user.username
            flash('Login successful!', 'success')
            return redirect(url_for('main.index'))
        flash('Invalid username or password', 'error')
    return render_template('login.html', form=form)

@auth.route('/register', methods=['GET', 'POST'])
def register():
    form = RegisterForm()
    if form.validate_on_submit():
        if User.get_by_username(form.username.data):
            flash('Username already exists', 'error')
            return render_template('register.html', form=form)
        
        user = User(
            username=form.username.data,
            email=form.email.data
        )
        user.set_password(form.password.data)
        user.save()
        flash('Registration successful! Please login.', 'success')
        return redirect(url_for('auth.login'))
    return render_template('register.html', form=form)

@auth.route('/logout')
def logout():
    session.clear()
    flash('You have been logged out', 'info')
    return redirect(url_for('main.index'))

Error Handling and Validation

Create custom error handlers and form validation:

# app/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, PasswordField
from wtforms.validators import DataRequired, Length, Email, EqualTo

class LoginForm(FlaskForm):
    username = StringField('Username', validators=[DataRequired()])
    password = PasswordField('Password', validators=[DataRequired()])

class RegisterForm(FlaskForm):
    username = StringField('Username', validators=[DataRequired(), Length(min=3, max=50)])
    email = StringField('Email', validators=[DataRequired(), Email()])
    password = PasswordField('Password', validators=[DataRequired(), Length(min=6)])
    confirm_password = PasswordField('Confirm Password', 
                                   validators=[DataRequired(), EqualTo('password')])

class PostForm(FlaskForm):
    title = StringField('Title', validators=[DataRequired(), Length(max=200)])
    content = TextAreaField('Content', validators=[DataRequired()])

# Error handlers in __init__.py
@app.errorhandler(404)
def not_found(error):
    return render_template('404.html'), 404

@app.errorhandler(500)
def internal_error(error):
    db.session.rollback()
    return render_template('500.html'), 500

Advanced SQLAlchemy Features

Database Constraints and Indexes

Add constraints and indexes for data integrity and performance:

class User(db.Model):
    __tablename__ = 'users'
    
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(50), unique=True, nullable=False, index=True)
    email = db.Column(db.String(100), unique=True, nullable=False, index=True)
    password_hash = db.Column(db.String(255), nullable=False)
    is_active = db.Column(db.Boolean, default=True)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
    
    # Table args for composite indexes
    __table_args__ = (
        db.Index('idx_username_email', 'username', 'email'),
        db.CheckConstraint('LENGTH(username) >= 3', name='username_min_length'),
    )

class Post(db.Model):
    __tablename__ = 'posts'
    
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(200), nullable=False, index=True)
    slug = db.Column(db.String(250), unique=True, index=True)
    content = db.Column(db.Text)
    author_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'))
    status = db.Column(db.Enum('draft', 'published', 'archived', name='post_status'), 
                      default='draft')
    view_count = db.Column(db.Integer, default=0)
    created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
    
    __table_args__ = (
        db.Index('idx_author_status', 'author_id', 'status'),
        db.Index('idx_status_created', 'status', 'created_at'),
    )

Query Optimization

Use lazy loading and eager loading appropriately:

# Lazy loading (default) - separate queries
post = Post.query.get(1)
print(post.author.username)  # Triggers second query

# Eager loading with joinedload - one query with JOIN
from sqlalchemy.orm import joinedload

posts = Post.query.options(joinedload(Post.author)).all()
for post in posts:
    print(post.author.username)  # No additional query

# Subquery loading - two queries but more efficient for one-to-many
from sqlalchemy.orm import subqueryload

users = User.query.options(subqueryload(User.posts)).all()

# Select specific columns to reduce memory
posts = db.session.query(Post.id, Post.title, Post.created_at).all()

Custom Query Methods

Add helper methods for frequently used queries:

class Post(db.Model):
    # ... existing code ...
    
    @staticmethod
    def get_published(page=1, per_page=10):
        return Post.query.filter_by(status='published').order_by(
            Post.created_at.desc()
        ).paginate(page=page, per_page=per_page, error_out=False)
    
    @staticmethod
    def get_popular(limit=10):
        return Post.query.filter_by(status='published').order_by(
            Post.view_count.desc()
        ).limit(limit).all()
    
    @staticmethod
    def get_by_tag(tag_name, page=1, per_page=10):
        return Post.query.join(Post.tags).filter(
            Tag.name == tag_name
        ).paginate(page=page, per_page=per_page, error_out=False)
    
    def increment_view_count(self):
        self.view_count += 1
        db.session.commit()

Validation Mixins

Create reusable validation patterns:

from sqlalchemy.orm import validates

class User(db.Model):
    # ... existing code ...
    
    @validates('email')
    def validate_email(self, key, email):
        if '@' not in email:
            raise ValueError('Invalid email address')
        return email.lower()
    
    @validates('username')
    def validate_username(self, key, username):
        if len(username) < 3:
            raise ValueError('Username must be at least 3 characters')
        if not username.isalnum():
            raise ValueError('Username must be alphanumeric')
        return username

class Post(db.Model):
    # ... existing code ...
    
    @validates('title')
    def validate_title(self, key, title):
        if len(title) < 5:
            raise ValueError('Title must be at least 5 characters')
        return title

Testing with pytest

Create tests/test_app.py:

import pytest
from app import create_app
from app.models import db, User, Post

@pytest.fixture
def app():
    app = create_app('testing')
    app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
    
    with app.app_context():
        db.create_all()
        yield app
        db.session.remove()
        db.drop_all()

@pytest.fixture
def client(app):
    return app.test_client()

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

def test_user_registration(app):
    with app.app_context():
        # Test user creation
        user = User(username='testuser', email='[email protected]')
        user.set_password('password123')
        user.save()
        
        # Retrieve and verify
        retrieved_user = User.get_by_username('testuser')
        assert retrieved_user is not None
        assert retrieved_user.email == '[email protected]'
        assert retrieved_user.check_password('password123')

def test_post_crud(app):
    with app.app_context():
        # Create test user
        user = User(username='author', email='[email protected]')
        user.set_password('pass')
        user.save()
        
        # Create post
        post = Post(title='Test Post', content='Test content', author_id=user.id)
        post.save()
        
        # Read post
        retrieved_post = Post.get_by_id(post.id)
        assert retrieved_post.title == 'Test Post'
        assert retrieved_post.author.username == 'author'  # Test relationship
        
        # Update post
        retrieved_post.title = 'Updated Title'
        retrieved_post.save()
        
        updated_post = Post.get_by_id(post.id)
        assert updated_post.title == 'Updated Title'
        
        # Delete post
        retrieved_post.delete()
        assert Post.get_by_id(post.id) is None

def test_pagination(app):
    with app.app_context():
        # Create test user
        user = User(username='testauthor', email='[email protected]')
        user.set_password('password')
        user.save()
        
        # Create multiple posts
        for i in range(15):
            post = Post(title=f'Post {i}', content=f'Content {i}', author_id=user.id)
            post.save()
        
        # Test pagination
        pagination = Post.get_all(page=1, per_page=10)
        assert len(pagination.items) == 10
        assert pagination.total == 15
        assert pagination.pages == 2
        
        # Test second page
        pagination2 = Post.get_all(page=2, per_page=10)
        assert len(pagination2.items) == 5

Run tests with:

pytest

Caching for Performance

Use Flask-Caching to improve performance:

from flask_caching import Cache

cache = Cache()

# In __init__.py
cache.init_app(app, config={'CACHE_TYPE': 'simple'})

# In routes
@main.route('/posts')
@cache.cached(timeout=300)  # Cache for 5 minutes
def posts():
    posts = Post.get_all()
    return render_template('posts.html', posts=posts)

Background Tasks with Celery

For heavy tasks, use Celery:

# tasks.py
from celery import Celery
from app import create_app

celery = Celery('tasks', broker='redis://localhost:6379/0')

@celery.task
def send_email_notification(user_email, post_title):
    # Send email logic here
    pass

# In routes
from .tasks import send_email_notification

@main.route('/post/new', methods=['POST'])
def new_post():
    # ... post creation logic ...
    send_email_notification.delay(user.email, post.title)
    # ...

API Documentation with Swagger

Use Flask-RESTX for API documentation:

from flask_restx import Api, Resource, fields

api = Api(app, version='1.0', title='Flask CRUD API', description='A simple CRUD API')

post_model = api.model('Post', {
    'title': fields.String(required=True, description='Post title'),
    'content': fields.String(required=True, description='Post content'),
})

@api.route('/api/posts')
class PostsResource(Resource):
    @api.marshal_list_with(post_model)
    def get(self):
        posts = Post.get_all()
        return posts
    
    @api.expect(post_model)
    def post(self):
        data = api.payload
        post = Post(title=data['title'], content=data['content'], author_id=1)
        post.save()
        return {'message': 'Post created'}, 201

Deployment with Gunicorn

For production deployment, use Gunicorn:

pip install gunicorn

# Run with gunicorn
gunicorn -w 4 -b 0.0.0.0:8000 run:app

# Or create systemd service
sudo nano /etc/systemd/system/flask-app.service

Service file content:

[Unit]
Description=Flask CRUD App
After=network.target

[Service]
User=www-data
Group=www-data
WorkingDirectory=/path/to/flask-crud-app
Environment="PATH=/path/to/flask-crud-app/venv/bin"
ExecStart=/path/to/flask-crud-app/venv/bin/gunicorn -w 4 -b 127.0.0.1:8000 run:app
Restart=always

[Install]
WantedBy=multi-user.target

Conclusion

We have successfully created a complete CRUD web application with Flask and MySQL using SQLAlchemy ORM! From basic setup to advanced features like authentication, testing, caching, and deployment.

Key takeaways:

  • Flask provides high flexibility for development
  • SQLAlchemy ORM makes database interactions type-safe and maintainable
  • Database migrations with Flask-Migrate simplify schema changes
  • Virtual environment is crucial for dependency management
  • Good folder structure makes maintenance easier
  • Testing should be part of the development workflow
  • Caching and background tasks improve performance
  • API documentation helps with frontend integration

You can extend this application with other features like file upload, real-time notifications, or integration with external APIs. Happy coding!

If you have any questions, feel free to discuss in the comments or community forums.