Can CeylanVienna-based, globally curious.
Learn/Backend

APScheduler vs Celery: choosing the right background job tool

APScheduler is the right tool for one background job on a fixed schedule. It becomes the wrong tool the moment you need per-user workloads, priority queues, or retry logic.

2026-04-18·2 min read·intermediate

What APScheduler is good at

APScheduler (BackgroundScheduler) runs inside your existing Python process. No Redis, no separate worker, no deployment complexity. You define a job, attach a schedule, and it runs.

from apscheduler.schedulers.background import BackgroundScheduler

scheduler = BackgroundScheduler()
scheduler.add_job(run_scrape_job, "interval", hours=4)
scheduler.start()

That's it. One background thread, one job, fires every 4 hours. For a single-user tool or a small internal application, this is exactly the right amount of complexity.

Where it breaks down

APScheduler becomes the wrong tool when:

1. You need per-user scheduling APScheduler has one job queue, shared across all users. If User A has a Pro subscription that entitles them to hourly scrapes and User B is on the free tier with daily scrapes, you can't express this cleanly in APScheduler. You'd end up with a single job that fetches all users, checks their tier, and conditionally runs, which is a manual queue re-implementation inside a scheduler.

2. You need priority APScheduler doesn't distinguish between high-priority and low-priority jobs. When the queue backs up, everything waits equally. A task queue lets you define priority lanes.

3. You need reliable retry APScheduler will silently drop a job if it fails mid-execution (with logging, if configured). It has no built-in retry with backoff. For jobs where failure recovery matters, sending emails, processing payments, syncing to external APIs, you need explicit retry logic that a task queue provides natively.

4. You need horizontal scale APScheduler lives in one process. You can't run two instances of your app without both schedulers firing the same jobs simultaneously. Task queues with a shared broker (Redis, RabbitMQ) handle this coordination automatically.

The migration trigger

The practical signal to migrate from APScheduler to a task queue (Celery + Redis is the standard Python choice) is when you find yourself writing scheduling logic inside the job:

def run_scrape_job():
    users = get_all_users()
    for user in users:
        if user.tier == "pro" and should_run_now(user):
            scrape_for_user(user)

That should_run_now check is a scheduler inside a scheduler. Stop, extract it, and use a proper task queue.

What the migration looks like

The core change: instead of one job that loops over users, you have individual tasks dispatched per user.

# Celery task
@app.task(bind=True, max_retries=3)
def scrape_for_user(self, user_id: str):
    ...

# Dispatcher (runs on a schedule via Celery Beat)
@app.task
def dispatch_scrape_jobs():
    for user in get_active_users():
        scrape_for_user.apply_async(
            args=[user.id],
            priority=9 if user.tier == "pro" else 5,
            countdown=0,
        )

Each user's scrape is now an independent task with its own retry history, priority, and execution record.

The rule

Use APScheduler when you have one job (or a small fixed set) on a fixed schedule. Switch to a task queue when you need per-entity scheduling, priority, or reliable retry. The boundary is usually "when the job starts asking about the user, not just running for all users."

More like this, straight to your inbox.

I write about Backend and a handful of other things I actually care about. No schedule, no filler. Just when I have something worth saying.

More on Backend

Batch email sends before rate limits look like caps

A newsletter send to 13 people reported 5 accepted and 8 failed. It looked like a hidden recipient cap. The real problem was parallel API calls hitting a provider rate limit.

Separate the editorial date from the publish timestamp, they mean different things

Content systems routinely conflate two different concepts: the date the author wrote something, and when it was actually published. Treating them as one field causes sorting bugs, broken date displays, and incorrect analytics. They need to be separate from the start.

Layering data sources: accept both APIs as fallback, don't choose one

Financial data from a single free API is unreliable. Layer a secondary source on top, not as a replacement, but as a fallback when the primary returns None. You get resilience without complexity.

If this raised a question, I'd be happy to talk about it.

Find me →
← Back to Learn