Scheduled tasks with discord.ext.tasks

discord.py has a scheduler built in — no extra package. Loop every N minutes or run daily at a set time with @tasks.loop, started safely after the bot is ready.

The JavaScript side reaches for node-cron; discord.py doesn't need an extra package at all. discord.ext.tasks ships with the library — decorate a method with @tasks.loop(...) and it runs on a schedule you choose. This recipe builds a repeating announcement and a daily-at-a-time post, started the safe way.

At a glance
You need a working bot from the discord.py guide; the cogs guide helps but isn't required
Plan free or premium — but a timed bot only fires while it's online (see below)
Time about fifteen minutes

An interval loop

A @tasks.loop with an interval runs your method every so often. Start it once in setup_hook, and use before_loop to wait until the bot is ready so get_channel actually finds channels:

import os

import discord
from discord.ext import commands, tasks
from dotenv import load_dotenv

load_dotenv()

ANNOUNCE_CHANNEL_ID = 123456789012345678  # replace with your channel's ID


class MyBot(commands.Bot):
    def __init__(self):
        super().__init__(command_prefix="!", intents=discord.Intents.default())

    async def setup_hook(self):
        self.daily_announce.start()

    @tasks.loop(hours=24)
    async def daily_announce(self):
        channel = self.get_channel(ANNOUNCE_CHANNEL_ID)
        if channel is not None:
            await channel.send("☀️ Daily announcement!")

    @daily_announce.before_loop
    async def before_daily(self):
        await self.wait_until_ready()


bot = MyBot()


@bot.event
async def on_ready():
    print(f"Listening as {bot.user}", flush=True)


bot.run(os.environ["DISCORD_TOKEN"])

The three parts that make it work:

  • @tasks.loop(hours=24) turns the method into a loop that runs every 24 hours. The interval takes seconds=, minutes=, or hours=.
  • self.daily_announce.start() in setup_hook kicks it off — call .start() exactly once.
  • @daily_announce.before_loop with await self.wait_until_ready() delays the first run until login finishes. Without it, an interval loop can run immediately on start, before get_channel can see anything, and quietly find nothing.

🎯 Good to know: An interval loop fires once right away (after before_loop), then on every interval. So hours=24 posts on startup, then daily — not "24 hours after you started".

Run at a specific time of day

For "every day at exactly 09:00", pass a time instead of an interval. Give it a timezone so the hour means what you expect:

from datetime import time
from zoneinfo import ZoneInfo

RUN_AT = time(hour=9, minute=0, tzinfo=ZoneInfo("Europe/London"))

@tasks.loop(time=RUN_AT)
async def morning_post(self):
    channel = self.get_channel(ANNOUNCE_CHANNEL_ID)
    if channel is not None:
        await channel.send("Good morning! ☕")

With time=, the loop runs once per day at that clock time in the given zone (pass a list of times to run at several fixed times a day). zoneinfo is part of the Python standard library, so there's nothing to install.

Managing a running loop

The loop object has methods you'll use as your bot grows:

Call Does
self.daily_announce.start() begin the loop (once)
self.daily_announce.cancel() stop it
self.daily_announce.is_running() check whether it's active
self.daily_announce.change_interval(minutes=30) change the cadence at runtime

To survive a slow channel or a hiccup, wrap the body in try/except (an unhandled exception stops the loop) — or add an error handler with @daily_announce.error.

In a cog

If your bot uses cogs, a loop lives just as neatly inside one. Define it as a method, start it in the cog's __init__ or in cog_load, and reference self.bot to reach channels:

class Scheduler(commands.Cog):
    def __init__(self, bot):
        self.bot = bot
        self.heartbeat.start()

    @tasks.loop(minutes=30)
    async def heartbeat(self):
        channel = self.bot.get_channel(ANNOUNCE_CHANNEL_ID)
        if channel:
            await channel.send("Still here. 🫡")

    def cog_unload(self):
        self.heartbeat.cancel()   # stop the loop if the cog unloads

tasks or the panel's Schedules page?

The same split as the JavaScript recipe:

Use... for...
discord.ext.tasks actions inside Discord — post, DM, run bot logic
Panel Schedules actions on the server — nightly restart, backup, console command

⚠️ Heads up: A loop only runs while the bot process is up. On a free plan the bot stops when the session timer expires and the loop stops with it. For dependable daily posts, keep the bot online — see Keeping apps online.

Verify it works

Set a fast test loop — @tasks.loop(seconds=30) posting to a test channel — start the bot, and watch a message land within the interval. Once you've seen it fire, switch back to your real cadence.

Troubleshooting

  • The loop never posts — you didn't call .start(), or it's not being reached. Start it in setup_hook (or the cog's __init__) and check the console for errors.
  • get_channel returns None — the bot wasn't ready when the loop first ran. Add the before_loop with await self.wait_until_ready().
  • The loop ran once, then stopped — the body raised an exception, which cancels the loop. Wrap it in try/except or add an @loop.error handler.
  • Wrong time of day — a time= without tzinfo uses UTC. Attach a ZoneInfo zone.

Next steps

Was this guide helpful?