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 takesseconds=,minutes=, orhours=.self.daily_announce.start()insetup_hookkicks it off — call.start()exactly once.@daily_announce.before_loopwithawait self.wait_until_ready()delays the first run until login finishes. Without it, an interval loop can run immediately on start, beforeget_channelcan see anything, and quietly find nothing.
🎯 Good to know: An interval loop fires once right away (after
before_loop), then on every interval. Sohours=24posts 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 insetup_hook(or the cog's__init__) and check the console for errors. get_channelreturnsNone— the bot wasn't ready when the loop first ran. Add thebefore_loopwithawait self.wait_until_ready().- The loop ran once, then stopped — the body raised an exception, which cancels the loop. Wrap it in
try/exceptor add an@loop.errorhandler. - Wrong time of day — a
time=withouttzinfouses UTC. Attach aZoneInfozone.