A default that asks where it is running
FastAPI
pull request 16102
changes one default from True to
"auto". Getting that to work needed a function that
collapses the third value immediately, and a fixed stack depth so the
warning blames the right line.
fastapi/fastapi PR 16102 at 7abcdfb, explained 2026-09-01
Background
New to serving a frontend from FastAPI? Expand for the deeper background.
A single-page application is, once built, a folder of files. A
build tool reads your source and writes HTML, JavaScript, and CSS
into an output directory, conventionally named
dist or build.
You can serve that folder from the same FastAPI application that
serves your API, which means one deployment instead of two.
app.frontend() does it:
app = FastAPI()
@app.get("/api/users")
def read_users():
return [{"name": "Ada"}]
app.frontend("/", directory="dist")
The frontend is added as low-priority routes, meaning FastAPI
tries them last. So /api/users still reaches your
endpoint, and everything else falls through to the files in
dist. The class that serves those files is called
_FrontendStaticFiles, and it sits at the bottom of
this story.
One more piece of vocabulary. An APIRouter is a group
of routes you can build separately and attach to an application,
the way a Django or Flask developer would think of a blueprint.
FastAPI holds one internally, so
app.frontend() does its work by calling
router.frontend() on it. Both are public, and both
matter later.
One detail matters for what follows. That last line runs when the
module is imported, at application startup, long before any
request arrives. So FastAPI has a choice: check that
dist exists now, or wait and fail when someone asks
for a file.
Checking now is usually right. A missing directory is a deployment mistake, and a crash at startup is far easier to diagnose than a broken page in production. Programmers call this failing fast.
Before this change, app.frontend() took a boolean, and
it defaulted to checking:
fastapi/applications.py, before the change
check_dir: Annotated[
bool,
Doc(
"""
Check that the frontend directory exists when the app is created.
"""
),
] = True,
The check lives at the bottom of the stack, in
_FrontendStaticFiles, the class that serves the files.
It is five lines, and it raises before the application object
finishes being built.
fastapi/routing.py:1907
if check_dir and not os.path.isdir(directory):
raise RuntimeError(
f"Frontend directory '{directory}' does not exist. "
f"Resolved absolute path: '{_get_resolved_absolute_path(directory)}'"
)
Now consider running that on your laptop. You clone the repository,
create a virtual environment, start the API to work on an endpoint,
and the process refuses to boot, because you have not run the
frontend build and dist does not exist. You did not
want the frontend. You wanted the endpoint.
One workaround is check_dir=False. It also silences the
check in production, where you wanted it, so quieting a local
annoyance costs the safeguard everywhere.
Intuition
The change gives the option a third value. Instead of yes or no, the
default is now "auto", meaning: decide for me, based on
where I am running.
The signal is an environment variable. When
FASTAPI_ENV is "development", a missing
directory produces a warning and the application starts. Anywhere
else, it raises as before.
You do not set that variable yourself. The
fastapi dev command sets it for you, which is what makes
the default useful: run your project the way you already run it
locally, and the friendlier behavior follows. That command ships in a
separate package, and section 6 of the walkthrough returns to what
this repository can and cannot prove about it.
| check_dir | FASTAPI_ENV | Directory | What happens at startup |
|---|---|---|---|
"auto" |
development |
missing | Warning. The application starts |
"auto" |
development |
present | Nothing. The application starts |
"auto" |
anything else, or unset | missing | RuntimeError |
True |
development |
missing | RuntimeError |
False |
any | missing |
Nothing at startup. RuntimeError per request
|
Row four matters most. Passing check_dir=True still
means check, even in development. The convenience is in the default,
and an explicit value always wins.
Row five is worth a second look, because False does not
mean safe. It moves the failure rather than removing it: the
application starts, and then the first request for a file raises
RuntimeError from inside the static-file layer, which
reaches the client as a 500. The test for that behavior is named
test_check_dir_false_allows_missing_directory_and_fails_on_request,
which says it plainly.
What the developer sees
The process exits. To work on an API endpoint you must either build
the frontend or edit the source to pass
check_dir=False.
The server starts. Note the first token: the warning is attributed to
main.py:8, the line in your own file that called
app.frontend(). Getting that attribution right is what
most of this page is about.
Three values in, two values out
A three-valued option is a hazard if the third value is allowed to
travel. Every layer downstream would have to know what
"auto" means. Concretely: the class that serves the
files would read FASTAPI_ENV to decide whether to raise,
and the class above it would read it again to decide whether to warn.
Two readings, two chances to disagree.
So the change resolves it once, immediately, at the public surface.
One small function turns bool | Literal["auto"] into a
plain bool, and everything below it receives a value
that carries no ambiguity.
The one place the third value exists
The type says this out loud. The resolver's return annotation is
bool, so "auto" cannot leave it, and the
three internal layers below now declare
check_dir: bool with no default at all. A caller that
forgets to pass it gets a TypeError rather than a
silent True.
The whole decision, in one picture. Read the top half as the resolver and the bottom half as the layer that mounts the files.
flowchart TD
A["app.frontend(directory='dist')"] --> B{"check_dir is 'auto'?"}
B -->|no. True or False| C["return it unchanged"]
B -->|yes| D{"FASTAPI_ENV is 'development'?"}
D -->|no| E["return True. check the directory"]
D -->|yes| F{"directory exists?"}
F -->|yes| G["return False. nothing to warn about"]
F -->|no| H["warn at stacklevel 3<br/>then return False"]
C --> I["_FrontendStaticFiles"]
E --> I
G --> I
H --> I
I --> J{"check_dir and directory missing?"}
J -->|yes| K["raise RuntimeError"]
J -->|no| L["mount the static files"]
yes branch out of "directory exists?". It
returns False, which switches off the downstream check
even though that check would have passed. The resolver has already
looked at the filesystem itself, so re-checking would be redundant
work with the same answer. Harmless here, and worth noticing: the
return value means "has this been settled", not "does the directory
exist".
Code walkthrough
The flow, end to end: your code calls
app.frontend(), the resolver collapses three values into
two, and a plain boolean travels down four functions to the class
that serves the files, which either raises or mounts them. The chain
is FastAPI.frontend, then
APIRouter.frontend, then
add_frontend_route, then _FrontendRoute,
then _FrontendStaticFiles. Only the last one acts on the
value; the rest pass it along.
Six files changed: two in the library, one the docs, one the tests, and two the dependency pins.
1. The public surface, twice
frontend() exists on both FastAPI and
APIRouter, with the same signature. Both change
identically.
fastapi/applications.py:1249
check_dir: Annotated[ - bool, + bool | Literal["auto"], Doc( """ - Check that the frontend directory exists when the app is created. + Check that the frontend directory exists when the app is created. When + set to `"auto"`, skip the check with a warning when `FASTAPI_ENV` is + `"development"`, and check it otherwise. The `fastapi dev` command + sets `FASTAPI_ENV` to `"development"` if it is not already set. """ ), - ] = True, + ] = "auto",
The same hunk lands on APIRouter.frontend at
fastapi/routing.py:2645. Changing a default is a
compatibility decision, and this one is safe in the direction that
matters: code that passed an explicit
True or False behaves exactly as before,
and code that passed nothing only becomes more permissive, and only
when FASTAPI_ENV says development.
2. The resolver
fastapi/routing.py:1880
def _resolve_frontend_check_dir(
*,
directory: str | os.PathLike[str],
check_dir: bool | Literal["auto"],
) -> bool:
if check_dir != "auto":
return check_dir
if os.environ.get("FASTAPI_ENV") != "development":
return True
if not os.path.isdir(directory):
warnings.warn(
f"Frontend directory '{directory}' does not exist. "
f"Resolved absolute path: '{_get_resolved_absolute_path(directory)}'",
stacklevel=3,
)
return False
Sixteen lines, and each one is an early exit. Read the signature
first: it takes bool | Literal["auto"] and returns
bool. That narrowing is the function's whole purpose. It
converts a wider type into a narrower one, so nothing downstream has
to handle the wider case.
Note how it reads the environment: an inequality against one exact
string. Any other value takes the strict branch, so
FASTAPI_ENV=dev is not development, and neither is
Development. That is a reasonable choice given who
writes the variable, which is a tool rather than a person, though the
code does not say so and I am inferring it.
3. Why calling it twice is safe
FastAPI.frontend resolves, then delegates to
APIRouter.frontend, which resolves again. On the
app.frontend() path the resolver runs twice for one
call.
fastapi/applications.py:1291
+ check_dir = routing._resolve_frontend_check_dir( + directory=directory, check_dir=check_dir + ) self.router.frontend( path, directory=directory, fallback=fallback, check_dir=check_dir,
The first line of the resolver is what makes that harmless. It
returns bool, so the second call receives
True or False, and
check_dir != "auto" sends it straight back out. The
function is idempotent: applying it twice gives the same answer as
applying it once, because every value it can return falls into its
own early-exit branch.
The warning depends on that early return, as the next step shows.
4. Counting frames
A warning is only useful if it points at the line that caused it.
Python's warnings.warn takes a
stacklevel argument saying which frame to blame,
counting from the frame that contains the
warnings.warn call itself as 1. The change passes
stacklevel=3.
So the question is what sits in position 3. Here is the stack when
your code calls app.frontend(), counted the way
stacklevel counts. The highlighted frame is the one
stacklevel=3 selects.
- 1the warn call, inside _resolve_frontend_check_dir routing.py:1890
- 2FastAPI.frontend applications.py:1291
- 3your code main.py:8
And when your code calls router.frontend() instead:
- 1the warn call, inside _resolve_frontend_check_dir routing.py:1890
- 2APIRouter.frontend routing.py:2689
- 3your code main.py:8
Your code lands in position 3 on both paths, so
stacklevel=3 is correct for both. Called from lower
down, in add_frontend_route or below, the distance to
user code would differ by entry point and no single number would
work. That looks like why the resolver is called at the top of each
public method, though the code does not say so.
Now recall step 3. On the app.frontend() path the
resolver is reached a second time, through
APIRouter.frontend. Count that stack the same way:
- 1the warn call, inside _resolve_frontend_check_dir routing.py:1890
- 2APIRouter.frontend routing.py:2689
- 3FastAPI.frontend applications.py:1294
- 4your code main.py:8
One frame deeper, so position 3 is now FastAPI's own source. A
warning from this second call would blame
applications.py instead of your file, and the reader
would get two warnings for one mistake. The early return at
check_dir != "auto" is what stops the second call before
it reaches the warn.
Two tests pin exactly this, one per entry point, and both assert on the filename rather than the message.
tests/test_frontend.py:1197
def test_check_dir_auto_router_warning_points_to_user_code(monkeypatch, tmp_path: Path):
monkeypatch.setenv("FASTAPI_ENV", "development")
router = APIRouter()
with pytest.warns(UserWarning, match="does not exist") as warnings:
router.frontend("/", directory=tmp_path / "missing")
assert warnings[0].filename == __file__
warnings[0] also quietly asserts there is a first
warning to inspect, and the app-path test at
tests/test_frontend.py:1186 makes the same assertion
from the side where a duplicate could appear.
5. Removing a default so it cannot be forgotten
Three internal signatures lose = True. They keep the
type and lose the default.
fastapi/routing.py:1904, :2047, :2121, one line each
- check_dir: bool = True, + check_dir: bool,
All three parameters are keyword-only, marked by the bare
* earlier in each signature, so a call site that omits
check_dir now raises TypeError at call time
instead of quietly defaulting to True. Before the
change, a new internal caller that forgot to thread the resolved
value through would have silently reinstated the strict behavior.
Deleting the default removes that failure mode.
Only the lowest of the three acts on the value.
fastapi/routing.py:1898
class _FrontendStaticFiles(StaticFiles):
def __init__(
self,
*,
directory: str | os.PathLike[str],
fallback: Literal["auto", "index.html", "404.html"] | None,
check_dir: bool,
) -> None:
self.fallback = fallback
if check_dir and not os.path.isdir(directory):
raise RuntimeError(
f"Frontend directory '{directory}' does not exist. "
f"Resolved absolute path: '{_get_resolved_absolute_path(directory)}'"
)
The warning message and the exception message are identical text.
That is what lets the tests match "does not exist"
against both a UserWarning and a
RuntimeError.
6. The dependency floor
pyproject.toml:61
- "fastapi-cli[standard] >=0.0.8", + "fastapi-cli[standard] >=0.0.32",
The whole feature rests on something setting
FASTAPI_ENV=development, and that something is the
fastapi dev command, which lives in the separate
fastapi-cli package. The pull request raises that
package's floor from 0.0.8 to 0.0.32 in all
three extras that include it: standard at line 61,
standard-no-fastapi-cloud-cli at line 80, and
all at line 98. The first two request the matching
fastapi-cli extra; all requests
fastapi-cli[standard], the same one line 61 does.
uv.lock follows, moving the resolved version from
0.0.20 to 0.0.32.
fastapi-cli is an external dependency, and
fastapi/cli.py is thirteen lines that try to import it
and raise a helpful error if it is absent. So the claim that
fastapi dev sets
FASTAPI_ENV appears in this repository only as prose, in
two docstrings and one documentation page. Nothing in the library
sets the variable, and no test here can prove the CLI does. The
version floor is the only enforcement, and it is not testable from
this checkout either. Everything else on this page is verifiable
against the code; that one link in the chain is not.
7. The test that had to change
Four tests are new. One was edited, and it is the interesting one.
tests/test_frontend.py:1179
- app.frontend("/", directory="missing") + app.frontend("/", directory="missing", check_dir=True)
The test asserts that a missing directory raises. It sets no environment variable, so under the new default it takes the non-development branch and still raises. It would still pass.
What the edit buys is independence from the shell. A developer with
FASTAPI_ENV=development exported would get a warning
instead of the exception, and
pytest.raises(RuntimeError) would fail. FastAPI also
sets filterwarnings = ["error"] in its pytest
configuration, so that warning becomes a
UserWarning exception, which is still not the
RuntimeError the test demands. Pinning
check_dir=True makes the test check the explicit
contract it was always about, whatever the environment says.
The four new tests map onto the four branches of the resolver.
| Test | Line | Branch it pins |
|---|---|---|
auto_warns_in_development |
1186 | auto, dev, missing: warns, and blames user code |
auto_router_warning_points_to_user_code |
1197 | the same, through the router entry point |
true_fails_in_development |
1207 | explicit True beats development mode |
auto_fails_outside_development |
1215 |
auto with FASTAPI_ENV=production: still raises
|
The last one sets production rather than leaving the
variable unset. That pins the resolver's treatment of any
non-development value, which an unset variable would not reach: unset
would pass whether the code compared against one exact string or
merely asked whether the variable had a value at all.
8. Documentation
docs/en/docs/tutorial/frontend.md:109
-By default, `app.frontend()` checks that the directory exists when the app is created. +By default, `app.frontend()` uses `check_dir="auto"`. + +When the `FASTAPI_ENV` environment variable is set to `development`, **FastAPI** only +shows a warning if the frontend build output directory is missing. ... + +In any other environment, **FastAPI** raises an error when the app is created. ...
The surrounding section, unchanged, already explained
check_dir=False and why the directory might legitimately
be absent when the application object is built: the frontend is
produced by a separate build step. This change adds the case that
sits between always-check and never-check.
Quiz
Five questions about why the change is shaped the way it is. Click an option to see the answer.
1. FASTAPI_ENV=development, check_dir left
at its default, and the dist directory
does exist. What does the resolver return?
False; the only
difference is whether a warning is emitted on the way. The resolver
has already asked the filesystem, so the downstream check would
repeat that work for the same answer. The return value means "this
is settled", not "the directory exists". And
"auto" can never be returned: the annotation is
bool.
2. Why is the resolver called at the top of both
FastAPI.frontend and
APIRouter.frontend, rather than once, lower down, in
add_frontend_route?
add_frontend_route the distance to user code
differs by entry point, so no single stacklevel could
be right and the warning would point into FastAPI's own source.
add_frontend_route is public, but that is not what
constrains this. The environment is read once either way, and the
ordering does not matter because the second resolver call never
reaches the read.
3. On the app.frontend() path the resolver runs twice
for a single call. In development with a missing directory, why is
there exactly one warning?
applications.py rather than to the
caller, which is what
assert warnings[0].filename == __file__ would catch.
Python's warning registry does dedupe by location, but relying on
that would be relying on a default a caller can change.
4. The three internal signatures went from
check_dir: bool = True to
check_dir: bool. What does deleting the default buy,
given that every existing call site already passes a value?
True would switch development mode off for whichever
path forgot to pass the value, while the other path kept working,
and nothing would point at the omission. The parameters stay
keyword-only, and the narrowed type is what keeps
"auto" out.
5. An alternative design: drop the resolver, and let
_FrontendStaticFiles read FASTAPI_ENV
itself and decide whether to raise or warn. What does that cost?
check_dir would still be honored, since the
class would receive it either way.