When I added reminders to a small appointment bot, my first instinct was to reach for a job scheduler: one job per booking, fired at the right time. I ended up with something much smaller: one loop and two columns. Here is why, and how it works.

The problem with one job per booking

A job per booking means every change to a booking has to touch the scheduler too. A client cancels: remove the job. The owner moves an appointment: reschedule the job. The bot restarts: the in-memory jobs are gone unless you add a persistent job store.

That is a second source of truth, and it will drift from the bookings table sooner or later.

Poll the table instead

The bookings table already knows everything needed. So the bot asks it one question every minute: which active bookings start soon and have not been reminded yet?

async def reminder_loop(bot): while True: try: due = store.due_reminders(now()) for kind, label in (("day", "tomorrow"), ("hours", "soon")): for r in due[kind]: try: await bot.send_message(r["user_id"], f"Reminder: you have a booking {label}") except Exception as e: logging.warning("Reminder not delivered: %s", e) except Exception: logging.exception("Reminder loop error") await asyncio.sleep(60) 

It is started once next to polling:

task = asyncio.create_task(reminder_loop(bot)) try: await dp.start_polling(bot) finally: task.cancel() 

Cancelled bookings drop out on their own because the query only looks at active rows. Moved bookings are picked up at their new time. After a restart nothing is lost, because nothing lived in memory.

Two flags, not one

The bot sends two reminders: a day before and two hours before. Each gets its own column, reminded_day and reminded_hours, both 0 by default.

def due_reminders(self, now): out = {"day": [], "hours": []} for kind, col, delta in (("day", "reminded_day", timedelta(hours=24)), ("hours", "reminded_hours", timedelta(hours=2))): rows = self.db.execute( "SELECT * FROM bookings WHERE status='active' AND %s=0 " "AND start>? AND start<=?" % col, (now.isoformat(), (now + delta).isoformat())).fetchall() with self.db: for r in rows: self.db.execute("UPDATE bookings SET %s=1 WHERE id=?" % col, (r["id"],)) out[kind] = rows return out 

The column name is interpolated into the SQL, which is safe here only because it comes from a fixed tuple in the code and never from user input. All values still go through parameters.

Three details that matter

Mark before you send. The flag is set when the row is selected, not after the message is delivered. If sending fails, the client misses one reminder. The other order risks sending the same reminder every minute while Telegram is returning errors, which is worse.

Catch errors per message. A client can block the bot at any time, and send_message then raises. Without the inner try, one blocked user stops reminders for everyone after them in the list.

Short-notice bookings. Someone books at 10:00 for 12:00. That booking is inside both windows at once, so a naive loop sends "tomorrow" and "soon" back to back. I still mark both flags, but drop the day-before message when the visit is less than three hours away:

out["day"] = [r for r in out["day"] if datetime.fromisoformat(r["start"]) - now > timedelta(hours=3)] 

When this is not enough

Polling every minute means a reminder can be up to a minute late, which is fine for appointments. It also assumes one bot process. With several workers you would need to claim rows atomically, for example with a single UPDATE ... WHERE reminded_day=0 RETURNING *. For a bot serving one business, the loop above is enough.

The full bot

This reminder code is part of a complete appointment bot: booking flow, double-booking protection, owner notifications and admin commands. I wrote about the slot logic in a previous post. If you want the whole thing as working code, I sell the template here: https://payhip.com/b/VH20Y ($19, source code and setup guide).