CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/flask-best-practices

Flask patterns -- application factory, blueprints, error handlers, extensions, request lifecycle, configuration, logging, CLI commands

98

1.28x
Quality

98%

Does it follow best practices?

Impact

98%

1.28x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files
name:
flask-best-practices
description:
Flask patterns -- application factory, blueprints, error handlers, extensions, request lifecycle, configuration, logging, CLI commands, and project structure. Use when building or reviewing Flask APIs, setting up a new Flask project, migrating from a single-file app to a structured project, or choosing between Flask patterns.
keywords:
flask, flask blueprint, flask app factory, flask error handler, flask extensions, flask request, flask response, flask config, flask api, flask rest, flask patterns, flask middleware, flask init_app, flask before_request, flask after_request, flask teardown, flask logging, flask cli, flask click
license:
MIT

Flask Best Practices

Application factory, blueprints, error handling, extensions, request lifecycle, configuration, logging, CLI commands, and project structure for Flask.


1. Project Structure

project/
├── app/
│   ├── __init__.py          # create_app() factory
│   ├── extensions.py        # Extension instances (no app binding)
│   ├── errors.py            # Custom exceptions + register_error_handlers()
│   ├── db.py                # Database connection + queries
│   ├── models.py            # Data classes / schemas
│   └── routes/
│       ├── __init__.py      # Empty or blueprint imports
│       ├── menu.py          # menu_bp
│       └── orders.py        # orders_bp
├── tests/
│   ├── conftest.py          # App + client fixtures using create_app(testing=True)
│   ├── test_orders.py
│   └── test_menu.py
├── requirements.txt
├── config.py                # Config classes (Development, Testing, Production)
└── run.py                   # from app import create_app; app = create_app()

Each route file defines exactly one Blueprint. Extensions live in extensions.py and are initialized in the factory. Error handlers are registered centrally, not per-blueprint.


2. Application Factory

Never create the app at module level. Use a create_app() factory function in app/__init__.py:

WRONG -- module-level app

# WRONG: Module-level app makes testing impossible and causes circular imports
from flask import Flask
app = Flask(__name__)

@app.route('/items')
def get_items():
    return jsonify([])

RIGHT -- factory function

# app/__init__.py
import os
from flask import Flask

def create_app(config_name=None):
    app = Flask(__name__)

    # Configuration
    if config_name == 'testing':
        app.config.from_mapping(
            TESTING=True,
            DATABASE_PATH=':memory:',
            SECRET_KEY='test-secret',
        )
    else:
        app.config.from_mapping(
            DATABASE_PATH=os.getenv('DATABASE_PATH', 'data.db'),
            SECRET_KEY=os.getenv('SECRET_KEY', 'dev-only-change-in-production'),
        )

    # Extensions -- use init_app pattern
    from app.extensions import cors, limiter
    cors.init_app(app)
    limiter.init_app(app)

    # Blueprints
    from app.routes.menu import menu_bp
    from app.routes.orders import orders_bp
    app.register_blueprint(menu_bp, url_prefix='/api')
    app.register_blueprint(orders_bp, url_prefix='/api')

    # Error handlers
    from app.errors import register_error_handlers
    register_error_handlers(app)

    # Request lifecycle hooks
    from app.db import get_db
    @app.teardown_appcontext
    def teardown_db(exception):
        db = getattr(app, '_database', None)
        if db is not None:
            db.close()

    return app

Key rules for create_app:

  • The function MUST be named create_app and live in app/__init__.py
  • Import blueprints and extensions INSIDE the function to avoid circular imports
  • Accept a config parameter (string or dict) to support testing, development, and production
  • NEVER call app.run() inside create_app() -- the caller decides how to run
  • Register ALL blueprints, extensions, and error handlers inside this function

3. Extensions -- init_app Pattern

Create extension instances WITHOUT the app, then bind them in the factory:

WRONG -- binding extension to app at import time

# WRONG: Creates a global app dependency, breaks testing
from flask import Flask
from flask_cors import CORS

app = Flask(__name__)
cors = CORS(app)

RIGHT -- deferred initialization with init_app

# app/extensions.py
from flask_cors import CORS
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

cors = CORS()
limiter = Limiter(key_func=get_remote_address)

Then in the factory:

# Inside create_app()
from app.extensions import cors, limiter
cors.init_app(app)
limiter.init_app(app)

Key rules:

  • Extension instances are created in extensions.py with NO app argument
  • init_app(app) is called inside create_app() only
  • This allows multiple app instances (testing, production) to share extension definitions

4. Blueprints for Route Organization

Group related routes into blueprints. Each blueprint lives in its own file under app/routes/.

# app/routes/orders.py
from flask import Blueprint, request, jsonify
from app.db import get_db
from app.errors import ValidationError, NotFoundError

orders_bp = Blueprint('orders', __name__)

@orders_bp.route('/orders', methods=['POST'])
def create_order():
    data = request.get_json()
    if not data or not data.get('customer_name', '').strip():
        raise ValidationError('customer_name is required')
    if not data.get('items'):
        raise ValidationError('At least one item is required')

    db = get_db()
    order = db.create_order(data)
    return jsonify({'data': order}), 201

@orders_bp.route('/orders/<int:order_id>')
def get_order(order_id):
    db = get_db()
    order = db.get_order(order_id)
    if not order:
        raise NotFoundError('Order', order_id)
    return jsonify({'data': order})

@orders_bp.route('/orders')
def list_orders():
    db = get_db()
    orders = db.list_orders()
    return jsonify({'data': orders})

Key rules:

  • One blueprint per file, one file per resource/domain
  • Blueprint variable name ends with _bp (e.g., orders_bp, menu_bp)
  • Register blueprints in create_app() with app.register_blueprint(bp, url_prefix='/api')
  • NEVER use @app.route() directly -- always use blueprint routes
  • Blueprints should import from app.errors to raise custom exceptions (not return error dicts manually)

5. Error Handling -- Custom Exceptions and Handlers

WRONG -- inconsistent error handling per route

# WRONG: Each route formats errors differently
@orders_bp.route('/orders/<int:order_id>')
def get_order(order_id):
    order = db.get_order(order_id)
    if not order:
        return jsonify({'msg': 'not found'}), 404  # inconsistent shape

RIGHT -- custom exception hierarchy with central handlers

# app/errors.py
from flask import jsonify

class AppError(Exception):
    def __init__(self, message, status_code, code='ERROR'):
        self.message = message
        self.status_code = status_code
        self.code = code

class NotFoundError(AppError):
    def __init__(self, resource, id=None):
        msg = f'{resource} {id} not found' if id else f'{resource} not found'
        super().__init__(msg, 404, 'NOT_FOUND')

class ValidationError(AppError):
    def __init__(self, message):
        super().__init__(message, 400, 'VALIDATION_ERROR')

def register_error_handlers(app):
    @app.errorhandler(AppError)
    def handle_app_error(e):
        return jsonify({'error': {'code': e.code, 'message': e.message}}), e.status_code

    @app.errorhandler(404)
    def handle_404(e):
        return jsonify({'error': {'code': 'NOT_FOUND', 'message': 'Endpoint not found'}}), 404

    @app.errorhandler(405)
    def handle_405(e):
        return jsonify({'error': {'code': 'METHOD_NOT_ALLOWED', 'message': 'Method not allowed'}}), 405

    @app.errorhandler(Exception)
    def handle_generic(e):
        app.logger.exception('Unhandled exception')
        return jsonify({'error': {'code': 'INTERNAL_ERROR', 'message': 'An unexpected error occurred'}}), 500

Key rules:

  • ALL error responses use shape {"error": {"code": "...", "message": "..."}}
  • Custom exceptions inherit from a base AppError class
  • register_error_handlers(app) is called inside create_app()
  • The generic Exception handler MUST log the error with app.logger.exception() before returning 500
  • NEVER return raw exception messages or stack traces in error responses
  • Route handlers raise custom exceptions; they do NOT manually build error JSON

6. Request Lifecycle Hooks

IMPORTANT: before_request, after_request, and teardown_appcontext

# Inside create_app() or on a blueprint
import uuid
from flask import g, request

@app.before_request
def before_request():
    g.request_id = request.headers.get('X-Request-ID', str(uuid.uuid4()))

@app.after_request
def after_request(response):
    response.headers['X-Request-ID'] = g.get('request_id', '')
    return response  # MUST return the response object

@app.teardown_appcontext
def teardown(exception):
    db = g.pop('db', None)
    if db is not None:
        db.close()

Key rules:

  • after_request MUST return the response object -- forgetting this silently breaks the app
  • Use g (flask.g) for per-request state -- NOT module-level globals
  • teardown_appcontext runs even if an exception occurred -- use it for cleanup (DB connections, file handles)
  • NEVER store per-request state in module-level variables -- use flask.g

7. Request Context vs Application Context

IMPORTANT: Know which context you are in

# Application context -- available with app.app_context()
# Use for: database init, CLI commands, background tasks
with app.app_context():
    init_db()  # OK: has access to current_app, g

# Request context -- available during HTTP request handling
# Use for: route handlers, before_request/after_request hooks
# Has access to: request, session, g, current_app
@orders_bp.route('/orders')
def list_orders():
    # request and g are available here
    pass

Key rules:

  • flask.request and flask.session are ONLY available inside a request context
  • flask.current_app and flask.g are available in BOTH request and application contexts
  • Use with app.app_context(): when running code outside of a request (tests, CLI commands, init scripts)
  • In tests, use the test client which automatically pushes a request context

8. Configuration from Environment

WRONG -- hardcoded configuration

# WRONG: Hardcoded values, no way to override per environment
app.config['DATABASE_URL'] = 'sqlite:///production.db'
app.config['DEBUG'] = True  # debug in production!

RIGHT -- environment-based configuration with sensible defaults

# config.py
import os

class BaseConfig:
    SECRET_KEY = os.getenv('SECRET_KEY', 'dev-only-change-in-production')
    DATABASE_PATH = os.getenv('DATABASE_PATH', 'data.db')

class DevelopmentConfig(BaseConfig):
    DEBUG = True

class TestingConfig(BaseConfig):
    TESTING = True
    DATABASE_PATH = ':memory:'

class ProductionConfig(BaseConfig):
    DEBUG = False

config_by_name = {
    'development': DevelopmentConfig,
    'testing': TestingConfig,
    'production': ProductionConfig,
}

Then in the factory:

def create_app(config_name=None):
    app = Flask(__name__)
    config_name = config_name or os.getenv('FLASK_ENV', 'development')
    app.config.from_object(config_by_name[config_name])
    ...

Key rules:

  • Config values come from environment variables with sensible defaults
  • NEVER hardcode secrets or database URLs
  • Use app.config.from_object() or app.config.from_mapping() -- not direct assignment scattered across files
  • TESTING = True should use in-memory databases and disable external services
  • DEBUG must be False in production -- never set app.run(debug=True) in production code

9. Logging Setup

# Inside create_app()
import logging

if not app.debug:
    handler = logging.StreamHandler()
    handler.setLevel(logging.INFO)
    formatter = logging.Formatter(
        '%(asctime)s %(levelname)s %(name)s: %(message)s'
    )
    handler.setFormatter(formatter)
    app.logger.addHandler(handler)
    app.logger.setLevel(logging.INFO)

Key rules:

  • Use app.logger (not print() or bare logging.getLogger()) inside the app
  • Configure logging inside create_app() -- not at module level
  • Use app.logger.exception() in error handlers to capture tracebacks
  • In production, log to stderr/stdout for container compatibility

10. CLI Commands with Click

# Inside create_app()
import click

@app.cli.command('init-db')
def init_db_command():
    """Initialize the database."""
    from app.db import init_db
    init_db()
    click.echo('Database initialized.')

@app.cli.command('seed')
@click.argument('count', default=10)
def seed_command(count):
    """Seed the database with sample data."""
    from app.db import seed_db
    seed_db(count)
    click.echo(f'Seeded {count} records.')

Key rules:

  • Register CLI commands inside create_app() using @app.cli.command()
  • CLI commands run inside an application context automatically
  • Use click.echo() for output (not print())

11. Testing with the Factory

# tests/conftest.py
import pytest
from app import create_app

@pytest.fixture
def app():
    app = create_app('testing')
    yield app

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

# tests/test_orders.py
def test_create_order(client):
    response = client.post('/api/orders', json={
        'customer_name': 'Alice',
        'items': [{'menu_item_id': 1, 'quantity': 2}]
    })
    assert response.status_code == 201
    data = response.get_json()
    assert 'data' in data

def test_create_order_missing_name(client):
    response = client.post('/api/orders', json={'items': []})
    assert response.status_code == 400
    data = response.get_json()
    assert data['error']['code'] == 'VALIDATION_ERROR'

Key rules:

  • Tests create the app via create_app('testing') -- never import a global app
  • Use app.test_client() for HTTP-level testing
  • Verify both success and error response shapes

Checklist

  • Application factory (create_app()) in app/__init__.py -- not module-level app
  • Extensions created in extensions.py with no app, bound via init_app(app) in factory
  • Routes organized in blueprints (one per file under app/routes/)
  • Blueprints registered in create_app() with url_prefix
  • Custom error hierarchy (AppError base class with NotFoundError, ValidationError)
  • Error handlers registered via register_error_handlers(app) in factory
  • Consistent error format: {"error": {"code": "...", "message": "..."}}
  • Generic Exception handler logs with app.logger.exception() before returning 500
  • Config from environment variables with defaults -- no hardcoded secrets
  • flask.g for per-request state -- no module-level request globals
  • after_request hooks return the response object
  • teardown_appcontext for cleanup (DB connections)
  • Logging configured inside create_app() using app.logger
  • CLI commands registered with @app.cli.command()
  • Tests use create_app('testing') and app.test_client()
  • No app.run(debug=True) in production code

Verifiers

  • flask-app-factory -- Use application factory pattern with create_app(), not module-level app
  • flask-blueprints -- Organize routes into blueprints, register in factory
  • flask-error-handling -- Custom exception hierarchy with consistent error envelope
  • flask-extensions -- Extensions use init_app pattern, defined in extensions.py
  • flask-config-and-lifecycle -- Environment config, request hooks, g for per-request state
Workspace
tessl-labs
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
tessl-labs/flask-best-practices badge