17.6. Scheduled Tasks
Table of Contents
Why Scheduled Tasks Matter
In many backend systems, some work should run at a specific time or repeatedly, not directly in response to a user request. These are scheduled tasks.
Typical examples:
- Send a daily summary email to users at 08:00.
- Clean up old sessions every hour.
- Recalculate statistics every night.
- Sync data with an external API every 5 minutes.
- Generate a backup at midnight.
Doing this inside normal HTTP request handlers is a bad idea. An HTTP request is short lived, but these tasks must run reliably and often when no user is online. Scheduled tasks solve this.
Key idea: A scheduled task is a job that runs automatically at a given time or interval, independent of any single HTTP request.
Scheduled tasks are usually built on top of background jobs and workers, which you met in earlier sections. The scheduler decides when to enqueue a job, and the workers decide how to run it.
Types of Scheduled Tasks
Time based vs Event based
There are two broad ways to think about scheduled work:
- Time based scheduling
Run at a specific time or interval. Examples: - Every day at 03:00, run
cleanup_old_orders. - Every 5 minutes, run
sync_with_payment_gateway. - Once at a specific date, send a release announcement.
- Event based scheduling
Triggered by an event, not by the clock: - When a user registers, send a welcome email after 10 minutes.
- When an order is paid, recheck payment status after 1 hour.
This is still a form of scheduling, because you set a task to run in the future, but it is not tied to a fixed time like “every day at midnight.”
Both types can use the same tools. The scheduler library usually supports:
- “Run every N minutes.”
- “Run at 03:00 every day.”
- “Run at this exact timestamp in the future.”
One time vs Recurring
Another useful distinction:
- One time tasks
Scheduled to run once in the future, then never again.
Example: Remind user about an expiring subscription 7 days from now. - Recurring tasks
Scheduled to run repeatedly with a pattern.
Examples: - Every Monday at 09:00.
- Every 10 minutes, forever.
Table comparison:
| Aspect | One time task | Recurring task |
|---|---|---|
| Trigger | Specific date/time once | Interval or cron-like schedule |
| Example | “Run at 2026-12-31 23:59:00” | “Run every day at 04:00” |
| Common use | Reminders, delayed notifications | Maintenance, reports, data sync |
Scheduling with Cron
Before Python tools, it is important to know the classic Unix solution: cron.
What is cron?
On Linux and many Unix systems, cron is a built-in scheduler that runs commands at specified times. A backend application can use cron to:
- Run a script every night.
- Call a management command every hour.
- Trigger backups.
You define tasks in a crontab (cron table).
Basic crontab line format:
* * * * * command to run
| | | | |
| | | | +----- day of week (0-7, Sunday is 0 or 7)
| | | +------- month (1-12)
| | +--------- day of month (1-31)
| +----------- hour (0-23)
+------------- minute (0-59)
Cron pattern:
minute hour day-of-month month day-of-week
Cron timing examples
| Schedule | Cron expression |
|---|---|
| Every minute | * |
| Every day at 03:00 | 0 3 * |
| Every Monday at 09:30 | 30 9 1 |
| Every 15 minutes | /15 * |
| At 00:00 on the 1st of each month | 0 0 1 |
You can test your understanding. For example:
- “Every day at 18:45” →
45 18 * - “Every 5 minutes during working hours (09:00 to 17:59)” →
/5 9-17
Cron then runs the command you specify at those times.
Example: Running a Python script with cron
Imagine you have a Python script:
# cleanup_sessions.sh
#!/usr/bin/env bash
cd /path/to/app
source venv/bin/activate
python -m app.scripts.cleanup_sessionsYou can schedule it:
0 * * * * /path/to/cleanup_sessions.sh >> /var/log/cleanup.log 2>&1This runs the script at the start of every hour and appends output to a log file.
Pros and cons of cron
Pros:
- Already available on most Linux servers.
- Very simple to set up.
- Works with any language or command.
Cons:
- Limited visibility from your Python app, cron is external.
- Harder to scale across many servers safely.
- No built-in retries or job tracking.
- Time zone handling is manual.
Cron is fine for small projects or for system-level tasks like backups. For application-level tasks that need to integrate with your database and message queues, Python based schedulers are usually better.
Scheduling in Python
Instead of system level cron, you can use Python libraries that run inside your application environment. Common patterns:
- Use a task queue (like Celery with Redis) and a scheduler component.
- Use a standalone scheduler library (like APScheduler) to run Python functions at certain times.
Important separation:
Often the scheduler just enqueues jobs, and workers execute jobs.
Do not execute heavy logic directly inside the scheduler loop.
Celery beat: scheduling Celery tasks
If you use Celery for background jobs, Celery beat is the scheduler component.
Architecture:
- You define Celery tasks in your application.
- You define a beat schedule that says when to run which task.
- Celery beat periodically sends tasks to the broker (for example Redis).
- Celery workers pick up and execute those tasks.
Basic Celery task
# tasks.py
from celery import Celery
app = Celery("myapp", broker="redis://localhost:6379/0")
@app.task
def cleanup_sessions():
print("Cleaning up expired sessions...")
# logic hereDefining periodic tasks
You can define schedule in Python:
from celery.schedules import crontab
app.conf.beat_schedule = {
"cleanup-sessions-every-hour": {
"task": "tasks.cleanup_sessions",
"schedule": crontab(minute=0), # every hour at minute 0
},
}Or using intervals:
app.conf.beat_schedule = {
"sync-stats-every-5-minutes": {
"task": "tasks.sync_stats",
"schedule": 300.0, # seconds
},
}Running beat and workers
Typically, you run:
celery -A tasks worker --loglevel=info
celery -A tasks beat --loglevel=infoin two different processes or containers.
This keeps scheduling and execution separated.
Adding one time tasks
Celery can also handle delayed one time tasks, for example:
# schedule to run 10 minutes later
from datetime import timedelta
cleanup_sessions.apply_async(countdown=600)This is more event based, as described earlier.
APScheduler
APScheduler is a pure Python scheduling library. It is often used in:
- Standalone Python services.
- Application servers with a long running process.
It supports multiple trigger types:
- Interval: run every N seconds/minutes/hours.
- Cron: run at times similar to Unix cron.
- Date: one time execution at a specific datetime.
Basic APScheduler example
from datetime import datetime
from apscheduler.schedulers.background import BackgroundScheduler
def send_daily_report():
print(f"Sending daily report at {datetime.utcnow()}")
scheduler = BackgroundScheduler()
# Run every day at 08:00 UTC
scheduler.add_job(
send_daily_report,
trigger="cron",
hour=8,
minute=0
)
scheduler.start()
# Keep your main program alive
try:
while True:
pass
except KeyboardInterrupt:
scheduler.shutdown()
This will call send_daily_report every day at 08:00 UTC.
Trigger types in APScheduler
| Trigger | Description | Example |
|---|---|---|
date | Run once at a given datetime | “At 2026-01-01 00:00” |
interval | Run every N time units | “Every 10 minutes” |
cron | Cron style schedule | “At 03:00 every day” |
Example of an interval job:
# Every 5 minutes
scheduler.add_job(
sync_external_api,
trigger="interval",
minutes=5
)Example of a one time job:
from datetime import datetime, timedelta
run_at = datetime.utcnow() + timedelta(days=7)
scheduler.add_job(
remind_subscription,
trigger="date",
run_date=run_at,
args=[user_id]
)APScheduler with web frameworks
If you run APScheduler inside a web app (for example FastAPI), remember:
- The scheduler must be started once when the app starts.
- The scheduler process must always be running somewhere.
- In multi instance deployments, running the same scheduler in many instances can cause duplicate jobs. You must handle that with:
- Only one instance running the scheduler, or
- Use APScheduler with a shared job store and coordination, or
- Use a centralized queue like Celery instead of APScheduler for production grade scheduling.
Time Zones and Daylight Saving Time
Scheduling across time zones is tricky. If your system has users around the world, you must decide which time zone the scheduler uses.
Common options:
- Always schedule in UTC.
- For user specific schedules, store the user’s time zone and convert.
Best practice: Store and process times in UTC internally, convert to local time only at the edges (API / UI).
Example problem
You schedule a daily job at 02:30 local time. When daylight saving time (DST) starts or ends:
- The time 02:30 might not exist at all on that day, or
- The clock might jump backward from 02:59 to 02:00, causing ambiguity.
Libraries like APScheduler and Python’s zoneinfo (or pytz) can help, but you still need clear rules about what should happen.
Generally:
- For system wide maintenance jobs, pick UTC times that are stable.
- For user facing schedules, save:
- The intended local time, for example “9:00”.
- The user’s time zone, for example “Europe/Berlin”.
- Convert to UTC each time you calculate the next run.
Idempotency and Safety of Scheduled Jobs
If a scheduled job runs multiple times accidentally, what happens? This is common if:
- The job fails halfway and then is retried.
- Two scheduler instances misfire.
- A worker is slow and overlaps with the next scheduled run.
To keep your system safe, scheduled tasks should usually be idempotent.
Idempotent operation:
An operation is idempotent if running it multiple times has the same effect as running it once.
Non idempotent example
def send_billing_email(user_id):
# BAD: will send multiple copies if retried
email_service.send("Monthly bill", to=user_id)If this job is retried or accidentally scheduled twice, the user gets multiple emails.
More idempotent approach
You can track executions:
def send_billing_email(user_id, billing_period):
if has_already_sent_email(user_id, billing_period):
return # do nothing
email_service.send("Monthly bill", to=user_id)
mark_email_sent(user_id, billing_period)Now, even if the task is executed multiple times, only one email is sent.
Other common strategies:
- Use database constraints to avoid duplicate inserts.
- Use a lock, for example Redis lock or advisory lock in PostgreSQL, so that only one worker can execute a specific job instance at a time.
- Design cleanup tasks so repeated execution is harmless, for example always delete all rows older than a certain date.
Monitoring and Managing Scheduled Tasks
Scheduled jobs are long lived. They might run for months or years, so you need to:
- Log their execution.
- Detect when they fail.
- Observe how long they take.
- Make sure they do not overlap in dangerous ways.
Logging and metrics
For each scheduled task, log:
- Start time.
- End time or duration.
- Result (success or failure).
- Important parameters (for example date processed, user count, etc).
Example:
import logging
from datetime import datetime
logger = logging.getLogger(__name__)
def cleanup_sessions():
started_at = datetime.utcnow()
logger.info("cleanup_sessions started", extra={"started_at": started_at.isoformat()})
try:
removed = perform_cleanup()
logger.info(
"cleanup_sessions finished",
extra={"removed": removed, "started_at": started_at.isoformat()}
)
except Exception:
logger.exception("cleanup_sessions failed")
raiseYou can also expose metrics like:
- Number of successful runs.
- Number of failed runs.
- Average duration.
These metrics are very helpful when integrated with monitoring tools.
Avoiding overlapping executions
If a task is scheduled every 5 minutes, but sometimes takes 10 minutes to finish, you might have overlapping instances. That can:
- Overload your database.
- Cause inconsistent state.
Ways to prevent this:
- Use a lock (for example Redis, database advisory lock).
- Let the task check if another instance is running before it starts.
- Choose a schedule interval that is safely larger than the worst case runtime.
Example using a simple lock idea:
def run_with_lock(lock_key, func, *args, **kwargs):
if not acquire_lock(lock_key): # uses Redis or DB
return # lock is held, skip this run
try:
return func(*args, **kwargs)
finally:
release_lock(lock_key)Then:
def do_daily_aggregation():
run_with_lock("daily_aggregation_lock", _do_daily_aggregation_impl)Scheduled Tasks in Practice
To connect everything, here are a few realistic scenarios and how scheduling might look.
Daily cleanup with Celery beat
Goal: Remove users’ expired password reset tokens once per day.
- Define the task:
from celery import Celery
from datetime import datetime, timedelta
app = Celery("myapp", broker="redis://localhost:6379/0")
@app.task
def cleanup_password_reset_tokens():
expiration_time = datetime.utcnow() - timedelta(hours=24)
# Delete all tokens older than expiration_time
deleted = (
db_session.query(PasswordResetToken)
.filter(PasswordResetToken.created_at < expiration_time)
.delete()
)
db_session.commit()
print(f"Deleted {deleted} tokens")- Configure beat schedule:
from celery.schedules import crontab
app.conf.beat_schedule = {
"cleanup-password-reset-tokens-daily": {
"task": "tasks.cleanup_password_reset_tokens",
"schedule": crontab(hour=2, minute=0), # 02:00 UTC every day
},
}Now Celery beat schedules the task. Celery workers execute it.
User specific reminders with delayed tasks
Goal: Send a reminder email 3 days before a user’s subscription expires.
In the business logic when the user subscribes:
from datetime import timedelta
def on_user_subscribed(user_id, expires_at):
reminder_time = expires_at - timedelta(days=3)
now = datetime.utcnow()
delay_seconds = max(0, (reminder_time - now).total_seconds())
send_renewal_reminder.apply_async(
args=[user_id],
countdown=delay_seconds
)This sets a one time scheduled task for that user.
send_renewal_reminder is a Celery task that checks again that the subscription is still expiring before sending the email, which makes it safer and more idempotent.
Scheduled statistics with APScheduler
Goal: Recalculate daily statistics at midnight.
from apscheduler.schedulers.blocking import BlockingScheduler
scheduler = BlockingScheduler()
@scheduler.scheduled_job("cron", hour=0, minute=0)
def calculate_daily_stats():
# aggregate yesterday's data
pass
if __name__ == "__main__":
scheduler.start()You can deploy this as a separate service. It runs indefinitely and only does scheduling and stats.
Summary
- Scheduled tasks are jobs that run automatically at specific times or intervals.
- Unix cron is a simple, system level scheduler that runs commands on a schedule.
- Python libraries like Celery beat and APScheduler integrate scheduling with your application, task queues, and workers.
- Always think about time zones, daylight saving time, idempotency, and overlapping execution when designing scheduled tasks.
- Use logging and metrics so you can see what scheduled tasks are doing over time.
With these ideas, you can design backend systems that perform important work reliably and automatically, even when no user is making requests.
Views: 7
KAHIBARO