Category: Discord Bot Tutorials

Discord Bot Tutorial#30Build a Poll & Survey Bot! Native Polls + Buttons with Real-Time Tallies (Python Example) 🗳️

This post was translated from the Korean original.

Contents10 sections

Hi there! It's Mojangcam 😎

Last time, we built an Economy (currency & shop) system so members could earn coins and buy items. Your server basically got a new "thing to play with"!

This time, let's build a feature you'll use all the time when running a server: a Poll & Survey bot. We'll cover both approaches.

  1. Native polls — the bot posts Discord's own built-in poll UI (the easiest option!)
  2. Custom button polls — you build it yourself with buttons and control live tallies, duplicate-vote prevention, and closing the poll exactly the way you want

Picking a date for a server event, choosing the next piece of content, asking for a yes/no on a rule change... If you've been adding emoji reactions and counting them by hand every time, today's post puts an end to that! 🙌

![](./img-01.png)

📌 What you need

  • Python 3.8+ (3.10 or later recommended)
  • discord.py 2.x — native Poll requires 2.5 or later
  • python-dotenv — a must, so you never write your bot token directly in the code!
Terminal
pip install -U "discord.py>=2.5" python-dotenv

You can check the installed version like this.

Terminal
python -c "import discord; print(discord.__version__)"

And never hardcode your bot token. Create a .env file in your project folder.

Text
token=여기에_봇_토큰_붙여넣기

💡 Tip: Make sure to add a .env line to your .gitignore! If your token gets pushed to GitHub, Discord will automatically invalidate it. (Which is kind of a relief, honestly 😅)

🧩 Setting up Intents

To receive vote events from native polls, you need the polls intent. If you only plan to use the button approach, you can skip it.

Python
intents = discord.Intents.default()
intents.message_content = True
intents.polls = True  # needed to receive poll events (on_poll_vote_add, etc.)

📌 Step 1 - Post a native poll (the easiest route)

Starting with discord.py 2.5, there's a discord.Poll class, so your bot can post the Discord app's built-in poll UI as-is.

Python
@bot.tree.command(name="투표", description="Posts a built-in Discord poll")
async def native_poll(interaction: discord.Interaction, 질문: str, 항목: str):
    # Options come in comma-separated: "Chicken,Pizza,Jokbal"
    choices = [c.strip() for c in 항목.split(",") if c.strip()][:10]

    if len(choices) < 2:
        await interaction.response.send_message("Please enter at least 2 options!", ephemeral=True)
        return

    poll = discord.Poll(question=질문, duration=datetime.timedelta(hours=24))
    for c in choices:
        poll.add_answer(text=c)

    await interaction.response.send_message(poll=poll)

Let's break it down 💡

  • discord.Poll(question, duration) : Creates the poll object. question can be up to 300 characters, and duration is set with a datetime.timedelta, up to 32 days (768 hours).
  • poll.add_answer(text=..., emoji=...) : Adds an option. You can have up to 10. You can also attach an emoji with the emoji argument.
  • send_message(poll=poll) : Just pass it to the message as poll= and you're done! Discord handles tallying, closing, and duplicate prevention for you.
  • Note that your bot needs the Create Polls (Send Polls) permission.

If you want to catch the moment a vote is added or removed, use events.

Python
@bot.event
async def on_poll_vote_add(user, answer):
    print(f"{user} voted for '{answer.text}'")

💬 This event only fires when the message is in the bot's cache. To catch older messages too, use on_raw_poll_vote_add.

Native polls are really convenient, but they have limits: a 10-option cap, no role restrictions, no way to render results nicely in your own embed, and no way to process the tally while voting is in progress. That's why we move on to Step 2!


📌 Step 2 - Custom button poll (live tallies)

The structure is simple: one button per option, and a dictionary that stores who clicked what.

Python
class PollView(discord.ui.View):
    def __init__(self, question: str, choices: list[str], author_id: int, timeout: float = 3600):
        super().__init__(timeout=timeout)
        self.question = question
        self.choices = choices
        self.author_id = author_id
        self.votes: dict[int, int] = {}  # {user ID: choice index}

        for idx, label in enumerate(choices):
            self.add_item(VoteButton(label=label, index=idx))

Let's break it down 💡

  • self.votes : This is the key part. The key is the user ID, so if the same person clicks again, the value just gets overwritten. In other words, double voting is blocked by design. Changed your mind? Just click a different button!
  • timeout : Once this many seconds pass, the buttons stop working automatically. 3600 = 1 hour.
  • author_id : Stored so that only the person who opened the poll can click "close early."

Here's what the button itself looks like.

Python
class VoteButton(discord.ui.Button):
    def __init__(self, label: str, index: int):
        super().__init__(label=label, style=discord.ButtonStyle.secondary)
        self.index = index

    async def callback(self, interaction: discord.Interaction):
        view: PollView = self.view
        view.votes[interaction.user.id] = self.index
        await interaction.response.edit_message(embed=view.build_embed())
  • callback : Runs the moment someone clicks the button. It records the vote and redraws the message right away. That's the secret behind "live tallies"!
  • edit_message : Instead of sending a new message, it swaps out the existing one. Your chat stays clean 👍

📌 Step 3 - Draw the results as a bar chart

Just showing numbers is a bit boring, so let's build a bar chart out of block characters.

Python
def build_embed(self) -> discord.Embed:
    counts = [0] * len(self.choices)
    for choice_idx in self.votes.values():
        counts[choice_idx] += 1
    total = sum(counts)

    lines = []
    for label, cnt in zip(self.choices, counts):
        ratio = (cnt / total) if total else 0
        bar = "█" * round(ratio * 12) or "░"
        lines.append(f"`{bar:<12}` **{cnt} votes** ({ratio*100:.0f}%) · {label}")

    embed = discord.Embed(title=f"🗳️ {self.question}", description="\n".join(lines))
    embed.set_footer(text=f"{total} votes total · Click a button to vote (click another to change it)")
    return embed
  • bar:<12 : Wrapping it in backticks and fixing the width to 12 cells keeps the bars neatly aligned instead of ragged.
  • or "░" : Adds a single blank-cell character so a 0-vote row doesn't end up empty.
  • The total vote count equals len(self.votes). One person, one vote!

![](./img-02.png)


📌 Full code (copy-paste OK)

Python
# discord.py 2.5+ / Python 3.10+
# pip install -U "discord.py>=2.5" python-dotenv
import os
import datetime

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

load_dotenv()
token = os.getenv("token")

intents = discord.Intents.default()
intents.message_content = True
intents.polls = True

bot = commands.Bot(command_prefix="!", intents=intents)

class VoteButton(discord.ui.Button):
    def __init__(self, label: str, index: int):
        super().__init__(label=label, style=discord.ButtonStyle.secondary)
        self.index = index

    async def callback(self, interaction: discord.Interaction):
        view: "PollView" = self.view
        if view.closed:
            await interaction.response.send_message("This poll is already closed!", ephemeral=True)
            return
        view.votes[interaction.user.id] = self.index
        await interaction.response.edit_message(embed=view.build_embed(), view=view)

class CloseButton(discord.ui.Button):
    def __init__(self):
        super().__init__(label="Close Poll", style=discord.ButtonStyle.danger, row=4)

    async def callback(self, interaction: discord.Interaction):
        view: "PollView" = self.view
        if interaction.user.id != view.author_id:
            await interaction.response.send_message("Only the person who opened the poll can close it!", ephemeral=True)
            return
        view.closed = True
        for item in view.children:
            item.disabled = True
        await interaction.response.edit_message(embed=view.build_embed(), view=view)
        view.stop()

class PollView(discord.ui.View):
    def __init__(self, question: str, choices: list[str], author_id: int, timeout: float = 3600):
        super().__init__(timeout=timeout)
        self.question = question
        self.choices = choices
        self.author_id = author_id
        self.votes: dict[int, int] = {}
        self.closed = False

        for idx, label in enumerate(choices):
            self.add_item(VoteButton(label=label, index=idx))
        self.add_item(CloseButton())

    def build_embed(self) -> discord.Embed:
        counts = [0] * len(self.choices)
        for choice_idx in self.votes.values():
            counts[choice_idx] += 1
        total = sum(counts)

        lines = []
        for label, cnt in zip(self.choices, counts):
            ratio = (cnt / total) if total else 0
            bar = "█" * round(ratio * 12) or "░"
            lines.append(f"`{bar:<12}` **{cnt} votes** ({ratio * 100:.0f}%) · {label}")

        title = ("🔒 [마감] " if self.closed else "🗳️ ") + self.question
        embed = discord.Embed(title=title, description="\n".join(lines), color=0x5865F2)
        footer = f"{total} votes total"
        footer += " · Final results" if self.closed else " · Click a button to vote (click another to change it)"
        embed.set_footer(text=footer)
        return embed

    async def on_timeout(self):
        self.closed = True
        for item in self.children:
            item.disabled = True

@bot.event
async def on_ready():
    await bot.tree.sync()
    print(f"{bot.user} is ready!")

@bot.tree.command(name="투표", description="Starts a button poll (separate options with commas)")
async def poll_command(interaction: discord.Interaction, 질문: str, 항목: str, 시간: int = 1):
    choices = [c.strip() for c in 항목.split(",") if c.strip()][:5]
    if len(choices) < 2:
        await interaction.response.send_message("Please enter at least 2 options! e.g. Chicken,Pizza,Jokbal", ephemeral=True)
        return

    view = PollView(질문, choices, interaction.user.id, timeout=시간 * 3600)
    await interaction.response.send_message(embed=view.build_embed(), view=view)

@bot.tree.command(name="기본투표", description="Posts a native Discord poll")
async def native_poll(interaction: discord.Interaction, 질문: str, 항목: str, 시간: int = 24):
    choices = [c.strip() for c in 항목.split(",") if c.strip()][:10]
    if len(choices) < 2:
        await interaction.response.send_message("Please enter at least 2 options!", ephemeral=True)
        return

    poll = discord.Poll(question=질문, duration=datetime.timedelta(hours=시간))
    for c in choices:
        poll.add_answer(text=c)

    await interaction.response.send_message(poll=poll)

@bot.event
async def on_poll_vote_add(user, answer):
    print(f"[Vote] {user} → {answer.text}")

bot.run(token)

📌 Results

Start the bot and try this on your server (the question is "What should we eat tonight?", and the options are chicken, pizza, and jokbal).

Text
/투표 질문:오늘 저녁 뭐 먹을까요? 항목:치킨,피자,족발 시간:2

An embed shows up with 3 buttons plus a "Close Poll" button. The moment anyone clicks a button, the bar chart updates instantly. If the same person clicks a different button, their vote moves, and clicking the same button again doesn't add another vote.

When the person who opened the poll clicks "Close Poll", every button gets disabled and the title changes to 🔒 [마감] (Closed). Even if nobody clicks it, on_timeout locks it for you once 시간 (the duration, 1 hour by default) passes.

Try the native poll with /기본투표. The familiar poll UI from the Discord app pops up just as it is!

![](./img-03.png)


📌 Common errors

1. AttributeError: module 'discord' has no attribute 'Poll'

  • Cause: Your discord.py version is lower than 2.5. The Poll class was added in 2.5.
  • Fix: Run pip install -U "discord.py>=2.5", then check with python -c "import discord; print(discord.__version__)".

2. AttributeError: 'Intents' object has no attribute 'polls'

  • Cause: Same version issue as above. The polls intent also arrived in 2.5.
  • Fix: Upgrade, or if you only use the button approach, just delete the intents.polls = True line.

3. I clicked a poll button and got "This interaction failed"

  • Cause: You restarted the bot. A View object only works while it's alive in memory.
  • Fix: For short polls, just open a new one. If you want a poll that survives the bot going offline, you need to build a persistent view with timeout=None + a custom_id, and save the votes in a DB (SQLite). If the votes only live in an in-memory dictionary, they all vanish on restart!

4. Missing Permissions — the native poll won't post

  • Cause: The bot's role doesn't have the Create Polls (Send Polls) permission.
  • Fix: Go to Server Settings → Roles → your bot's role and turn that permission on. Check the per-channel permission overrides too.

5. My slash commands don't show up on the server

  • Cause: A global sync (bot.tree.sync()) can take up to an hour to take effect.
  • Fix: While testing, sync to a single server with await bot.tree.sync(guild=discord.Object(id=서버ID)) and it shows up immediately.

📌 Wrapping up

Today we built a poll & survey bot in two different ways 🙌

  • Native polls — done in 5 lines of code. Discord handles tallying and closing, so it's the easiest
  • Custom button polls — live bar charts, duplicate prevention, and early closing, all under your control

"I just want a quick answer" → native. "I want to show results nicely and add conditions" → buttons. That's all you need to remember!

In the next episode, we'll save these poll results to a database so they don't disappear when the bot restarts. It'll be the root-cause fix for error #3 above. Stay tuned! 😎

📚 Discord Bot Tutorial series

Series · Part 30 of 32

Discord Bot Tutorial

  1. #1Discord Bot Tutorial - Mastering Slash Commands!
  2. #2Adding Options (Arguments) to Slash Commands!
  3. #3Adding Subcommands to Slash Commands!
  4. #4Sending Embeds with Slash Commands!
  5. #5Implementing Button Interactions!
  6. #6Implementing a Dropdown (Select Menu)!
  7. #7Getting User Input with a Modal (Python Example)
  8. #8Styling Embeds with Images and Fields (Python Example)
  9. #9Connecting AI (Claude & ChatGPT API) to Your Bot! (Python Example)
  10. #10Adding Multi-Language (i18n) Support! (Python Example)
  11. #11Building a Web Dashboard with Flask! (Python Example)
  12. #12Managing Command Cooldowns and Permissions! (Python Example)
  13. #13Automating Roles and Channels to Manage Your Server! (Python Example)
  14. #14Saving Data! JSON vs SQLite (Python Example)
  15. #15슬래시 명령어 그룹(Group)으로 명령어 정리하기! (파이썬 예제) (Korean)
  16. #16리마인더·알림 기능 만들기! 봇이 먼저 말 걸게 하기 (파이썬 예제) (Korean)
  17. #17RAG로 문서 기반 답변봇 만들기! 우리 서버 전용 FAQ 봇 (파이썬 예제) (Korean)
  18. #18투표(Poll) 기능 만들기! 다수결로 정하는 서버 규칙 (파이썬 예제) (Korean)
  19. #19Cog로 코드 쪼개기! 1000줄 봇도 안 더러워지는 구조 (파이썬 예제) (Korean)
  20. #20자동 환영 메시지·온보딩 만들기! 규칙 동의 버튼으로 자동 역할 부여 (파이썬 예제) (Korean)
  21. #21키워드 자동응답·금지어 필터 만들기! 경고 누적으로 자동 제재까지 (파이썬 예제) (Korean)
  22. #22슬래시 명령어 자동완성(Autocomplete) 만들기! 금지어를 골라서 추가·삭제 (파이썬 예제) (Korean)
  23. #23aiosqlite로 비동기 DB 제대로 설계하기! JSON 탈출 (파이썬 예제) (Korean)
  24. #24채널 난독화(Channel Obfuscation) 대응하기! 11/16부터 안 보이는 채널이 생겨요 (파이썬 예제) (Korean)
  25. #25봇이 죽지 않게! 에러 핸들링·로깅 체계 잡기 (파이썬 예제) (Korean)
  26. #26티켓(Ticket) 문의 시스템 만들기! 버튼 하나로 1:1 상담 채널 자동 생성 (파이썬 예제) (Korean)
  27. #27티켓 담당자 배정·우선순위 만들기! 스태프가 여러 명이어도 안 꼬이게 (파이썬 예제) (Korean)
  28. #28레벨·경험치(XP) 시스템 만들기! 채팅할수록 레벨업, 랭킹판까지 (파이썬 예제) (Korean)
  29. #29화폐·상점(Economy) 시스템 만들기! 코인 벌어서 아이템 사기 (파이썬 예제) (Korean)
  30. #30Build a Poll & Survey Bot! Native Polls + Buttons with Real-Time Tallies (Python Example) 🗳️You are here
  31. #32Build a Server Log & Audit Log Channel! Auto-Log Message Deletes, Edits, Joins/Leaves, and Kicks (Python Example) 🕵️
  32. #33임시 음성방 확장, 파티방 만들기! 방장 위임·강퇴·초대 전용(비공개) 방 + 전용 채팅방 자동 생성 (파이썬 예제) 🎉 (Korean)
Show all 32Collapse

Author

Mojangcam

A hands-on dev and IT log: code I write, servers I run, and the errors I fix along the way.