Module 4: Background Tasks
BackgroundTasks in FastAPI
Capsule overview
FastAPI ships with a built-in mechanism for running functions after the response has been sent to the client: BackgroundTasks. It's an object you inject as a parameter in your endpoint, you add functions to it with add_task(), and FastAPI runs them automatically once the response has gone out. The client gets their response immediately — the background functions run afterward, without blocking anything.
In this capsule you'll learn the basic use of BackgroundTasks: how to inject it, how to add tasks with arguments, how to add multiple tasks to a single endpoint, and the most common patterns — email simulation, structured logging, and data cleanup. All of it with runnable code you can execute and watch in the server console.
Your first background task
The basic pattern has three steps: define the function, inject BackgroundTasks, and add the task:
from fastapi import FastAPI, BackgroundTasks
import logging
import time
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI()
def send_notification(email: str, message: str):
"""Simulates sending an email (takes 2 seconds)."""
logger.info(f"Sending email to {email}...")
time.sleep(2)
logger.info(f"Email sent to {email}: {message}")
@app.post("/tasks", status_code=201)
def create_task(title: str, background_tasks: BackgroundTasks):
task = {"id": 1, "title": title, "status": "pending"}
background_tasks.add_task(
send_notification,
"team@example.com",
f"New task created: {title}",
)
return task
curl -X POST "http://127.0.0.1:8000/tasks?title=Deploy+v3"
Here's what happens:
- Immediately: the client receives
{"id": 1, "title": "Deploy v3", "status": "pending"} - 2 seconds later: in the server console you see:
INFO: Sending email to team@example.com... INFO: Email sent to team@example.com: New task created: Deploy v3
The client doesn't wait the 2 seconds the email takes. They get their response instantly.
The anatomy of add_task
background_tasks.add_task(function, arg1, arg2, kwarg1=value1)
| Parameter | What it is | Example |
|---|---|---|
function | The function to run | send_notification |
*args | Positional arguments | "team@example.com", "message" |
**kwargs | Keyword arguments | urgent=True, retries=3 |
The function can be sync or async:
import asyncio
def sync_task(data: str):
"""A sync function — runs in a thread pool."""
time.sleep(1)
logger.info(f"Sync task: {data}")
async def async_task(data: str):
"""An async function — runs in the event loop."""
await asyncio.sleep(1)
logger.info(f"Async task: {data}")
@app.post("/example")
def example(background_tasks: BackgroundTasks):
background_tasks.add_task(sync_task, "sync data")
background_tasks.add_task(async_task, "async data")
return {"message": "Tasks added"}
Multiple background tasks
You can add several tasks to a single endpoint. They run sequentially, in the order you added them:
from fastapi import FastAPI, BackgroundTasks
import logging
import time
from datetime import datetime
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI()
def log_event(event: str, details: str):
"""Records an event in the log."""
logger.info(f"[EVENT] {event}: {details}")
def send_email(to: str, subject: str):
"""Simulates sending an email."""
time.sleep(1)
logger.info(f"[EMAIL] To: {to} | Subject: {subject}")
def update_analytics(action: str, resource_id: int):
"""Simulates updating analytics."""
time.sleep(0.5)
logger.info(f"[ANALYTICS] {action} on resource {resource_id}")
@app.post("/tasks", status_code=201)
def create_task(title: str, background_tasks: BackgroundTasks):
task = {"id": 42, "title": title, "status": "pending", "created_at": datetime.now().isoformat()}
background_tasks.add_task(log_event, "TASK_CREATED", f"Task '{title}' created")
background_tasks.add_task(send_email, "team@example.com", f"New task: {title}")
background_tasks.add_task(update_analytics, "create", 42)
return task
After the client gets the response, in the console you see (with the delays):
INFO: [EVENT] TASK_CREATED: Task 'Deploy v3' created
INFO: [EMAIL] To: team@example.com | Subject: New task: Deploy v3
INFO: [ANALYTICS] create on resource 42
The three functions run in order: log → email → analytics.
BackgroundTasks with Depends
BackgroundTasks integrates with dependency injection. You can receive it inside a dependency:
from fastapi import FastAPI, BackgroundTasks, Depends
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI()
def audit_logger(background_tasks: BackgroundTasks):
"""A dependency that adds automatic logging."""
def log_action(action: str, resource_type: str, resource_id: int):
logger.info(f"[AUDIT] {action} {resource_type}:{resource_id}")
def add_audit(action: str, resource_type: str, resource_id: int):
background_tasks.add_task(log_action, action, resource_type, resource_id)
return add_audit
@app.post("/tasks", status_code=201)
def create_task(title: str, audit: callable = Depends(audit_logger)):
task = {"id": 1, "title": title}
audit("CREATE", "task", 1)
return task
@app.delete("/tasks/{task_id}")
def delete_task(task_id: int, audit: callable = Depends(audit_logger)):
audit("DELETE", "task", task_id)
return {"message": f"Task {task_id} deleted"}
The audit_logger dependency receives BackgroundTasks automatically (FastAPI injects it) and returns an add_audit function that adds logs in the background. Every endpoint that uses this dependency gets automatic logging with no repetitive code.
Pattern: email notification (simulated)
The most common use case. It simulates sending an email with logging:
from fastapi import FastAPI, BackgroundTasks
from pydantic import BaseModel, Field
from datetime import datetime
import logging
import time
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
app = FastAPI()
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
assignee_email: str
priority: int = Field(default=1, ge=1, le=5)
def send_task_notification(email: str, task_title: str, task_id: int):
"""Simulates sending an email notification."""
logger.info(f"Preparing email for {email}...")
time.sleep(2)
logger.info(
f"Email sent to {email}: "
f"'You were assigned task #{task_id}: {task_title}'"
)
tasks_db: list[dict] = []
@app.post("/tasks", status_code=201)
def create_task(task: TaskCreate, background_tasks: BackgroundTasks):
new_id = len(tasks_db) + 1
task_data = {
"id": new_id,
"title": task.title,
"assignee_email": task.assignee_email,
"priority": task.priority,
"status": "pending",
"created_at": datetime.now().isoformat(),
}
tasks_db.append(task_data)
background_tasks.add_task(
send_task_notification,
task.assignee_email,
task.title,
new_id,
)
return task_data
curl -X POST http://127.0.0.1:8000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Review PR #42", "assignee_email": "dev@example.com", "priority": 3}'
The response arrives immediately. Two seconds later, the log confirms the "email" went out.
Pattern: structured logging
Record operations with context so you can debug them:
from fastapi import FastAPI, BackgroundTasks, Request
import logging
import json
from datetime import datetime
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("api.audit")
app = FastAPI()
def write_audit_log(
action: str,
resource: str,
resource_id: int,
user_agent: str,
timestamp: str,
):
"""Writes a structured audit log entry."""
log_entry = {
"action": action,
"resource": resource,
"resource_id": resource_id,
"user_agent": user_agent,
"timestamp": timestamp,
}
logger.info(f"AUDIT: {json.dumps(log_entry)}")
@app.post("/tasks", status_code=201)
def create_task(
request: Request,
title: str,
background_tasks: BackgroundTasks,
):
task = {"id": 1, "title": title}
background_tasks.add_task(
write_audit_log,
action="CREATE",
resource="task",
resource_id=1,
user_agent=request.headers.get("user-agent", "unknown"),
timestamp=datetime.now().isoformat(),
)
return task
Pattern: data cleanup
Clean up temporary or expired data after an operation:
from fastapi import FastAPI, BackgroundTasks
import logging
from datetime import datetime, timedelta
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI()
temp_files: list[dict] = [
{"name": "report_old.csv", "created": datetime.now() - timedelta(days=10)},
{"name": "export_recent.csv", "created": datetime.now() - timedelta(hours=2)},
{"name": "backup_old.zip", "created": datetime.now() - timedelta(days=30)},
]
def cleanup_old_files(max_age_days: int = 7):
"""Deletes temporary files older than max_age_days."""
cutoff = datetime.now() - timedelta(days=max_age_days)
old_files = [f for f in temp_files if f["created"] < cutoff]
for f in old_files:
temp_files.remove(f)
logger.info(f"Deleted old file: {f['name']}")
logger.info(f"Cleanup complete: {len(old_files)} files removed, {len(temp_files)} remaining")
@app.post("/reports/generate")
def generate_report(background_tasks: BackgroundTasks):
report = {"name": f"report_{datetime.now().strftime('%Y%m%d')}.csv", "created": datetime.now()}
temp_files.append(report)
background_tasks.add_task(cleanup_old_files, max_age_days=7)
return {"message": "Report generated", "filename": report["name"]}
Every time a new report gets generated, the old files get cleaned up in the background.
BackgroundTasks vs doing everything in the endpoint
To see the difference in timing:
from fastapi import FastAPI, BackgroundTasks
import time
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI()
def slow_operation(label: str):
time.sleep(3)
logger.info(f"Slow operation '{label}' completed")
@app.post("/without-background")
def without_background():
"""Everything synchronous — the client waits."""
start = time.time()
slow_operation("inline")
elapsed = time.time() - start
return {"message": "Done", "response_time_seconds": round(elapsed, 2)}
@app.post("/with-background")
def with_background(background_tasks: BackgroundTasks):
"""The slow operation in the background — the client doesn't wait."""
start = time.time()
background_tasks.add_task(slow_operation, "background")
elapsed = time.time() - start
return {"message": "Done", "response_time_seconds": round(elapsed, 2)}
curl -X POST http://127.0.0.1:8000/without-background
# → {"message": "Done", "response_time_seconds": 3.0} ← 3 seconds of waiting
curl -X POST http://127.0.0.1:8000/with-background
# → {"message": "Done", "response_time_seconds": 0.0} ← immediate
# (3 seconds later, in the console: "Slow operation 'background' completed")
Connection with the project
In the project capsule (05), you'll use BackgroundTasks for three operations: an email notification when tasks are created, audit logging when tasks are modified/deleted, and cleanup of completed tasks. Each one runs after the response without affecting the client's experience.
Troubleshooting
Problem 1: The background task doesn't run
Cause: You're probably not injecting BackgroundTasks as a parameter of the endpoint.
Fix:
# ❌ BackgroundTasks isn't injected — the variable doesn't exist
@app.post("/tasks")
def create_task(title: str):
background_tasks.add_task(...) # NameError
# ✅ Inject it as a parameter
@app.post("/tasks")
def create_task(title: str, background_tasks: BackgroundTasks):
background_tasks.add_task(...)
Problem 2: I don't see the background task's logs
Cause: Logging isn't configured, or the level is too high.
Fix:
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Make sure you're using logger.info(), not print()
# print() may not show up immediately because of buffering
Problem 3: The background task gets the wrong arguments
Cause: add_task passes positional arguments. If you mix up the order, the values arrive wrong.
Fix: Use keyword arguments for clarity:
# ❌ Confusing positionals
background_tasks.add_task(send_email, "subject", "to@email.com")
# ✅ Explicit keywords
background_tasks.add_task(send_email, to="to@email.com", subject="subject")
Problem 4: The server restarts and the tasks are lost
Cause: This is the expected behavior. BackgroundTasks is in-process — if uvicorn restarts (because of --reload or a crash), the pending tasks are lost.
Fix: For tasks that can't be lost, use Celery or RQ (Capsule 04). For development with --reload, this is normal and acceptable.
Problem 5: A background task blocks other requests
Cause: A heavy sync function in the background task can block if the thread pool is full.
Fix: For very heavy work, use an async function or consider Celery:
# ❌ A sync function that blocks for a long time
def heavy_sync_task():
time.sleep(60) # Blocks a pool thread for 60 seconds
# ✅ An async function that doesn't block the thread pool
async def heavy_async_task():
await asyncio.sleep(60) # Blocks no threads
Exercises
Exercise 1: A basic background task with logging (Easy)
Create a POST /orders endpoint that accepts product and customer_email. Return the created order immediately, and in the background log "Processing order of {product} for {customer_email}" with a 1-second delay.
See solution
from fastapi import FastAPI, BackgroundTasks
import logging
import time
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI()
def process_order(product: str, email: str):
time.sleep(1)
logger.info(f"Processing order of {product} for {email}")
@app.post("/orders", status_code=201)
def create_order(product: str, customer_email: str, background_tasks: BackgroundTasks):
order = {"id": 1, "product": product, "customer_email": customer_email, "status": "created"}
background_tasks.add_task(process_order, product, customer_email)
return order
curl -X POST "http://127.0.0.1:8000/orders?product=Laptop&customer_email=ana@test.com"
# → Immediate response
# (1 second later, in the console): INFO: Processing order of Laptop for ana@test.com
Exercise 2: Multiple background tasks (Easy)
Create a POST /users/register endpoint that accepts name and email. After returning the response, run three background tasks: (1) log the registration, (2) send a welcome email (2s delay), (3) add the user to the newsletter list. Each one should log a different message.
See solution
from fastapi import FastAPI, BackgroundTasks
import logging
import time
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI()
def log_registration(name: str):
logger.info(f"[REGISTER] New user registered: {name}")
def send_welcome_email(email: str, name: str):
time.sleep(2)
logger.info(f"[EMAIL] Welcome email sent to {email} for {name}")
def add_to_newsletter(email: str):
logger.info(f"[NEWSLETTER] {email} added to newsletter list")
@app.post("/users/register", status_code=201)
def register_user(name: str, email: str, background_tasks: BackgroundTasks):
user = {"id": 1, "name": name, "email": email, "active": True}
background_tasks.add_task(log_registration, name)
background_tasks.add_task(send_welcome_email, email, name)
background_tasks.add_task(add_to_newsletter, email)
return user
curl -X POST "http://127.0.0.1:8000/users/register?name=Ana&email=ana@test.com"
# → Immediate response
# Console (in order):
# [REGISTER] New user registered: Ana
# [EMAIL] Welcome email sent to ana@test.com for Ana (2s later)
# [NEWSLETTER] ana@test.com added to newsletter list
Exercise 3: A background task with Depends (Medium)
Create a notification_service dependency that receives BackgroundTasks and returns a notify(message, channel) function. The function logs the message along with the channel. Use this dependency in two endpoints: POST /tasks and DELETE /tasks/{id}.
See solution
from fastapi import FastAPI, BackgroundTasks, Depends
import logging
import time
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI()
def send_notification(message: str, channel: str):
time.sleep(0.5)
logger.info(f"[{channel.upper()}] {message}")
def notification_service(background_tasks: BackgroundTasks):
def notify(message: str, channel: str = "general"):
background_tasks.add_task(send_notification, message, channel)
return notify
@app.post("/tasks", status_code=201)
def create_task(title: str, notify=Depends(notification_service)):
task = {"id": 1, "title": title, "status": "pending"}
notify(f"Task created: {title}", "tasks")
return task
@app.delete("/tasks/{task_id}")
def delete_task(task_id: int, notify=Depends(notification_service)):
notify(f"Task #{task_id} deleted", "tasks")
notify(f"Cleanup needed for task #{task_id}", "maintenance")
return {"message": f"Task {task_id} deleted"}
curl -X POST "http://127.0.0.1:8000/tasks?title=Deploy"
# → Immediate response + [TASKS] Task created: Deploy
curl -X DELETE http://127.0.0.1:8000/tasks/1
# → Immediate response + [TASKS] Task #1 deleted + [MAINTENANCE] Cleanup needed...
Exercise 4: Conditional — a background task only if a condition is met (Medium)
Create a PATCH /tasks/{id} endpoint that updates the status. If the new status is "completed", add a background task that logs "Task #{id} completed — notifying the team". If the status isn't "completed", don't add any background task.
See solution
from fastapi import FastAPI, BackgroundTasks, HTTPException
from pydantic import BaseModel
from typing import Optional, Literal
import logging
import time
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI()
tasks_db = [
{"id": 1, "title": "Setup CI/CD", "status": "in_progress"},
{"id": 2, "title": "Write docs", "status": "pending"},
]
class TaskUpdate(BaseModel):
status: Optional[Literal["pending", "in_progress", "completed"]] = None
title: Optional[str] = None
def notify_completion(task_id: int, task_title: str):
time.sleep(1)
logger.info(f"Task #{task_id} '{task_title}' completed — notifying the team")
@app.patch("/tasks/{task_id}")
def update_task(task_id: int, updates: TaskUpdate, background_tasks: BackgroundTasks):
task = next((t for t in tasks_db if t["id"] == task_id), None)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
update_data = updates.model_dump(exclude_unset=True)
was_completed = task["status"] == "completed"
for field, value in update_data.items():
task[field] = value
if task["status"] == "completed" and not was_completed:
background_tasks.add_task(notify_completion, task_id, task["title"])
return task
curl -X PATCH http://127.0.0.1:8000/tasks/1 \
-H "Content-Type: application/json" \
-d '{"status": "completed"}'
# → Immediate response
# 1 second later: Task #1 'Setup CI/CD' completed — notifying the team
curl -X PATCH http://127.0.0.1:8000/tasks/2 \
-H "Content-Type: application/json" \
-d '{"status": "in_progress"}'
# → Immediate response, no background task (it isn't "completed")
Exercise 5: A background task that writes to a file (Hard)
Create a POST /events endpoint that accepts an event_type and details. Return confirmation immediately. In the background, write the event to an events.log file with a timestamp, the type, and the details. Each event is a new line. Check that the file gets updated after every request.
See solution
from fastapi import FastAPI, BackgroundTasks
from pydantic import BaseModel
from datetime import datetime
from pathlib import Path
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI()
LOG_FILE = Path("events.log")
class EventCreate(BaseModel):
event_type: str
details: str
def write_event_to_file(event_type: str, details: str, timestamp: str):
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(f"[{timestamp}] {event_type}: {details}\n")
logger.info(f"Event written to {LOG_FILE}: {event_type}")
@app.post("/events", status_code=201)
def create_event(event: EventCreate, background_tasks: BackgroundTasks):
timestamp = datetime.now().isoformat()
background_tasks.add_task(
write_event_to_file,
event.event_type,
event.details,
timestamp,
)
return {
"message": "Event registered",
"event_type": event.event_type,
"timestamp": timestamp,
}
@app.get("/events/log")
def read_events_log():
if not LOG_FILE.exists():
return {"events": []}
content = LOG_FILE.read_text(encoding="utf-8")
return {"events": content.strip().split("\n") if content.strip() else []}
curl -X POST http://127.0.0.1:8000/events \
-H "Content-Type: application/json" \
-d '{"event_type": "USER_LOGIN", "details": "User ana@test.com logged in"}'
# → Immediate response
curl http://127.0.0.1:8000/events/log
# → {"events": ["[2026-03-13T...] USER_LOGIN: User ana@test.com logged in"]}
Summary
BackgroundTasksis injected as a parameter of the endpoint — FastAPI provides it automaticallyadd_task(func, *args, **kwargs)registers a function to run after the response- Multiple tasks get added with multiple
add_taskcalls — they run sequentially - Sync functions run in a thread pool, async functions run in the event loop
- The client doesn't wait — the response arrives before the background tasks even start
- Common patterns: email notification, audit logging, data cleanup
- With Depends: you can encapsulate background task logic in reusable dependencies
- Conditional tasks: only call
add_taskif a condition is met (e.g. status → completed) - Logging is essential — background tasks run silently; without logging you have no idea whether they failed
Additional resources
- FastAPI - Background Tasks — The official tutorial with complete examples
- Starlette - Background — The underlying implementation of BackgroundTasks
- Python logging - Basic Tutorial — How to configure and use logging in Python
- FastAPI - Dependencies — How to combine BackgroundTasks with Depends
- Real Python - Logging — A practical guide to logging in Python
Next capsule: Patterns and error handling — You'll learn to chain tasks, handle errors in the background without affecting the client, and track the state of your operations.