Dev.to WebDev πŸ›  Dev πŸ‘ 1 πŸ“– 9 min read

My FastAPI Dependency Ran Once When I Needed It Twice

I had a FastAPI endpoint that needed two different rate-limit checks: one against the user's account, one against their IP. I wrote a clean little dependency to do a check, declared it twice with different arguments, and

My FastAPI Dependency Ran Once When I Needed It Twice

I had a FastAPI endpoint that needed two different rate-limit checks: one against the user's account, one against their IP. I wrote a clean little dependency to do a check, declared it twice with different arguments, and shipped it. It passed review. It worked in testing.

And in production, one of the two checks just… wasn't happening. Users who should have been blocked by the IP limit were sailing through. The account check fired; the IP check behaved as if it had already run and returned the same answer. I added logging. The dependency function was being called once. I declared it twice. It ran once.

I stared at that for a long time. "I literally wrote Depends() twice. Why is my code only running once?" The answer turned out to be a feature of FastAPI I didn't know existed, documented in one short paragraph I'd never read. This is that paragraph, and the three hours it cost me.

The setup that looked obviously correct

Here's the shape of what I had. A dependency that performs a check and returns a result:

from fastapi import Depends, FastAPI

app = FastAPI()

def rate_limit_check():
    # record a hit, return how many remain, etc.
    result = do_a_check()
    log.info("rate_limit_check ran")   # my debugging line, later
    return result

@app.get("/action")
async def action(
    check_a: dict = Depends(rate_limit_check),
    check_b: dict = Depends(rate_limit_check),   # same dependency, declared again
):
    return {"a": check_a, "b": check_b}

I expected rate_limit_check to run twice, once for check_a and once for check_b. Two checks, two calls. That's what "declare it twice" means, right?

It ran once. check_a and check_b were handed the exact same object: the result of a single call. My "two independent checks" were one check wearing two names. And my log line, "rate_limit_check ran", printed exactly one time per request, which is the clue that finally cracked it.

The thing nobody told me: FastAPI caches dependencies per request

Here's the mechanic, straight from the FastAPI docs, that I'd never read:

If one of your dependencies is declared multiple times for the same path operation… FastAPI will know to call that sub-dependency only once per request. And it will save the returned value in a "cache" and pass it to all the dependants that need it in that specific request, instead of calling the dependency multiple times for the same request.

Read that slowly, because it's the whole bug. FastAPI deliberately caches the result of a dependency within a single request. If the same dependency shows up more than once in one request (declared directly twice like mine, or, far more commonly, because several of your dependencies share a common sub-dependency), FastAPI calls it once, stashes the return value, and hands that same cached value to everyone who asked for it.

And this is, in almost every case, exactly what you want. It's the feature that makes dependency injection efficient. The classic example: many of your endpoints' dependencies all depend on get_current_user, which hits the database. Without caching, one request might run get_current_user five times: five identical database queries for the same user in the same request. With caching, it runs once, and the five dependants share the result. The cache is a performance gift. It turns "every dependency re-does its sub-dependencies" into "shared work happens once."

So FastAPI wasn't broken. It was doing me a favor I hadn't asked for and didn't know about. My two rate-limit checks looked to FastAPI like "the same dependency, twice, in one request," so it ran it once and shared the result, precisely as designed. The feature that saves everyone else a redundant database query had silently collapsed my two checks into one.

Why it was invisible until it wasn't

This is the part that makes the bug so sneaky: the caching is invisible for the overwhelming majority of dependencies, because most dependencies are pure.

A dependency that just reads the current user, or parses a query param, or opens a config: calling it twice vs. once makes no observable difference. Same input, same output, no side effects. For those (which is most of them), the cache is pure upside and you'll never notice it exists.

It only becomes a bug when your dependency has a side effect or is supposed to produce a fresh result each time, exactly my case. A rate-limit check records a hit; running it once instead of twice means one hit recorded instead of two, and one limit enforced instead of two. Other examples that bite the same way:

  • a dependency that generates a fresh token or nonce
  • one that's supposed to re-read mutable state
  • one that increments a counter
  • one that does a time-sensitive re-check

For those, "called once and cached" is wrong, but it looks right in every test where you only exercise one path at a time, and it's completely invisible in the code, because nothing on the page says "this is cached." The caching is a property of the framework, not of your code.

That's the signature of this whole family of bug: the behavior that bites you isn't written anywhere in your file. It's a default the framework applies underneath, silently, and you only discover it exists when it does the wrong thing for your specific case.

The one-keyword fix

Once I understood the cause, the fix was almost insulting in its simplicity. FastAPI gives you a parameter to opt out of the per-request cache: use_cache=False. Set it, and the dependency is called every time it's needed, fresh, no sharing:

from typing import Annotated
from fastapi import Depends, FastAPI

@app.get("/action")
async def action(
    check_a: Annotated[dict, Depends(rate_limit_check, use_cache=False)],
    check_b: Annotated[dict, Depends(rate_limit_check, use_cache=False)],
):
    return {"a": check_a, "b": check_b}

With use_cache=False, rate_limit_check runs twice, once per declaration, exactly as I'd originally assumed it would. Both checks fire, both hits are recorded, both limits enforced. The docs describe this as an "advanced scenario where you know you need the dependency to be called at every step (possibly multiple times) in the same request instead of using the cached value," which is a precise description of a dependency with side effects.

Two notes: the docs recommend the Annotated[...] form over the old = Depends(...) default-argument form, so I've used that above. And honestly, in hindsight, a rate-limit check with a recorded side effect was arguably the wrong thing to model as a cached dependency in the first place. But use_cache=False is the correct, direct fix when you do need a dependency to re-run.

The rule I took away

FastAPI dependencies are cached per request by default. The same dependency declared or reached more than once in a single request runs once, and everyone shares the result. That's a performance feature for pure dependencies, and a silent bug for dependencies with side effects or ones that must produce a fresh value. For those, pass use_cache=False.

The deeper takeaway, the one that outlasts this specific bug: know which of your dependencies are pure and which have side effects.

  • Pure dependencies (read the user, parse a param, load config) love the cache. Let them have it.
  • Side-effecting or must-be-fresh dependencies (record a hit, generate a nonce, increment, re-check mutable state) are the ones where the default caching quietly does the wrong thing.

The moment a dependency does something rather than just computes something, stop and ask: "is it a problem if this runs once and gets shared across the whole request?" If yes, use use_cache=False.

And the usual sting applies: this never showed up in development. One check, one path, one request at a time, and the cache looks identical to no cache when you only ever trigger the dependency once. It took a real endpoint with two declarations of the same dependency, under scrutiny, to reveal that "twice in my code" meant "once at runtime."

How to catch it before it catches you

  1. Audit dependencies that have side effects or must be fresh. Anything that records, increments, generates, sends, or re-reads mutable state: if it can be reached more than once in a request (directly, or as a shared sub-dependency), it's cached, and that may be wrong. Add use_cache=False where re-running matters.
  2. Remember shared sub-dependencies trigger this too, not just declaring a dependency twice. If dependency A and dependency B both depend on C, then C is cached and runs once per request. Usually great; a bug if C has side effects.
  3. Don't model side effects as cached dependencies without thinking. A "check-and-record" is often better as an explicit call inside the endpoint (or a dependency you intend to be use_cache=False), so the behavior is visible rather than relying on a framework default.
  4. Test with the dependency reached multiple times, not once. The bug is invisible to single-invocation tests by its nature. Exercise the path where it's declared or shared more than once.

I added use_cache=False to my two rate-limit checks, watched both log lines finally print, and confirmed the IP limit started blocking the users it should have been blocking all along. One keyword. Three hours. The framework had been helping me the entire time. I just hadn't known it was there.

The 30-second takeaway

  • FastAPI caches a dependency's result per request by default. If the same dependency is declared more than once, or reached via a shared sub-dependency, it runs once, and all dependants get the same cached value.
  • This is a performance feature for pure dependencies (e.g. get_current_user reached by many dependencies runs one DB query instead of five). It's the right default almost always.
  • It becomes a silent bug when a dependency has a side effect (records a hit, increments, sends, generates a token) or must produce a fresh value each time. It runs once when you needed it to run twice, and nothing in your code shows the caching.
  • It's invisible in development: calling a dependency once vs. twice looks identical until the dependency actually needs to re-run under a real multi-declaration path.
  • The fix is one keyword: Depends(my_dep, use_cache=False) forces it to run every time it's needed. Use the Annotated[...] form the docs recommend.
  • The rule: know which dependencies are pure (let them cache) and which have side effects or must be fresh (set use_cache=False). When a dependency does something rather than just computes, ask whether running once-and-shared is a problem.

Open your FastAPI app and find every dependency that records, increments, or generates something. Ask of each one: can this be reached more than once in a request, and is it a problem if it runs only once? If yes, you have the exact bug that cost me an afternoon, and use_cache=False is the one-line fix.

Your turn πŸ‘‡

Have you been bitten by a framework default that silently did the wrong thing? I'd like to hear your stories in the comments.

If this was useful, follow me here for more stories from the backend trenches, and subscribe to The Backend Tech Lead on Substack for weekly blueprints.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.