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.

Key concept The same check is right in one place and wrong in another. In production a missing build directory is a deployment failure and should stop the process. On a developer's machine it is Tuesday.

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

Before: fastapi dev, with no frontend built
RuntimeError: Frontend directory 'dist' does not exist. Resolved absolute path: '/home/ada/project/dist'

The process exits. To work on an API endpoint you must either build the frontend or edit the source to pass check_dir=False.

After: fastapi dev, with no frontend built
main.py:8: UserWarning: Frontend directory 'dist' does not exist. Resolved absolute path: '/home/ada/project/dist' INFO: Uvicorn running on http://127.0.0.1:8000

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

your code
"auto"
app.frontend
"auto"
the resolver
False
everything below

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"]
Edge case Follow the 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.

  1. 1the warn call, inside _resolve_frontend_check_dir   routing.py:1890
  2. 2FastAPI.frontend   applications.py:1291
  3. 3your code   main.py:8

And when your code calls router.frontend() instead:

  1. 1the warn call, inside _resolve_frontend_check_dir   routing.py:1890
  2. 2APIRouter.frontend   routing.py:2689
  3. 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:

  1. 1the warn call, inside _resolve_frontend_check_dir   routing.py:1890
  2. 2APIRouter.frontend   routing.py:2689
  3. 3FastAPI.frontend   applications.py:1294
  4. 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.

What this page cannot show you 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.

The transferable part When one option needs different behavior in different environments, the shape that works is a widened type at the public boundary and a resolver that narrows it immediately. Keep the extra value in exactly one place, return the narrow type so it cannot travel, delete the defaults underneath so nobody can skip the resolver by accident, and let an explicit argument always win over the automatic one. Here the logic took sixteen lines. The stack depth took the care.

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?

Both development branches 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?

From 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?

The early return does the work, and this is why idempotence matters here rather than being a tidy property. A second warning would also be attributed to 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?

The payoff is for call sites nobody has written yet. A silent 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?

The first option is a real argument, which is what makes it worth considering. Locality is a virtue, and the original directory check does live in that class. What defeats it is the stack. Attribution depends on the distance to the caller, and the deeper you push a warning the less it can know about who to blame. An explicit check_dir would still be honored, since the class would receive it either way.