Last Updated: June 01, 2026
Beginner
Twitter Bots can be super useful to help automate some of the interactions on social media in order to build and grow engagement but also automate some tasks. There has been many changes on the twitter developer account and sometimes it’s uncertain how to even create a tweet bot. This article will walk through step bey step on how to create a twitter bot with the latest Twitter API v2 and also provide some code you can copy and paste in your next project. We also end with how to create a more useful bot that can post some articles about python automatically.
In a nutshell, how a twitter bot works is that you will need to run your code for a twitter bot in your own compute that can be triggered from a Twitter webhook (not covered) which is called by twitter based on a given event, or by having your program run periodically to read and send tweets (covered in this article). Either way, there are some commonalities and in this article we will walk through how to read tweets, and then to send tweets which are from google news related to python!
Python developer and educator with 15+ years building production systems across data engineering, web APIs, and AI tooling. Founder of Python How To Program — 270+ in-depth tutorials covering the modern Python stack.
Step 1: Sign up for Developer program
If you haven’t already you will need to either sign in or sign up for a twitter account through twitter.com. Make sure your twitter account has an email address allocated to it (if you’re not aware, you can create a twitter account with just your mobile phone number)

Next go to developer.twitter.com and sign up for the developer program (yes, you need to sign up for a second time). This enables you to create applications.

First you’ll need to answer some questions on purpose of the developer account. You can chose “Make a Bot”

Next you will need to agree to the terms and conditions, and then a verification email will be sent to your email address from your twitter account.
When you click on the email to verify your account, you can then enter your app name. This is an internal name and something that will make it easy for you to reference.

Once you click on keys, you will then be given a set of security token keys like below. Please copy them in a safe place as your python code will need to use them to access your specific bot. If you do lose your keys, or someone gets access to them for some reason, you can generate new keys from your developer.twitter.com console.
There are two keys which you will need to use:
- API Key (think of this like a username)
- API Key Secret (think of this like a password)
- Bearer Token (used for read queries such as getting latest tweets)
There is also a third key, a Bearer Token, but this you can ignore. It is for certain types of requests

At the bottom of the screen you’ll see a “Skip to Dashboard”, when you click on that you’ll then see the overview of your API metrics.
Within this screen you can see the limits of the number of calls per month for example and how much you have already consumed.

Next, click on the project and we have to generate the access tokens. Currently with the previous keys you can only read tweets, you cannot create ones as yet.
After clicking on the project, chose the “keys and tokens” tab and at the bottom you can generate the “Access Tokens”. In this screen you can also re-generate the API Keys and Bearer Token you just created before in case your keys were compromised or you forgot them.

Just like before, generate the keys and copy them.

By now, you have 5 security toknes:
- API Key – also known as the Consumer Key (think of this like a username)
- API Key Secret – also known as the Consumer Secret (think of this like a password)
- Bearer Token (used for read queries such as getting latest tweets)
- Access Token (‘username’ to allow you to create tweets)
- Access Token Secret (‘password’ to allow you to create tweets)
Step 2: Test your twitter API query
Now that you have the API keys, you can do some tests. If you are using a linux based machine you can use the curl command to do a query. Otherwise, you can use a site such as https://reqbin.com/curl to do an online curl request.
Here’s a simple example to get the most recent tweets. It uses the API https://api.twitter.com/2/tweets/search/recent which must include the query keyword which includes a range of parameter options (find out the list in the twitter query documentation).
curl --request GET 'https://api.twitter.com/2/tweets/search/recent?query=from:pythonhowtocode' --header 'Authorization: Bearer <your bearer token from step 1>'
The output is as follows:
{
"data": [{
"id": "1523251860110405633",
"text": "See our latest article on THE complete beginner guide on creating a #discord #bot in #python \n\nEasily add this to your #100DaysOfCode #100daysofcodechallenge #100daysofpython \n\nhttps://t.co/4WKvDVh1g9"
}],
"meta": {
"newest_id": "1523251860110405633",
"oldest_id": "1523251860110405633",
"result_count": 1
}
}
Here’s a much more complex example. This includes the following parameters:
%23– which is the escape characters for#and searches for hashtags. Below example is hashtag#python(case insensitive)%20– this is an escape character for a space and separates different filters with anANDoperation-is:retweet– this excludes retweets. The ‘-‘ sign preceding theisnegates the actual filter-is:reply– this excludes replies. The ‘-‘ sign preceding theisnegates the actual filtermax_results=20– an integer that defines the maximum number of return results and in this case 20 resultsexpansions=author_id– this makes sure to include the username internal twitter id and also the actual username under anincludessection at the bottom of the returned JSONtweet.fields=public_metrics,created_at– returns the interaction metrics such as number of likes, number of retweets, etc as well as the time (in GMT timezone) when the tweet was createduser.fields=created_at,location– this returns when the user account was created and the user self-reported location in their profile.
curl --request GET 'https://api.twitter.com/2/tweets/search/recent?query=%23python%20-is:retweet%20-is:reply&max_results=20&expansions=author_id&tweet.fields=public_metrics,created_at&user.fields=created_at,location' --header 'Authorization: Bearer <Your Bearer Token from Step 1>'
Result of this looks like the following – notice that the username details is in the includes section below where you can link the tweet with the username with the author_id field.
{{
"data": [{
"id": "1523688996676812800",
"text": "NEED a #JOB?\nSign up now https://t.co/o7lVlsl75X\nFREE. NO MIDDLEMEN\n#Jobs #AI #DataAnalytics #MachineLearning #Python #JavaScript #WomenWhoCode #Programming #Coding #100DaysofCode #DEVCommunity #gamedev #gamedevelopment #indiedev #IndieGameDev #Mobile #gamers #RHOP #BTC #ETH #SOL https://t.co/kMYD2417jR",
"author_id": "1332714745871421443",
"public_metrics": {
"retweet_count": 3,
"reply_count": 0,
"like_count": 0,
"quote_count": 0
},
"created_at": "2022-05-09T15:39:00.000Z"
},
....
}],
"includes": {
"users": [{
"name": "Job Preference",
"id": "1332714745871421443",
"username": "JobPreference",
"created_at": "2020-11-28T15:56:01.000Z"
},
....
}
Step 3: Reading tweets with python code
Building on top of the tests conducted on Step 2, it is a simple extra step in order to convert this to python code using the requests module which we’ll show first and after show a simpler way with the library tweepy. You can simply use the library to convert the curl command into a bit of python code. Here’s a structured version of this code where the logic is encapsulated in a class.
import requests, json
from urllib.parse import quote
from pprint import pprint
class TwitterBot():
URL_SEARCH_RECENT = 'https://api.twitter.com/2/tweets/search/recent'
def __init__(self, bearer_key):
self.bearer_key = bearer_key
def search_recent(self, query, include_retweets=False, include_replies=False):
url = self.URL_SEARCH_RECENT + "?query=" + quote(query)
if not include_retweets: url += quote(' ')+'-is:retweet'
if not include_replies: url += quote(' ')+'-is:reply'
url += '&max_results=20&expansions=author_id&tweet.fields=public_metrics,created_at&user.fields=created_at,location'
headers = {'Authorization': 'Bearer ' + self.bearer_key }
r = requests.get(url, headers = headers)
r.encoding = r.apparent_encoding. #Ensure to use UTF-8 if unicode characters
return json.loads(r.text)
#create an instance and pass in your Bearer Token
t = TwitterBot('<Insert your Bearer Token from Step 1>')
pprint( t.search_recent( '#python') )
The above code is fairly straightforward and does the following:
TwitterBot class– this class encapsulates the logic to send the API requestsTwitterBot.search_recent– this method takes in the query string, then escapes any special characters, then calls therequests.get()to call thehttps://api.twitter.com/2/tweets/search/recentAPI callpprint()– this simply prints the output in a more readable format
This is the output:


However, there is a simpler way which is to use tweepy.
pip install tweepy
Next you can use the tweepy module to search recent tweets:
import tweepy
client = tweepy.Client(bearer_token='<insert your token here from previous step>')
query = '#python -is:retweet -is:reply' #exclude retweets and replies with '-'
tweets = client.search_recent_tweets( query=query,
tweet_fields=['public_metrics', 'context_annotations', 'created_at'],
user_fields=['username','created_at','location'],
expansions=['entities.mentions.username','author_id'],
max_results=10)
#The details of the users is in the 'includes' list
user_data = {}
for raw_user in tweets.includes['users']:
user_data[ raw_user.id ] = raw_user
for index, tweet in enumerate(tweets.data):
print(f"[{index}]::@{user_data[tweet.author_id]['username']}::{tweet.created_at}::{tweet.text.strip()}\n")
print("------------------------------------------------------------------------------")
Output as follows:

Please note, that after calling the API a few times your number of tweets consumed will have increased and may have hit the limit. You can always visit the dashboard at https://developer.twitter.com/en/portal/dashboard to see how many requests have been consumed. Notice, that this does not count the number of actual API calls but the actual number of tweets. So it can get consumed pretty quickly.

Step 4: Sending out a tweet
So far we’ve only been reading tweets. In order to send a tweet you can use the create_tweet() function of tweepy.
client = tweepy.Client( consumer_key= "<API key from above - see step 1>",
consumer_secret= "<API Key secret - see step 1>",
access_token= "<Access Token - see step 1>",
access_token_secret= "<Access Token Secret - see step 1>")
# Replace the text with whatever you want to Tweet about
response = client.create_tweet(text='A little girl walks into a pet shop and asks for a bunny. The worker says” the fluffy white one or the fluffy brown one”? The girl then says, I don’t think my python really cares.')
print(response)
Output from Console:

Output from Twitter:

How to Send Automated Tweets About the Latest News
To make this a bit more of a useful bot rather than simply tweet out static text, we’ll make it tweet about the latest things happened in the news about python.
In order to search for news information, you can use the python library pygooglenews
pip install pygooglenews
The library searches Google news RSS feed and was developed by Artem Bugara. You can see the full article of he developed the Google News library. You can put in a keyword and also time horizon to make it work. Here’s an example to find the latest python articles in last 24 hours.
from pygooglenews import GoogleNews
gn = GoogleNews()
search = gn.search('python programming', when = '12h')
for article in search['entries']:
print(article.title)
print(article.published)
print(article.source.title)
print('-'*80) #string multiplier - show '-' 80 times
Here’s the output:
So, the idea would be to show a random article on the twitter bot which is related to python programming. The gn.search() functions returns a list of all the articles under the entries dictionary item which has a list of those articles. We will simply pick a random one and construct the tweet with the article title and the link to the article.
import tweepy
from pygooglenews import GoogleNews
from random import randint
client = tweepy.Client( consumer_key= "<your consumer/API key - see step 1>",
consumer_secret= "<your consumer/API secret - see step 1>",
access_token= "<your access token key - see step 1>",
access_token_secret= "<your access token secret - see step 1>")
gn = GoogleNews()
search = gn.search('python programming', when = '24h')
#Find random article in last 24 hours using randint between index 0 and the last index
article = search['entries'][ randint( 0, len( search['entries'])-1 ) ]
#construct the tweet text
tweet_text = f"In python news: {article.title}. See full article: {article.link}. #python #pythonprogramming"
#Fire off the tweet!
response = client.create_tweet( tweet_text )
print(response)
Output from the console on the return result:

And, most importantly, here’s the tweet from our @pythonhowtocode! Twitter automatically pulled the article image

This has currently been scheduled as a daily background job!
How To Use Python questionary for Interactive CLI Prompts
Intermediate
You have a command-line tool that accepts a dozen options, and right now users pass them all as flags: --env production --region us-east-1 --deploy true. Nobody remembers the exact flag names, typos break things silently, and every new user has to read the docs just to run a basic command. The tool works fine — but actually using it feels like defusing a bomb.
Python’s questionary library solves this by giving you a set of beautifully rendered interactive prompts: arrow-key menus, checkboxes, masked password fields, and confirmation dialogs — all in the terminal, with zero dependencies on a browser or GUI framework. It wraps Python Prompt Toolkit under the hood and handles all the TTY complexity for you. Installation is one command: pip install questionary.
In this article we’ll walk through every major prompt type in questionary — text input, single-select menus, multi-select checkboxes, confirmation dialogs, and password fields. We’ll show how to chain prompts together into a multi-step workflow, apply validation, and then build a real-life project setup wizard that you can adapt for your own CLI tools. By the end you’ll be able to replace any flag-heavy CLI with one that guides users naturally through each choice.
questionary in Python: Quick Example
Here is a self-contained example that shows the three most common prompt types working together. Run it in a terminal (not a Jupyter notebook — questionary requires an interactive TTY).
# quick_example.py
import questionary
name = questionary.text("What is your name?").ask()
env = questionary.select(
"Which environment?",
choices=["development", "staging", "production"],
).ask()
confirmed = questionary.confirm(
f"Deploy {name}'s changes to {env}?", default=False
).ask()
if confirmed:
print(f"Deploying to {env}...")
else:
print("Cancelled.")
Output (interactive session in terminal):
? What is your name? Alice
? Which environment? (Use arrow keys)
> development
staging
production
? Deploy Alice's changes to development? (y/N) y
Deploying to development...
The .ask() method blocks until the user answers and returns the value — a string for text/select, a bool for confirm. All three calls are synchronous, which keeps the code easy to read and reason about. For a quick async variant, questionary also provides .ask_async(), but we will stick with the synchronous API throughout this article.
What Is questionary and When Should You Use It?
questionary is a Python library for building interactive terminal menus. It builds on top of prompt_toolkit and provides a clean, high-level API for the most common interactive patterns you would need in a CLI tool. Unlike argparse or click, which require users to know exactly what flags to pass, questionary prompts guide users step by step — making your tools more approachable for teammates who don’t live in the terminal all day.
questionary shines for: onboarding wizards, scaffolding scripts, deployment confirmations, config generators, and any workflow where the user needs to make several choices in sequence. It is not designed for non-interactive scripts (cron jobs, CI pipelines) — in those contexts you would pass values directly as arguments and skip the prompts altogether. You can detect that scenario with sys.stdin.isatty() and fall back to flag-based input.
| Prompt Type | Method | Returns | Best For |
|---|---|---|---|
| Free text | questionary.text() | str | Names, paths, free-form input |
| Single choice | questionary.select() | str | Pick one from a fixed list |
| Multiple choice | questionary.checkbox() | list[str] | Pick any combination |
| Yes/No | questionary.confirm() | bool | Destructive action gating |
| Password | questionary.password() | str | Secrets, API keys |
| Path | questionary.path() | str | File/directory selection with tab-complete |
| Autocomplete | questionary.autocomplete() | str | Long lists with fuzzy filtering |
Install questionary with pip before running any of the examples below:
# Install questionary
pip install questionary
Successfully installed questionary-2.0.1 prompt-toolkit-3.0.47 wcwidth-0.2.13
Text Input with Validation
The text() prompt accepts free-form keyboard input. On its own it accepts any string, including an empty one — which is rarely what you want. The validate parameter lets you enforce rules inline. Pass a function that returns True on valid input or an error message string when it fails; questionary will keep re-prompting until the rule passes.
# text_prompt.py
import questionary
def must_not_be_empty(value):
if not value.strip():
return "Project name cannot be blank."
if len(value) > 40:
return "Keep the project name under 40 characters."
return True
project_name = questionary.text(
"Enter project name:",
validate=must_not_be_empty,
).ask()
print(f"Project: {project_name}")
Output:
? Enter project name:
>> Please enter a valid value (Project name cannot be blank.)
? Enter project name: my-new-api
Project: my-new-api
The validation function is called on every keystroke as the user types, so feedback is immediate rather than appearing only after they press Enter. If the function returns the string True (not just a truthy value), questionary treats that as valid — so make sure to return the boolean True, not a non-empty string when the value is good. A non-empty string is always treated as an error message.
Single-Choice Menus with select()
The select() prompt renders a scrollable arrow-key menu where the user picks exactly one option. The choices parameter accepts either a plain list of strings or a list of Choice objects if you want to display one label but return a different underlying value.
# select_prompt.py
import questionary
from questionary import Choice
region = questionary.select(
"Choose a deployment region:",
choices=[
Choice("US East (N. Virginia)", value="us-east-1"),
Choice("EU West (Ireland)", value="eu-west-1"),
Choice("Asia Pacific (Sydney)", value="ap-southeast-2"),
],
use_shortcuts=True,
).ask()
print(f"Selected region slug: {region}")
Output:
? Choose a deployment region: (Use arrow keys)
> US East (N. Virginia)
EU West (Ireland)
Asia Pacific (Sydney)
Selected region slug: us-east-1
Setting use_shortcuts=True assigns number shortcuts (1, 2, 3…) to each choice so power users can skip the arrow key navigation entirely. The value returned by .ask() is always the value field of the selected Choice, not the display label — which means your downstream code works with slugs like us-east-1 rather than human-readable strings that might contain spaces or special characters.
Multi-Choice Menus with checkbox()
When a user needs to select multiple items from a list, checkbox() is the right tool. Users navigate with arrow keys, toggle individual items with the spacebar, and confirm their full selection by pressing Enter. The return value is always a list — even if only one item is selected.
# checkbox_prompt.py
import questionary
features = questionary.checkbox(
"Which features should we enable?",
choices=[
"Authentication (JWT)",
"Rate limiting",
"API documentation (Swagger)",
"Email notifications",
"Audit logging",
],
).ask()
if not features:
print("No features selected -- deploying a blank slate.")
else:
print(f"Enabling: {', '.join(features)}")
Output:
? Which features should we enable? (Use arrow keys, press Space to select, Enter to confirm)
o Authentication (JWT)
o Rate limiting
> o API documentation (Swagger)
o Email notifications
o Audit logging
Enabling: Authentication (JWT), API documentation (Swagger)
Always check whether the returned list is empty before iterating over it. Users can press Enter without selecting anything, which gives you an empty list rather than None. If one or more choices should always be pre-selected, pass checked=True inside a Choice object: Choice("Rate limiting", checked=True).
Confirmation Prompts
Before any irreversible action — deleting files, dropping a database, pushing to production — add a confirm() gate. It renders a Y/N prompt and returns a Python bool. The default parameter controls what Enter alone submits: set it to False for destructive operations so that a stray Enter key does not accidentally confirm deletion.
# confirm_prompt.py
import questionary
import sys
target = "production"
proceed = questionary.confirm(
f"This will wipe all data in {target}. Are you sure?",
default=False,
).ask()
if not proceed:
print("Aborted -- no changes made.")
sys.exit(0)
print(f"Wiping {target}... (not really, this is a demo)")
Output:
? This will wipe all data in production. Are you sure? (y/N) N
Aborted -- no changes made.
The uppercase letter in (y/N) signals the default. With default=False the N is uppercase, meaning Enter without typing anything registers as “No”. Flip it to default=True and the prompt shows (Y/n). This visual cue is standard terminal UX and users familiar with the shell will immediately understand it without reading any instructions.
Password Input
The password() prompt works identically to text() except that every character the user types is echoed as a bullet point (*), keeping secrets off the screen. This is the right choice for API keys, tokens, and passwords in setup wizards — never ask for these via a plain text prompt or a command-line flag (flags end up in shell history).
# password_prompt.py
import questionary
api_key = questionary.password(
"Enter your API key:",
validate=lambda val: True if len(val) >= 16 else "API keys are at least 16 characters."
).ask()
# In a real script you would pass this to your client, not print it.
print(f"Key accepted (length {len(api_key)})")
Output:
? Enter your API key: ****************
Key accepted (length 32)
The value returned by .ask() is the raw string the user typed — questionary does not hash it or store it anywhere. Handle it the same way you would any secret: pass it directly to your client library, never log it, and never embed it in error messages.
Chaining Prompts into a Multi-Step Workflow
questionary does not have a built-in form or wizard abstraction — but you do not need one. Python’s own control flow handles branching naturally. Call each .ask() in sequence, use regular if statements to branch based on earlier answers, and collect results in a dict. This pattern is readable, testable, and easy to extend.
# chained_prompts.py
import questionary
answers = {}
answers["name"] = questionary.text("Project name:").ask()
answers["type"] = questionary.select(
"Project type:",
choices=["API", "CLI tool", "Data pipeline", "Web app"],
).ask()
if answers["type"] == "API":
answers["auth"] = questionary.select(
"Authentication method:",
choices=["JWT", "OAuth2", "API key", "None"],
).ask()
else:
answers["auth"] = None
answers["ci"] = questionary.confirm("Add GitHub Actions CI?", default=True).ask()
print("\n-- Configuration Summary --")
for key, value in answers.items():
if value is not None:
print(f" {key}: {value}")
Output:
? Project name: payment-service
? Project type: API
? Authentication method: JWT
? Add GitHub Actions CI? (Y/n) Y
-- Configuration Summary --
name: payment-service
type: API
auth: JWT
ci: True
Notice that the “Authentication method” prompt only appears when the user selects “API” — a conditional prompt that would be awkward to model with command-line flags. The answers dict gives you a clean, serialisable snapshot of the entire session that you can write to a config file, pass to a scaffolding function, or log (minus any sensitive fields) for debugging.
Real-Life Example: Python Project Setup Wizard
Here is a complete CLI wizard that gathers project configuration and writes a minimal pyproject.toml to the current directory. It demonstrates validation, conditional branching, checkbox multi-select, and a final confirmation gate — all the patterns from this article working together.
# project_wizard.py
import questionary
from questionary import Choice
import sys
def run_wizard():
print("=== Python Project Setup Wizard ===\n")
name = questionary.text(
"Package name (lowercase, hyphens ok):",
validate=lambda v: True if v.replace("-", "").isalnum() and v == v.lower()
else "Use lowercase letters and hyphens only.",
).ask()
version = questionary.text("Version:", default="0.1.0").ask()
python_min = questionary.select(
"Minimum Python version:",
choices=["3.9", "3.10", "3.11", "3.12"],
default="3.11",
).ask()
extras = questionary.checkbox(
"Include optional tooling:",
choices=[
Choice("pytest (testing)", value="pytest"),
Choice("ruff (linting)", value="ruff"),
Choice("mypy (type checks)", value="mypy"),
Choice("black (formatting)", value="black"),
],
).ask()
license_type = questionary.select(
"License:",
choices=["MIT", "Apache-2.0", "GPL-3.0", "Proprietary"],
).ask()
confirmed = questionary.confirm(
f"\nWrite pyproject.toml for '{name}'?", default=True
).ask()
if not confirmed:
print("Cancelled.")
sys.exit(0)
dev_deps = "\n".join(
f' "{dep}>=0",' for dep in (extras or [])
)
toml_content = f"""[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build"
[project]
name = "{name}"
version = "{version}"
requires-python = ">={python_min}"
license = {{text = "{license_type}"}}
[project.optional-dependencies]
dev = [
{dev_deps}
]
"""
with open("pyproject.toml", "w") as f:
f.write(toml_content)
print(f"\nWrote pyproject.toml for {name} ({version})")
if extras:
print(f"Dev tools included: {', '.join(extras)}")
if __name__ == "__main__":
run_wizard()
Output:
=== Python Project Setup Wizard ===
? Package name (lowercase, hyphens ok): payment-api
? Version: 0.1.0
? Minimum Python version: 3.11
? Include optional tooling: (Space to select)
> x pytest (testing)
o ruff (linting)
x mypy (type checks)
o black (formatting)
? License: MIT
? Write pyproject.toml for 'payment-api'? (Y/n) Y
Wrote pyproject.toml for payment-api (0.1.0)
Dev tools included: pytest, mypy
The wizard collects seven pieces of information but only prompts what is relevant — a real advantage over a generic argument parser. To extend it, add a questionary.path() prompt asking where to write the file, or add a select() for the build backend. The entire wizard state lives in local variables, which makes unit-testing the logic straightforward: you can mock .ask() to return fixed values and assert that the generated TOML matches expectations.
Frequently Asked Questions
Why does .ask() return None sometimes?
.ask() returns None when the user presses Ctrl+C to cancel the prompt. Always check for None before using the returned value, or use .ask()‘s raise_keyboard_interrupt=False default behaviour and handle None explicitly. If you prefer an exception on Ctrl+C, pass raise_keyboard_interrupt=True and wrap the call in a try/except KeyboardInterrupt block.
How do I use questionary in CI or non-interactive environments?
questionary requires an interactive TTY. In a CI pipeline where stdin is not a terminal, calls to .ask() will raise an error or hang. The standard pattern is to detect non-interactive mode with sys.stdin.isatty() before running prompts and fall back to reading values from environment variables or command-line arguments. If you need testable prompts, look at questionary’s unsafe_prompt() or use a test runner that can simulate a TTY.
Can I pre-select a default value for select() and text()?
Yes. For text(), pass default="your-default" and the field will be pre-filled — the user can edit it or press Enter to accept. For select(), pass the display string of the option you want pre-highlighted as the default parameter. For checkbox(), pass checked=True inside individual Choice objects to pre-tick specific items.
Can I customise the colours and style of the prompts?
Yes. questionary exposes a style parameter on every prompt that accepts a questionary.Style object built from a list of CSS-like token/colour pairs. Common tokens are question, answer, pointer, selected, and highlighted. Colours can be named ("cyan", "red") or hex strings ("#ff6600"). This is useful for branding internal tools or distinguishing critical prompts visually.
Does questionary support async/await?
Yes — every prompt method has an async counterpart. Instead of .ask(), call await prompt.ask_async() inside an async function. This integrates cleanly with asyncio-based applications. Under the hood, questionary uses prompt_toolkit‘s async event loop, so you do not need to bridge two separate loops. The API is otherwise identical to the synchronous version.
How does questionary compare to PyInquirer or InquirerPy?
All three libraries share the same conceptual origin (JavaScript’s Inquirer.js). questionary has the most Pythonic API — each prompt type is a standalone function rather than a dict config. InquirerPy is more feature-complete (fuzzy search, editor prompts) but has a steeper API surface. PyInquirer is older and less maintained. For most projects, questionary’s balance of simplicity and capability is the right starting point; switch to InquirerPy if you need fuzzy-search menus or editor integration.
Conclusion
We covered the full questionary toolkit: text() for free-form input with inline validation, select() for arrow-key menus with display/value separation, checkbox() for multi-select lists, confirm() for safe destructive-action gates, and password() for keeping secrets off the screen. We also showed how regular Python if statements are all you need to chain prompts into conditional multi-step wizards, and wrapped everything into a project setup tool that writes a pyproject.toml.
A natural extension of the real-life example is to add a questionary.path() prompt for the output directory, integrate with a Cookiecutter template to scaffold full project structures, or call the wizard as a sub-command of a larger click-based CLI. questionary and Click pair very well — Click handles flag parsing for non-interactive use, questionary handles the interactive path, and you branch between them with sys.stdin.isatty().
Full documentation, changelog, and the list of available token names for custom styling are at the official questionary docs: questionary.readthedocs.io. The source is on GitHub at github.com/tmbo/questionary.
Related Articles
Further Reading: For more details, see the Python HTTP client documentation.
Pro Tips for Building a Better Twitter Bot
1. Respect Rate Limits with Exponential Backoff
The Twitter API enforces strict rate limits. Instead of crashing when you hit one, implement exponential backoff to retry gracefully. Wrap your API calls in a retry function that doubles the wait time after each failed attempt, starting from 1 second up to a maximum of 64 seconds. This keeps your bot running reliably without getting your credentials revoked.
# rate_limit_handler.py
import time
import requests
def api_call_with_backoff(url, headers, max_retries=5):
wait_time = 1
for attempt in range(max_retries):
response = requests.get(url, headers=headers)
if response.status_code == 200:
return response.json()
elif response.status_code == 429:
print(f"Rate limited. Waiting {wait_time}s...")
time.sleep(wait_time)
wait_time = min(wait_time * 2, 64)
else:
response.raise_for_status()
raise Exception("Max retries exceeded")
Output:
Rate limited. Waiting 1s...
Rate limited. Waiting 2s...
{'data': [{'id': '1234567890', 'text': 'Hello world'}]}
2. Never Hardcode API Keys
Store your API credentials in environment variables or a .env file, never in your source code. If you accidentally push hardcoded keys to a public GitHub repo, bots will find and abuse them within minutes. Use the python-dotenv library to load credentials from a .env file that you add to your .gitignore.
# secure_credentials.py
import os
from dotenv import load_dotenv
load_dotenv()
BEARER_TOKEN = os.getenv("TWITTER_BEARER_TOKEN")
API_KEY = os.getenv("TWITTER_API_KEY")
API_SECRET = os.getenv("TWITTER_API_SECRET")
if not BEARER_TOKEN:
raise ValueError("TWITTER_BEARER_TOKEN not set in .env file")
3. Add Logging Instead of Print Statements
Replace print() calls with Python’s built-in logging module. Logging gives you timestamps, severity levels, and the ability to write to files — essential for debugging a bot that runs unattended. When your bot tweets something unexpected at 3 AM, logs are the only way to figure out what happened.
# bot_with_logging.py
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler("bot.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
logger.info("Bot started successfully")
logger.warning("Approaching rate limit: 14/15 requests used")
logger.error("Failed to post tweet: 403 Forbidden")
Output:
2026-03-26 10:15:30 [INFO] Bot started successfully
2026-03-26 10:15:31 [WARNING] Approaching rate limit: 14/15 requests used
2026-03-26 10:15:32 [ERROR] Failed to post tweet: 403 Forbidden
4. Track Posted Content to Avoid Duplicates
Bots that post the same content repeatedly get flagged and suspended. Keep a simple record of what you have already tweeted using a JSON file or SQLite database. Before posting, check if the content has been posted before. This is especially important for news bots that might encounter the same story from multiple sources.
5. Use a Scheduler for Consistent Posting
Instead of running your bot in a loop with time.sleep(), use a proper scheduler like schedule or APScheduler. Schedulers handle timing more reliably, support cron-like expressions, and make it easy to run different tasks at different intervals. For production bots, consider using system-level scheduling with cron (Linux) or Task Scheduler (Windows).
Frequently Asked Questions
Can I still build a Twitter bot with the API?
Yes, but access has changed. The free tier of the X (formerly Twitter) API v2 allows basic posting. For reading tweets or higher volume, you need a paid plan. Check current pricing at developer.x.com.
What Python library should I use for the Twitter/X API?
Use tweepy for the most mature Python wrapper with v2 API support. It handles OAuth 2.0 authentication, rate limiting, and provides clean methods for posting, searching, and streaming.
How do I authenticate with the Twitter API v2?
Use OAuth 2.0 Bearer Token for read-only access or OAuth 1.0a for posting. Generate credentials in the X Developer Portal, then pass them to tweepy.Client().
What are the rate limits for the Twitter API?
Rate limits vary by endpoint and plan. The free tier allows 1,500 tweets per month. Always implement rate limit handling with tweepy’s wait_on_rate_limit=True.
What can a Twitter bot do?
Bots can auto-post content, reply to mentions, retweet by keyword, track hashtags, analyze sentiment, and provide automated responses. Always follow the X API terms of service.
Related Articles
- How To Build a Discord Bot with Python
- How To Handle API Rate Limits in Python
- How To Use Python Requests for REST APIs
Continue Learning Python
Tutorials you might also find useful:
Hey,
Thank you so much! I have tried sample codes from other tutorials, including twitter API documentation and none of that really worked. Your code works nice, thank you really.
David
Thanks for the feedback, glad it was helpful.