Flask Application Factory — Deep Dive

Why module-level apps break

Consider the standard beginner pattern:

# app.py
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///prod.db'
db = SQLAlchemy(app)

# models.py
from app import db  # Circular if app imports models

This creates three coupled problems:

  1. Circular importsapp.py imports models for registration, models import app.py for db
  2. Configuration lock-in — The database URI is hardcoded at import time
  3. Shared mutable state — Tests modify app.config and affect each other

The factory pattern addresses all three by deferring app creation until runtime.

The init_app protocol

Flask extensions follow an informal protocol. The constructor accepts an optional app:

class SQLAlchemy:
    def __init__(self, app=None):
        self.app = app
        if app is not None:
            self.init_app(app)
    
    def init_app(self, app):
        app.config.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
        app.extensions['sqlalchemy'] = self
        app.teardown_appcontext(self.shutdown_session)

The init_app method stores state on the app, not on the extension. This means one SQLAlchemy() instance can serve multiple apps, each with its own database connection. The extension locates its per-app state through current_app.extensions['sqlalchemy'].

This protocol enables the factory pattern’s killer feature: creating extensions at module level (breaking circular imports) while binding them to specific apps later.

App context and current_app

Flask uses a context-local stack to track which app is “current.” Inside a request or with app.app_context():, current_app points to the active app. Extensions use this to find their per-app state.

# extensions.py
db = SQLAlchemy()  # No app yet

# In a view function (inside request context):
def get_users():
    # db uses current_app to find the right database connection
    return db.session.query(User).all()

The critical implication: code that uses current_app or extensions cannot run at import time. It needs an active app context. This is why factory-pattern code often wraps initialization logic in deferred functions or with app.app_context() blocks.

Production factory structure

A battle-tested factory follows this ordering:

def create_app(config_name=None):
    config_name = config_name or os.environ.get('FLASK_CONFIG', 'default')
    app = Flask(__name__, instance_relative_config=True)
    
    # 1. Load configuration (order matters: later overrides earlier)
    app.config.from_object(configs[config_name])
    app.config.from_pyfile('config.py', silent=True)  # instance config
    app.config.from_prefixed_env()  # FLASK_* env vars (Flask 2.2+)
    
    # 2. Ensure instance folder exists
    os.makedirs(app.instance_path, exist_ok=True)
    
    # 3. Initialize extensions
    db.init_app(app)
    migrate.init_app(app, db)
    login_manager.init_app(app)
    cache.init_app(app)
    mail.init_app(app)
    
    # 4. Register blueprints
    from .auth import auth_bp
    from .main import main_bp
    from .api import api_bp
    app.register_blueprint(auth_bp)
    app.register_blueprint(main_bp)
    app.register_blueprint(api_bp, url_prefix='/api')
    
    # 5. Register error handlers
    register_error_handlers(app)
    
    # 6. Register CLI commands
    register_cli(app)
    
    # 7. Register shell context
    @app.shell_context_processor
    def shell_context():
        return {'db': db, 'User': User}
    
    return app

Configuration layering

The three-layer config (from_object, from_pyfile, from_prefixed_env) follows the 12-factor app principle. The class provides defaults, the instance config handles deploy-specific overrides without touching source control, and environment variables handle secrets.

instance_relative_config=True tells Flask to look for instance config relative to the instance folder (outside the package), keeping secrets out of version control.

Lazy initialization with app_context

Some setup requires the application context. Database seeding, cache warming, or registering signal handlers often need current_app. Use app.app_context() inside the factory:

def create_app(config_name='default'):
    app = Flask(__name__)
    app.config.from_object(configs[config_name])
    db.init_app(app)
    
    with app.app_context():
        # This runs during factory execution, with full app context
        from . import models  # Ensures models are imported for migrations
        if app.config.get('AUTO_CREATE_TABLES'):
            db.create_all()
    
    return app

Caution: Keep with app.app_context() blocks minimal. Heavy initialization (HTTP calls, cache warming) inside the factory slows startup and complicates testing. Prefer before_first_request (deprecated in Flask 2.3) or explicit initialization commands.

Extension ordering and dependencies

Extension initialization order matters when extensions depend on each other:

# Flask-Login needs the app's secret key (set by config)
# Flask-Migrate needs both the app and db
# Flask-Admin needs the app and registered models

db.init_app(app)                    # First: database
migrate.init_app(app, db)           # Second: depends on db
login_manager.init_app(app)         # Third: depends on config
admin.init_app(app)                 # Last: depends on models

Get this wrong and you’ll see confusing errors like “application not registered on db instance” or missing configuration keys.

Testing patterns with factories

Fixture-per-test isolation

@pytest.fixture
def app():
    app = create_app('testing')
    with app.app_context():
        db.create_all()
        yield app
        db.session.remove()
        db.drop_all()

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

@pytest.fixture
def runner(app):
    return app.test_cli_runner()

Each test gets a pristine database. The db.session.remove() call ensures connections are returned to the pool before drop_all.

Transaction rollback for speed

Creating and dropping tables per test is slow. A faster approach wraps each test in a transaction that rolls back:

@pytest.fixture
def db_session(app):
    with app.app_context():
        connection = db.engine.connect()
        transaction = connection.begin()
        session = db.session
        session.configure(bind=connection)
        yield session
        transaction.rollback()
        connection.close()

This avoids DDL overhead but requires careful handling of nested transactions and connection-bound sessions.

Celery integration

Celery workers need an app context for database access. The factory integrates via a shared celery_app:

# extensions.py
from celery import Celery
celery = Celery()

# factory
def create_app(config_name='default'):
    app = Flask(__name__)
    # ...
    celery.conf.update(app.config.get('CELERY', {}))
    
    class ContextTask(celery.Task):
        def __call__(self, *args, **kwargs):
            with app.app_context():
                return self.run(*args, **kwargs)
    
    celery.Task = ContextTask
    return app

The ContextTask wrapper ensures every task execution has an app context, giving tasks access to db, current_app, and other context-dependent objects.

Multi-app patterns

The factory can create multiple apps in a single process, useful for serving a main site and an admin panel:

from werkzeug.middleware.dispatcher import DispatcherMiddleware

frontend = create_app('production')
admin = create_admin_app('production')

application = DispatcherMiddleware(frontend, {
    '/admin': admin
})

Each app has its own config, extensions, and routes. The DispatcherMiddleware routes requests based on URL prefix. This is heavier than blueprints but provides true isolation — different databases, different session configs, different middleware stacks.

Common pitfalls

Importing the app at module level in tasks/commands. Don’t do from myapp import app in Celery tasks or management commands. Call create_app() or use the ContextTask pattern above.

Registering extensions conditionally. If you skip debug_toolbar.init_app(app) in production, any code that imports from the debug toolbar extension will fail with import errors — even if it’s never called. Guard the import, not just the initialization.

Mutating app after creation. The factory should be the only place that configures the app. If CLI commands or background jobs modify app.config after creation, you’ll get hard-to-reproduce inconsistencies between workers.

One thing to remember: The application factory works because Flask extensions store their state on the app object, not on themselves. This one design decision — per-app state via init_app and current_app — is what makes multiple configurations, isolated testing, and worker integration possible from a single codebase.

pythonflaskwebarchitecture

See Also