Getting Started

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 and Click 8.5.0, 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 was executed on Python 3.13. Flask 3.2 has not been released yet, so everything described as "Flask 3.2" below is upcoming and may still change. The page takes you from an empty directory to a running app and explains what each step does and which library does the work underneath: Werkzeug runs the development server and the debugger, Click provides the flask command, and python-dotenv (optional) loads .env files. The app is the first version of the Bookshelf that grows over the following pages of this section.

Create a Project and a Virtual Environment

Every Python project needs its own virtual environment so that its packages do not collide with those of other projects. The mechanics of virtual environments, package managers and pyproject.toml are covered in the Python Reference, in Virtual Environments & Packaging, and are not repeated here. The two common ways to get a Flask project going are the standard library and uv.

With the standard library, as in the official installation guide:

mkdir bookshelf
cd bookshelf
python3 -m venv .venv
. .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install Flask

With uv, which creates the environment in .venv for you, records the versions it resolved in uv.lock and lets you run commands without activating anything:

uv init bookshelf --bare
cd bookshelf
uv add flask
uv run flask --version

flask --version prints the versions of Python, Flask and Werkzeug, which is the first thing to paste into a bug report:

Python 3.13.15
Flask 3.1.3
Werkzeug 3.1.9

Inside Python the supported way to read the installed version is the package metadata. The __version__ attribute of the flask module has been deprecated since Flask 3.0 and Flask 3.2 removes it:

from importlib.metadata import version

print(version("flask"), version("werkzeug"))

Source: Flask: Changes

What pip install Flask Installs

Flask is a thin layer over the Pallets libraries. Installing it pulls in six distributions:

Distribution Role in Flask

Werkzeug

The WSGI toolkit: request and response objects, URL routing, the development server, the interactive debugger and the reloader, password hashing helpers and the test client.

Jinja

The template language behind render_template.

MarkupSafe

Comes with Jinja; its escape function and Markup class are what keep untrusted text from becoming HTML.

ItsDangerous

Signs data cryptographically; it protects the session cookie.

Click

Builds command-line programs; it provides the flask command and the way to add your own commands.

Blinker

Implements the signals Flask sends at points of the request cycle.

Optional Extras

Three optional packages change how the development tools behave. They are not installed by default, and Flask uses them only when it finds them:

Install Provides What changes

pip install "flask[dotenv]"

python-dotenv

The flask command and app.run() load .env and .flaskenv files. Without the package Flask prints a hint that the files are being ignored.

pip install "flask[async]"

asgiref

Views, hooks and error handlers may be coroutine functions (async def). See Async Views.

pip install "werkzeug[watchdog]"

watchdog

A faster, more efficient reloader for the development server. Without it the reloader polls file modification times ("stat" reloader).

The extras are declared in the package metadata: flask declares async (asgiref) and dotenv (python-dotenv), while watchdog is an extra of werkzeug, not of flask. A combined development install, with the quotes that stop zsh from expanding the square brackets, is:

uv add "flask[async,dotenv]" "werkzeug[watchdog]"
# or, with pip:
pip install "flask[async,dotenv]" "werkzeug[watchdog]"

If greenlet-based concurrency is what you want (gevent), that is a different path covered on the async pages of this section; Flask’s own async extra only runs coroutine views on a per-request event loop.

The Minimal App

Save the following as bookshelf.py. Do not name the file flask.py: it would shadow the package and break from flask import Flask.

from flask import Flask, request
from markupsafe import escape

app = Flask(__name__)


@app.route("/")
def index():
    return "<p>Hello, Bookshelf!</p>"


@app.route("/hello")
def hello():
    name = request.args.get("name", "reader")
    return f"<p>Hello, {escape(name)}!</p>"


client = app.test_client()
response = client.get("/")
print(response.status_code, response.content_type)
print(response.get_data(as_text=True))

What the code does:

  • Flask(__name__) creates the WSGI application object. The first argument is the import name of the module or package. Flask uses it to locate resources that live next to the code, such as templates/ and static/, so __name__ is the right value for almost every app.

  • @app.route("/") registers the function below it as the view for the URL rule /. The rule is stored in Werkzeug’s routing map. By default a rule answers GET, and Flask adds HEAD and OPTIONS automatically.

  • A view returns the body. A string becomes an HTML response with status 200; a dict or list becomes JSON; tuples add a status or headers. The full conversion rules belong to the responses pages of this section.

  • app.test_client() returns a Werkzeug test client that calls the app in process, without sockets. It is the quickest way to try a view and the basis of the testing page later in this section.

Running the block prints:

200 text/html; charset=utf-8
<p>Hello, Bookshelf!</p>

Application Discovery with flask --app

The flask command is installed by Flask, not by your project, so it needs to be told which application to load. The option is --app (short form -A) and takes an import string. It has three optional parts: a directory (the working directory is changed to it), a module path or Python file, and the name of an app or factory.

Value Meaning

(nothing)

Import wsgi.py or app.py from the current directory and detect the app or factory inside.

--app bookshelf

Import module bookshelf and detect the app or factory inside.

--app src/bookshelf

Change the working directory to src, then import bookshelf.

--app bookshelf.web

Import the dotted path bookshelf.web.

--app bookshelf:app2

Use the Flask instance named app2 in the module.

--app 'bookshelf:create_app("dev")'

Call the factory create_app with the string "dev". The text in parentheses is parsed as Python literals, so strings keep their quotes; put the whole value in single quotes for the shell.

Detection inside the imported module follows a fixed order: first an instance named app or application, then any single Flask instance (two instances are an error that asks for module:name), and finally a function named create_app or make_app, which is called without arguments. The application factory pattern and why you would want it have their own page, Application Factory & Project Layout.

flask routes is the easiest command to check that discovery worked, because it lists the URL rules of the loaded app:

flask --app bookshelf routes
Endpoint  Methods  Rule
--------  -------  -----------------------
hello     GET      /hello
index     GET      /
static    GET      /static/<path:filename>

The same command can be run in process with the CLI test runner, which is how the output above can be checked in a test. app.test_cli_runner() invokes the commands of app.cli by default; passing a FlaskGroup that wraps the app gives access to the built-in commands such as routes as well:

from flask.cli import FlaskGroup

result = app.test_cli_runner().invoke(cli=FlaskGroup(create_app=lambda: app), args=["routes"])
print(result.output)

If nothing matches, the command stops with Error: Could not locate a Flask application, and a module that imports but contains no instance or factory gives Failed to find Flask application or factory in module. Typical causes are a misspelled module name, running the command from a different directory than the one that holds the file, or a module that defines the app inside a function that is not called create_app or make_app.

The command can also be started as python -m flask --app bookshelf run, which is handy when the flask script is not on PATH or when you want to be sure which interpreter is used.

The Development Server

flask --app bookshelf run
 * Serving Flask app 'bookshelf'
 * Debug mode: off
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit

The log also contains a line that warns you the development server must not be used in production (omitted from the samples on this page). The server is Werkzeug’s run_simple. It handles requests in threads by default, binds to 127.0.0.1 so that only your own machine can reach it, and is explicitly not meant for production: it is not designed to be efficient, stable or secure. The production servers are the topic of the deployment pages of this section.

Open http://127.0.0.1:5000/ in a browser, or call it from a second terminal as shown in Calling the App.

flask run Options

Option Effect

--debug / --no-debug

Switch debug mode on or off. Also valid before the command: flask --app bookshelf --debug run is the same.

-h, --host

Interface to bind to. Default 127.0.0.1. Note that -h means host, not help; the help option is --help.

-p, --port

Port to bind to. Default 5000.

--cert, --key

Serve HTTPS. --cert takes a certificate file, adhoc (a throwaway certificate; needs the cryptography package) or an import path to an ssl.SSLContext; --key is the private key and is required with a certificate file.

--reload / --no-reload

Switch the reloader. The default follows debug mode.

--debugger / --no-debugger

Switch the interactive debugger. The default follows debug mode.

--with-threads / --without-threads

Switch multithreading of the server. On by default.

--extra-files

Extra files that trigger a reload when they change; several paths are separated with : (; on Windows).

--exclude-patterns

fnmatch patterns of files that do not trigger a reload; several patterns are separated with : (; on Windows).

The list matches the output of flask run --help for Flask 3.1.3. Any option can also be set from the environment with the pattern FLASK_COMMAND_OPTION, for example FLASK_RUN_PORT=8000; this is how a .flaskenv file sets defaults (see Environment Variables from .env and .flaskenv).

Starting the Server from Python

Flask.run() takes the same ideas as keyword arguments (host, port, debug and the Werkzeug options such as use_reloader, use_debugger, extra_files, exclude_patterns and passthrough_errors). Keep it behind a main guard so that importing the module from a production server does not start a second server:

# norun
if __name__ == "__main__":
    app.run(debug=True)

Three differences from flask run matter. python bookshelf.py is not the same as flask --app bookshelf run: with app.run() the server crashes if a reload meets a syntax error, whereas flask run keeps serving and shows the error in the debugger. If SERVER_NAME is configured, app.run() takes its default host and port from it. And a call to app.run() made while flask run is in charge is ignored with a message, so a leftover app.run() without a main guard does not start two servers.

Debug Mode

Debug mode is the setting that turns Flask into a comfortable development tool, and the one that must never reach production. Turn it on with --debug:

flask --app bookshelf run --debug
 * Serving Flask app 'bookshelf'
 * Debug mode: on
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit
 * Restarting with watchdog (fsevents)
 * Debugger is active!
 * Debugger PIN: 228-665-527

Debug mode sets app.debug, which is the DEBUG configuration key, and in flask run it switches on two Werkzeug features by default: the reloader and the interactive debugger. It also makes Flask let unhandled exceptions propagate to the server instead of turning them into a plain 500 response, and enables template auto-reload.

The environment variable behind the option is FLASK_DEBUG. Any value except 0, false, no or an empty value turns debug mode on. The sources are applied in this order: the debug argument of app.run() overrides everything else; the --debug option sets FLASK_DEBUG itself before the app loads, so a factory can read it; otherwise the environment variable decides.

The effect on exceptions is easy to see in a test, using two apps that differ only in debug:

def make_app(debug: bool) -> Flask:
    demo = Flask("demo")
    demo.debug = debug

    @demo.route("/boom")
    def boom():
        raise RuntimeError("simulated failure")

    return demo


production_like = make_app(debug=False)
print(production_like.test_client().get("/boom").status_code)

development = make_app(debug=True)
try:
    development.test_client().get("/boom")
except RuntimeError as exc:
    print("propagated:", exc)

The first app answers with 500; the second re-raises the exception, which is what lets the interactive debugger (or your test runner) see the real traceback. Flask also logs the traceback of the first one; that log output is expected.

The Reloader

With the reloader on, Werkzeug restarts the server process whenever a Python file your app imported changes. The flask run command starts a child process that runs the app and a parent that watches it:

  • If watchdog is installed, the reloader uses it, and the startup message says Restarting with watchdog (fsevents) (the name of the backend depends on the operating system). Without it the message says Restarting with stat and modification times are polled.

  • --extra-files adds files that are not imported modules, for example a TOML settings file or a translation catalog, and --exclude-patterns removes files and directories that should be ignored. When a watched file changes the log shows Detected change in '<path>', reloading.

  • Errors on reload are deferred: if you save a syntax error while the server runs, the server keeps running and the next request shows the error in the debugger. An error that already exists when you start flask run stops it at once with the traceback.

flask --app bookshelf run --debug --extra-files settings.toml --exclude-patterns '*.log'

Editing settings.toml while this runs prints Detected change in '.../settings.toml', reloading and restarts the server; writing to a .log file does not. The same behaviour is available from code as the extra_files and exclude_patterns arguments of app.run().

The reloader restarts the process, so state kept in memory (caches, in-process sessions, a global counter) is lost on each reload. That is a feature in development and a reason never to rely on module globals for data.

The Interactive Debugger and Its PIN

When a view raises an exception in debug mode, the server answers with an HTML page that shows the traceback. Each frame of the stack can open a console in that frame’s scope, which lets you inspect variables and run Python code in the server process. That is exactly why the debugger is a remote code execution feature, and why the official documentation says in bold not to run the development server or the built-in debugger in production.

To make it harder to abuse, the console is protected by a PIN. The PIN is printed in the server log (Debugger PIN: 228-665-527) and is derived in a stable way from facts about the machine and the project, so it stays the same across reloads. Entering it in the browser unlocks the consoles. The relevant details from the Werkzeug documentation:

  • WERKZEUG_DEBUG_PIN set to a number chooses the PIN (digits, with or without hyphens); set to off it disables the PIN check. Do not do this on any machine other than your own.

  • Too many wrong guesses lock the debugger until the server restarts.

  • The console only answers requests whose Host is a trusted one: localhost, a .localhost subdomain or 127.0.0.1 by default.

  • The PIN is explicitly "not meant to entirely secure the debugger" but to make attacks harder. Treat the debugger as dangerous even with the PIN.

export WERKZEUG_DEBUG_PIN=123-456-789   # a fixed PIN for local work only
flask --app bookshelf run --debug

Werkzeug accepts digits with or without hyphens as an explicit PIN; any other value is ignored and a PIN is generated as usual.

External Debuggers

IDE debuggers (PyCharm, VS Code) can step through a request before an error happens, which the built-in debugger cannot. Keep debug mode on so that Flask does not hide unhandled errors behind a generic 500 page, but switch off the two Werkzeug features that would compete with the IDE:

flask --app bookshelf run --debug --no-debugger --no-reload

With the built-in debugger off, the development server still catches unhandled exceptions and logs the traceback, answering 500, so it does not crash on the first error. Pass passthrough_errors=True to app.run() only if you really want the exception to reach your debugger and stop the server; the documentation notes that this is usually not what you want. In PyCharm, create a run configuration with module flask and the parameters --app bookshelf run --debug --no-debugger --no-reload.

What Debug Mode Must Never Touch

Never Why

--debug or debug=True on a server that others can reach

Anyone who gets past the PIN executes Python code as the user running the app, and tracebacks leak source and configuration.

--host=0.0.0.0 together with debug mode

It publishes the debugger to the whole network. Flask binds to 127.0.0.1 by default for this reason.

python bookshelf.py with app.run(debug=True) in a Dockerfile or service unit

The development server is single-purpose tooling. Use Gunicorn or Waitress and read FLASK_DEBUG from the environment that you control.

A committed .env that contains FLASK_DEBUG=1

The setting travels with the repository into deployments that load .env.

In production, use error-logging tools and logging as described on the debugging and logging pages, not a debugger.

Host and Port

By default the server listens on 127.0.0.1:5000. Change either with options or environment variables:

flask --app bookshelf run --port 8000
flask --app bookshelf run --host 0.0.0.0 --port 8000    # reachable from the network
FLASK_RUN_PORT=8000 flask --app bookshelf run           # same, from the environment

--host=0.0.0.0 listens on all interfaces, so phones and other computers can reach the app. Do it only when the debugger is off or when you trust everyone on the network.

Port 5000 Is Already in Use

If the port is taken the server stops, with a message like this (Flask 3.1.3, Werkzeug 3.1.9):

Address already in use
Port 5000 is in use by another program. Either identify and stop that program, or start the server with a different port.

Older versions show OSError: [Errno 98] Address already in use or, on Windows, OSError: [WinError 10013]. Either find the process (lsof -P -i :5000 on macOS and Linux, netstat -ano | findstr 5000 on Windows) and stop it, or pick another port with --port.

On macOS, Monterey and later start a service that listens on port 5000: the AirPlay Receiver. A fresh Flask project on a Mac is therefore very likely to hit this error. You can either use another port (--port 5001) or switch the service off by searching for "AirPlay Receiver" in System Settings. Setting FLASK_RUN_PORT in .flaskenv makes the choice stick for the project, which is the better habit.

Environment Variables from .env and .flaskenv

Typing --app bookshelf --debug --port 8000 every time is tedious. When python-dotenv is installed (the dotenv extra), the flask command and app.run() read dotenv files and set environment variables from them:

File Purpose

.flaskenv

Public defaults that belong in the repository: FLASK_APP, FLASK_RUN_PORT.

.env

Private values (secret key, database URL, tokens). It must not be committed.

a file given with -e / --env-file

An extra file for one run, for example .env.local. Needs python-dotenv, otherwise Click reports an error.

A project could use them like this. Notice that the secret key does not appear in the repository files; it is generated once, locally:

# .flaskenv  (committed)
FLASK_APP=bookshelf
FLASK_RUN_PORT=5001

# .env  (listed in .gitignore, never committed)
FLASK_DEBUG=1
FLASK_SECRET_KEY=...paste the output of the command below...
python -c 'import secrets; print(secrets.token_hex())'

The precedence rules, from highest to lowest, are:

  1. a variable already set in the real environment (including one set on the command line);

  2. a file given with -e;

  3. .env;

  4. .flaskenv.

Flask only fills in keys that are not already in os.environ, and it searches for the files upwards from the directory where you call flask. The following example demonstrates the precedence with the same function the command uses, flask.cli.load_dotenv, inside a throwaway directory:

import os
import tempfile
from pathlib import Path

from flask.cli import load_dotenv

with tempfile.TemporaryDirectory() as tmp:
    root = Path(tmp)
    (root / ".flaskenv").write_text("BOOKSHELF_A=flaskenv\nBOOKSHELF_B=flaskenv\nBOOKSHELF_C=flaskenv\nBOOKSHELF_D=flaskenv\n")
    (root / ".env").write_text("BOOKSHELF_B=env\nBOOKSHELF_C=env\nBOOKSHELF_D=env\n")
    (root / "local.env").write_text("BOOKSHELF_C=local\nBOOKSHELF_D=local\n")

    os.environ["BOOKSHELF_D"] = "real environment"
    previous = Path.cwd()
    os.chdir(root)
    try:
        load_dotenv("local.env")
        for key in ("BOOKSHELF_A", "BOOKSHELF_B", "BOOKSHELF_C", "BOOKSHELF_D"):
            print(key, "=", os.environ[key])
    finally:
        os.chdir(previous)
        for key in ("BOOKSHELF_A", "BOOKSHELF_B", "BOOKSHELF_C", "BOOKSHELF_D"):
            os.environ.pop(key, None)

The four lines printed show the winner of each level: BOOKSHELF_A = flaskenv, BOOKSHELF_B = env, BOOKSHELF_C = local and BOOKSHELF_D = real environment.

More details worth knowing:

  • Before Flask 3.1, an explicit -e file did not take precedence over .env and .flaskenv; since 3.1 it does, and load_dotenv(path, load_defaults=False) loads only the given file.

  • Since Flask 2.0 the current directory is no longer changed to the directory of the loaded file, and files are read as UTF-8.

  • The files are loaded by the flask command and by app.run() only. A production server (Gunicorn, Waitress) does not read them. Either call flask.cli.load_dotenv() yourself in the WSGI entry point, or, better in production, let the platform provide real environment variables.

  • FLASK_SKIP_DOTENV=1 disables loading even when python-dotenv is installed, for instance when a project runner has already loaded the files.

  • Any variable named FLASK_ followed by a key can be pulled into the app configuration with app.config.from_prefixed_env(), which strips the prefix. That is how FLASK_SECRET_KEY above becomes SECRET_KEY. The configuration page of this section covers it fully.

import os
import secrets

os.environ["FLASK_SECRET_KEY"] = secrets.token_hex()  # stands in for the real environment
config_demo = Flask("config_demo")
config_demo.config.from_prefixed_env()
print(len(config_demo.config["SECRET_KEY"]))
del os.environ["FLASK_SECRET_KEY"]

Without python-dotenv you can still set variables by exporting them in the activate script of the virtual environment, but the documentation prefers dotenv because .flaskenv can be committed and works wherever the project is checked out.

HTML Escaping

A view that returns a string returns HTML. Any value that came from a user (a query parameter, a form field, a path segment, a database row that a user edited) must be escaped before it is put into that HTML, or an attacker can inject markup and script into your pages. This is cross-site scripting (XSS). In templates, Jinja escapes for you (covered on the template pages), but in a string returned from a view you must do it yourself with MarkupSafe:

attack = "<script>alert(1)</script>"
safe = client.get("/hello", query_string={"name": attack})
print(safe.get_data(as_text=True))
<p>Hello, &lt;script&gt;alert(1)&lt;/script&gt;!</p>

escape is imported from markupsafe. The aliases flask.escape and flask.Markup were deprecated in Flask 2.3 and are gone in Flask 3.0, as is from jinja2 import Markup in Jinja 3.1; books and tutorials that still use these imports fail with ImportError today. Markup marks text as already safe, so wrapping user input in it, or using the safe filter on it, switches the protection off. Use it only on text you produced yourself.

Calling the App

With the server running (see The Development Server), any HTTP client works. curl is installed almost everywhere:

curl -i http://127.0.0.1:5000/
curl "http://127.0.0.1:5000/hello?name=Ada"
curl --get --data-urlencode "name=<b>Ada</b>" http://127.0.0.1:5000/hello
HTTP/1.1 200 OK
Server: Werkzeug/3.1.9 Python/3.13.15
Date: Wed, 07 Oct 2026 13:02:29 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 24
Connection: close

<p>Hello, Bookshelf!</p>

The Server header shows the development server; a production deployment hides or replaces it. The third curl shows escaping at work and prints <p>Hello, <b>Ada</b>!</p>.

HTTPie is friendlier for JSON APIs later in the section. Its == syntax adds a query-string parameter:

http :5000/hello name==Ada

The HTTPie command above is shown for reference and was not run on this page’s check; the curl commands were run against a live flask run server.

Async Views

With the async extra, a view, hook or error handler may be a coroutine function. Flask starts an event loop in a worker, runs the view there and returns the result:

import asyncio

async_app = Flask("async_demo")


@async_app.route("/slow")
async def slow():
    await asyncio.sleep(0.01)
    return {"status": "done"}


async_client = async_app.test_client()
print(async_client.get("/slow").get_json())

Without flask[async] the same code raises a RuntimeError that asks you to install the extra. Async views do not make a WSGI app faster: each request still occupies one worker for its whole duration, and tasks created with asyncio.create_task that are still running when the view returns are cancelled. The page on async, gevent and Quart in this section explains when to choose each.

The Development Loop

The previous sections add up to a short loop. This is what happens between saving a file and seeing the result:

flowchart LR edit["Edit bookshelf.py, templates or .env"] --> watch["Reloader detects the change"] watch --> restart["Parent process restarts the server child"] restart --> request["Browser or curl sends a request"] request --> ok{"Unhandled exception?"} ok -- no --> page["Response shown"] ok -- yes --> dbg["Werkzeug debugger page with traceback and PIN console"] page --> edit dbg --> edit
Figure 1. The development loop of `flask run --debug`

Before the loop starts, the flask command prepares the app in a fixed order, which explains most startup problems:

sequenceDiagram participant U as You participant C as flask command (Click) participant D as dotenv loader participant S as ScriptInfo participant W as Werkzeug server U->>C: flask --app bookshelf run --debug C->>D: load -e file, .env, .flaskenv (if python-dotenv) C->>C: set FLASK_DEBUG from --debug C->>S: remember import string "bookshelf" S->>S: import module, find app or factory C->>W: run_simple(host, port, app, reloader, debugger) W-->>U: Running on http://127.0.0.1:5000
Figure 2. What the `flask` command does before the first request

Reading Companion: The Flaskr Tutorial

The official documentation contains a complete tutorial, Flaskr, a small blog with registration, login, a database, a blueprint per feature, tests and packaging. It is the best companion to this section’s pages if you prefer to read a finished project: it starts with the same flask --app flaskr run --debug loop, uses the application factory, SQLite and Jinja, installs the project with pip install -e . and finishes with tests and deployment. The Bookshelf here is a different, original application, so you can follow both without duplicating work. The tutorial is at Flask: Tutorial.

Pitfalls

Symptom Cause and fix

ImportError: cannot import name 'Flask' from 'flask'

The project contains a file or folder called flask.py or flask/. Rename it.

Error: Could not locate a Flask application

No --app, FLASK_APP, wsgi.py or app.py was found. Add --app, or put FLASK_APP in .flaskenv.

Error: Failed to find Flask application or factory in module

The module has no instance named app or application, no single Flask instance and no create_app or make_app. Use module:name.

Detected multiple Flask applications in module

More than one Flask instance at module level. Use --app module:name.

Changes are not picked up

Debug mode is off, the file is outside the watched modules (use --extra-files), or the pattern is excluded.

.env is ignored and a tip appears in the log

python-dotenv is not installed. Install flask[dotenv].

The server answers on localhost but not from another device

It binds to 127.0.0.1 by default. See Host and Port before using --host=0.0.0.0.

Everything works in development and breaks under Gunicorn

app.run() code or .env loading that exists only in the development path. Production servers import the app object and do not read dotenv files.

Security Notes for This Page

  • The debugger and the development server are for your own machine only; the PIN does not change that.

  • Never commit .env; put secrets in it or in real environment variables and generate them with secrets.token_hex(). The examples on this page contain no real key.

  • Escape every untrusted value you put into an HTML string, and prefer templates, which escape by default.

  • Keep --host at the default unless you have a reason, and never combine 0.0.0.0 with debug mode.

  • Pin your dependencies (uv.lock or a requirements file with versions): Flask 3.1.1 to 3.1.3 were security releases, so staying current matters.

Where Older Books and Tutorials Are Outdated

Most Flask books and tutorials were written for versions before 2.2 and show commands that either still work in a different form or fail today. These are the items that touch getting started; the complete table of old and new code is on the What’s Changed & Migration page of this section.

Older idiom you will meet Status Use instead

export FLASK_APP=app.py then flask run

Still works.

flask --app bookshelf run --debug. --app and a top-level --debug arrived in Flask 2.2.0 (2022-08-01) and the run command’s own --debug option in 2.2.3 (2023-02-15).

FLASK_ENV=development, app.env, the ENV config key

Deprecated in 2.2.0, removed in 2.3.0 (2023-04-25). Setting it silently does nothing.

--debug or FLASK_DEBUG=1. Flask no longer distinguishes "development" from debug mode.

.flaskenv containing FLASK_ENV=development

Ignored since 2.3.

Replace it with FLASK_DEBUG=1 in a local .env, not in a committed file.

flask run --eager-loading or --lazy-loading

Removed in 2.2.0.

Nothing: the app is loaded eagerly once and lazily on reload, and errors are shown immediately.

@with_appcontext on every command of app.cli

No longer needed since 2.2.0.

Plain @app.cli.command(); an app context is already active.

python app.py with app.run(debug=True) in a Dockerfile or in production

Works, but it is unsafe.

A production WSGI server (Gunicorn, Waitress, mod_wsgi, uWSGI) and no debugger.

python setup.py install for the project (the cookbooks)

Legacy packaging.

pyproject.toml and pip install -e ., as the official tutorial does.

virtualenvwrapper

Still works, rarely needed.

python -m venv .venv or uv.

from flask import escape, Markup and from jinja2 import Markup

Removed (Flask 3.0 and Jinja 3.1).

from markupsafe import escape, Markup.

flask.__version__

Deprecated in 3.0, removed in Flask 3.2 (unreleased).

importlib.metadata.version("flask").

Source: Flask: Changes

The Flask Framework Cookbook (both editions) opens with virtual environments, flask run with FLASK_APP, and a configuration chapter that installs the app with setup.py; the Full-Stack Flask and React book starts with a virtual environment, a .flaskenv file and flask run, but its .flaskenv and its Dockerfiles use FLASK_ENV, which is removed in the Flask version that book targets. The commands on this page replace each of those. The configuration loaders (from_pyfile, from_object, from_envvar, class-based configs), the static and instance folders, blueprints and installable packages from the same cookbook chapter belong to later pages of this section.

Recent Changes That Affect Getting Started

Version Change

2.2.0 (2022-08-01)

--app, --debug and --env-file options of the flask command; FLASK_ENV and app.env deprecated; no more @with_appcontext for app.cli commands; --eager-loading removed.

2.2.3 (2023-02-15)

--debug option on flask run itself.

2.3.0 (2023-04-25)

FLASK_ENV, ENV and app.env removed.

3.0.0 (2023-09-30)

__version__ deprecated.

3.1.0 (2024-11-13)

Python 3.8 dropped. -e path takes precedence over .env and .flaskenv; load_dotenv gained load_defaults.

3.1.1 (2025-05-13)

flask --help loads the app and plugins first so that every command is listed.

3.2.0 (unreleased)

Python 3.9 dropped and __version__ removed; IPv6 addresses with a port are parsed correctly in run and in the test client. Check the changelog of the final release before relying on any of this.

Source: Flask: Changes

Where to Go Next

The following pages of this section build on this one: the introduction and architecture page explains what Flask, Werkzeug and Jinja are responsible for; the application factory and configuration pages move this single file into a package with environment-specific settings; and the routing and request pages grow the two example views into the Bookshelf API.