CLI & Shell

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Flask 3.1.3 (released 2026-02-18), Werkzeug 3.1.9, Click 8.5.0 and python-dotenv 1.2.4, checked on 2026-10-07. Flask 3.1 supports Python 3.9 and newer; the code on this page uses Python 3.10+ syntax and every Python block was executed on Python 3.13, including real flask processes started with python -m flask. The IDE configurations and the entry point snippet are shown but not executed. Flask 3.2 has not been released yet; nothing in the Flask 3.2 changelog checked on that date changes the command line, and the context changes announced for it are described in the Application & Request Lifecycle page. Installing Flask installs a console script called flask. It is a Click program that Flask ships and that looks your application up on demand, so one command gives you the development server, a route listing, an interactive shell, the commands of your extensions (flask db upgrade, for instance) and the maintenance commands you write yourself. The page uses the Bookshelf application of the previous pages and answers four questions: how flask finds the app, what the built-in commands do, how to add your own commands and how to test them.

What the flask Command Is

pip install Flask puts a flask script in the virtual environment. Its entry point is flask.cli:main, and python -m flask runs the same function, which is handy when the script is not on the PATH or when an IDE or a process manager wants a module to run. The object behind the command is a FlaskGroup, a subclass of AppGroup, which is itself a subclass of Click’s Group. Click parses the command line, shows --help, converts option types and reads environment variables; Flask adds the part Click cannot know about: finding and loading your application.

  • FlaskGroup registers the built-in commands run, shell and routes, three eager options that act before anything else (--env-file, --app and --debug), and --version.

  • Commands from installed packages are added from the flask.commands entry point group (see Plugins).

  • Commands from your application are added from app.cli, a Click AppGroup that every Flask object has. Looking at app.cli means the app has to be imported first, so flask --help imports your application to list its commands.

  • A ScriptInfo object travels in the Click context. It stores the --app string and loads the application once, so every command of one invocation sees the same app instance.

flowchart TD A["flask --env-file F --app X --debug COMMAND"] --> B["eager options: load dotenv files, remember X, set FLASK_DEBUG"] B --> C{"COMMAND is built-in or from a plugin?"} C -->|yes| D["run it (run, shell and routes load the app themselves)"] C -->|no| E["ScriptInfo.load_app() imports the app"] E --> F["look COMMAND up in app.cli"] F --> G["push an application context and run it"]
Figure 1. How the flask command resolves a command

Two consequences of this design matter in practice. First, built-in and plugin commands are looked up before the app is loaded, so flask --version and flask run --help work even when the application is broken, while flask routes or any command of app.cli shows the load error instead. Second, because the flask script belongs to Flask and not to your project, it works with any layout; the alternative of shipping your own script built on FlaskGroup is covered in Custom Scripts.

Application Discovery: How --app Resolves

The --app option (short -A) is the first thing every flask invocation needs. The value has three optional parts: a path, a module (a file or a dotted import) and a name that can carry factory arguments in parentheses. The table of examples is in Application Factory & Project Layout; this page shows the full algorithm that sits behind it, which the official page only summarises:

  1. With no --app (and no FLASK_APP environment variable) Flask tries to import wsgi.py first and then app.py from the current directory. The first one that imports and contains an application wins.

  2. The module is searched for an object called app, then one called application, and then for any single Flask instance among the module’s globals. Two or more candidates are an error that asks for module:name.

  3. If there is no instance, Flask looks for a function called create_app and then one called make_app and calls it without arguments. A factory with a required parameter fails with a message that tells you to write module:create_app(args).

  4. With module:name the name is a plain attribute (an instance or a function). If it is a function it is called; what it returns must be a Flask object.

  5. With module:name(args) the text in parentheses is parsed with ast.literal_eval, so arguments are Python literals such as "dev" or debug=True, and a name like len is rejected.

  6. A path is accepted as well as a dotted name: Flask works out the import name and puts the directory that contains the top-level module on sys.path, which is why --app src/hello works without installing anything.

The FLASK_APP environment variable takes the same value; the command-line option wins when both are present. The following block builds throwaway projects and runs the real command against them. The helper flask() runs python -m flask in a directory and returns the exit code and the combined output.

import os
import pathlib
import subprocess
import sys
import tempfile

root = pathlib.Path(tempfile.mkdtemp())
CLEAN_ENV = {k: v for k, v in os.environ.items() if not k.startswith("FLASK_")}


def project(name, files):
    """Create a throwaway project directory and return it."""
    directory = root / name
    directory.mkdir(parents=True, exist_ok=True)
    for filename, body in files.items():
        path = directory / filename
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(body)
    return directory


def flask(*args, cwd, env=None, input=None):
    result = subprocess.run(
        [sys.executable, "-m", "flask", *args],
        cwd=cwd,
        env={**CLEAN_ENV, **(env or {})},
        input=input,
        capture_output=True,
        text=True,
        timeout=60,
    )
    return result.returncode, (result.stdout + result.stderr).strip()


APP = "from flask import Flask\napp = Flask(__name__)\n\n@app.get('/{path}')\ndef view():\n    return 'ok'\n"

both = project("both", {"wsgi.py": APP.format(path="from-wsgi"), "app.py": APP.format(path="from-app")})
code, out = flask("routes", cwd=both)  # no --app: wsgi.py is tried before app.py
assert code == 0 and "/from-wsgi" in out and "/from-app" not in out
print(out)

two = project("two", {"pair.py": "from flask import Flask\nfirst = Flask('a')\nsecond = Flask('b')\n"})
code, out = flask("--app", "pair", "routes", cwd=two)  # two instances: ambiguous
assert code != 0
print(out.splitlines()[-1])
code, out = flask("--app", "pair:second", "routes", cwd=two)  # module:name settles it
assert code == 0

broken = project("broken", {"shop.py": "import package_that_does_not_exist\n"})
code, out = flask("--app", "shop", "routes", cwd=broken)  # an ImportError inside the module is reported as such
assert code != 0 and "ImportError" in out
print(next(line for line in out.splitlines() if line.startswith("Error:")))

src = project("src_layout", {"src/hello.py": APP.format(path="hello")})
code, out = flask("--app", "src/hello", "routes", cwd=src)  # the directory goes on sys.path
assert code == 0 and "/hello" in out
code, out = flask("routes", cwd=src, env={"FLASK_APP": "src/hello"})  # FLASK_APP takes the same value
assert code == 0 and "/hello" in out
Endpoint  Methods  Rule
--------  -------  -----------------------
static    GET      /static/<path:filename>
view      GET      /from-wsgi
Error: Detected multiple Flask applications in module 'pair'. Use 'pair:name' to specify the correct one.
Error: While importing 'shop', an ImportError was raised:

Factories with arguments work the same way, and the arguments reach the factory before any command runs. A factory that reads its argument to choose a configuration is the cleanest way to run one codebase under several settings:

factory = project(
    "factory",
    {
        "bookshelf.py": (
            "from flask import Flask\n"
            "\n"
            "def create_app(config_name='default', *, title='Bookshelf'):\n"
            "    app = Flask(__name__)\n"
            "    app.config['TITLE'] = title\n"
            "    app.add_url_rule(f'/{config_name}', 'index', lambda: title)\n"
            "    return app\n"
        )
    },
)
code, out = flask("--app", "bookshelf", "routes", cwd=factory)  # defaults for every parameter: auto-detected
assert code == 0 and "/default" in out
code, out = flask("--app", 'bookshelf:create_app("dev", title="Dev Shelf")', "routes", cwd=factory)
assert code == 0 and "/dev" in out
print(out)
code, out = flask("--app", "bookshelf:create_app(title=len)", "routes", cwd=factory)  # not a literal
assert code != 0
print(out.splitlines()[-1])
Endpoint  Methods  Rule
--------  -------  -----------------------
index     GET      /dev
static    GET      /static/<path:filename>
Error: Failed to parse arguments as literal values: 'create_app(title=len)'.

Pitfalls of discovery:

  • The factory runs once per process that loads the app. flask run --debug starts a parent process that watches files and a child process that serves requests, and both load the app, so the factory body (and any print or connection opened in it) runs twice. Keep factories free of side effects that cannot be repeated.

  • A name such as app that points at a Flask object created inside if __name__ == "__main__": is not found, because the module is imported, not run as a script.

  • Setting FLASK_APP in the shell for the whole session is convenient and still supported, but the documented form is flask --app <module> run --debug (Flask 2.2). The older books and tutorials that tell you to export FLASK_APP=... and export FLASK_ENV=development are covered in What Changed.

  • --app has to come before the command: flask run --app bookshelf is rejected with "No such option" because run has no such option. Only --debug is accepted in both places.

Options Before the Command and dotenv Files

Four options belong to the flask group itself and are processed before the command is chosen.

Option Effect Added or changed in

-A, --app IMPORT

The application or factory to load, module:name or module:name(args). Eager, so it is also known to --help.

2.2

-e, --env-file FILE

Loads one more dotenv file. The file must exist. It takes precedence over .env and .flaskenv; variables already in the environment still win. Needs python-dotenv.

2.2; precedence over the default files since 3.1

--debug / --no-debug

Sets debug mode for any command by writing FLASK_DEBUG in the environment before the factory runs, so a factory can read it. When the flag is absent, FLASK_DEBUG from the environment decides.

2.2

--version

Prints the Python, Flask and Werkzeug versions.

0.12

flask run has its own --debug as well, added in 2.2.3, so flask --app bookshelf --debug run and flask --app bookshelf run --debug are equivalent. Debug mode is controlled by FLASK_DEBUG (values 0, false and no, in any case, mean off) or the flag; the FLASK_ENV variable that older material used was removed in Flask 2.3.

dotenv files. When python-dotenv is installed, flask reads .flaskenv and .env before it looks at --app, so neither --app nor FLASK_APP has to be typed. The rules, as implemented in flask.cli.load_dotenv:

  1. Variables already set in the process environment always win; a dotenv file never overwrites them.

  2. Among the files, --env-file beats .env, which beats .flaskenv.

  3. .flaskenv is meant to be committed and holds public settings such as FLASK_APP; .env is not committed and holds secrets.

  4. The files are searched in the current directory and then upwards, so running flask from a sub-directory still finds the project’s files. Since Flask 2.0 the working directory is no longer changed to the file’s location, and the files are read as UTF-8.

  5. Only the flask command and app.run() load them. A production process started by Gunicorn or Waitress never reads them, so call flask.cli.load_dotenv() yourself or, better, inject real environment variables (the Configuration page shows the options).

  6. Without python-dotenv the command prints a yellow tip on stderr when it sees the files and loads nothing. With FLASK_SKIP_DOTENV set to a true value Flask skips the default files even when python-dotenv is installed, for instance when a process manager already loaded them. The value 0, false or no means "do not skip".

The block creates a .flaskenv, an .env and an extra file, defines a command that prints one variable, and shows who wins:

envdemo = project(
    "envdemo",
    {
        "shelf.py": (
            "import os\n"
            "import click\n"
            "from flask import Flask\n"
            "\n"
            "app = Flask(__name__)\n"
            "\n"
            "@app.cli.command('where')\n"
            "def where():\n"
            "    click.echo(os.environ.get('BOOKSHELF_MODE', 'unset'))\n"
        ),
        ".flaskenv": "\n".join(["FLASK_APP=shelf", "BOOKSHELF_MODE=from-flaskenv", ""]),
        ".env": "BOOKSHELF_MODE=from-dotenv\n",
        "staging.env": "BOOKSHELF_MODE=from-env-file\n",
    },
)
modes = {}
modes["files"] = flask("where", cwd=envdemo)[1]  # no --app needed: .flaskenv sets FLASK_APP
modes["env-file"] = flask("--env-file", "staging.env", "where", cwd=envdemo)[1]
modes["process"] = flask("--env-file", "staging.env", "where", cwd=envdemo, env={"BOOKSHELF_MODE": "from-process"})[1]
modes["skip"] = flask("--app", "shelf", "where", cwd=envdemo, env={"FLASK_SKIP_DOTENV": "1"})[1]
assert modes == {
    "files": "from-dotenv",
    "env-file": "from-env-file",
    "process": "from-process",
    "skip": "unset",
}
print(modes)
{'files': 'from-dotenv', 'env-file': 'from-env-file', 'process': 'from-process', 'skip': 'unset'}

A .env file holds secrets and must be listed in .gitignore. A .flaskenv file must never contain a secret, because it is committed. Both are read as plain text and are not a replacement for a secret store in production.

flask run: The Development Server

flask run starts Werkzeug’s development server (werkzeug.serving.run_simple) with the loaded application. It replaces app.run() in almost every case: it works with factories, it takes its settings from options and environment variables, and it does not require a __main__ block in your code. The server is for development only; Werkzeug and Flask both state that it is not designed to be secure, stable or efficient, and the deployment page of this section lists the production servers.

Option Default Meaning

-h, --host TEXT

127.0.0.1

Interface to bind to. 0.0.0.0 exposes the server to the whole network, including the debugger if it is on.

-p, --port INTEGER

5000

Port to bind to. On recent macOS versions the AirPlay Receiver listens on 5000; see Getting Started.

--cert PATH

none

HTTPS. A certificate file (then --key is required), the word adhoc for a throw-away certificate (needs the cryptography package; --key is rejected), or the import path of an ssl.SSLContext object.

--key FILE

none

Private key that belongs to --cert.

--reload / --no-reload

debug mode

Restart the process when code changes. On by default only when debug is on.

--debugger / --no-debugger

debug mode

Interactive debugger in the browser on unhandled exceptions. On by default only when debug is on.

--with-threads / --without-threads

threads on

Handle each request in its own thread.

--extra-files PATH

none

More files that trigger a reload. Several paths are separated by : (; on Windows).

--exclude-patterns PATH

none

fnmatch patterns whose files do not trigger a reload. Separated like --extra-files.

--debug / --no-debug

from FLASK_DEBUG

Debug mode, as for the group option.

The reloader, the debugger PIN and the WERKZEUG_DEBUG_PIN variable are explained in the Getting Started page and not repeated here. Three points are specific to the command:

  • Failures while reloading are deferred. When a reload hits a syntax error the server keeps running and shows the error in the browser through the debugger, so you can fix the file; outside the reloader a failure to load the app stops the command immediately.

  • app.run() becomes a no-op under the CLI. FlaskGroup sets the variable FLASK_RUN_FROM_CLI so that a stray app.run() at module level (not inside if __name__ == "__main__":) cannot start a second server and block the command. Flask prints a red warning that it ignored the call.

  • --eager-loading and --lazy-loading no longer exist (removed in 2.2), so books that use them fail with "No such option".

The next block starts the real server on a free port, makes one request and stops it. It also shows that the port can come from the environment (FLASK_RUN_PORT, see Options From Environment Variables):

import socket
import time
import urllib.request

server = project(
    "server",
    {"hello.py": "from flask import Flask\napp = Flask(__name__)\n\n@app.get('/')\ndef index():\n    return 'Bookshelf is up'\n"},
)


def free_port():
    with socket.socket() as sock:
        sock.bind(("127.0.0.1", 0))
        return sock.getsockname()[1]


def serve_and_get(args, env=None, port=None):
    port = port or free_port()
    process = subprocess.Popen(
        [sys.executable, "-m", "flask", *args(port)],
        cwd=server,
        env={**CLEAN_ENV, **(env or {})},
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    try:
        for _ in range(100):
            try:
                with urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=2) as response:
                    return response.status, response.read().decode()
            except OSError:
                time.sleep(0.1)
        raise RuntimeError("the server did not start")
    finally:
        process.terminate()
        process.wait(timeout=10)


# the option form
print(serve_and_get(lambda port: ["--app", "hello", "run", "--port", str(port), "--host", "127.0.0.1"]))

# the environment form: no --port on the command line
port = free_port()
print(serve_and_get(lambda _: ["--app", "hello", "run"], env={"FLASK_RUN_PORT": str(port)}, port=port))
(200, 'Bookshelf is up')
(200, 'Bookshelf is up')

Security notes for the server: never bind 0.0.0.0 with the debugger enabled, because anyone who can reach the port and learns or guesses the PIN can run code on the machine; do not run flask run, --debug or app.run(debug=True) in a container image or on a production host (the older books do exactly that in their Docker chapters); and treat an adhoc certificate as a convenience for local HTTPS testing only, because every restart creates a new one and no browser trusts it.

flask routes: Listing the URL Map

flask routes prints every rule of the application’s url_map with its endpoint and methods. It is the quickest way to check that a blueprint was registered, that url_prefix did what you meant and which of two overlapping rules Flask tries first. HEAD and OPTIONS are hidden unless you pass --all-methods. When any rule has a subdomain (or a host, if the app uses host matching) an extra column appears. --sort (short -s) accepts endpoint (the default), methods, domain, rule and match; match keeps the order in which the URL map holds the rules, which is registration order (the static rule of the application is registered first). Since Werkzeug 2.2 a request is matched by a state machine that prefers static parts to converters, so in the listing below /books/new still wins over /books/<int:book_id> although it is listed second; the block checks it.

from flask import Blueprint, Flask
from flask.cli import FlaskGroup

shelf = Flask("bookshelf", subdomain_matching=True)
shelf.config["SERVER_NAME"] = "bookshelf.test"
api = Blueprint("api", __name__, url_prefix="/api", subdomain="api")


@shelf.get("/books/<int:book_id>")
def book_detail(book_id):
    return str(book_id)


@shelf.get("/books/new")
def book_form():
    return "form"


@api.get("/books")
def book_list():
    return "[]"


shelf.register_blueprint(api)
cli = FlaskGroup(create_app=lambda: shelf)  # the built-in commands are reachable through a FlaskGroup
runner = shelf.test_cli_runner()
result = runner.invoke(cli, ["routes", "--sort", "match"])
assert result.exit_code == 0, result.output
print(result.output)
assert shelf.test_client().get("/books/new").data == b"form"  # static parts beat converters, whatever the listing order
Endpoint       Methods  Subdomain  Rule
-------------  -------  ---------  -----------------------
static         GET                 /static/<path:filename>
book_detail    GET                 /books/<int:book_id>
book_form      GET                 /books/new
api.book_list  GET      api        /api/books

A missing rule, a wrong url_prefix or a wrong subdomain in this listing is the first thing to look at when a URL answers with a 404 or the wrong view. Routing itself is covered in Routing & URL Building.

flask shell: An Interactive Shell With the App Loaded

flask shell starts an interactive Python session with an application context already pushed. Without it, you would import the app, push a context by hand, and import every model each time. The command is built on the standard library’s code.interact; it is not IPython and has no special display or editing features beyond what the interpreter offers. The banner shows the Python version, the application’s import name and the instance folder path, which tells you at a glance which app and which instance (and therefore which config.py and SQLite file) you are about to modify:

Python 3.13.15 (main, Aug 25 2026, 13:50:12) [Clang 22.1.3 ] on darwin
App: bookshelf
Instance: /home/ana/bookshelf/instance
>>>

What is in the namespace:

  1. The names defined by the file named in the PYTHONSTARTUP environment variable, if it is set (the regular interpreter startup script is honoured).

  2. Whatever app.make_shell_context() returns: by default app and g, plus every dictionary returned by a function registered with @app.shell_context_processor.

  3. Tab completion and history when readline is available; completion uses the shell’s own namespace.

The processors are called every time the shell starts, inside the application context, so they can touch current_app, configuration and extensions. Extensions use them too: Flask-SQLAlchemy registers one that adds db and the model classes (see Flask-SQLAlchemy).

import secrets

shell_app_dir = project(
    "shellapp",
    {
        "shelfshell.py": (
            "from flask import Flask, current_app\n"
            "\n"
            "class Book:\n"
            "    def __init__(self, title):\n"
            "        self.title = title\n"
            "\n"
            "def create_app():\n"
            "    app = Flask(__name__)\n"
            "    app.config['PAGE_SIZE'] = 10\n"
            "\n"
            "    @app.shell_context_processor\n"
            "    def add_names():\n"
            "        return {'Book': Book, 'page_size': current_app.config['PAGE_SIZE']}\n"
            "\n"
            "    return app\n"
        )
    },
)

# What the shell will contain, without starting it
sys.path.insert(0, str(shell_app_dir))
import shelfshell

shell_app = shelfshell.create_app()
with shell_app.app_context():  # the flask shell command pushes this context for you
    namespace = shell_app.make_shell_context()
assert set(namespace) == {"app", "g", "Book", "page_size"}
print(sorted(namespace))

# And the real command, with a script on standard input instead of a terminal
script = "print(Book('Dune').title, page_size, app.name)\n"
code, out = flask("--app", "shelfshell", "shell", cwd=shell_app_dir, input=script)
assert code == 0 and "Dune 10 shelfshell" in out
print(next(line for line in out.splitlines() if "Dune" in line))
['Book', 'app', 'g', 'page_size']
>>> Dune 10 shelfshell

Feeding a script to flask shell on standard input, as the block does, is a way to run a one-off piece of code in the app’s context. For anything you will run twice, write a custom command instead (next section): it has a name, a help text, arguments and a test.

Requests in the shell. The shell has an application context but no request, so request, session and url_for outside SERVER_NAME do not work and g starts empty. The way to get one is app.test_request_context(), pushed and popped by hand; running the before_request hooks needs app.preprocess_request(). The context system itself is explained in Application & Request Lifecycle.

from flask import Flask, g, request, url_for

demo = Flask("shell-demo")


@demo.before_request
def load_current_user():
    g.user = "ana"


@demo.get("/books/<int:book_id>", endpoint="book")
def book(book_id):
    return str(book_id)


ctx = demo.test_request_context("/books/7?page=2")
ctx.push()
try:
    demo.preprocess_request()  # runs before_request functions, like a real request would
    print(request.path, request.args["page"], g.user, url_for("book", book_id=7))
finally:
    ctx.pop()  # pops the context and runs the teardown functions
/books/7 2 ana /books/7

Pitfalls and security of the shell:

  • The shell uses the real configuration. flask shell against a production DATABASE_URL edits production data without review, so read the Instance: line and the config before you type, and prefer a custom command with a confirmation prompt for anything destructive.

  • The shell does not reload code. After editing a model, exit and start it again, or importlib.reload the module.

  • Anyone who can open a shell in a production container has the application’s secrets (its configuration is in memory). Restrict who can run kubectl exec or docker exec, and do not offer a shell through the web application.

  • Creating tables by typing db.create_all() in the shell, as Full-Stack Flask and React does in its SQL chapter, is fine for a first local run, but it neither alters existing tables nor leaves a history; use Database Migrations for anything that outlives a laptop.

Custom Commands With Click

A custom command is a Python function decorated with Click decorators and registered on a group. Flask’s app.cli is an AppGroup; its command() decorator wraps the function so that an application context is pushed before it runs, which means current_app, g, the database extension and app.config work inside it. Click does everything else: the name, the help text (from the docstring), arguments, options, type conversion, prompts, confirmation and exit codes. The name defaults to the function name with underscores turned into dashes (import_books becomes import-books); pass a string to choose it.

import json
import sqlite3

import click
from flask import Flask, current_app, g


def get_db():
    if "db" not in g:
        g.db = sqlite3.connect(current_app.config["DATABASE"])
        g.db.execute("CREATE TABLE IF NOT EXISTS book (title TEXT PRIMARY KEY, author TEXT, year INTEGER)")
    return g.db


def create_app(database):
    app = Flask("bookshelf")
    app.config["DATABASE"] = database

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

    @app.cli.command("add-book")
    @click.argument("title")
    @click.option("--author", required=True, help="Name of the author.")
    @click.option("--year", type=click.IntRange(min=1000, max=2100), default=2000, show_default=True)
    def add_book(title, author, year):
        """Add one book to the catalogue."""
        db = get_db()
        try:
            db.execute("INSERT INTO book VALUES (?, ?, ?)", (title, author, year))
        except sqlite3.IntegrityError:
            raise click.ClickException(f"{title!r} is already in the catalogue") from None
        db.commit()
        click.echo(f"Added {title!r} ({year})")

    @app.cli.command("count-books")
    @click.option("--as-json", is_flag=True, help="Print JSON instead of text.")
    def count_books(as_json):
        """Print the number of books."""
        total = get_db().execute("SELECT COUNT(*) FROM book").fetchone()[0]
        click.echo(json.dumps({"books": total}) if as_json else f"{total} books")

    return app


cmd_app = create_app(str(root / "commands.db"))
runner = cmd_app.test_cli_runner()

result = runner.invoke(args=["add-book", "Dune", "--author", "Frank Herbert", "--year", "1965"])
assert result.exit_code == 0 and result.output == "Added 'Dune' (1965)\n"
print(result.output.strip())

result = runner.invoke(args=["add-book", "Dune", "--author", "Frank Herbert"])  # duplicate: ClickException
assert result.exit_code == 1
print(result.output.strip())

result = runner.invoke(args=["add-book", "Emma", "--author", "Jane Austen", "--year", "3000"])  # outside the range
assert result.exit_code == 2
print(result.output.splitlines()[-1])

assert runner.invoke(args=["count-books", "--as-json"]).output == '{"books": 1}\n'
print(runner.invoke(args=["add-book", "--help"]).output)
Added 'Dune' (1965)
Error: 'Dune' is already in the catalogue
Error: Invalid value for '--year': 3000 is not in the range 1000<=x<=2100.
Usage: bookshelf add-book [OPTIONS] TITLE

  Add one book to the catalogue.

Options:
  --author TEXT         Name of the author.  [required]
  --year INTEGER RANGE  [default: 2000; 1000<=x<=2100]
  --help                Show this message and exit.

Notes on writing commands:

  • Arguments and options. @click.argument is positional and required unless given a default; @click.option is named. The types (click.IntRange, click.Path, click.Choice, click.DateTime, click.File) validate before your function runs and print a usage error with exit code 2. Options can read an environment variable (see the next section), prompt (prompt=True), ask for confirmation (confirmation_option) or hide what is typed (hide_input).

  • Output and exit codes. Use click.echo instead of print (it handles encodings and redirected output) and click.echo(..., err=True) for diagnostics. Raise click.ClickException for an expected failure (exit code 1, message prefixed by Error:), click.UsageError for bad usage (exit code 2), or call sys.exit(code) or ctx.exit(code). An uncaught exception prints a traceback and exits with 1. Schedulers and CI jobs rely on that non-zero code.

  • Application context. Commands on app.cli, on a group created with AppGroup, and on bp.cli get the context for free since Flask 2.2, and so do their parameter callbacks. A command built with plain @click.command() needs flask.cli.with_appcontext (shown below). Pass with_appcontext=False to an AppGroup command() for a command that must not load the application, for instance one that creates it.

  • Idempotence and transactions. Commands are re-run after failures. Make them safe to repeat (INSERT OR IGNORE, a check before creating) and commit or roll back explicitly, because teardown_appcontext closes the connection but does not commit for you unless your extension does.

  • Everything is on the app. app.cli.add_command(group) and @app.cli.command() run when the factory builds the app, so a command defined in a module that the factory never imports does not exist. In a factory-based project the cli.py module with an init_app(app) function, shown in Application Factory & Project Layout, is the usual fix.

Source: Click: Options

Groups: AppGroup and add_command

Commands that belong together are put in a group so that the top-level listing stays readable. flask.cli.AppGroup is a click.Group whose command() and group() decorators add the application context. Register the group with app.cli.add_command. Groups nest, and with_appcontext is applied to the commands of nested AppGroup objects too.

from flask.cli import AppGroup, with_appcontext

books_cli = AppGroup("books", help="Maintain the catalogue.")
users_cli = AppGroup("users", help="Manage accounts.")


@books_cli.command("purge")
@click.option("--older-than", type=click.IntRange(min=1), default=30, show_default=True)
@click.confirmation_option(prompt="Delete the old books?")
def purge_books(older_than):
    """Delete books that have not been borrowed for a while."""
    click.echo(f"purged books older than {older_than} days from {current_app.name}")


@users_cli.command("create")
@click.argument("name")
@click.password_option()  # prompts twice, hides the input, never takes the password as an argument
def create_user(name, password):
    """Create an account; the password is typed, not passed on the command line."""
    click.echo(f"created {name} with a {len(password)}-character password")


@click.command("stats")  # a plain Click command: needs the decorator to get an application context
@with_appcontext
def stats():
    """Show the application name."""
    click.echo(f"app is {current_app.name}")


group_app = Flask("shelf")
group_app.cli.add_command(books_cli)
group_app.cli.add_command(users_cli)
group_app.cli.add_command(stats)
runner = group_app.test_cli_runner()

result = runner.invoke(args=["books", "purge", "--older-than", "90"], input="y\n")  # answers the confirmation
assert result.exit_code == 0, result.output
print(result.output.splitlines()[-1])

result = runner.invoke(args=["books", "purge", "--yes"])  # confirmation_option adds --yes for scripts
assert result.exit_code == 0

result = runner.invoke(args=["books", "purge"], input="n\n")  # declined: aborted with exit code 1
assert result.exit_code == 1 and "Aborted" in result.output

typed = secrets.token_urlsafe(12)  # a generated test value, never a literal password
result = runner.invoke(args=["users", "create", "ana"], input=f"{typed}\n{typed}\n")
assert result.exit_code == 0 and str(len(typed)) in result.output
print(result.output.splitlines()[-1])
assert runner.invoke(args=["stats"]).output == "app is shelf\n"
print(runner.invoke(args=[]).output)
purged books older than 90 days from shelf
created ana with a 16-character password
Usage: shelf [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  books  Maintain the catalogue.
  stats  Show the application name.
  users  Manage accounts.

Never pass a password, token or key as a command-line argument: it ends up in the shell history and in the process list that every user of the machine can read. Prompt for it (click.password_option, click.prompt(hide_input=True)), read it from an environment variable or a file with restricted permissions, or generate it (secrets.token_urlsafe()) and print it once.

Blueprint commands

A blueprint carries its own cli group, so a module that owns a feature can also own the commands that maintain it. The commands become available when the blueprint is registered and live in a group named after the blueprint, unless cli_group says otherwise: a string renames the group (in Blueprint(...) or in register_blueprint(..., cli_group=...)) and None merges the commands into the top level, where a name clash with a built-in command or another blueprint means the one registered last silently replaces the other.

from flask import Blueprint

stock = Blueprint("stock", __name__)  # group "stock"
reports = Blueprint("reports", __name__, cli_group=None)  # merged into the top level


@stock.cli.command("count")
def stock_count():
    click.echo("42 books in stock")


@reports.cli.command("weekly")
def weekly():
    click.echo("weekly report")


bp_app = Flask("bp-demo")
bp_app.register_blueprint(stock)
bp_app.register_blueprint(reports)
renamed = Flask("bp-demo-2")
renamed.register_blueprint(stock, cli_group="inventory")  # per registration

assert bp_app.test_cli_runner().invoke(args=["stock", "count"]).output == "42 books in stock\n"
assert bp_app.test_cli_runner().invoke(args=["weekly"]).output == "weekly report\n"
assert renamed.test_cli_runner().invoke(args=["inventory", "count"]).output == "42 books in stock\n"

The Blueprints page of this section covers the same feature from the blueprint side, in Blueprints.

Options From Environment Variables

The flask group sets Click’s auto_envvar_prefix to FLASK. Every option of every command that does not name its own variable then reads one automatically, built from the prefix, the command path and the option name, upper-cased with dashes as underscores: FLASK_RUN_PORT for flask run --port, FLASK_ROUTES_SORT and FLASK_BOOKS_PURGE_OLDER_THAN for the nested command above. Boolean flags take values such as 1 or true. A value on the command line beats the variable, and the variable beats the option’s default. The rule makes .flaskenv a handy place for per-project defaults (FLASK_RUN_PORT=8000), and it works for your own commands too, provided they are run through flask.

The prefix is a property of the FlaskGroup, not of app.cli. app.test_cli_runner() invokes app.cli directly, so tests do not see the automatic variables unless you pass auto_envvar_prefix="FLASK" to invoke(). An option can also name its variable explicitly with envvar="BOOKSHELF_PAGE_SIZE", which then works in both situations.

env_project = project(
    "envopts",
    {
        "shelf.py": (
            "import click\n"
            "from flask import Flask\n"
            "from flask.cli import AppGroup\n"
            "\n"
            "app = Flask(__name__)\n"
            "books = AppGroup('books')\n"
            "\n"
            "@books.command('purge')\n"
            "@click.option('--older-than', type=int, default=30)\n"
            "def purge(older_than):\n"
            "    click.echo(f'older-than={older_than}')\n"
            "\n"
            "@app.cli.command('page')\n"
            "@click.option('--size', type=int, default=10, envvar='BOOKSHELF_PAGE_SIZE')\n"
            "def page(size):\n"
            "    click.echo(f'size={size}')\n"
            "\n"
            "app.cli.add_command(books)\n"
        )
    },
)
assert flask("--app", "shelf", "books", "purge", cwd=env_project)[1] == "older-than=30"
assert flask("--app", "shelf", "books", "purge", cwd=env_project, env={"FLASK_BOOKS_PURGE_OLDER_THAN": "90"})[1] == "older-than=90"
assert flask("--app", "shelf", "books", "purge", "--older-than", "7", cwd=env_project, env={"FLASK_BOOKS_PURGE_OLDER_THAN": "90"})[1] == "older-than=7"
assert flask("--app", "shelf", "page", cwd=env_project, env={"BOOKSHELF_PAGE_SIZE": "25"})[1] == "size=25"

Plugins: Commands From Installed Packages

Any installed distribution can add commands to the flask script, with no code in your application, by declaring an entry point in the group flask.commands. The name of the entry point becomes the command name and its value must be a Click command or group. The entry point is read with importlib.metadata (since Flask 2.1; earlier versions used pkg_resources), and the commands are loaded when the group is asked for a command or a listing. Because they are loaded before the application, a plugin command works even when the app itself cannot be imported, which is what you want from a command such as a health check or a setup helper.

[project.entry-points."flask.commands"]
bookshelf-doctor = "bookshelf_tools.commands:doctor"
# bookshelf_tools/commands.py
import click


@click.command()
def doctor():
    """Check the Bookshelf environment."""
    click.echo("all good")

Once the package is installed in the same virtual environment as Flask, flask bookshelf-doctor runs the command. The next block proves it without installing anything: it writes the metadata an installed package would have (a .dist-info directory with entry_points.txt) into a temporary directory and puts that directory on PYTHONPATH of a real flask process.

plugin_dir = root / "plugin_site"
(plugin_dir / "bookshelf_tools").mkdir(parents=True)
(plugin_dir / "bookshelf_tools" / "__init__.py").write_text("")
(plugin_dir / "bookshelf_tools" / "commands.py").write_text(
    "import click\n\n@click.command()\ndef doctor():\n    \"\"\"Check the Bookshelf environment.\"\"\"\n    click.echo('all good')\n"
)
dist_info = plugin_dir / "bookshelf_tools-0.1.dist-info"
dist_info.mkdir()
(dist_info / "METADATA").write_text("Metadata-Version: 2.1\nName: bookshelf-tools\nVersion: 0.1\n")
(dist_info / "entry_points.txt").write_text("[flask.commands]\nbookshelf-doctor = bookshelf_tools.commands:doctor\n")

nowhere = project("nowhere", {})  # a directory with no application at all
code, out = flask("bookshelf-doctor", cwd=nowhere, env={"PYTHONPATH": str(plugin_dir)})
assert code == 0 and out == "all good"
print(out)
code, out = flask("--help", cwd=nowhere, env={"PYTHONPATH": str(plugin_dir)})
assert "bookshelf-doctor" in out
all good

Extensions use the two mechanisms. Flask-Migrate registers its db group on app.cli when you call Migrate(app, db) or init_app, so flask db exists only for apps that configured it; packages such as Flask-Limiter and APIFlask publish entry points, which is why limiter and spec appear in the flask --help of an environment that has them installed. Plugin commands are added after the built-in ones and share one namespace with them, so a plugin command named like a built-in (run, shell, routes) replaces it; choose distinctive names.

Custom Scripts With FlaskGroup

The flask script is generic. A project that has a single way to build its app can ship its own console script that is a FlaskGroup bound to a factory, so users never type --app and the project can add commands that belong to the script rather than to the app. The group is declared with @click.group(cls=FlaskGroup, create_app=...). The factory is called without arguments (Flask has not passed a script_info argument since 2.1).

FlaskGroup parameter Meaning

create_app

Callable that returns the application, used instead of --app.

add_default_commands

When False, run, shell and routes are not added. Default True.

add_version_option

Adds --version. Default True.

load_dotenv

Load .env and .flaskenv. Default True.

set_debug_flag

Set app.debug from FLASK_DEBUG after loading. Default True.

Commands defined on the group itself (@cli.command()) are plain Click commands without an application context; the commands of app.cli are still merged in, so the script exposes both. A FlaskGroup can also be nested as a sub-command of a larger Click program (Flask 2.2).

from click.testing import CliRunner
from flask.cli import FlaskGroup


def make_bookshelf():
    app = Flask("bookshelf")

    @app.cli.command("seed")
    def seed():
        """Seed the catalogue."""
        click.echo(f"seeding {current_app.name}")

    return app


@click.group(cls=FlaskGroup, create_app=make_bookshelf, add_version_option=False, load_dotenv=False)
def bookshelf():
    """Management script for the Bookshelf."""


@bookshelf.command()
def about():
    """Prints a line; has no application context."""
    click.echo("Bookshelf management script")


runner = CliRunner()
result = runner.invoke(bookshelf, ["about"])
assert result.output == "Bookshelf management script\n"
result = runner.invoke(bookshelf, ["seed"])
assert result.output == "seeding bookshelf\n"
result = runner.invoke(bookshelf, ["--help"])
print("\n".join(line for line in result.output.splitlines() if line.startswith("  ") and line.split()[0] in {"about", "seed", "routes", "run", "shell"}))


@click.group()
def ops():
    """Umbrella script."""


ops.add_command(bookshelf, "shelf")  # FlaskGroup as a sub-command of another group
assert runner.invoke(ops, ["shelf", "seed"]).output == "seeding bookshelf\n"

The relevant lines of the listing (an environment with Flask extensions that publish entry points shows more):

  about    Prints a line; has no application context.
  routes   Show the routes for the app.
  run      Run a development server.
  seed     Seed the catalogue.
  shell    Run a shell in the app context.

The script is published as a console script, and installing the project makes the command available:

[project.scripts]
bookshelf = "bookshelf.cli:bookshelf"
pip install -e .
bookshelf run --debug
bookshelf seed

Two warnings. The group still accepts --app, so a user can point the script at another app by accident; pass add_default_commands=False and handle run yourself if that is a problem. And, as the official documentation says, an error in module-level code of a custom script breaks the reloader, because the reloader can no longer load the entry point; the plain flask command, which lives outside your code, does not have that problem, so it is recommended for most projects.

Testing Commands

app.test_cli_runner() returns a FlaskCliRunner, which extends Click’s CliRunner. Its invoke() runs a command in the same process, with captured output and no real terminal, and returns a Result. Without a cli argument it invokes app.cli, with a ScriptInfo that returns the app under test, so there is no --app and no import by name. Attributes of the result that tests use:

Attribute Content

exit_code

0 on success, 1 for ClickException, Abort or an exception, 2 for a usage error.

output

Standard output and standard error as the user would see them, interleaved.

stdout, stderr

The two streams separately (Click 8.2 and newer keep both).

exception

The exception raised by the command, or SystemExit; invoke(..., catch_exceptions=False) lets exceptions propagate instead, which gives better tracebacks in a failing test.

Parameters worth knowing: args (a list or a string), input (text for prompts and confirmations), env (variables set during the call) and auto_envvar_prefix (see above). The built-in commands are reachable by passing a FlaskGroup as the first argument, as the routes example did. A pytest fixture keeps tests short:

import pytest


@pytest.fixture
def app(tmp_path):
    app = create_app(str(tmp_path / "test.db"))
    app.config.update(TESTING=True)
    return app


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


def test_add_book_and_count(runner):
    result = runner.invoke(args=["add-book", "Emma", "--author", "Jane Austen"])
    assert result.exit_code == 0
    assert "Added 'Emma'" in result.output
    assert runner.invoke(args=["count-books"]).output == "1 books\n"


def test_missing_author_is_a_usage_error(runner):
    result = runner.invoke(args=["add-book", "Emma"])
    assert result.exit_code == 2
    assert "--author" in result.output


def test_usage_errors_are_exit_codes(runner):
    result = runner.invoke(args=["add-book"], catch_exceptions=False)
    assert result.exit_code == 2  # usage errors are not exceptions: Click turns them into an exit code


# a minimal pytest-free run of the same tests, so that the block is self-checking
import tempfile

for test in (test_add_book_and_count, test_missing_author_is_a_usage_error, test_usage_errors_are_exit_codes):
    scratch = pathlib.Path(tempfile.mkdtemp())
    test(create_app(str(scratch / "test.db")).test_cli_runner())
print("3 passed")
3 passed

Pitfalls: the mix_stderr argument of CliRunner that older tutorials pass was removed in Click 8.2, so app.test_cli_runner(mix_stderr=False) now raises TypeError; read result.stderr instead. State left by one test (a database file, an environment variable) leaks into the next unless the fixture isolates it (tmp_path, monkeypatch). invoke() runs in the test’s process, so it cannot catch problems that only exist at start-up (a bad --app, a broken import): add one or two smoke tests that run python -m flask --app bookshelf routes in a subprocess, as the discovery examples on this page do. The test client and the other test helpers are covered in Application & Request Lifecycle (contexts in tests); a page on testing Flask applications comes later in this section.

Running Commands in Jobs and Containers

Custom commands are the supported way to run maintenance work (purging expired rows, importing a feed, sending a digest) because they use the exact configuration and extensions of the web application. A scheduler only has to run the command and look at its exit code:

flask --app bookshelf books purge --yes

In a container, run the same command as a one-off process of the image, not by opening a shell in the running web container: docker run --rm bookshelf-image flask --app bookshelf books purge --yes, or a Kubernetes Job or CronJob whose container command is that line. The Docker and Kubernetes sections of this site explain images, jobs and cron schedules (Docker, Kubernetes). Use --yes (or an option that defaults to the safe answer) for any confirmation prompt, since a job has no terminal, and make sure the command exits non-zero on failure so that the scheduler can retry or alert. Database migrations are run the same way (flask --app bookshelf db upgrade) as a separate job before the new web pods start. Work that must run on request, retry and scale on its own belongs in a task queue rather than in the CLI; the Celery page of this section covers it.

IDE Run Configurations

An IDE has to start the flask command the same way a terminal does, so every configuration comes down to "run the module flask with these parameters, in this directory, with these environment variables".

PyCharm. PyCharm Professional has a Flask run configuration. Community Edition (and any command other than run) uses a generic Python configuration: choose Run, Edit Configurations, click + and pick Python, switch the Script path drop-down to Module name and enter flask, and put the command in Parameters, for example --app bookshelf run --debug. Copy the configuration and change the parameters to run shell, routes or your own commands. If the project is installed as a package in the virtual environment, uncheck the PYTHONPATH options so the run matches deployment.

Visual Studio Code. The Python debugger extension generates a "Flask" launch configuration that runs the module and sets the app in env. The --no-debugger and --no-reload flags matter: the IDE debugger and Werkzeug’s own debugger and reloader (which restarts the process and would detach the IDE’s debugger) are two separate mechanisms, and only one of them should be active.

{
    "name": "Flask: Bookshelf",
    "type": "debugpy",
    "request": "launch",
    "module": "flask",
    "env": {
        "FLASK_APP": "bookshelf",
        "FLASK_DEBUG": "1"
    },
    "args": ["run", "--no-debugger", "--no-reload"],
    "jinja": true,
    "justMyCode": true
}

In both IDEs, a configuration for shell needs an interactive console, so run it from the IDE’s terminal rather than through the debugger. A .flaskenv file holds FLASK_APP for both the IDE and the terminal, so the configuration does not have to repeat it.

Pitfalls and Security

  • The server and the debugger are for development. flask run --debug, app.run(debug=True) and --host 0.0.0.0 together put an interactive Python console on the network. Do not ship them in a Dockerfile, a systemd unit or a tutorial’s "deploy" step; run the production servers described in the deployment page of this section.

  • Secrets stay out of arguments and out of .flaskenv. Command-line arguments are visible to other users in the process list and are kept in the shell history; .flaskenv is committed. Use prompts, environment variables injected by the platform, or an uncommitted .env.

  • Destructive commands ask first. Use click.confirmation_option or click.confirm(abort=True), show the target (database name, environment) in the question, and provide --yes for jobs. Never hardcode a production URL in a command; read it from the configuration.

  • A shell or a command can reach everything the app can. Treat kubectl exec, docker exec and SSH access to the host as production access, and log who ran what.

  • --env-file and .env are code-adjacent. A .env file in a parent directory is picked up when you run flask from a sub-directory, which can load settings from an unrelated project. FLASK_SKIP_DOTENV=1 turns the lookup off.

  • Factories run at import time of the command. Anything the factory connects to (a database, a broker) is contacted by flask --help and flask routes too; keep slow or failing connections out of the factory (lazy connections are the norm for extensions).

What Changed and Where Older Material Is Outdated

Version Change

0.11

The flask script and flask.cli appear, to start the development server through Click.

1.0

FLASK_APP can name a factory (with arguments), .env and .flaskenv are loaded when python-dotenv is installed, flask routes, --cert and --key, and Flask.test_cli_runner are added.

1.1

Blueprints get a cli group; flask run --extra-files is added; the entry point is simplified.

2.0

Better load-error messages, .env files read as UTF-8 and the working directory no longer changed, tab completion and history in flask shell.

2.1

The CLI no longer passes script_info to factories; --exclude-patterns is added; entry points are read with importlib.metadata.

2.2

--app / -A, --debug and --env-file / -e are added; @with_appcontext is no longer required on commands of app.cli and bp.cli; FlaskGroup can be nested; --eager-loading and --lazy-loading are removed; FLASK_ENV and app.env are deprecated. flask run --debug arrives in 2.2.3.

2.3

FLASK_ENV, the ENV config key and app.env are removed.

3.0

--extra-files and --exclude-patterns fixes; --key typo fixed (3.0.1 and 3.0.2).

3.1

-e path takes precedence over .env and .flaskenv; load_dotenv and FlaskGroup accept load_defaults / load_dotenv_defaults; FlaskCliRunner.invoke type hints fixed (3.1.1).

3.2 (not released)

No command-line change is announced as of 2026-10-07.

Source: Flask: Changes

Where the books and older tutorials are out of date:

  • Flask-Script and manage.py. Old books and tutorials add a manage.py with Manager(app) and @manager.command. Flask-Script was last released in 2017 and the book recipes that use it no longer apply; Click commands on app.cli replace it, and flask db (Flask-Migrate) replaced its db sub-commands.

  • export FLASK_APP=... is still honoured but the documented form is flask --app bookshelf run --debug, and a .flaskenv file removes the need for both.

  • FLASK_ENV=development in shells, .flaskenv files and Dockerfiles was removed in 2.3 and does nothing in 3.x. Use --debug or FLASK_DEBUG=1, and never leave debug on in a Dockerfile.

  • @with_appcontext on every command is no longer needed on app.cli and bp.cli commands (2.2); it is only for plain Click commands.

  • flask run --eager-loading / --lazy-loading were removed in 2.2.

  • create_app(script_info) factories, which were passed the ScriptInfo, stopped receiving it in 2.1.

  • CliRunner(mix_stderr=False) was removed in Click 8.2, see Testing Commands.

  • Typing db.create_all() in flask shell, as the SQL chapter of Full-Stack Flask and React does, still works but is a first-run shortcut; the same book’s flask db init, migrate and upgrade flow is current and is described in Database Migrations.

  • Starting the app with python app.py and app.run(debug=True) is still possible, but it skips the CLI, ignores FLASK_RUN_* variables and, in the Docker and deployment chapters of some books, ships the debugger to production.

Summary

You want to Use

Point Flask at the app

flask --app module, flask --app 'module:create_app("dev")', FLASK_APP or a .flaskenv file.

Run the development server

flask --app bookshelf run --debug, with --host, --port, --cert adhoc and FLASK_RUN_PORT as needed.

See the routes

flask routes --sort match.

Explore the data

flask shell, with @app.shell_context_processor for the names you always import.

Add maintenance commands

@app.cli.command(), AppGroup, bp.cli, Click arguments and options, click.confirmation_option.

Ship commands with a package

An entry point in the group flask.commands.

Ship your own script

@click.group(cls=FlaskGroup, create_app=...) and [project.scripts].

Test commands

app.test_cli_runner().invoke(args=[...]), with input= for prompts.