How to Schedule Tasks in Python Without Celery and Workarounds
Every backend developer has probably faced this. You're building a web service with FastAPI or Flask, the project is still small, running on a single server. Then the product manager asks: "Hey, let's send customers a digest every morning at 9 AM, and reset expired carts every hour?"
The first instinct is to bolt on Celery with Celery Beat and Redis. But spinning up a broker, configuring workers, and monitoring daemons just for two background tasks feels like overkill. The second option is system cron, but that spreads logic across application code and server configuration. You want the scheduler embedded in your code, capable of handling asyncio, and scaling across multiple nodes when needed.
This is where the APScheduler (Advanced Python Scheduler) library comes in handy.
What the library can do
APScheduler has been around for a while, and the project is currently transitioning to version 4.0. The fourth branch is still in pre-release status, but it has significantly restructured the library's architecture.
Previously it was primarily a local scheduler for a single process, but now the tool has evolved into a full-fledged distributed queue and scheduling system. The simplest use case still runs in just three lines of code.
The core concept: you declare regular Python functions or coroutines and attach a trigger with the necessary execution conditions.
from apscheduler.schedulers.asyncio import AsyncIOScheduler
scheduler = AsyncIOScheduler()
async def send_digest():
print("Отправляем утренний дайджест...")
# Запуск каждый будний день в 9 утра
scheduler.add_job(send_digest, 'cron', day_of_week='mon-fri', hour=9, minute=0)
scheduler.start()
The library handles time tracking, calculates offsets, and invokes functions at the right moment.
Schedule options
Four built-in trigger types are available:
- Cron trigger. Familiar Linux-style syntax. You can flexibly set weekdays, months, specific hours, and minutes.
- Intervals. Run every N seconds, minutes, or hours. Useful for regular polling of external APIs.
- Calendar trigger. Needed when the interval depends on the length of a month or year. For example, run a task strictly on the first day of every month at noon.
- One-time run. Fires exactly once at a specified future date and time.
If the built-in conditions aren't enough, triggers can be combined using compound rules or you can write your own class with custom logic.
Protection against common failures
In practice, background tasks regularly encounter overloads and lag. APScheduler includes several useful mechanisms that are often overlooked in custom solutions built on while True and sleep.
First, limiting concurrent runs. If you configured a heavy report export every five minutes, but the previous run got stuck for seven minutes, the scheduler won't launch a second instance in parallel and won't overload the database.
Second, the jitter parameter. It adds a random delay to the task start time. Imagine you have a hundred workers, and they all need to refresh the cache at exactly 00:00. Without jitter, the database will receive an instant load spike. With a random offset of a couple of seconds, the load is distributed evenly.
Third, misfire grace time handling. If the server froze under load or was rebooting, the scheduler checks how late the task is. If the delay fits within the acceptable limit, the task will run; if not, it will be skipped without queue buildup.
Storage and distributed mode
For simple scripts, tasks can be kept in memory. But if the service restarts, the schedule will be reset. To avoid this, the library supports persistent storage:
- PostgreSQL
- MySQL
- SQLite
- MongoDB
In version four, the scheduler learned to work in a distributed cluster. Multiple application instances connect to a shared database and event broker (Redis, PostgreSQL LISTEN/NOTIFY, and MQTT are supported).
This achieves horizontal scaling: one node goes down, the others pick up tasks from the queue.
# Пример концепции работы с постоянным хранилищем
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
jobstores = {
'default': SQLAlchemyJobStore(url='postgresql+asyncpg://user:pass@localhost/mydb')
}
scheduler = AsyncIOScheduler(jobstores=jobstores)
Synchronous and asynchronous code
The library fits well into the modern Python stack. Different scheduler implementations are available:
AsyncIOSchedulerand integration with Trio for modern async backends like FastAPI, Litestar, or Aiohttp.BackgroundSchedulerandBlockingSchedulerfor synchronous scripts, Django, and Flask.
You don't need to wrap async functions in synchronous workarounds with asyncio.run() — the scheduler natively awaits coroutines inside the main event loop.
Where this comes in handy
I usually reach for APScheduler in three typical situations:
- Small microservices and bots. When deploying Celery or RQ would be excessive, and periodic tasks are needed directly within the process.
- Deferred user actions. For example, send an email asking to rate an order exactly 24 hours after delivery.
- Cleaning up temporary data and periodic synchronization of reference data from external systems.
Is it worth using in a project
If you're writing in Python and need predictable time-based task execution, APScheduler is one of the most mature options.
The only nuance right now: the transition period between versions 3.x and 4.0. The third branch has been tested in production for years, is maximally stable, but has limitations in distributed operation. Version 4.0 brings modern architecture and scaling, but the author honestly warns about possible breaking API changes before the final release.
For current production, it's safer to stick with the stable 3.x branch, and version 4.0 is worth trying in pet projects or new microservices while keeping an eye on upcoming changes.
Related projects