The idea
A page is more than HTML: it needs a stylesheet, a logo, maybe a script. These files don't change per request, so they're called static files. Flask serves them from a folder named static/ next to your app, with no route to write.
shop/
├── app.py
├── templates/
│ └── base.html
└── static/
├── style.css
└── logo.svg
The static route
Every Flask app automatically has a route /static/<path:filename> that serves files from static/:
from flask import Flask
app = Flask(__name__)
client = app.test_client()
r = client.get("/static/style.css")
print(r.status_code, r.mimetype)
print(r.get_data(as_text=True))
print(client.get("/static/missing.css").status_code)
200 text/css
body { font-family: system-ui, sans-serif; color: #1A1D3A; }
h1 { color: #5669FD; }
404
Flask sets the right Content-Type from the file extension (text/css for .css, image/svg+xml for .svg), which is how the browser knows what it received. A missing file is a normal 404.
Linking static files: url_for("static")
In templates, link static files with url_for("static", filename=...), never a hard-coded path:
from flask import Flask, render_template_string
app = Flask(__name__)
PAGE = """<!doctype html>
<html>
<head><link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"></head>
<body><img src="{{ url_for('static', filename='logo.svg') }}" alt="Toko Kita"><h1>Toko Kita</h1></body>
</html>"""
@app.route("/")
def home():
return render_template_string(PAGE)
print(app.test_client().get("/").get_data(as_text=True))
<!doctype html>
<html>
<head><link rel="stylesheet" href="/static/style.css"></head>
<body><img src="/static/logo.svg" alt="Toko Kita"><h1>Toko Kita</h1></body>
</html>
Why url_for again? Because in production the static folder is often moved: served from /assets/, or from a CDN at another domain, or with a version in the name for caching. Change one setting, and every url_for("static", ...) link follows.
Caching and cache busting
Browsers cache static files so pages load fast. The catch: after you change style.css, visitors may keep the old copy. The usual fix is cache busting: add a version to the URL, so a changed file has a new URL:
from flask import Flask, render_template_string
app = Flask(__name__)
VERSION = "2024.07.1"
with app.test_request_context():
print(render_template_string("{{ url_for('static', filename='style.css', v=version) }}", version=VERSION))
/static/style.css?v=2024.07.1
Extra keyword arguments to url_for become query parameters. Bump VERSION on each release, and every browser fetches the new stylesheet.
Sending files from other places
Static files are public: anyone who guesses the URL gets them. For files that need checks (an invoice for the logged-in customer) or live elsewhere, use send_from_directory in your own route:
from flask import Flask, send_from_directory, abort
app = Flask(__name__)
ALLOWED = {"price-list.pdf", "catalog-2024.pdf"}
@app.route("/downloads/<name>")
def download(name):
if name not in ALLOWED:
abort(404)
return send_from_directory("downloads", name, as_attachment=True)
client = app.test_client()
r = client.get("/downloads/price-list.pdf")
print(r.status_code, r.headers["Content-Disposition"])
print(client.get("/downloads/../app.py").status_code)
200 attachment; filename=price-list.pdf
404
send_from_directory(folder, name)refuses paths that try to escape the folder (../app.py), a classic attack called path traversal.as_attachment=TruesetsContent-Disposition: attachment, so the browser downloads the file instead of opening it.- The allow-list (
ALLOWED) is your own extra check.
Generated files: CSV reports
A route can also return a file it creates on the fly, such as a CSV export. Set the headers yourself:
import csv
import io
from flask import Flask, Response
app = Flask(__name__)
ROWS = [("category", "revenue"), ("Electronics", 5113000), ("Kitchen", 2610000)]
@app.route("/export.csv")
def export():
buffer = io.StringIO()
csv.writer(buffer).writerows(ROWS)
return Response(buffer.getvalue(), mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=revenue.csv"})
r = app.test_client().get("/export.csv")
print(r.mimetype, r.headers["Content-Disposition"])
print(r.get_data(as_text=True))
text/csv attachment; filename=revenue.csv
category,revenue
Electronics,5113000
Kitchen,2610000
Response(body, mimetype=..., headers=...) gives full control over the response, when returning a string isn't enough.
Why this matters for clean code
Treat file locations like any other configuration: never hard-code them in templates. url_for("static", ...) keeps links working when infrastructure changes, send_from_directory keeps downloads safe, and generated files go through one small route with explicit headers. Each piece does one job, in one place.
Recap
- Files in
static/are served at/static/<filename>automatically, with the right Content-Type. - Link them with
url_for("static", filename="..."); extra arguments become query parameters (cache busting). - Static files are public; for checked or private files, write a route with
send_from_directory(folder, name, as_attachment=True). send_from_directoryblocks path traversal; add your own allow-list too.- Return generated files with
Response(body, mimetype=..., headers={"Content-Disposition": ...}).