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.
-
FlaskGroupregisters the built-in commandsrun,shellandroutes, three eager options that act before anything else (--env-file,--appand--debug), and--version. -
Commands from installed packages are added from the
flask.commandsentry point group (see Plugins). -
Commands from your application are added from
app.cli, a ClickAppGroupthat everyFlaskobject has. Looking atapp.climeans the app has to be imported first, soflask --helpimports your application to list its commands. -
A
ScriptInfoobject travels in the Click context. It stores the--appstring and loads the application once, so every command of one invocation sees the same app instance.
Source: Flask: Command Line Interface
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:
-
With no
--app(and noFLASK_APPenvironment variable) Flask tries to importwsgi.pyfirst and thenapp.pyfrom the current directory. The first one that imports and contains an application wins. -
The module is searched for an object called
app, then one calledapplication, and then for any singleFlaskinstance among the module’s globals. Two or more candidates are an error that asks formodule:name. -
If there is no instance, Flask looks for a function called
create_appand then one calledmake_appand calls it without arguments. A factory with a required parameter fails with a message that tells you to writemodule:create_app(args). -
With
module:namethe name is a plain attribute (an instance or a function). If it is a function it is called; what it returns must be aFlaskobject. -
With
module:name(args)the text in parentheses is parsed withast.literal_eval, so arguments are Python literals such as"dev"ordebug=True, and a name likelenis rejected. -
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/helloworks 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 --debugstarts 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
appthat points at a Flask object created insideif __name__ == "__main__":is not found, because the module is imported, not run as a script. -
Setting
FLASK_APPin the shell for the whole session is convenient and still supported, but the documented form isflask --app <module> run --debug(Flask 2.2). The older books and tutorials that tell you toexport FLASK_APP=...andexport FLASK_ENV=developmentare covered in What Changed. -
--apphas to come before the command:flask run --app bookshelfis rejected with "No such option" becauserunhas no such option. Only--debugis 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 |
|---|---|---|
|
The application or factory to load, |
2.2 |
|
Loads one more dotenv file. The file must exist. It takes precedence over |
2.2; precedence over the default files since 3.1 |
|
Sets debug mode for any command by writing |
2.2 |
|
Prints the Python, Flask and Werkzeug versions. |
0.12 |
Source: Flask: Command Line Interface
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:
-
Variables already set in the process environment always win; a dotenv file never overwrites them.
-
Among the files,
--env-filebeats.env, which beats.flaskenv. -
.flaskenvis meant to be committed and holds public settings such asFLASK_APP;.envis not committed and holds secrets. -
The files are searched in the current directory and then upwards, so running
flaskfrom 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. -
Only the
flaskcommand andapp.run()load them. A production process started by Gunicorn or Waitress never reads them, so callflask.cli.load_dotenv()yourself or, better, inject real environment variables (the Configuration page shows the options). -
Without python-dotenv the command prints a yellow tip on stderr when it sees the files and loads nothing. With
FLASK_SKIP_DOTENVset 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 value0,falseornomeans "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 |
|---|---|---|
|
|
Interface to bind to. |
|
|
Port to bind to. On recent macOS versions the AirPlay Receiver listens on 5000; see Getting Started. |
|
none |
HTTPS. A certificate file (then |
|
none |
Private key that belongs to |
|
debug mode |
Restart the process when code changes. On by default only when debug is on. |
|
debug mode |
Interactive debugger in the browser on unhandled exceptions. On by default only when debug is on. |
|
threads on |
Handle each request in its own thread. |
|
none |
More files that trigger a reload. Several paths are separated by |
|
none |
|
|
from |
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.FlaskGroupsets the variableFLASK_RUN_FROM_CLIso that a strayapp.run()at module level (not insideif __name__ == "__main__":) cannot start a second server and block the command. Flask prints a red warning that it ignored the call. -
--eager-loadingand--lazy-loadingno 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
Source: Flask API: flask.cli.FlaskGroup
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:
-
The names defined by the file named in the
PYTHONSTARTUPenvironment variable, if it is set (the regular interpreter startup script is honoured). -
Whatever
app.make_shell_context()returns: by defaultappandg, plus every dictionary returned by a function registered with@app.shell_context_processor. -
Tab completion and history when
readlineis 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 shellagainst a productionDATABASE_URLedits production data without review, so read theInstance: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.reloadthe 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 execordocker 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.argumentis positional and required unless given a default;@click.optionis 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.echoinstead ofprint(it handles encodings and redirected output) andclick.echo(..., err=True)for diagnostics. Raiseclick.ClickExceptionfor an expected failure (exit code 1, message prefixed byError:),click.UsageErrorfor bad usage (exit code 2), or callsys.exit(code)orctx.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 withAppGroup, and onbp.cliget the context for free since Flask 2.2, and so do their parameter callbacks. A command built with plain@click.command()needsflask.cli.with_appcontext(shown below). Passwith_appcontext=Falseto anAppGroupcommand()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, becauseteardown_appcontextcloses 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 thecli.pymodule with aninit_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)
Source: Flask API: flask.cli.AppGroup
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 |
|---|---|
|
Callable that returns the application, used instead of |
|
When |
|
Adds |
|
Load |
|
Set |
Source: Flask API: flask.cli.FlaskGroup
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 |
|---|---|
|
|
|
Standard output and standard error as the user would see them, interleaved. |
|
The two streams separately (Click 8.2 and newer keep both). |
|
The exception raised by the command, or |
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
Source: Flask: Command Line Interface
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.0together 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;.flaskenvis committed. Use prompts, environment variables injected by the platform, or an uncommitted.env. -
Destructive commands ask first. Use
click.confirmation_optionorclick.confirm(abort=True), show the target (database name, environment) in the question, and provide--yesfor 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 execand SSH access to the host as production access, and log who ran what. -
--env-fileand.envare code-adjacent. A.envfile in a parent directory is picked up when you runflaskfrom a sub-directory, which can load settings from an unrelated project.FLASK_SKIP_DOTENV=1turns the lookup off. -
Factories run at import time of the command. Anything the factory connects to (a database, a broker) is contacted by
flask --helpandflask routestoo; 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 |
1.0 |
|
1.1 |
Blueprints get a |
2.0 |
Better load-error messages, |
2.1 |
The CLI no longer passes |
2.2 |
|
2.3 |
|
3.0 |
|
3.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 amanage.pywithManager(app)and@manager.command. Flask-Script was last released in 2017 and the book recipes that use it no longer apply; Click commands onapp.clireplace it, andflask db(Flask-Migrate) replaced itsdbsub-commands. -
export FLASK_APP=...is still honoured but the documented form isflask --app bookshelf run --debug, and a.flaskenvfile removes the need for both. -
FLASK_ENV=developmentin shells,.flaskenvfiles and Dockerfiles was removed in 2.3 and does nothing in 3.x. Use--debugorFLASK_DEBUG=1, and never leave debug on in a Dockerfile. -
@with_appcontexton every command is no longer needed onapp.cliandbp.clicommands (2.2); it is only for plain Click commands. -
flask run --eager-loading/--lazy-loadingwere removed in 2.2. -
create_app(script_info)factories, which were passed theScriptInfo, stopped receiving it in 2.1. -
CliRunner(mix_stderr=False)was removed in Click 8.2, see Testing Commands. -
Typing
db.create_all()inflask shell, as the SQL chapter of Full-Stack Flask and React does, still works but is a first-run shortcut; the same book’sflask db init,migrateandupgradeflow is current and is described in Database Migrations. -
Starting the app with
python app.pyandapp.run(debug=True)is still possible, but it skips the CLI, ignoresFLASK_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 |
|
Run the development server |
|
See the routes |
|
Explore the data |
|
Add maintenance commands |
|
Ship commands with a package |
An entry point in the group |
Ship your own script |
|
Test commands |
|
References
Books consulted for the concept narrative (publisher pages only; nothing is copied):