Disabling 304 Not Modified in FastAPI's StaticFiles

by Christoph Schiessl on Python and FastAPI

I recently encountered a caching problem with FastAPI's StaticFiles sub-application and decided to write a quick article because it turned out to be quite an interesting story after some digging.

Firstly, you need to know that StaticFiles is trying to be quite smart by supporting two approaches to HTTP caching: the Etag header and the Last-Modified header. If you don't remember how these work in detail, you can check out my articles about them.

Secondly, you must know that either is sufficient to make FastAPI's StaticFiles respond with 304 Not Modified. In other words, if it either determines based on the If-None-Match header or based on the If-Modified-Since header that caching is possible, then this is enough for StaticFiles to short-circuit the request and respond with 304 Not Modified.

This is a big problem for certain use cases because the ETag and the Last-Modified response headers are computed from the requested file's modification time, but there's one key difference. The modification time in the Last-Modified response header must be rounded to whole seconds so that it can be formatted according to RFC 5332. However, the ETag response header is just an MD5 hash sum; hence, there's no need for rounding — fractional seconds are taken into consideration.

This can lead to false positives when files are rapidly modified and immediately requested after modification. This is a realistic use case if you have, for instance, a static site generator with a build script that watches for file changes and some sort of auto-reloading mechanism in your browser to immediately reload files as they are being changed.

We can easily demonstrate the problem with a FastAPI application like the following:

import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pathlib import Path

app = FastAPI()

site_app = StaticFiles(directory=Path("_site"), html=True)
app.mount("/", site_app, name="_site")

if __name__ == '__main__':
    uvicorn.run(app=app, port=8000)
$ tree --noreport _site
_site
└── index.html

If you run this program with python app.py, you can send HTTP requests to trigger a false-positive 304 Not Modified response. We still need, though, a small Python program to get a file modification time on disk and format it for use in an If-Modified-Since request header.

import os
import sys
from email.utils import format_datetime
from datetime import datetime, UTC

mtime = datetime.fromtimestamp(os.stat(sys.argv[1]).st_mtime, UTC)
print(format_datetime(mtime, usegmt=True), end="")

This script takes the path to some file as an argument, reads the file's modification time from disk, and then proceeds to use the email.utils module to format the modification time in accordance with RFC 5322.

$ touch _site/index.html && python modification_time.py _site/index.html && echo && \
  touch _site/index.html && python modification_time.py _site/index.html && echo
Sun, 19 May 2024 18:30:15 GMT
Sun, 19 May 2024 18:30:15 GMT

This already demonstrates the core problem: The file _site/index.html was touched twice, but its RFC 5322 formatted modification time is unchanged! That said, we can go one step further and also demonstrate the problem within the context of FastAPI's StaticFiles:

$ touch _site/index.html && \
  export if_modified_since_header="If-Modified-Since: $(python modification_time.py _site/index.html)" && \
  http --verbose GET http://localhost:8000/index.html "$if_modified_since_header" && \
  touch _site/index.html && \
  http --verbose GET http://localhost:8000/index.html "$if_modified_since_header"
GET /index.html HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8000
If-Modified-Since: Thu, 30 May 2024 11:51:26 GMT
User-Agent: HTTPie/3.2.2


HTTP/1.1 304 Not Modified
date: Thu, 30 May 2024 11:51:25 GMT
etag: "7df03c46a66912df39b4e071635dfc6e"
server: uvicorn


GET /index.html HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8000
If-Modified-Since: Thu, 30 May 2024 11:51:26 GMT
User-Agent: HTTPie/3.2.2


HTTP/1.1 304 Not Modified
date: Thu, 30 May 2024 11:51:25 GMT
etag: "7b31b0a2b807845e4e9a147e423ea40f"
server: uvicorn

The second 304 Not Modified response is incorrect because the file has been modified (touch _site/index.html). The reason is that the Last-Modifeid and If-Modified-Since headers use timestamps that are rounded to whole seconds. Hence, the headers do not precisely reflect the latest modification time of the file, which in turn causes StaticFiles to determine that the second 304 Not Modified response is still warranted based on the rounded timestamp. The ETag header isn't suffering from the same problem because it's taking fractional seconds into consideration.

Workaround with Monkey Patching

The easiest solution is to turn off caching entirely in StaticFiles with a monkey patch. This has the effect of modifying the StaticFiles class itself, which can be problematic in cases in which your application uses StaticFiles in multiple places. In other words, you can't turn off caching for particular instances of StaticFiles. It's an all-or-nothing decision — either you monkey patch the class and, thereby, all of its instances, or you don't.

import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pathlib import Path

StaticFiles.is_not_modified = lambda self, *args, **kwargs: False

app = FastAPI()

# The sub-applications mounted on `/site1` and `/site2` never respond with 304 Not Modified ...
app.mount("/site1", StaticFiles(directory=Path("_site"), html=True))
app.mount("/site2", StaticFiles(directory=Path("_site"), html=True))

if __name__ == '__main__':
    uvicorn.run(app=app, port=8000)

The StaticFiles class has a method called is_not_modified() that returns a boolean, determining whether a 304 Not Modified response is possible. For my monkey patch, I'm overwriting this method always to return False (i.e., 304 Not Modified is never possible).

Workaround with Inheritance

A far better approach to solving the problem is inheritance. It's easy to create a subclass of StaticFiles to provide the new behavior (disabled caching), but it doesn't modify the behavior of the StaticFiles class itself. Therefore, you can have the best of both worlds:

import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pathlib import Path

class StaticFilesWithoutCaching(StaticFiles):
    def is_not_modified(self, *args, **kwargs) -> bool:
        return super().is_not_modified(*args, **kwargs) and False

app = FastAPI()

# The sub-application mounted on `/site1` never responds with 304 Not Modified,
# but the sub-application mounted on `/site2` still has the default behavior.
app.mount("/site1", StaticFilesWithoutCaching(directory=Path("_site"), html=True))
app.mount("/site2", StaticFiles(directory=Path("_site"), html=True))

if __name__ == '__main__':
    uvicorn.run(app=app, port=8000)

I'm still calling the original method, though, to play it safe, just in case this method happens to have side effects.

Thank you very much for reading, and see you soon!

Ready to Learn More Web Development?

Join my Mailing List to receive one article per week.


I send one email per week on building performant and resilient Web Applications with Python, JavaScript and PostgreSQL. No spam. Unscubscribe at any time.

Continue Reading?

Here are a few more Articles for you ...


HTTP Caching with Last-Modified and If-Modified-Since Headers

Learn about timestamp-based caching in HTTP using the Last-Modified and If-Modified-Since headers, with Python's FastAPI as an example.

By Christoph Schiessl on Python and FastAPI

HTTP Caching with ETag and If-None-Match Headers

Learn how to use ETag and If-None-Match headers to limit your web application's resource consumption by preventing data retransfers.

By Christoph Schiessl on Python and FastAPI

Showcasing Weak and Strong ETag Headers with FastAPI

Comparison of strong and weak ETag headers in HTTP caching, with an example using FastAPI to demonstrate their behavior.

By Christoph Schiessl on Python and FastAPI

Christoph Schiessl

Hi, I'm Christoph Schiessl.

I help you build robust and fast web applications.


I'm available for hire as a freelance web developer, so you can take advantage of the more than a decade of experience I have collected working on many projects across several industries. Most of my clients are building web-based SaaS applications in a B2B context and depend on my expertise in various capacities.

More often than not, my involvement includes hands-on development work using technologies like Python, JavaScript, and PostgreSQL. Furthermore, if you already have an established team, I can support you as a technical product manager with a passion for simplifying complex processes. Lastly, I'm also an avid writer and educator who takes pride in breaking down technical concepts into the simplest possible terms.