Tutorial

Building a Discord Reminder Bot in Python

Build a Python Discord bot with slash commands, SQLite storage and a background loop that pings you when it’s time, then keep it running 24/7.

DevStories
·
9 min read
·
3 views
Building a Discord Reminder Bot in Python

Why a reminder bot

A lot of Discord bot tutorials end with a command that replies "pong". This one builds something that does real work: a bot that remembers what you tell it, survives a restart, and speaks up on its own when the time comes.

By the end you'll have a bot where you type /remind when:30m message:stretch and it pings you in the same channel half an hour later. Reminders survive restarts because they live in SQLite, not in memory. It's all Python with discord.py 2.x, in a single file.

What we're building

Two slash commands and one background loop. /remind takes a duration like 10m, 2h or 1h30m plus a message, and saves it. /reminders shows what you still have pending. Every 15 seconds the loop looks for reminders that have come due, posts them in the channel where you asked, and deletes them.

I went with plain durations instead of things like "next Tuesday at 3pm". Natural-language dates are a rabbit hole of timezones and ambiguity, and most of the time you just want "in an hour". You can bolt a smarter parser on later.

Creating the bot in Discord

Before any code, Discord needs to know your bot exists. This takes about five minutes.

  1. Go to the Developer Portal at discord.com/developers/applications and click New Application. The name you pick is what shows up as the bot's name.

  2. Open the Bot tab and click Reset Token. Copy it somewhere safe right away, because Discord only shows it once. Treat it like a password: anyone with it can run your bot.

  3. Leave the privileged intents switched off. This bot never reads message text, so it doesn't need the Message Content intent, and skipping it saves you a verification headache if the bot ever grows.

  4. Open OAuth2, then URL Generator. Tick the bot and applications.commands scopes. The second one is what lets slash commands appear at all. Under bot permissions, tick View Channels and Send Messages.

  5. Open the URL it generates, pick your own test server, and authorize. The bot will show up in the member list, offline for now.

Setting up the project

Make a folder, a virtual environment, and install two packages.

mkdir reminder-bot && cd reminder-bot
python -m venv .venv
source .venv/bin/activate   # on Windows: .venv\Scripts\activate
pip install discord.py python-dotenv

Put the token in a file called .env next to your code, not in the code itself. The second line is optional for now, and I'll explain it in a minute.

DISCORD_TOKEN=paste-your-token-here
TEST_GUILD_ID=123456789012345678

Add .env, .venv/ and reminders.db to your .gitignore before your first commit. If a token ever does end up in a public repo, go back to the Bot tab and reset it immediately.

Now create bot.py. The top of the file is just imports, config, and a few constants:

import logging
import os
import re
import sqlite3
import time

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

load_dotenv()
TOKEN = os.environ["DISCORD_TOKEN"]
TEST_GUILD_ID = os.getenv("TEST_GUILD_ID")  # optional, makes command syncing instant

log = logging.getLogger("reminderbot")

UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
MAX_SECONDS = 365 * 86400  # don't accept reminders more than a year out

The bot itself

The bot is a small subclass of commands.Bot. Slash commands don't need a prefix, so when_mentioned is just a placeholder, and the default intents are enough.

class ReminderBot(commands.Bot):
    def __init__(self):
        super().__init__(
            command_prefix=commands.when_mentioned,
            intents=discord.Intents.default(),
        )

    async def setup_hook(self):
        if TEST_GUILD_ID:
            guild = discord.Object(id=int(TEST_GUILD_ID))
            self.tree.copy_global_to(guild=guild)
            await self.tree.sync(guild=guild)
        else:
            await self.tree.sync()
        check_reminders.start()

    async def on_ready(self):
        log.info("Logged in as %s", self.user)


bot = ReminderBot()

setup_hook runs once, before the bot connects, which makes it the right place to sync commands and start background work.

The sync step is where people get stuck. Slash commands have to be registered with Discord, and a global sync can take a while to show up everywhere. Syncing to a single server is instant, so while you're building, put your test server's ID in TEST_GUILD_ID. To get it, turn on Developer Mode under User Settings, then Advanced, right-click your server, and choose Copy Server ID. Remove the line when you're ready to go global.

The /remind command

First, a function that turns text like 1h30m into seconds. The regex insists that the whole string is made of number-and-unit pieces, so 10 minutes or 5mm get rejected instead of half-understood. 0m is rejected too, since a zero-second reminder is pointless.

def parse_duration(text):
    """Turn '90m', '1h30m' or '2d' into seconds. Returns None if it doesn't parse."""
    text = text.strip().lower().replace(" ", "")
    # the whole string must be number+unit pieces, with nothing left over
    if not re.fullmatch(r"(?:\d+[smhdw])+", text):
        return None
    total = sum(int(n) * UNITS[u] for n, u in re.findall(r"(\d+)([smhdw])", text))
    return total or None

Then the command. Discord turns the function arguments into the command's options, and app_commands.describe supplies the little hint text people see while typing. Range[str, 1, 200] makes Discord itself enforce the message length, so I don't have to.

@bot.tree.command(name="remind", description="Remind me about something later")
@app_commands.describe(
    when="How long from now, like 10m, 2h, 1d or 1h30m",
    message="What to remind you about",
)
async def remind(
    interaction: discord.Interaction,
    when: str,
    message: app_commands.Range[str, 1, 200],
):
    seconds = parse_duration(when)
    if seconds is None or seconds > MAX_SECONDS:
        await interaction.response.send_message(
            "I couldn't read that time. Try something like `10m`, `2h` or `1h30m` (up to a year).",
            ephemeral=True,
        )
        return

    due_at = int(time.time()) + seconds
    db.execute(
        "INSERT INTO reminders (user_id, channel_id, message, due_at) VALUES (?, ?, ?, ?)",
        (interaction.user.id, interaction.channel_id, message, due_at),
    )
    db.commit()
    # <t:...:R> is Discord's own relative-time format, so it shows up in everyone's timezone
    await interaction.response.send_message(f"Okay, I'll remind you <t:{due_at}:R>.")

Two small things I like here. Error replies are ephemeral, meaning only the person who ran the command sees them, so a typo doesn't clutter the channel. And the confirmation uses Discord's <t:...:R> timestamp format, which renders as "in 30 minutes" and counts down on its own. It also means I never have to think about timezones.

The /reminders command is the same idea in reverse: look up the caller's rows and show them privately.

@bot.tree.command(name="reminders", description="See your pending reminders")
async def reminders(interaction: discord.Interaction):
    rows = db.execute(
        "SELECT message, due_at FROM reminders WHERE user_id = ? ORDER BY due_at LIMIT 10",
        (interaction.user.id,),
    ).fetchall()
    if not rows:
        await interaction.response.send_message("You have nothing pending.", ephemeral=True)
        return
    lines = [f"<t:{due_at}:R>: {message}" for message, due_at in rows]
    await interaction.response.send_message("\n".join(lines), ephemeral=True)

Saving reminders so a restart doesn't eat them

If you keep reminders in a Python list, the first crash or redeploy wipes them all. SQLite fixes that with almost no effort: it ships with Python, and the whole database is one file. Put this near the top of bot.py, right under the constants:

db = sqlite3.connect("reminders.db")
db.execute(
    """CREATE TABLE IF NOT EXISTS reminders (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id INTEGER NOT NULL,
        channel_id INTEGER NOT NULL,
        message TEXT NOT NULL,
        due_at INTEGER NOT NULL
    )"""
)
db.commit()

Each row is one reminder: who asked, which channel they asked in, what to say, and when it's due. due_at is a Unix timestamp, a plain integer, which makes "what's due right now?" a simple comparison.

One honest caveat. The sqlite3 module is synchronous, so each query briefly blocks the bot. For a tiny local file that's microseconds and nobody will notice. If the bot grows to thousands of reminders or you move to a database on another machine, switch to aiosqlite or an async Postgres driver.

The loop that actually sends them

Saving a reminder is half the job. Something also has to wake up regularly, find the ones that are due, and post them. discord.py has tasks.loop for exactly this. Mine runs every 15 seconds, which is plenty precise for reminders measured in minutes and hours.

First, a helper that delivers one reminder. It tries the original channel and falls back to a DM if the channel was deleted or the bot lost access to it.

async def deliver(user_id, channel_id, message):
    """Post the reminder in its channel; fall back to a DM if the channel is gone."""
    try:
        channel = bot.get_channel(channel_id) or await bot.fetch_channel(channel_id)
        await channel.send(
            f"<@{user_id}> reminder: {message}",
            # ping the user, but never let a reminder text trigger @everyone or a role
            allowed_mentions=discord.AllowedMentions(users=True, roles=False, everyone=False),
        )
        return
    except discord.HTTPException:
        pass
    try:
        user = await bot.fetch_user(user_id)
        await user.send(f"Reminder: {message}")
    except discord.HTTPException:
        pass

The allowed_mentions line matters more than it looks. The reminder text is whatever a user typed, and without it someone could write /remind 1m @everyone and have your bot ping the whole server.

Then the loop itself, plus the two lines that start the bot:

@tasks.loop(seconds=15)
async def check_reminders():
    due = db.execute(
        "SELECT id, user_id, channel_id, message FROM reminders WHERE due_at <= ?",
        (int(time.time()),),
    ).fetchall()
    for reminder_id, user_id, channel_id, message in due:
        try:
            await deliver(user_id, channel_id, message)
        except Exception:
            # one bad reminder shouldn't kill the loop, and tasks.loop stops on unhandled errors
            log.exception("Failed to deliver reminder %s", reminder_id)
        db.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,))
        db.commit()


@check_reminders.before_loop
async def before_check_reminders():
    await bot.wait_until_ready()


if __name__ == "__main__":
    bot.run(TOKEN, root_logger=True)

A reminder is deleted whether or not delivery worked. That's deliberate: if the failure is permanent, retrying every 15 seconds forever is worse than dropping it. And because due_at <= now catches everything overdue, a bot that was offline for an hour will deliver its missed reminders, late but intact, as soon as it comes back.

Run it with python bot.py, go to your test server, type /remind, and try 10s first so you don't have to wait.

Keeping it running

A reminder bot that's offline when you need it is useless, so it has to run somewhere that stays on. Your laptop is fine for testing. For real use, the simplest setup is a small Linux VPS and systemd, which restarts the bot if it crashes and starts it again after a reboot.

Copy the project to the server, create the virtual environment and .env there, then add a service file at /etc/systemd/system/reminderbot.service:

[Unit]
Description=Discord reminder bot
After=network-online.target
Wants=network-online.target

[Service]
User=bot
WorkingDirectory=/home/bot/reminder-bot
ExecStart=/home/bot/reminder-bot/.venv/bin/python bot.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Then turn it on and follow the logs:

sudo systemctl enable --now reminderbot
journalctl -u reminderbot -f

WorkingDirectory isn't decoration. The database path is relative, and systemd starts services in the root directory by default, where the bot's user can't create reminders.db. Without that line the bot would fail on startup.

If you'd rather use Docker, the same idea applies: mount a volume for reminders.db so the file outlives the container. And copy that file somewhere now and then. It's your only record of what people asked for.

Things that will trip you up

Blocking the event loop. discord.py runs everything on one async event loop. A time.sleep(5) or a slow requests.get() inside a command freezes the whole bot for those seconds, and if it lasts long enough Discord drops the connection. Use await asyncio.sleep() and an async HTTP client like aiohttp or httpx. This bot's own database calls are the one exception, covered above.

The three-second rule. Discord expects a reply to a slash command within three seconds, otherwise the user sees "The application did not respond." If a command has real work to do, call await interaction.response.defer() first, then send the answer later with interaction.followup.send(...). /remind replies instantly, so it never needs this, but the first command you write that calls an API will.

An unhandled error stops the loop. If anything inside check_reminders raises something unexpected, the task quietly stops running and no reminders go out. That's why the delivery call is wrapped in try/except and logged. Check your logs now and then.

Pings from user text. Covered in the loop section, but worth repeating: anything a user types and your bot repeats can contain @everyone. Restrict allowed_mentions every time.

Commands that don't show up. Almost always one of three things: you forgot the applications.commands scope when inviting the bot, you never synced, or Discord's client is showing a stale list. Re-invite with the right scope, check that setup_hook ran, and refresh the client with Ctrl+R.

Timezones. There's nothing to get wrong here, because reminders are stored as Unix timestamps and displayed with <t:...:R>, so everyone sees times in their own zone. The moment you add "remind me at 9am", this changes completely, and I'll come back to it below.

Where to take it next

That's the whole bot: one file and no dependencies beyond discord.py and python-dotenv. If you want to keep building, these are the upgrades I'd try, roughly in order of effort:

  • A /cancel command that deletes a reminder by id. You'd show the id in /reminders and add a WHERE id = ? AND user_id = ? delete, so nobody can cancel someone else's.

  • A snooze button on the reminder message, using discord.py's discord.ui.View and Button.

  • Recurring reminders. Add a repeat_seconds column, and when one fires, insert the next occurrence instead of just deleting the row.

  • Times of day like "tomorrow at 9am". This is where timezones become real: you'd store each user's zone, use Python's zoneinfo to convert, and save the result as a UTC timestamp, so the loop doesn't change at all.

  • Moving to aiosqlite once the bot is busy enough for synchronous queries to matter.

Slash commands, a database, and a background task show up in a lot of Discord bots, so the next one should come together faster.

Comments(0)

Join the discussion securely with your ZyVOP account.

Continue on ZyVOP to Comment

No comments yet. Be the first to comment!

DevStories

Passionate developer sharing knowledge about modern web technologies and best practices.

Subscribe to DevStories's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.

30Save