0
# Static File Serving
1
2
Starlette provides efficient static file serving with automatic content type detection, HTTP caching, range request support, and security features for serving CSS, JavaScript, images, and other static assets.
3
4
## StaticFiles Class
5
6
```python { .api }
7
from starlette.staticfiles import StaticFiles
8
from starlette.responses import Response
9
from starlette.types import Scope
10
from typing import List, Optional
11
from os import PathLike
12
import os
13
14
class StaticFiles:
15
"""
16
ASGI application for serving static files.
17
18
Features:
19
- Automatic content type detection
20
- HTTP caching headers (ETag, Last-Modified)
21
- Range request support for partial content
22
- Directory traversal protection
23
- Index file serving
24
- Package-based file serving
25
"""
26
27
def __init__(
28
self,
29
directory: str | PathLike[str] | None = None,
30
packages: List[str | tuple[str, str]] | None = None,
31
html: bool = False,
32
check_dir: bool = True,
33
follow_symlink: bool = False,
34
) -> None:
35
"""
36
Initialize static file server.
37
38
Args:
39
directory: Directory path to serve files from
40
packages: Python packages to serve files from
41
html: Whether to serve .html files for extensionless URLs
42
check_dir: Whether to check directory exists at startup
43
follow_symlink: Whether to follow symbolic links
44
"""
45
self.directory = directory
46
self.packages = packages or []
47
self.html = html
48
self.check_dir = check_dir
49
self.follow_symlink = follow_symlink
50
51
if check_dir:
52
self.check_config()
53
54
def check_config(self) -> None:
55
"""
56
Validate static files configuration.
57
58
Raises:
59
RuntimeError: If configuration is invalid
60
"""
61
62
def get_directories(
63
self,
64
directory: str | None = None,
65
packages: List[str | tuple[str, str]] | None = None,
66
) -> List[PathLike[str]]:
67
"""
68
Get list of directories to serve files from.
69
70
Args:
71
directory: Override directory
72
packages: Override packages
73
74
Returns:
75
List of directory paths to search for files
76
"""
77
78
def get_path(self, scope: Scope) -> str:
79
"""
80
Get file path from request scope.
81
82
Args:
83
scope: ASGI request scope
84
85
Returns:
86
File path relative to served directories
87
"""
88
89
def lookup_path(self, path: str) -> tuple[str | None, os.stat_result | None]:
90
"""
91
Find file in configured directories.
92
93
Args:
94
path: Relative file path
95
96
Returns:
97
Tuple of (full_path, stat_result) or (None, None) if not found
98
"""
99
100
def get_response(self, path: str, scope: Scope) -> Response:
101
"""
102
Create response for file path.
103
104
Args:
105
path: File path to serve
106
scope: ASGI request scope
107
108
Returns:
109
HTTP response for the file
110
"""
111
112
@staticmethod
113
def file_response(
114
full_path: str | PathLike[str],
115
stat_result: os.stat_result,
116
scope: Scope,
117
status_code: int = 200,
118
) -> Response:
119
"""
120
Create optimized file response.
121
122
Args:
123
full_path: Full path to file
124
stat_result: File statistics
125
scope: ASGI request scope
126
status_code: HTTP status code
127
128
Returns:
129
FileResponse or NotModifiedResponse
130
"""
131
132
@staticmethod
133
def is_not_modified(
134
response_headers: Headers,
135
request_headers: Headers,
136
) -> bool:
137
"""
138
Check if file has been modified based on HTTP headers.
139
140
Args:
141
response_headers: Response headers (ETag, Last-Modified)
142
request_headers: Request headers (If-None-Match, If-Modified-Since)
143
144
Returns:
145
True if file has not been modified
146
"""
147
148
class NotModifiedResponse(Response):
149
"""
150
HTTP 304 Not Modified response.
151
152
Returned when client's cached version is still current
153
based on ETag or Last-Modified headers.
154
"""
155
156
def __init__(self, headers: Mapping[str, str] | None = None) -> None:
157
super().__init__(status_code=304, headers=headers)
158
```
159
160
## Basic Static File Serving
161
162
### Simple Directory Serving
163
164
```python { .api }
165
from starlette.applications import Starlette
166
from starlette.routing import Mount
167
from starlette.staticfiles import StaticFiles
168
169
# Serve files from directory
170
app = Starlette(routes=[
171
Mount("/static", StaticFiles(directory="static"), name="static"),
172
])
173
174
# Directory structure:
175
# static/
176
# ├── css/
177
# │ └── style.css
178
# ├── js/
179
# │ └── app.js
180
# └── images/
181
# └── logo.png
182
183
# URLs will be:
184
# /static/css/style.css -> static/css/style.css
185
# /static/js/app.js -> static/js/app.js
186
# /static/images/logo.png -> static/images/logo.png
187
```
188
189
### Multiple Static Directories
190
191
```python { .api }
192
from starlette.routing import Mount
193
194
app = Starlette(routes=[
195
# Different mount points for different asset types
196
Mount("/css", StaticFiles(directory="assets/css"), name="css"),
197
Mount("/js", StaticFiles(directory="assets/js"), name="js"),
198
Mount("/images", StaticFiles(directory="assets/images"), name="images"),
199
200
# General static files
201
Mount("/static", StaticFiles(directory="static"), name="static"),
202
])
203
```
204
205
### Package-based Static Files
206
207
```python { .api }
208
# Serve files from Python package
209
app = Starlette(routes=[
210
Mount("/assets", StaticFiles(packages=["mypackage"]), name="assets"),
211
])
212
213
# Serve from multiple packages
214
app = Starlette(routes=[
215
Mount("/vendor", StaticFiles(packages=[
216
"bootstrap",
217
"jquery",
218
("mylib", "static"), # Serve mylib/static/ directory
219
]), name="vendor"),
220
])
221
222
# Package structure:
223
# mypackage/
224
# ├── __init__.py
225
# ├── static/
226
# │ ├── style.css
227
# │ └── script.js
228
229
# URLs will be:
230
# /assets/style.css -> mypackage/static/style.css
231
# /assets/script.js -> mypackage/static/script.js
232
```
233
234
## Advanced Configuration
235
236
### HTML File Serving
237
238
```python { .api }
239
# Enable HTML mode for SPA (Single Page Application) support
240
app = Starlette(routes=[
241
Mount("/", StaticFiles(directory="dist", html=True), name="spa"),
242
])
243
244
# With html=True:
245
# /about -> looks for dist/about.html or dist/about/index.html
246
# / -> looks for dist/index.html
247
# /dashboard/users -> looks for dist/dashboard/users.html
248
249
# Without html=True (default):
250
# /about.html -> dist/about.html (exact match only)
251
```
252
253
### Symbolic Link Handling
254
255
```python { .api }
256
# Follow symbolic links (security consideration)
257
app = Starlette(routes=[
258
Mount("/static", StaticFiles(
259
directory="static",
260
follow_symlink=True # Default: False for security
261
), name="static"),
262
])
263
264
# Security note: Only enable follow_symlink if you trust
265
# all symbolic links in the directory tree
266
```
267
268
### Directory Validation
269
270
```python { .api }
271
# Disable directory check at startup (for dynamic directories)
272
app = Starlette(routes=[
273
Mount("/uploads", StaticFiles(
274
directory="user_uploads",
275
check_dir=False # Don't validate directory exists at startup
276
), name="uploads"),
277
])
278
279
# Useful when directory might be created after application starts
280
```
281
282
## URL Generation
283
284
### Generating Static File URLs
285
286
```python { .api }
287
from starlette.routing import Route, Mount
288
from starlette.responses import HTMLResponse
289
290
async def homepage(request):
291
# Generate URLs for static files
292
css_url = request.url_for("static", path="css/style.css")
293
js_url = request.url_for("static", path="js/app.js")
294
image_url = request.url_for("static", path="images/logo.png")
295
296
html = f"""
297
<!DOCTYPE html>
298
<html>
299
<head>
300
<link rel="stylesheet" href="{css_url}">
301
</head>
302
<body>
303
<img src="{image_url}" alt="Logo">
304
<script src="{js_url}"></script>
305
</body>
306
</html>
307
"""
308
return HTMLResponse(html)
309
310
app = Starlette(routes=[
311
Route("/", homepage),
312
Mount("/static", StaticFiles(directory="static"), name="static"),
313
])
314
```
315
316
### Template Integration
317
318
```python { .api }
319
from starlette.templating import Jinja2Templates
320
321
templates = Jinja2Templates(directory="templates")
322
323
async def template_page(request):
324
return templates.TemplateResponse("page.html", {
325
"request": request,
326
"title": "My Page"
327
})
328
329
# In templates/page.html:
330
# <link rel="stylesheet" href="{{ url_for('static', path='css/style.css') }}">
331
# <script src="{{ url_for('static', path='js/app.js') }}"></script>
332
```
333
334
## Caching and Performance
335
336
### HTTP Caching Headers
337
338
Static files automatically include caching headers:
339
340
```python { .api }
341
# Automatic headers for static files:
342
# - ETag: Based on file modification time and size
343
# - Last-Modified: File modification timestamp
344
# - Content-Length: File size
345
# - Content-Type: Detected from file extension
346
347
# Client requests with caching headers:
348
# If-None-Match: "etag-value"
349
# If-Modified-Since: "timestamp"
350
351
# Server responds with:
352
# 304 Not Modified (if unchanged)
353
# 200 OK with file content (if changed)
354
```
355
356
### Cache Control
357
358
```python { .api }
359
from starlette.middleware.base import BaseHTTPMiddleware
360
from starlette.responses import Response
361
362
class CacheControlMiddleware(BaseHTTPMiddleware):
363
"""Add Cache-Control headers to static files."""
364
365
def __init__(self, app, max_age: int = 31536000): # 1 year
366
super().__init__(app)
367
self.max_age = max_age
368
369
async def dispatch(self, request, call_next):
370
response = await call_next(request)
371
372
# Add cache headers for static files
373
if request.url.path.startswith("/static/"):
374
response.headers["Cache-Control"] = f"public, max-age={self.max_age}"
375
376
return response
377
378
# Apply to application
379
app.add_middleware(CacheControlMiddleware, max_age=86400) # 1 day
380
```
381
382
### Content Compression
383
384
```python { .api }
385
from starlette.middleware.gzip import GZipMiddleware
386
387
# Add compression for static files
388
app.add_middleware(GZipMiddleware, minimum_size=500)
389
390
# Compresses CSS, JS, HTML, and other text files
391
# Binary files (images, videos) are not compressed
392
```
393
394
## Security Considerations
395
396
### Path Traversal Protection
397
398
```python { .api }
399
# StaticFiles automatically prevents directory traversal
400
# These requests are automatically rejected:
401
# /static/../../../etc/passwd
402
# /static/..%2F..%2Fetc%2Fpasswd
403
# /static/....//....//etc/passwd
404
405
# Safe paths only:
406
# /static/css/style.css ✓
407
# /static/subdir/file.js ✓
408
```
409
410
### Content Type Validation
411
412
```python { .api }
413
import mimetypes
414
from starlette.staticfiles import StaticFiles
415
from starlette.responses import Response
416
417
class SecureStaticFiles(StaticFiles):
418
"""StaticFiles with content type restrictions."""
419
420
ALLOWED_TYPES = {
421
'text/css',
422
'text/javascript',
423
'application/javascript',
424
'image/png',
425
'image/jpeg',
426
'image/gif',
427
'image/svg+xml',
428
'font/woff',
429
'font/woff2',
430
'application/font-woff',
431
'application/font-woff2',
432
}
433
434
def get_response(self, path: str, scope) -> Response:
435
# Get the normal response
436
response = super().get_response(path, scope)
437
438
# Check content type
439
content_type = response.media_type
440
if content_type not in self.ALLOWED_TYPES:
441
# Return 403 for disallowed file types
442
return Response("Forbidden file type", status_code=403)
443
444
return response
445
446
# Use secure static files
447
app = Starlette(routes=[
448
Mount("/static", SecureStaticFiles(directory="static"), name="static"),
449
])
450
```
451
452
### File Size Limits
453
454
```python { .api }
455
class LimitedStaticFiles(StaticFiles):
456
"""StaticFiles with size limits."""
457
458
def __init__(self, *args, max_size: int = 10 * 1024 * 1024, **kwargs):
459
super().__init__(*args, **kwargs)
460
self.max_size = max_size # 10MB default
461
462
def get_response(self, path: str, scope) -> Response:
463
full_path, stat_result = self.lookup_path(path)
464
465
if stat_result and stat_result.st_size > self.max_size:
466
return Response("File too large", status_code=413)
467
468
return super().get_response(path, scope)
469
```
470
471
## Custom Static File Handlers
472
473
### Custom File Processing
474
475
```python { .api }
476
import os
477
from starlette.staticfiles import StaticFiles
478
from starlette.responses import Response
479
480
class ProcessedStaticFiles(StaticFiles):
481
"""StaticFiles with custom processing."""
482
483
def get_response(self, path: str, scope) -> Response:
484
# Custom processing for CSS files
485
if path.endswith('.css'):
486
return self.process_css_file(path, scope)
487
488
# Custom processing for JS files
489
if path.endswith('.js'):
490
return self.process_js_file(path, scope)
491
492
# Default handling for other files
493
return super().get_response(path, scope)
494
495
def process_css_file(self, path: str, scope) -> Response:
496
"""Process CSS files (minification, variable substitution, etc.)"""
497
full_path, stat_result = self.lookup_path(path)
498
499
if not full_path:
500
return Response("Not found", status_code=404)
501
502
# Read and process CSS
503
with open(full_path, 'r') as f:
504
content = f.read()
505
506
# Custom processing (minification, variable replacement, etc.)
507
processed_content = self.minify_css(content)
508
509
return Response(
510
processed_content,
511
media_type="text/css",
512
headers={
513
"Last-Modified": format_date_time(stat_result.st_mtime),
514
"ETag": f'"{stat_result.st_mtime}-{stat_result.st_size}"',
515
}
516
)
517
518
def minify_css(self, content: str) -> str:
519
"""Basic CSS minification."""
520
# Remove comments
521
import re
522
content = re.sub(r'/\*.*?\*/', '', content, flags=re.DOTALL)
523
# Remove extra whitespace
524
content = re.sub(r'\s+', ' ', content)
525
return content.strip()
526
```
527
528
### Dynamic File Generation
529
530
```python { .api }
531
import json
532
from datetime import datetime
533
from starlette.responses import JSONResponse
534
535
class DynamicStaticFiles(StaticFiles):
536
"""Serve some files dynamically."""
537
538
def get_response(self, path: str, scope) -> Response:
539
# Generate manifest.json dynamically
540
if path == 'manifest.json':
541
return self.generate_manifest(scope)
542
543
# Generate sitemap.xml dynamically
544
if path == 'sitemap.xml':
545
return self.generate_sitemap(scope)
546
547
return super().get_response(path, scope)
548
549
def generate_manifest(self, scope) -> Response:
550
"""Generate web app manifest."""
551
manifest = {
552
"name": "My Web App",
553
"short_name": "MyApp",
554
"start_url": "/",
555
"display": "standalone",
556
"background_color": "#ffffff",
557
"theme_color": "#000000",
558
"generated_at": datetime.now().isoformat(),
559
}
560
561
return JSONResponse(manifest, headers={
562
"Cache-Control": "no-cache" # Don't cache dynamic content
563
})
564
565
def generate_sitemap(self, scope) -> Response:
566
"""Generate XML sitemap."""
567
base_url = f"{scope['scheme']}://{scope['server'][0]}"
568
569
sitemap = f"""<?xml version="1.0" encoding="UTF-8"?>
570
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
571
<url>
572
<loc>{base_url}/</loc>
573
<lastmod>{datetime.now().date()}</lastmod>
574
</url>
575
</urlset>"""
576
577
return Response(
578
sitemap,
579
media_type="application/xml",
580
headers={"Cache-Control": "no-cache"}
581
)
582
```
583
584
## Testing Static Files
585
586
### Testing Static File Serving
587
588
```python { .api }
589
from starlette.testclient import TestClient
590
import os
591
592
def test_static_files():
593
# Create test file
594
os.makedirs("test_static", exist_ok=True)
595
with open("test_static/test.css", "w") as f:
596
f.write("body { color: red; }")
597
598
app = Starlette(routes=[
599
Mount("/static", StaticFiles(directory="test_static"), name="static"),
600
])
601
602
client = TestClient(app)
603
604
# Test file serving
605
response = client.get("/static/test.css")
606
assert response.status_code == 200
607
assert response.text == "body { color: red; }"
608
assert response.headers["content-type"] == "text/css"
609
610
# Test 404 for missing file
611
response = client.get("/static/missing.css")
612
assert response.status_code == 404
613
614
# Test caching headers
615
response = client.get("/static/test.css")
616
assert "etag" in response.headers
617
assert "last-modified" in response.headers
618
619
# Test conditional request
620
etag = response.headers["etag"]
621
response = client.get("/static/test.css", headers={
622
"If-None-Match": etag
623
})
624
assert response.status_code == 304
625
626
def test_url_generation():
627
app = Starlette(routes=[
628
Route("/", lambda r: JSONResponse({"css_url": str(r.url_for("static", path="style.css"))})),
629
Mount("/static", StaticFiles(directory="static"), name="static"),
630
])
631
632
client = TestClient(app)
633
response = client.get("/")
634
assert response.json()["css_url"] == "http://testserver/static/style.css"
635
```
636
637
### Performance Testing
638
639
```python { .api }
640
import time
641
import statistics
642
643
def test_static_file_performance():
644
app = Starlette(routes=[
645
Mount("/static", StaticFiles(directory="static"), name="static"),
646
])
647
648
client = TestClient(app)
649
650
# Measure response times
651
times = []
652
for _ in range(100):
653
start = time.time()
654
response = client.get("/static/large_file.js")
655
end = time.time()
656
657
assert response.status_code == 200
658
times.append(end - start)
659
660
# Check performance metrics
661
avg_time = statistics.mean(times)
662
max_time = max(times)
663
664
assert avg_time < 0.1 # Average under 100ms
665
assert max_time < 0.5 # Max under 500ms
666
```
667
668
Static file serving in Starlette provides efficient, secure, and flexible asset delivery with automatic optimization, caching, and security features suitable for production applications.