5.9.4. Local File Storage
Table of Contents
Why Local File Storage Matters
When you build backend applications, sooner or later you must handle files: user avatars, documents, reports, exports, logs, and more. Local file storage means you save files directly on the server’s filesystem instead of in an external service like Amazon S3.
Local storage is simple, fast to start with, and great for development or small projects. This chapter focuses on how to do it correctly and safely.
Key idea: Local file storage = files stored on the server’s filesystem, referenced in your app by paths or URLs, not stored in the database.
You should already know how to upload and download files in general. Here we focus on how to organize and manage those files on the local filesystem.
Basic Concepts of Local File Storage
Files vs Database Records
Usually, you do not store entire files in your database. Instead, you:
- Store the file content on disk (local storage).
- Store a reference to the file in the database.
Typical database fields:
| Column | Example value | Purpose |
|---|---|---|
id | 42 | Record identifier |
user_id | 10 | Who owns the file |
file_name | report.pdf | Original name |
stored_name | f2b7aafc-9ab1-4b6c-b65b-1c43f5f3.pdf | Unique name on disk |
path | uploads/reports/2026/08/f2b7...3.pdf | Relative path on disk |
size_bytes | 204800 | File size |
mime_type | application/pdf | File type |
created_at | 2026-08-28 12:34:56 | When it was uploaded |
The database knows about the file, but the actual bytes live on disk.
Choosing a Storage Location
Project-relative vs Absolute Paths
You usually configure a base directory for your files, for example:
/my-app/
app/
venv/
uploads/ <-- base storage directory
static/In code you might have:
BASE_DIR = Path(__file__).resolve().parent.parent
UPLOADS_DIR = BASE_DIR / "uploads"
Whenever you save a file, you join UPLOADS_DIR with a relative path such as "avatars/user_10.png".
Avoid hard-coded absolute paths like /home/username/app/uploads inside your code. Instead, use a configuration setting or environment variable such as:
FILE_STORAGE_DIR=/var/myapp/uploadsAnd load it in Python:
import os
from pathlib import Path
UPLOADS_DIR = Path(os.environ.get("FILE_STORAGE_DIR", "uploads"))This makes it easy to change the storage location in different environments (development, staging, production).
Directory Structure and Organization
Local storage can grow messy if you dump everything into a single folder. A good directory structure helps performance and maintainability.
Example Directory Structures
Some common patterns:
| Pattern | Example path | Use case |
|---|---|---|
| By file type | uploads/avatars/123.png | Simple separation by purpose |
| By user | uploads/users/10/avatar.png | User-specific files |
| By date | uploads/2026/08/28/file-uuid.pdf | Spreads files over folders |
| By user and date | uploads/users/10/2026/08/file-uuid.pdf | Large apps with many users |
| By hash prefix | uploads/ab/cd/ef/uuid.jpg | For millions of files |
Why you should avoid huge flat directories
Most filesystems perform badly if you put hundreds of thousands of files in a single directory. To avoid this, you can:
- Create subfolders by date:
uploads/2026/08/28/...- Or by user:
uploads/users/10/...- Or based on a hash:
- If
hash = "ab12cd34", useuploads/ab/12/...
In Python, constructing such paths:
from pathlib import Path
from datetime import datetime
import uuid
def build_user_file_path(user_id: int, original_name: str) -> Path:
today = datetime.utcnow()
extension = Path(original_name).suffix # e.g. ".png"
unique_name = f"{uuid.uuid4()}{extension}"
return Path("uploads") / "users" / str(user_id) / str(today.year) / f"{today.month:02d}" / unique_name
This returns a relative path like:
uploads/users/10/2026/08/550e8400-e29b-41d4-a716-446655440000.png
Generating Safe and Unique Filenames
You should never trust user-provided filenames directly.
Problems with user filenames
- May contain unsafe characters:
../../etc/passwd,evil.php, spaces, strange Unicode. - May conflict with existing files: two users upload
image.png. - May be too long.
Rule: Always sanitize or ignore the original filename and generate your own safe and unique filename.
Strategies for unique filenames
- UUID-based names
import uuid
from pathlib import Path
def generate_unique_name(original_filename: str) -> str:
ext = Path(original_filename).suffix.lower() # keep .png, .jpg, etc.
return f"{uuid.uuid4()}{ext}"- Timestamp + random
import time, secrets
from pathlib import Path
def generate_unique_name_ts(original_filename: str) -> str:
ext = Path(original_filename).suffix.lower()
ts = int(time.time() * 1000)
rand = secrets.token_hex(4)
return f"{ts}-{rand}{ext}"- Hash of content (for deduplication)
You can hash the file content with SHA-256 and use part of that:
import hashlib
from pathlib import Path
def hash_file_content(file_bytes: bytes, original_filename: str) -> str:
ext = Path(original_filename).suffix.lower()
file_hash = hashlib.sha256(file_bytes).hexdigest()
# Use first 16 chars to keep names shorter
return f"{file_hash[:16]}{ext}"Usually you also store the original name separately in the database to show it back to the user.
Writing Files Safely
Creating directories if missing
Before writing a file, ensure its parent directories exist:
from pathlib import Path
def save_bytes_to_path(base_dir: Path, relative_path: Path, content: bytes) -> Path:
full_path = base_dir / relative_path
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_bytes(content)
return full_pathThis method:
- Combines
base_dirandrelative_path. - Creates all missing parent directories.
- Writes the file content.
Streaming uploads instead of loading everything in memory
When files are large, you do not want to load all bytes into memory at once. Most web frameworks give you a file-like object you can stream to disk in chunks:
def save_uploaded_file(base_dir: Path, relative_path: Path, upload_file) -> Path:
full_path = base_dir / relative_path
full_path.parent.mkdir(parents=True, exist_ok=True)
with full_path.open("wb") as f:
for chunk in iter(lambda: upload_file.file.read(1024 * 1024), b""):
f.write(chunk)
return full_pathThis reads at most 1 MB at a time.
Avoiding directory traversal attacks
User input might try to escape your upload directory, for example with ../../etc/passwd. To guard against this, you:
- Generate your own file names and relative paths.
- If you ever use a user-provided path segment, validate it strictly.
Example of validation with Path.resolve:
def is_path_inside_base(base: Path, child: Path) -> bool:
base = base.resolve()
child = child.resolve()
try:
child.relative_to(base)
return True
except ValueError:
return FalseAfter computing a file path, you can assert:
if not is_path_inside_base(UPLOADS_DIR, full_path):
raise ValueError("Invalid file path")Reading and Serving Files
Your application needs to:
- Find the file based on the database record.
- Check permissions: only the owner or allowed users can access it.
- Serve the file over HTTP.
Finding the file
Typical record in your database:
class FileRecord(BaseModel):
id: int
user_id: int
path: str # e.g. "uploads/users/10/2026/08/file-uuid.png"
mime_type: str
original_name: strTo get the full path:
from pathlib import Path
full_path = UPLOADS_DIR.parent / record.path # if path is relative to project rootOr if you store path relative to uploads:
full_path = UPLOADS_DIR / record.path # e.g. "users/10/2026/08/file-uuid.png"Checking permissions
Before serving, check access:
- Is user logged in?
- Does
record.user_id == current_user.id? - Or is user an admin?
If not, return an HTTP 403 Forbidden or 404 Not Found.
Serving the file with correct headers
For example, in FastAPI (only the idea here, details belong to other chapters):
from fastapi.responses import FileResponse
return FileResponse(
path=str(full_path),
media_type=record.mime_type,
filename=record.original_name
)
This sends the file with proper Content-Type and optional Content-Disposition so browsers can display or download it.
Updating and Deleting Files
Replacing a file
When a user uploads a new avatar:
- Save new file to disk.
- Update database record to point to the new path.
- Delete the old file from disk.
Example:
import os
def replace_user_file(base_dir: Path, record, new_content: bytes, new_original_name: str):
old_full_path = base_dir / record.path
# 1. Save new file
new_name = generate_unique_name(new_original_name)
new_rel_path = Path("users") / str(record.user_id) / new_name
new_full_path = save_bytes_to_path(base_dir, new_rel_path, new_content)
# 2. Update database fields
record.path = str(new_rel_path)
record.file_name = new_original_name
# Save record to DB here (omitted)
# 3. Remove old file if it exists
try:
if old_full_path.exists():
old_full_path.unlink()
except OSError:
# Log error, but do not fail user request just because cleanup failed
passDeleting a file
When deleting a record:
- Delete the database record.
- Delete the associated file from disk.
def delete_user_file(base_dir: Path, record):
full_path = base_dir / record.path
# 1. Delete DB record (omitted)
# 2. Delete file
try:
if full_path.exists():
full_path.unlink()
except OSError:
# Log and continue
passYou must decide what happens if the file is missing. Usually you ignore it and log a warning.
Configuration and Environment-specific Paths
Local file storage paths should not be hard-coded in code. Use configuration instead.
Example configuration sources
- Environment variables:
FILE_STORAGE_DIR=/var/myapp/uploads- Config files:
config.toml,settings.yaml, etc.- Framework config:
- FastAPI / Django settings.
Example with environment variables and defaults
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
DEFAULT_UPLOADS_DIR = BASE_DIR / "uploads"
UPLOADS_DIR = Path(os.getenv("FILE_STORAGE_DIR", DEFAULT_UPLOADS_DIR))
In development, you use the default ./uploads. In production, you set FILE_STORAGE_DIR to some directory with enough space.
Security Considerations for Local Storage
Local file storage introduces several security concerns.
Executable files on the server
If a user uploads a file with .py, .php, .sh, etc, and you serve it from a web server that can execute those file types, you can get remote code execution.
To reduce risk:
- Store uploads outside the public web root. Your web server (like Nginx) should not treat uploads as executable scripts.
- Restrict allowed file types (for example only images or PDFs).
- Validate file contents, not just file extension. This is more advanced and is covered in File Validation and File Upload Security chapters.
Path validation
Never allow a user to control the full filesystem path.
Rule: Build all paths from safe base directories and safe, generated names. Never use raw user-provided path strings as filesystem paths.
Permissions on the filesystem
On Linux you can create a dedicated user, for example myapp, and give it write access only to /var/myapp/uploads. If an attacker breaks into the app, they can only modify that directory, not the whole server.
Backups and Local Storage
Files in local storage must be backed up, just like your database.
Common backup approaches:
- Periodically compress
uploads/to tarballs and copy them elsewhere: tar czf uploads-2026-08-28.tar.gz uploads/- Use tools like
rsyncto mirror the uploads directory to a backup server. - Use snapshot features from the underlying storage (e.g. LVM, ZFS).
You also need a plan for restore:
- Given a database backup and an uploads directory backup from the same date, you restore both so that file references still match real files.
Local Storage vs Object Storage (Brief Comparison)
You will learn more about object storage and S3-compatible storage in another chapter. Here, understand quickly how local storage compares.
| Aspect | Local file storage | Object storage (e.g. S3) |
|---|---|---|
| Setup | Very simple, built-in filesystem | Needs external service |
| Scalability | Limited to a single disk or server | Designed to scale |
| Multiple servers | Hard: need shared disk or syncing | Easy: all servers use same bucket |
| Access | Fast local disk access | Network-based, slightly more latency |
| Backups | You manage them manually | Often built-in or easier to integrate |
| Best for | Development, small projects, prototypes | Production, large apps, distributed apps |
Local storage is usually fine for:
- Developer machines.
- Single-server deployments.
- Small internal tools.
As your app grows, you may migrate to an object storage solution.
Example: Simple Local File Storage Helper
Below is a small utility module that summarizes many ideas from this chapter. In a real project you might create something similar.
# file_storage.py
import os
import uuid
from pathlib import Path
from datetime import datetime
from typing import BinaryIO
BASE_DIR = Path(__file__).resolve().parent.parent
DEFAULT_UPLOADS_DIR = BASE_DIR / "uploads"
UPLOADS_DIR = Path(os.getenv("FILE_STORAGE_DIR", DEFAULT_UPLOADS_DIR))
def generate_unique_name(original_filename: str) -> str:
ext = Path(original_filename).suffix.lower()
return f"{uuid.uuid4()}{ext}"
def build_relative_path(user_id: int, original_filename: str) -> Path:
today = datetime.utcnow()
unique_name = generate_unique_name(original_filename)
return Path("users") / str(user_id) / str(today.year) / f"{today.month:02d}" / unique_name
def save_file_stream(user_id: int, original_filename: str, file_obj: BinaryIO) -> str:
rel_path = build_relative_path(user_id, original_filename)
full_path = UPLOADS_DIR / rel_path
full_path.parent.mkdir(parents=True, exist_ok=True)
with full_path.open("wb") as f:
for chunk in iter(lambda: file_obj.read(1024 * 1024), b""):
f.write(chunk)
# Return relative path as string to store in DB
return str(rel_path)
def delete_file(relative_path: str) -> None:
full_path = UPLOADS_DIR / relative_path
try:
if full_path.exists():
full_path.unlink()
except OSError:
# Log in real app
passYour API endpoint would:
- Receive the file from the client.
- Call
save_file_streamwith the user id and original filename. - Store the returned relative path and metadata in the database.
Summary
- Local file storage saves file bytes on the server’s filesystem, with references stored in the database.
- Choose a clear and scalable directory structure, avoid huge flat folders.
- Always generate safe, unique filenames instead of trusting user input.
- Use configuration or environment variables to define the base storage directory.
- Write files safely, creating directories as needed and avoiding directory traversal.
- When serving files, validate permissions and send correct headers.
- Implement file replacement and deletion that update both the database and the filesystem.
- Pay attention to security: do not execute uploaded files, validate paths, restrict file types.
- Always back up both database and file storage together.
- Local storage is great for simpler deployments, but has limits compared to object storage.
Views: 5
KAHIBARO