How To Extract Table Data From Webpages With Python 3

How To Extract Table Data From Webpages With Python 3

Last Updated: June 01, 2026

Beginner

Introduction

Web scraping is one of the most practical skills in a Python developer’s toolkit, and extracting tables from websites is a perfect starting point. Whether you’re gathering financial data, sports statistics, research tables, or any structured information published on the web, Python makes the process straightforward and efficient. Table extraction is particularly valuable because HTML tables are semi-structured, with clear rows and columns that translate naturally into Python data structures.

The good news? You don’t need to be a web development expert to extract tables with Python. Modern libraries handle the heavy lifting for you, whether you’re working with simple HTML tables or complex nested structures. Python provides multiple approaches, each suited to different scenarios, so you can choose the right tool for your job.

In this tutorial, you’ll learn three powerful approaches to table extraction: the beginner-friendly pandas.read_html(), the flexible BeautifulSoup method, and techniques for handling complex table structures. By the end, you’ll build a real-world scraping script that downloads tabular data and exports it to CSV. Let’s get started.

Pubs - Python How To Program
Written by Pubs

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.

View all tutorials by Pubs →

Quick Example: Extract a Table in One Line

If you’re in a hurry, here’s the fastest way to extract any table from a webpage:

# extract_wikipedia_table.py
import pandas as pd

# Extract all tables from a Wikipedia page
url = 'https://en.wikipedia.org/wiki/List_of_countries_by_population_(United_Nations)'
tables = pd.read_html(url)

# Get the first table as a DataFrame
df = tables[0]
print(df.head())
print(f"\nTable shape: {df.shape}")

Output:

                Country or area  Population  Change
0  Republic of India      1,417,173,173  +33,201,000
1  People's Republic of China      1,425,893,465  +13,611,000
2         United States of America        338,289,857   +2,073,000
3                    Indonesia        275,501,339   +4,158,000
4                    Pakistan        240,485,658   +5,549,000

Table shape: (195, 3)

That’s it. One function call extracts the table and returns it as a pandas DataFrame, ready for analysis or export. Of course, real-world scenarios often require more control and error handling—which is exactly what the rest of this tutorial covers.

What is Web Scraping and Why Extract Tables?

Web scraping is the automated process of extracting data from websites. Tables represent some of the cleanest, most structured data on the web, making them ideal scraping targets. Instead of manually copying and pasting data, you can write a Python script to fetch, parse, and organize information in seconds.

Here’s a comparison of the three main approaches you’ll learn:

Method Best For Learning Curve Speed Flexibility
pandas.read_html() Simple HTML tables, static pages Very Easy Fast Low
BeautifulSoup Complex tables, custom parsing Moderate Fast High
Selenium JavaScript-heavy pages, dynamic tables Hard Slow Very High

For most use cases, you’ll start with pandas or BeautifulSoup. Selenium is overkill unless the table loads via JavaScript.

Tables on the web: structured data hiding in unstructured HTML.
Tables on the web: structured data hiding in unstructured HTML.

Extracting Tables with pandas.read_html()

The pandas library is the Pythonic way to work with tabular data. Its read_html() function is designed specifically for extracting HTML tables and returns them as DataFrames—the standard data structure in pandas. This method requires minimal setup and handles most common table structures automatically.

Installation and Basic Usage

First, install pandas if you haven’t already:

pip install pandas lxml

The lxml parser significantly speeds up HTML parsing. Now extract a table:

# extract_quotes_table.py
import pandas as pd

# Extract tables from quotes.toscrape.com
url = 'http://quotes.toscrape.com/js/'
tables = pd.read_html(url, match='Quote')

if tables:
    df = tables[0]
    print(df.head())
else:
    print("No matching table found")

Output (example):

                                                Quote Author
0  "The only way to do great work is to love what...  Steve Jobs
1  "If you love life, don't waste time. For time ...   Buddha
2  "The way to get started is to quit talking an...  Walt Disney
3  "Don't let yesterday take up too much of today...     Will Rogers
4  "You miss 100% of the shots you don't take."        Wayne Gretzky

Handling Multiple Tables

Websites often contain multiple tables. read_html() returns a list of all detected tables. You can filter by table index or use pattern matching:

# extract_multiple_tables.py
import pandas as pd

url = 'https://en.wikipedia.org/wiki/Python_(programming_language)'

# Extract all tables
all_tables = pd.read_html(url)
print(f"Found {len(all_tables)} tables")

# Get a specific table by index
first_table = all_tables[0]
print(first_table.head())

# Or filter by content using the match parameter
version_tables = pd.read_html(url, match='Release')
for i, table in enumerate(version_tables):
    print(f"\nTable {i}:")
    print(table.head(2))

Output (condensed):

Found 8 tables
       Release  Date  End of support
0      3.11    2022-10-24       2027-10-24
1      3.12    2023-10-02       2028-10-02

Extracting Tables with BeautifulSoup

BeautifulSoup gives you fine-grained control over HTML parsing. While pandas is faster for simple cases, BeautifulSoup shines when you need to clean messy data, handle custom table layouts, or combine table extraction with other web scraping tasks.

Installation and Basic Setup

pip install beautifulsoup4 requests

Parsing a Simple Table

# extract_books_beautifulsoup.py
from bs4 import BeautifulSoup
import requests

url = 'http://books.toscrape.com/'
response = requests.get(url)
soup = BeautifulSoup(response.content, 'html.parser')

# Find all table rows
rows = []
for article in soup.find_all('article', class_='product_pod'):
    title = article.find('h3').find('a')['title']
    price = article.find('p', class_='price_color').text
    availability = article.find('p', class_='instock availability').text.strip()

    rows.append({
        'Title': title,
        'Price': price,
        'Availability': availability
    })

# Display results
for row in rows[:5]:
    print(f"{row['Title']}: {row['Price']} - {row['Availability']}")

Output (sample):

A Light in the Attic: £51.77 - In stock
Tipping the Velvet: £53.74 - In stock
Soumission: £50.10 - In stock
Sharp Objects: £47.82 - In stock
Sapiens: £54.23 - In stock

Extracting from HTML Table Tags

When a page uses proper HTML <table> elements, BeautifulSoup makes extraction straightforward:

# extract_html_table_beautifulsoup.py
from bs4 import BeautifulSoup
import requests
import pandas as pd

url = 'https://en.wikipedia.org/wiki/List_of_countries_by_GDP_(nominal)'
response = requests.get(url)
soup = BeautifulSoup(response.content, 'html.parser')

# Find the first table
table = soup.find('table', class_='wikitable')

# Extract headers
headers = []
for th in table.find_all('th'):
    headers.append(th.get_text(strip=True))

# Extract rows
rows = []
for tr in table.find_all('tr')[1:]:  # Skip header row
    cells = [td.get_text(strip=True) for td in tr.find_all('td')]
    if cells:
        rows.append(cells)

# Create DataFrame
df = pd.DataFrame(rows, columns=headers)
print(df.head())

Output (example):

  Rank Country   GDP (USD Millions)
0    1   United States      27,360,000
1    2   China          17,920,000
2    3   Germany          4,080,000
3    4   Japan           4,230,000
4    5   India           3,730,000
pandas.read_html: scrape and parse in one line.
pandas.read_html: scrape and parse in one line.

Handling Complex Tables

Real-world tables often have colspan, rowspan, merged cells, or nested structures. Here’s how to handle them robustly:

Dealing with Colspan and Rowspan

When cells span multiple columns or rows, you need defensive parsing:

# extract_complex_table.py
from bs4 import BeautifulSoup
import pandas as pd

html = '''
Name Score
First Last Points
John Doe 95
''' soup = BeautifulSoup(html, 'html.parser') table = soup.find('table') # Extract with colspan handling data = [] for tr in table.find_all('tr')[1:]: # Skip header row = [] for td in tr.find_all(['td', 'th']): # Get colspan attribute (default to 1) colspan = int(td.get('colspan', 1)) cell_text = td.get_text(strip=True) # Repeat cell content for merged columns row.extend([cell_text] * colspan) if row: data.append(row) print(data)

Output:

[['John', 'Doe', '95']]

Handling Missing Data and Messy Tables

# clean_extracted_table.py
import pandas as pd
import re

# Simulate extracted data with messy values
raw_data = [
    ['Product', 'Price', 'Stock'],
    ['Widget A', '$19.99', 'Yes'],
    ['Widget B', 'N/A', ''],
    ['Widget C', '$29.50', 'No']
]

df = pd.DataFrame(raw_data[1:], columns=raw_data[0])

# Clean price column
df['Price'] = df['Price'].replace('N/A', None).str.replace('$', '', regex=False)
df['Price'] = pd.to_numeric(df['Price'], errors='coerce')

# Fill empty stock values
df['Stock'] = df['Stock'].replace('', 'Unknown')

print(df)
print(f"\nData types:\n{df.dtypes}")

Output:

   Product  Price     Stock
0  Widget A  19.99       Yes
1  Widget B    NaN  Unknown
2  Widget C  29.50        No

Data types:
Product     object
Price     float64
Stock      object

Exporting Table Data to CSV and Excel

Once you’ve extracted a table into a pandas DataFrame, exporting is trivial:

# export_table_data.py
import pandas as pd

# Create sample DataFrame
df = pd.DataFrame({
    'Name': ['Alice', 'Bob', 'Charlie'],
    'Department': ['Sales', 'Engineering', 'Marketing'],
    'Salary': [65000, 85000, 72000]
})

# Export to CSV
df.to_csv('employees.csv', index=False)
print("Exported to employees.csv")

# Export to Excel (requires openpyxl)
df.to_excel('employees.xlsx', sheet_name='Staff', index=False)
print("Exported to employees.xlsx")

# Export to JSON
df.to_json('employees.json', orient='records', indent=2)
print("Exported to employees.json")

Output:

Exported to employees.csv
Exported to employees.xlsx
Exported to employees.json

Install openpyxl for Excel support: pip install openpyxl

When BeautifulSoup grabs the table you can't get otherwise.
When BeautifulSoup grabs the table you can’t get otherwise.

Real-Life Example: Build a Complete Scraping Script

Let’s build a practical script that scrapes book data from books.toscrape.com, cleans it, and exports to CSV:

# scrape_books_complete.py
import requests
from bs4 import BeautifulSoup
import pandas as pd
import time

def scrape_books(base_url='http://books.toscrape.com/', max_pages=2):
    """
    Scrape books from toscrape.com and return as DataFrame
    """
    all_books = []

    for page_num in range(1, max_pages + 1):
        # Handle pagination
        if page_num == 1:
            url = base_url
        else:
            url = f"{base_url}page-{page_num}/"

        try:
            response = requests.get(url, timeout=10)
            response.raise_for_status()
        except requests.RequestException as e:
            print(f"Error fetching page {page_num}: {e}")
            continue

        soup = BeautifulSoup(response.content, 'html.parser')

        # Extract book data
        for article in soup.find_all('article', class_='product_pod'):
            try:
                title = article.find('h3').find('a')['title']
                price_text = article.find('p', class_='price_color').text
                price = float(price_text[1:])  # Remove £ symbol

                availability = article.find('p', class_='instock availability').text.strip()
                rating = article.find('p', class_='star-rating')['class'][1]

                all_books.append({
                    'Title': title,
                    'Price': price,
                    'Availability': availability,
                    'Rating': rating
                })
            except (AttributeError, ValueError, IndexError) as e:
                print(f"Error parsing book: {e}")
                continue

        # Be respectful to the server
        time.sleep(1)

    return pd.DataFrame(all_books)

# Run the scraper
if __name__ == '__main__':
    df = scrape_books(max_pages=2)

    print(f"Scraped {len(df)} books\n")
    print(df.head(10))

    # Export results
    df.to_csv('books_data.csv', index=False)
    print(f"\nData saved to books_data.csv")

    # Quick statistics
    print(f"\nAverage price: £{df['Price'].mean():.2f}")
    print(f"Rating distribution:\n{df['Rating'].value_counts().sort_index()}")

Output (sample):

Scraped 40 books

                                                 Title  Price Availability Rating
0  A Light in the Attic                         51.77  In stock  Three
1  Tipping the Velvet                           53.74  In stock   Not in stock Three
2  Soumission                                   50.10  In stock    In stock Three
3  Sharp Objects                                47.82  In stock    In stock  Four
4  Sapiens: A Brief History of Humankind       54.23  In stock    In stock  Five

Data saved to books_data.csv

Average price: £38.12
Rating distribution:
One     2
Two     5
Three  14
Four   12
Five    7

Key practices in this script:

  • Error handling with try-except blocks prevents crashes on malformed HTML
  • raise_for_status() catches HTTP errors early
  • time.sleep() respects the server and avoids rate-limiting
  • Data cleaning (removing currency symbols, parsing numbers)
  • Pagination handling for multi-page data

Frequently Asked Questions

Q: Is web scraping legal?

Web scraping is legal in most jurisdictions. However, always check the website’s robots.txt and terms of service. Respect rate limits, avoid overloading servers, and never scrape personal data without consent. Many sites publish data APIs as an alternative to scraping.

Q: How do I handle JavaScript-rendered tables?

If a table loads via JavaScript, pandas and BeautifulSoup won’t see it because they parse static HTML. Use Selenium to load the page in a browser, wait for JavaScript to execute, then scrape. See the related article on Selenium setup.

Q: What’s the best way to handle tables with headers in unexpected locations?

Use BeautifulSoup instead of pandas. Manually inspect the HTML structure and write logic to identify header rows. You can look for <thead> tags or <th> elements, or identify headers by visual inspection of the HTML.

Q: How do I avoid getting blocked while scraping?

Use time.sleep() between requests, set realistic User-Agent headers, rotate IP addresses if doing large-scale scraping, and always respect robots.txt. For high-volume work, consider using the site’s API or contacting the owner for data access.

Q: Can pandas.read_html() handle complex nested tables?

Not well. For deeply nested or complex structures, BeautifulSoup gives you the control to navigate the HTML tree manually. pandas works best with clean, well-formed tables using standard <table>, <tr>, <td> markup.

Q: How do I debug when table extraction fails?

First, print the page source to inspect the HTML structure: print(response.text) or save it to a file. Check for JavaScript rendering, unusual class names, or missing standard table tags. Use browser Developer Tools (F12) to examine the actual DOM.

Conclusion

You now have three proven approaches to extracting table data from webpages: the quick pandas.read_html() for simple cases, flexible BeautifulSoup for complex scenarios, and defensive parsing techniques for messy real-world data. Start with pandas for speed, switch to BeautifulSoup when you need control, and add Selenium only when tables load via JavaScript.

The key to successful web scraping is respecting servers, handling errors gracefully, and understanding the HTML you’re parsing. Use browser Developer Tools to inspect page structure, always add delays between requests, and test your scripts on small samples before scaling.

For more details, explore the official documentation:

The Quick Win: pandas.read_html

If the page has a <table> element, pd.read_html turns every table on the page into a DataFrame in one line:

# pip install pandas lxml html5lib

import pandas as pd

# Returns a list of DataFrames — one per  on the page
tables = pd.read_html("https://en.wikipedia.org/wiki/List_of_countries_by_GDP_(nominal)")

# Usually the first match is what you want
gdp = tables[0]
print(gdp.head())

# Filter and sort like any DataFrame
top_10 = gdp.head(10)
print(top_10[["Country", "GDP (nominal, billion USD)"]])

For semi-structured pages (Wikipedia, government data, financial reports), this is often all you need. Five seconds of code beats an hour of BeautifulSoup parsing.

BeautifulSoup for Hand-Rolled Tables

When tables are nested, irregular, or don’t use proper <table> tags, fall back to BeautifulSoup:

import requests
from bs4 import BeautifulSoup

resp = requests.get("https://example.com/data-page")
soup = BeautifulSoup(resp.text, "html.parser")

# Find the right table — by id, class, or position
table = soup.find("table", {"class": "data-grid"})

rows = []
for tr in table.find_all("tr"):
    cells = [td.get_text(strip=True) for td in tr.find_all(["td", "th"])]
    rows.append(cells)

# Convert to DataFrame
import pandas as pd
df = pd.DataFrame(rows[1:], columns=rows[0])
print(df.head())

Handling Dynamic / JavaScript-Rendered Tables

Single-page apps render tables client-side via JavaScript. requests + pd.read_html sees only an empty shell. Two paths to fix:

Path 1 — Find the underlying API. Open browser DevTools, Network tab, filter to XHR. The table data usually comes from a JSON endpoint. Hit that endpoint directly with requests:

import requests, pandas as pd

resp = requests.get(
    "https://api.example.com/v2/countries/gdp",
    headers={"Accept": "application/json"},
)
data = resp.json()
df = pd.DataFrame(data["items"])
print(df.head())

This is dramatically faster than rendering the page in a browser and parsing the resulting HTML. Always check for an API first.

Path 2 — Render the page with Playwright. When the API isn’t accessible (auth, anti-bot, generated state), Playwright runs the JS and returns the fully rendered HTML:

# pip install playwright
# python -m playwright install

from playwright.sync_api import sync_playwright
import pandas as pd

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("https://example.com/dynamic-table")
    page.wait_for_selector("table.data-grid")    # wait for data to load

    html = page.content()
    browser.close()

tables = pd.read_html(html)
print(tables[0].head())

Pagination and Multi-Page Tables

Many tables span multiple pages. Loop over pages, accumulate, then concat:

import pandas as pd

all_dfs = []
for page_num in range(1, 11):
    url = f"https://example.com/data?page={page_num}"
    tables = pd.read_html(url)
    if not tables:
        break
    all_dfs.append(tables[0])

combined = pd.concat(all_dfs, ignore_index=True)
print(combined.shape, combined.head())

Cleaning Extracted Data

Tables on the web are messy. Common cleanups after extraction:

# Trim whitespace and remove footnote markers like '[1]', '[2]'
df = df.applymap(lambda x: str(x).strip().replace("[1]", "").replace("[2]", ""))

# Convert numeric columns (often imported as strings)
df["GDP"] = (
    df["GDP"]
    .str.replace(",", "")
    .str.replace("$", "")
    .str.replace("billion", "")
    .astype(float)
)

# Drop rows that are all-NaN or just delimiters
df = df.dropna(how="all").reset_index(drop=True)

Common Pitfalls

  • Forgetting the lxml dependency. pd.read_html raises ImportError without lxml or html5lib installed. pip install lxml fixes it.
  • Skipping the API check. Scraping the rendered HTML when a JSON API exists is 10x slower and 10x more brittle. Always check DevTools first.
  • Ignoring robots.txt. Some sites prohibit scraping. Check robots.txt and Terms of Service before automating heavy traffic.
  • Rate-limit ignorance. Hitting a site with 1000 requests in a minute earns a ban. Add time.sleep(1) between requests, or use the Retry-After header from 429 responses.
  • Mishandling unicode. read_html defaults to a system encoding. If you see mojibake, pass encoding="utf-8" or fetch with requests first and parse the response.

FAQ

Q: pandas.read_html or BeautifulSoup?
A: read_html first — it’s one line. BeautifulSoup when the table isn’t a proper <table> tag or you need fine control over which cells to extract.

Q: How do I scrape a table behind login?
A: Authenticate via requests.Session() first (POST credentials, persist cookies), then GET the page. Playwright handles complex login flows automatically — point it at the login page, fill the form, wait for redirect.

Q: The page returns 403 / 429 — what do I do?
A: Set a real User-Agent header. Throttle to one request per second. If it’s still blocked, the site is using Cloudflare or similar — see anti-scraping countermeasures.

Q: How do I handle merged cells (rowspan / colspan)?
A: read_html and BeautifulSoup don’t always unfold spans correctly. Manual fix: walk the rows tracking active spans, repeating values across the implied cells.

Q: Polars or pandas for big tables?
A: For scraped data, pandas is fine. If you’ll process millions of rows downstream, switch to Polars after extraction (pl.from_pandas(df)).

Wrapping Up

Most table scraping comes down to three lines of pandas. When the page is static, pd.read_html is the right answer. When JS renders the data, look for the underlying JSON API first; fall back to Playwright if you must. BeautifulSoup is the escape hatch for irregular markup. Combine throttling, real User-Agents, and respect for robots.txt — and you’re a good citizen who also gets clean data.

How To Print String in Color in Python

How To Print String in Color in Python

Last Updated: June 01, 2026

Beginner

Pubs - Python How To Program
Written by Pubs

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.

View all tutorials by Pubs →

Introduction to Colored Terminal Output in Python

Have you ever noticed how modern command-line applications display colorful output? Success messages appear in green, warnings shine in yellow, and errors stand out in red. Whether you’re building CLI tools, debugging with colored logging output, or creating interactive terminal applications, adding color to your Python strings can dramatically improve user experience and make information easier to scan and understand.

The good news is that printing colored text in Python is surprisingly simple and doesn’t require complex external dependencies. Whether you prefer using built-in ANSI escape codes, lightweight libraries like colorama and termcolor, or powerful formatting tools like rich, Python offers multiple approaches to suit your needs. Even beginners can master colored text output in just a few minutes.

In this tutorial, we’ll explore four different methods to print colored strings in Python, from the most straightforward ANSI codes to advanced formatting options. By the end, you’ll understand how terminal colors work, how to choose the right approach for your project, and how to implement colored output in real-world applications like logging systems and status reporters.

Quick Example: Print Colored Text

Can’t wait to see colored text? Here’s the simplest way using ANSI escape codes:

# quick_color.py
print("\033[92mSuccess! Operation completed.\033[0m")
print("\033[93mWarning: Check this value.\033[0m")
print("\033[91mError: Something went wrong.\033[0m")

Output:

Success! Operation completed. [displayed in green]
Warning: Check this value. [displayed in yellow]
Error: Something went wrong. [displayed in red]
Color is the difference between unreadable logs and useful ones.
Color is the difference between unreadable logs and useful ones.

What are ANSI Escape Codes?

ANSI escape codes are special character sequences that tell your terminal to change text color and styling. They follow the pattern \033[CODEm where CODE represents a specific color or style. The sequence always ends with \033[0m to reset formatting back to normal.

Here’s a quick reference table of common ANSI color codes:

Color Foreground Code Background Code
Black 30 40
Red 31 41
Green 32 42
Yellow 33 43
Blue 34 44
Magenta 35 45
Cyan 36 46
White 37 47
Bright Green 92 102
Bright Yellow 93 103
Bright Red 91 101

ANSI codes also support text styling. Use 1 for bold, 2 for dim, 4 for underline. Combine multiple codes with semicolons: \033[1;92m creates bold bright green text.

Using ANSI Escape Codes Directly

The most minimal approach is to use ANSI codes directly in your strings. This works on macOS, Linux, and modern Windows 10+ systems. No installation required—just pure Python.

# ansi_colors.py
# Define reusable color constants
GREEN = "\033[92m"
YELLOW = "\033[93m"
RED = "\033[91m"
BLUE = "\033[94m"
RESET = "\033[0m"

# Use in print statements
print(f"{GREEN}Success!{RESET} Operation completed.")
print(f"{YELLOW}Warning:{RESET} Check this configuration.")
print(f"{RED}Error:{RESET} Connection failed.")
print(f"{BLUE}Info:{RESET} Processing 1000 items.")

# Bold and underline
BOLD_GREEN = "\033[1;92m"
UNDERLINE = "\033[4m"
print(f"{BOLD_GREEN}{UNDERLINE}Important Notice{RESET}")

# Background colors
BG_RED = "\033[41m"
WHITE = "\033[37m"
print(f"{BG_RED}{WHITE}CRITICAL{RESET} System failure detected.")

Output:

Success! Operation completed. [green text]
Warning: Check this configuration. [yellow warning]
Error: Connection failed. [red error]
Info: Processing 1000 items. [blue info]
Important Notice [bold green underlined]
CRITICAL System failure detected. [white text on red background]
Red for errors, green for success. Cognitive labor saved.
Red for errors, green for success. Cognitive labor saved.

Using the colorama Library (Cross-Platform)

While ANSI codes work great on most systems, Windows PowerShell can be finicky. The colorama library handles platform differences automatically and provides a cleaner API. Install it with pip install colorama.

# colorama_example.py
from colorama import Fore, Back, Style, init

# Initialize colorama (enables support on Windows)
init(autoreset=True)

print(f"{Fore.GREEN}Success!{Style.RESET_ALL} Your file was saved.")
print(f"{Fore.YELLOW}Warning:{Style.RESET_ALL} Disk space running low.")
print(f"{Fore.RED}Error:{Style.RESET_ALL} Database connection timeout.")

# Background colors
print(f"{Back.CYAN}{Fore.BLACK}Info{Style.RESET_ALL} Processing complete.")

# Text styling
print(f"{Style.BRIGHT}{Fore.BLUE}Important update available!{Style.RESET_ALL}")
print(f"{Style.DIM}{Fore.WHITE}Deprecated function{Style.RESET_ALL}")

# Combine multiple attributes
print(f"{Style.BRIGHT}{Fore.RED}{Back.YELLOW}ALERT{Style.RESET_ALL} Manual intervention required.")

Output:

Success! Your file was saved. [green]
Warning: Disk space running low. [yellow]
Error: Database connection timeout. [red]
Info Processing complete. [black text on cyan]
Important update available! [bright blue]
Deprecated function [dim white]
ALERT Manual intervention required. [bright red on yellow background]

Using the termcolor Library

For a lightweight alternative, termcolor offers a simple function-based API. Install with pip install termcolor. It’s ideal when you need quick colored output without extra features.

# termcolor_example.py
from termcolor import colored

# Simple colored text
print(colored("Success!", "green"), "Your changes were saved.")
print(colored("Warning:", "yellow"), "This feature is deprecated.")
print(colored("Error:", "red"), "Authentication failed.")
print(colored("Debug:", "cyan"), "Current value = 42")

# Add background colors
print(colored("CRITICAL", "white", "on_red"), "System overload detected.")
print(colored("INFO", "black", "on_green"), "Backup completed successfully.")

# Add text attributes (bold, dark, underline)
print(colored("Important", "blue", attrs=["bold"]))
print(colored("Deprecated", "white", attrs=["dark"]))
print(colored("Required", "red", attrs=["bold", "underline"]))

# Multiple attributes
print(colored("ALERT", "white", "on_yellow", attrs=["bold", "blink"]))

Output:

Success! Your changes were saved. [green]
Warning: This feature is deprecated. [yellow]
Error: Authentication failed. [red]
Debug: Current value = 42 [cyan]
CRITICAL System overload detected. [white on red]
INFO Backup completed successfully. [black on green]
Important [bold blue]
Deprecated [dark white]
Required [bold underlined red]
ALERT [bold yellow with blinking]
Same data, different signal-to-noise.
Same data, different signal-to-noise.

Using the rich Library for Advanced Formatting

The rich library is a powerhouse for terminal formatting, supporting colors, tables, panels, progress bars, and syntax highlighting. Install with pip install rich. Use it when you need professional-looking terminal output.

# rich_example.py
from rich.console import Console
from rich import print as rprint
from rich.panel import Panel
from rich.table import Table

console = Console()

# Simple colored text
console.print("[green]Success![/green] Operation completed.")
console.print("[yellow]Warning:[/yellow] Check system resources.")
console.print("[red]Error:[/red] Network timeout occurred.")

# Using rprint function (shorthand)
rprint("[bold blue]Important notice[/bold blue]")
rprint("[dim italic cyan]System info[/dim italic cyan]")

# Panels for highlighted messages
console.print(Panel("[green bold]✓ Deployment Successful[/green bold]", expand=False))
console.print(Panel("[yellow bold]⚠ Review Required[/yellow bold]", expand=False))
console.print(Panel("[red bold]✗ Critical Error[/red bold]", expand=False))

# Tables with colors
table = Table(title="System Status", show_header=True, header_style="bold magenta")
table.add_column("Component", style="cyan")
table.add_column("Status", style="green")
table.add_row("Database", "[green]Online[/green]")
table.add_row("Cache", "[yellow]Warming[/yellow]")
table.add_row("API", "[red]Offline[/red]")
console.print(table)

Output:

Success! Operation completed. [green]
Warning: Check system resources. [yellow]
Error: Network timeout occurred. [red]
Important notice [bold blue]
System info [dim italic cyan]
┌─ Deployment Successful ─┐ [green box]
⚠ Review Required [yellow box]
✗ Critical Error [red box]
[System Status table with colored columns]

Adding Color to Logging Output

Colored output becomes especially valuable in logging systems where different severity levels are immediately recognizable. Here’s how to add colors to Python’s logging module:

# colored_logging.py
import logging
from colorama import Fore, Style, init

init()

class ColoredFormatter(logging.Formatter):
    COLORS = {
        "DEBUG": Fore.CYAN,
        "INFO": Fore.GREEN,
        "WARNING": Fore.YELLOW,
        "ERROR": Fore.RED,
        "CRITICAL": f"{Fore.RED}{Style.BRIGHT}"
    }

    def format(self, record):
        log_color = self.COLORS.get(record.levelname, Fore.WHITE)
        record.levelname = f"{log_color}{record.levelname}{Style.RESET_ALL}"
        return super().format(record)

# Configure logger
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)

handler = logging.StreamHandler()
handler.setFormatter(ColoredFormatter(
    "%(levelname)s - %(name)s - %(message)s"
))
logger.addHandler(handler)

# Use the logger
logger.debug("Debugging information retrieved")
logger.info("Application started successfully")
logger.warning("Low memory condition detected")
logger.error("Failed to connect to database")
logger.critical("System shutdown initiated")

Output:

DEBUG - __main__ - Debugging information retrieved [cyan]
INFO - __main__ - Application started successfully [green]
WARNING - __main__ - Low memory condition detected [yellow]
ERROR - __main__ - Failed to connect to database [red]
CRITICAL - __main__ - System shutdown initiated [bright red]

Real-Life Example: Colored CLI Status Reporter

Let’s build a practical application that monitors system status and displays results with color-coded output. This demonstrates how colors improve information clarity in real scenarios.

# system_status.py
import psutil
from colorama import Fore, Style, init

init(autoreset=True)

def get_status_color(value, thresholds):
    """Determine color based on value thresholds."""
    if value < thresholds["warning"]:
        return Fore.GREEN, "OK"
    elif value < thresholds["critical"]:
        return Fore.YELLOW, "WARNING"
    else:
        return Fore.RED, "CRITICAL"

def report_system_status():
    """Display colored system status report."""
    print(f"{Style.BRIGHT}=== System Status Report ==={Style.RESET_ALL}\n")

    # CPU Usage
    cpu_percent = psutil.cpu_percent(interval=1)
    color, status = get_status_color(cpu_percent,
                                     {"warning": 50, "critical": 80})
    print(f"CPU Usage:    {color}{cpu_percent:>6.1f}%{Style.RESET_ALL} [{status}]")

    # Memory Usage
    mem = psutil.virtual_memory()
    color, status = get_status_color(mem.percent,
                                     {"warning": 70, "critical": 90})
    print(f"Memory:       {color}{mem.percent:>6.1f}%{Style.RESET_ALL} [{status}]")

    # Disk Usage
    disk = psutil.disk_usage("/")
    color, status = get_status_color(disk.percent,
                                     {"warning": 75, "critical": 90})
    print(f"Disk Usage:   {color}{disk.percent:>6.1f}%{Style.RESET_ALL} [{status}]")

    # Process Count
    process_count = len(psutil.pids())
    color = Fore.GREEN if process_count < 200 else Fore.YELLOW
    print(f"Processes:    {color}{process_count:>6}{Style.RESET_ALL}")

    print(f"\n{Fore.CYAN}Generated at {Style.BRIGHT}{get_timestamp()}{Style.RESET_ALL}")

def get_timestamp():
    """Return current timestamp."""
    from datetime import datetime
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")

if __name__ == "__main__":
    report_system_status()

Output:

=== System Status Report ===

CPU Usage:     45.2% [OK] [green]
Memory:        68.3% [OK] [green]
Disk Usage:    82.1% [WARNING] [yellow]
Processes:       187 [green]

Generated at 2026-03-18 14:32:15 [cyan]

Frequently Asked Questions

Q1: Why don’t colors work on my Windows system?

By default, older Windows versions don’t support ANSI codes. Solution: Use the colorama library which automatically enables support on Windows, or enable ANSI in Windows 10+ through system settings. The init() function in colorama handles this automatically.

Q2: Can I use colored text in files instead of just the console?

ANSI codes appear as raw escape sequences in text files. If you want colors in saved files, use HTML or Markdown instead. For console output only, ANSI codes work perfectly. When writing to log files, consider stripping codes with libraries like colorama.deinit() or use separate formatting for file vs. console output.

Q3: Which library should I choose for my project?

Choose based on your needs: Use raw ANSI codes for minimal dependencies; use colorama for reliable cross-platform support; use termcolor for simple, lightweight colored text; use rich for advanced formatting, tables, and panels. For most projects, colorama offers the best balance.

Q4: How do I create custom colors beyond the basic set?

Standard ANSI codes provide 8 basic colors plus 8 bright variants. For 256-color support, use escape codes like \033[38;5;196m (where 196 is a color index). For true color (16 million colors), use RGB format: \033[38;2;255;0;0m for red. The rich library handles 256-color and true color automatically.

Q5: Are ANSI colors supported in Jupyter notebooks?

Most Jupyter implementations don’t render terminal ANSI colors. However, the rich library integrates beautifully with Jupyter and automatically detects the environment to output proper HTML formatting. For Jupyter notebooks, rich is your best choice.

Q6: What about accessibility? Are colored outputs accessible?

Never rely on color alone to convey information. Always combine color with text labels, symbols, or other indicators. For example, use both color and text: “[ERROR]” in red rather than just red text. This ensures visually impaired users can understand your application.

Conclusion

Printing colored strings in Python is simple, powerful, and available through multiple approaches. Whether you choose raw ANSI codes for minimal overhead, colorama for cross-platform reliability, termcolor for lightweight simplicity, or rich for professional formatting, you now have the knowledge to add vibrant output to your applications.

Colored output transforms CLI applications from bland text dumps into clear, scannable interfaces. Your logging becomes easier to understand, your error messages stand out, and your users appreciate the improved clarity. Start with the approach that fits your project, and remember: colors enhance understanding—never use them as the only way to communicate critical information.

Ready to explore more? Check out the official documentation for colorama and rich to discover even more advanced features and capabilities.

How To Use Python TOML For Configuration Files Instead of INI

How To Use Python TOML For Configuration Files Instead of INI

Last Updated: June 01, 2026

Beginner

Every non-trivial Python application needs a configuration file. Whether you are storing database credentials, feature flags, API endpoints, or user preferences, you need a format that is easy for humans to read and edit, but structured enough for your code to parse reliably. For years, Python developers defaulted to INI files with configparser, but INI has serious limitations — no native data types, no nested sections, and everything is a string. TOML fixes all of that while staying just as readable, and since Python 3.11, you can parse it without installing anything.

Python 3.11 added tomllib to the standard library for reading TOML files. For writing TOML, the community standard is tomli_w, a lightweight package you can install with pip install tomli_w. Between these two tools, you have everything you need to replace INI, JSON, or YAML configuration files with something cleaner and more powerful. If you are on Python 3.10 or earlier, the tomli package provides the same reading API as tomllib.

In this article we will cover everything you need to work with TOML in Python. We will start with a quick example to get you up and running, then explain what TOML is and how it compares to INI, JSON, and YAML. From there we will dive into reading TOML files, understanding TOML data types, working with nested tables and arrays, writing TOML files, and handling common patterns like environment-specific configs. We will finish with a real-life project that builds a complete application configuration system. By the end, you will be ready to use TOML for all your Python configuration needs.

Pubs - Python How To Program
Written by Pubs

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.

View all tutorials by Pubs →

Python TOML Configuration: Quick Example

Here is a complete example that creates a TOML configuration file and reads it back. You can run this immediately to see TOML in action.

# quick_example.py
import tomllib
from pathlib import Path

# First, create a sample TOML config file
config_content = """
[app]
name = "MyWebApp"
version = "2.1.0"
debug = false

[database]
host = "localhost"
port = 5432
name = "myapp_db"
"""
Path("config.toml").write_text(config_content)

# Now read it back
with open("config.toml", "rb") as f:
    config = tomllib.load(f)

print(f"App: {config['app']['name']} v{config['app']['version']}")
print(f"Debug mode: {config['app']['debug']}")
print(f"Database: {config['database']['host']}:{config['database']['port']}")

Output:

App: MyWebApp v2.1.0
Debug mode: False
Database: localhost:5432

Notice something important: the debug value came back as a Python bool (capital-F False), and the port came back as an int. TOML preserves data types natively — unlike INI files where everything is a string and you have to convert manually. The tomllib.load() function reads the file in binary mode (that is why we open with "rb") and returns a regular Python dictionary.

Want to go deeper? Below we explain the TOML format in detail, compare it to alternatives, and build a production-ready configuration system you can drop into any project.

What Is TOML and Why Use It Instead of INI?

TOML stands for “Tom’s Obvious, Minimal Language.” It was created by Tom Preston-Werner (co-founder of GitHub) as a configuration file format that is easy for humans to read and write while being unambiguous for machines to parse. If you have ever used an INI file or edited a pyproject.toml for a Python package, you have already seen TOML in action — it is the official configuration format for Python packaging tools like pip, setuptools, and poetry.

The key advantage of TOML over INI is that TOML has real data types. In an INI file, the value port = 5432 is the string "5432", and you need to call int() to convert it. In TOML, that same line produces an actual integer. TOML also supports nested sections (tables within tables), arrays, dates, and inline tables — none of which INI can handle without awkward workarounds.

Here is how TOML compares to the other popular configuration formats.

FeatureTOMLINIJSONYAML
Human readableExcellentGoodModerateGood
CommentsYes (#)Yes (;/#)NoYes (#)
Native data typesString, int, float, bool, datetime, array, tableStrings onlyString, number, bool, null, array, objectAll of the above plus anchors
Nested sectionsYes (dotted keys and tables)NoYesYes
Python stdlib supportRead only (tomllib, 3.11+)Read/write (configparser)Read/write (json)No (needs PyYAML)
Trailing commasYes (in arrays)N/ANoN/A
Security concernsMinimalMinimalMinimalHigh (code execution risk)

TOML hits the sweet spot for configuration files: it is more expressive than INI, more readable than JSON (which lacks comments), and safer than YAML (which can execute arbitrary code if loaded unsafely). The fact that Python’s packaging ecosystem chose TOML as its standard format tells you a lot about where the community is headed. Now let us learn the format.

Understanding TOML Syntax

Before writing code, it helps to understand what a TOML file looks like. The format is simple — if you can read an INI file, you can read TOML. The main building blocks are key-value pairs, tables (sections), and arrays.

# sample_config.toml
# This is a comment — TOML uses the hash symbol

# Basic key-value pairs (top level)
title = "My Application"
version = 1

# A table (like an INI section)
[owner]
name = "Alice Johnson"
email = "alice@example.com"

# Nested table using dotted notation
[database.primary]
host = "db.example.com"
port = 5432
enabled = true

[database.replica]
host = "replica.example.com"
port = 5433
enabled = false

# Array of tables (list of objects)
[[servers]]
name = "alpha"
ip = "10.0.0.1"
role = "web"

[[servers]]
name = "beta"
ip = "10.0.0.2"
role = "worker"

Let us read this file and see how Python interprets each section. The tomllib.load() function converts the entire TOML file into a nested Python dictionary, preserving all the structure and data types.

# read_syntax.py
import tomllib
from pathlib import Path

# Create the config file
toml_content = '''
title = "My Application"
version = 1

[owner]
name = "Alice Johnson"
email = "alice@example.com"

[database.primary]
host = "db.example.com"
port = 5432
enabled = true

[database.replica]
host = "replica.example.com"
port = 5433
enabled = false

[[servers]]
name = "alpha"
ip = "10.0.0.1"
role = "web"

[[servers]]
name = "beta"
ip = "10.0.0.2"
role = "worker"
'''
Path("app_config.toml").write_text(toml_content)

with open("app_config.toml", "rb") as f:
    config = tomllib.load(f)

# Top-level keys
print(f"Title: {config['title']}")
print(f"Version: {config['version']} (type: {type(config['version']).__name__})")

# Table access
print(f"\nOwner: {config['owner']['name']}")

# Nested tables via dotted keys
print(f"\nPrimary DB: {config['database']['primary']['host']}")
print(f"Replica DB: {config['database']['replica']['host']}")

# Array of tables becomes a list of dicts
print(f"\nServers ({len(config['servers'])}):")
for server in config['servers']:
    print(f"  {server['name']} ({server['ip']}) - {server['role']}")

Output:

Title: My Application
Version: 1 (type: int)

Owner: Alice Johnson

Primary DB: db.example.com
Replica DB: replica.example.com

Servers (2):
  alpha (10.0.0.1) - web
  beta (10.0.0.2) - worker

The key things to notice: [database.primary] in TOML becomes config['database']['primary'] in Python — the dot creates nesting. The [[servers]] syntax (double brackets) creates an array of tables — each repeated [[servers]] block adds another dictionary to a list. And version = 1 became a Python int, not a string. These are the three patterns you will use most often in TOML configuration files.

Debug Dee comparing config scrolls
INI files: everything is a string. TOML files: everything is what it should be. Not a hard choice.

Reading TOML Files With tomllib

The tomllib module (Python 3.11+) provides two functions: load() for reading from a file object and loads() for parsing a string. Both return a Python dictionary. The file must be opened in binary mode ("rb") because TOML files are always UTF-8, and tomllib handles the decoding internally to avoid encoding issues.

# reading_toml.py
import tomllib

# Method 1: Load from a file
with open("config.toml", "rb") as f:
    config = tomllib.load(f)
print(f"From file: {config['app']['name']}")

# Method 2: Parse from a string
toml_string = '''
[server]
host = "0.0.0.0"
port = 8080
workers = 4
'''
config_from_string = tomllib.loads(toml_string)
print(f"From string: {config_from_string['server']['host']}:{config_from_string['server']['port']}")

# The result is a regular dict — you can use all dict methods
print(f"\nTop-level keys: {list(config_from_string.keys())}")
print(f"Server config: {dict(config_from_string['server'])}")

Output:

From file: MyWebApp
From string: 0.0.0.0:8080

Top-level keys: ['server']
Server config: {'host': '0.0.0.0', 'port': 8080, 'workers': 4}

One gotcha to watch out for: tomllib.load() requires binary mode ("rb"), not text mode ("r"). If you forget, you will get a TypeError. This is by design — it prevents encoding mismatches. The loads() function takes a regular string, which is convenient for testing or when your TOML content comes from an environment variable or API response rather than a file.

TOML Data Types and Python Mapping

One of TOML’s biggest advantages is its rich type system. Every value in a TOML file maps to a specific Python type, and the parser handles the conversion automatically. Let us see each type in action.

# data_types.py
import tomllib
from datetime import datetime, date, time

toml_data = '''
# Strings
name = "Alice"
path = 'C:\Users\alice'  # Single quotes = literal (no escapes)
bio = """
This is a
multi-line string."""

# Numbers
integer_val = 42
negative = -17
float_val = 3.14
scientific = 6.022e23
hex_val = 0xff
bin_val = 0b1010

# Booleans
enabled = true
debug = false

# Dates and times
created = 2025-01-15T10:30:00
birthday = 2025-01-15
alarm = 07:30:00

# Arrays (can be mixed types, but best practice is same type)
ports = [8080, 8081, 8082]
features = ["auth", "logging", "caching"]

# Inline table
point = {x = 10, y = 20}
'''

config = tomllib.loads(toml_data)

# Check the Python types
print(f"name: {config['name']!r} ({type(config['name']).__name__})")
print(f"integer_val: {config['integer_val']} ({type(config['integer_val']).__name__})")
print(f"float_val: {config['float_val']} ({type(config['float_val']).__name__})")
print(f"enabled: {config['enabled']} ({type(config['enabled']).__name__})")
print(f"created: {config['created']} ({type(config['created']).__name__})")
print(f"birthday: {config['birthday']} ({type(config['birthday']).__name__})")
print(f"ports: {config['ports']} ({type(config['ports']).__name__})")
print(f"point: {config['point']} ({type(config['point']).__name__})")
print(f"hex_val: {config['hex_val']} ({type(config['hex_val']).__name__})")
print(f"bio: {config['bio']!r}")

Output:

name: 'Alice' (str)
integer_val: 42 (int)
float_val: 3.14 (float)
enabled: True (bool)
created: 2025-01-15 10:30:00 (datetime)
birthday: 2025-01-15 (date)
ports: [8080, 8081, 8082] (list)
point: {'x': 10, 'y': 20} (dict)
hex_val: 255 (int)
bio: '\nThis is a\nmulti-line string.'

Every TOML type has a clean Python equivalent. Strings become str, integers become int (even hex and binary), floats become float, booleans become bool, dates and times become datetime.date, datetime.time, or datetime.datetime, arrays become list, and tables (including inline tables) become dict. Compare this to INI where port = 5432 gives you the string "5432" and you have to write config.getint('section', 'port') to get a number. TOML eliminates that entire category of boilerplate.

Writing TOML Files With tomli_w

The standard library only handles reading TOML. For writing, the community standard is tomli_w, a small package that converts Python dictionaries back to TOML format. Install it with pip install tomli_w.

# writing_toml.py
import tomli_w
import tomllib

# Build a configuration as a Python dictionary
config = {
    "app": {
        "name": "DataPipeline",
        "version": "1.0.0",
        "debug": False,
    },
    "database": {
        "host": "localhost",
        "port": 5432,
        "name": "pipeline_db",
        "pool_size": 10,
    },
    "logging": {
        "level": "INFO",
        "file": "/var/log/pipeline.log",
        "rotate_mb": 50,
    },
    "features": ["retry", "caching", "metrics"],
}

# Write to file
with open("pipeline_config.toml", "wb") as f:
    tomli_w.dump(config, f)

# Write to string (useful for previewing)
toml_string = tomli_w.dumps(config)
print("Generated TOML:\n")
print(toml_string)

# Verify by reading it back
with open("pipeline_config.toml", "rb") as f:
    verified = tomllib.load(f)
print(f"Verified: {verified['app']['name']} v{verified['app']['version']}")
print(f"Features: {verified['features']}")

Output:

Generated TOML:

features = [
    "retry",
    "caching",
    "metrics",
]

[app]
name = "DataPipeline"
version = "1.0.0"
debug = false

[database]
host = "localhost"
port = 5432
name = "pipeline_db"
pool_size = 10

[logging]
level = "INFO"
file = "/var/log/pipeline.log"
rotate_mb = 50

Verified: DataPipeline v1.0.0
Features: ['retry', 'caching', 'metrics']

Like tomllib.load(), the tomli_w.dump() function uses binary mode ("wb"). The dumps() function returns a string, which is handy for logging or previewing. Notice that tomli_w formats the output cleanly with proper indentation and section headers. The round-trip (write then read) produces identical data, so you can safely use this for configuration management tools that need to update config files programmatically.

Loop Larry writing on a scroll
TOML syntax looks friendly until you forget a closing bracket in a nested table. Then it’s personal.

Handling TOML Parsing Errors

When a TOML file has syntax errors, tomllib raises a TOMLDecodeError with a helpful message that includes the line and column number. You should always wrap your config loading in a try-except block so your application fails gracefully with a clear error message instead of a cryptic traceback.

# error_handling.py
import tomllib
import sys

# Example 1: Invalid TOML syntax
bad_toml = '''
[database]
host = "localhost"
port = not_a_number
'''

try:
    config = tomllib.loads(bad_toml)
except tomllib.TOMLDecodeError as e:
    print(f"TOML syntax error: {e}")

# Example 2: Safe config loading function
def load_config(filepath, required_keys=None):
    """Load and validate a TOML configuration file."""
    try:
        with open(filepath, "rb") as f:
            config = tomllib.load(f)
    except FileNotFoundError:
        print(f"Error: Config file '{filepath}' not found.")
        print("Create one from config.example.toml")
        return None
    except tomllib.TOMLDecodeError as e:
        print(f"Error: Invalid TOML in '{filepath}': {e}")
        return None

    # Validate required keys
    if required_keys:
        missing = [k for k in required_keys if k not in config]
        if missing:
            print(f"Error: Missing required sections: {missing}")
            return None

    return config

# Test with a valid file
config = load_config("config.toml", required_keys=["app", "database"])
if config:
    print(f"\nConfig loaded: {config['app']['name']}")
else:
    print("\nFailed to load config")

Output:

TOML syntax error: Invalid value (at line 4, column 8)

Config loaded: MyWebApp

The load_config() function demonstrates the defensive loading pattern you should use in production code. It handles three failure modes: file not found, invalid TOML syntax, and missing required sections. This approach gives users clear, actionable error messages instead of stack traces. You could extend this with schema validation using a library like pydantic if you need to verify value types and ranges as well.

Environment-Specific Configuration

A common pattern in real applications is having different configurations for development, staging, and production. TOML handles this cleanly by using separate tables for each environment, with a shared defaults section.

# env_config.py
import tomllib
from pathlib import Path
import copy

# Create a multi-environment config
config_content = '''
[defaults]
log_level = "INFO"
workers = 2
cache_ttl = 300

[defaults.database]
port = 5432
pool_size = 5

[development]
log_level = "DEBUG"
workers = 1

[development.database]
host = "localhost"
name = "myapp_dev"
pool_size = 2

[production]
log_level = "WARNING"
workers = 8

[production.database]
host = "db.production.internal"
name = "myapp_prod"
pool_size = 20
'''
Path("environments.toml").write_text(config_content)

def get_config(env_name):
    """Load config for a specific environment, merged with defaults."""
    with open("environments.toml", "rb") as f:
        all_configs = tomllib.load(f)

    # Start with defaults
    defaults = all_configs.get("defaults", {})
    config = copy.deepcopy(defaults)

    # Merge environment-specific overrides
    env_overrides = all_configs.get(env_name, {})
    for key, value in env_overrides.items():
        if isinstance(value, dict) and key in config and isinstance(config[key], dict):
            config[key].update(value)  # Merge nested dicts
        else:
            config[key] = value  # Override scalar values

    return config

# Get config for each environment
for env in ["development", "production"]:
    config = get_config(env)
    print(f"\n--- {env.upper()} ---")
    print(f"Log level: {config['log_level']}")
    print(f"Workers: {config['workers']}")
    print(f"DB: {config['database']['host']}:{config['database']['port']}")
    print(f"Pool size: {config['database']['pool_size']}")

Output:


--- DEVELOPMENT ---
Log level: DEBUG
Workers: 1
DB: localhost:5432
Pool size: 2

--- PRODUCTION ---
Log level: WARNING
Workers: 8
DB: db.production.internal:5432
Pool size: 20

The merging function starts with a deep copy of the defaults, then overlays the environment-specific values on top. Nested dictionaries (like database) are merged rather than replaced, so you only need to specify the values that differ from the defaults. Notice that the port value (5432) was inherited from defaults in both environments because neither development nor production overrode it. This pattern keeps your configuration DRY while still allowing full customization per environment.

Cache Katie at a crossroads
One config file per environment. tomllib.load() picks the right one. No more if-else chains.

Real-Life Example: Application Configuration Manager

Let us build a practical configuration system that you can drop into any Python project. It reads from a TOML file, supports defaults, validates required fields, and provides convenient dot-notation access to nested values.

# config_manager.py
import tomllib
import tomli_w
import copy
from pathlib import Path

class AppConfig:
    """A configuration manager backed by TOML files."""

    def __init__(self, filepath="config.toml"):
        self.filepath = Path(filepath)
        self._data = {}
        self._defaults = {
            "app": {"name": "Unnamed", "version": "0.0.0", "debug": False},
            "server": {"host": "127.0.0.1", "port": 8000, "workers": 2},
            "logging": {"level": "INFO", "file": None},
        }

    def load(self):
        """Load config from file, merged with defaults."""
        self._data = copy.deepcopy(self._defaults)
        if self.filepath.exists():
            with open(self.filepath, "rb") as f:
                file_data = tomllib.load(f)
            self._deep_merge(self._data, file_data)
            print(f"Loaded config from {self.filepath}")
        else:
            print(f"No config file found, using defaults")
        return self

    def _deep_merge(self, base, override):
        """Recursively merge override dict into base dict."""
        for key, value in override.items():
            if key in base and isinstance(base[key], dict) and isinstance(value, dict):
                self._deep_merge(base[key], value)
            else:
                base[key] = value

    def get(self, dotted_key, default=None):
        """Access nested values with dot notation: config.get('server.port')."""
        keys = dotted_key.split(".")
        current = self._data
        for key in keys:
            if isinstance(current, dict) and key in current:
                current = current[key]
            else:
                return default
        return current

    def set(self, dotted_key, value):
        """Set a nested value: config.set('server.port', 9000)."""
        keys = dotted_key.split(".")
        current = self._data
        for key in keys[:-1]:
            current = current.setdefault(key, {})
        current[keys[-1]] = value

    def save(self):
        """Write current config back to the TOML file."""
        with open(self.filepath, "wb") as f:
            tomli_w.dump(self._data, f)
        print(f"Config saved to {self.filepath}")

    def show(self):
        """Display the current configuration."""
        print(tomli_w.dumps(self._data))

# --- Demo usage ---
if __name__ == "__main__":
    # Create a sample config file
    sample = """
[app]
name = "TaskTracker"
version = "3.2.1"
debug = true

[server]
host = "0.0.0.0"
port = 5000

[database]
url = "postgresql://localhost/tasks"
pool_size = 10
"""
    Path("config.toml").write_text(sample)

    # Load and use the config manager
    config = AppConfig("config.toml").load()

    print(f"\nApp: {config.get('app.name')} v{config.get('app.version')}")
    print(f"Server: {config.get('server.host')}:{config.get('server.port')}")
    print(f"Workers: {config.get('server.workers')}")  # From defaults
    print(f"Debug: {config.get('app.debug')}")
    print(f"DB: {config.get('database.url')}")
    print(f"Missing key: {config.get('cache.redis', 'not configured')}")

    # Modify and save
    config.set("server.workers", 8)
    config.set("cache.redis", "redis://localhost:6379")
    config.save()

    print("\nUpdated config:")
    config.show()

Output:

Loaded config from config.toml

App: TaskTracker v3.2.1
Server: 0.0.0.0:5000
Workers: 2
Debug: True
DB: postgresql://localhost/tasks
Missing key: not configured
Config saved to config.toml

Updated config:
[app]
name = "TaskTracker"
version = "3.2.1"
debug = true

[server]
host = "0.0.0.0"
port = 5000
workers = 8

[logging]
level = "INFO"

[database]
url = "postgresql://localhost/tasks"
pool_size = 10

[cache]
redis = "redis://localhost:6379"

This AppConfig class gives you a clean, reusable configuration layer. The get() method supports dot-notation for nested access (config.get('server.port')), the set() method creates intermediate dictionaries automatically, and the deep merge ensures that defaults fill in any gaps without overwriting values from the config file. You can extend this with environment variable overrides (reading os.environ and merging on top) or schema validation with pydantic for type checking.

Frequently Asked Questions

What if I am using Python 3.10 or earlier?

Install the tomli package with pip install tomli. It has the exact same API as the built-in tomllib. A common pattern is to use a try/except import: try: import tomllib except ModuleNotFoundError: import tomli as tomllib. This way your code works on both old and new Python versions without changes. Many popular packages like pip itself use this exact pattern.

Why does tomllib only read TOML and not write it?

The Python core developers decided to include only the reading side because there is a clear, agreed-upon way to parse TOML, but writing TOML involves style choices (formatting, ordering, comment preservation) that are harder to standardize. The tomli_w package fills this gap perfectly and is maintained by the same developer who wrote tomli (which became tomllib). Installing one extra package for writing is a small price for having a stable reading implementation in the standard library.

How does pyproject.toml relate to this?

The pyproject.toml file is a TOML file that Python packaging tools use to define project metadata, dependencies, and build configuration. It follows the exact same TOML syntax covered in this article. You read it with tomllib.load() just like any other TOML file. Tools like pip, setuptools, poetry, and hatch all read from pyproject.toml — it replaced the older setup.py and setup.cfg approach for modern Python projects.

Can I preserve comments when modifying a TOML file?

Neither tomllib nor tomli_w preserves comments — when you load a TOML file, comments are discarded, and when you write it back, only the data is included. If comment preservation is important (for example, in a user-facing config file), consider the tomlkit package (pip install tomlkit). It parses TOML while preserving formatting, comments, and whitespace, making it ideal for tools that need to edit config files without disturbing the user’s layout.

Should I use TOML or YAML for my project?

For application configuration files, TOML is generally the better choice. It is simpler, safer (no code execution risk), and has standard library support in Python. YAML is better suited for complex data serialization tasks where you need features like anchors, references, and custom types — but those same features are what make YAML a security risk if you use yaml.load() instead of yaml.safe_load(). The Python community’s adoption of TOML for pyproject.toml is a strong signal that TOML is the preferred format for configuration going forward.

How do I migrate from configparser (INI) to TOML?

The structure is similar enough that migration is usually straightforward. INI sections become TOML tables, and key-value pairs stay the same syntactically. The main changes are: remove the type conversion calls (getint(), getboolean(), etc.) because TOML handles types natively, convert comma-separated values to TOML arrays, and add proper quoting to string values. For nested sections, replace [section:subsection] with [section.subsection]. A typical migration takes less than an hour even for large config files.

Conclusion

You now know how to use TOML for Python configuration files using tomllib (reading) and tomli_w (writing). We covered the TOML syntax and its data types, how to read and parse TOML files, how to write TOML from Python dictionaries, error handling and validation, environment-specific configuration patterns, and a complete configuration manager class you can use in your own projects. TOML gives you the readability of INI with the expressiveness of JSON and the safety that YAML lacks — it is the right default choice for Python configuration files in 2025 and beyond.

Try extending the AppConfig class we built — add environment variable overrides, integrate it with pydantic for schema validation, or build a CLI tool that reads and modifies TOML configs. The patterns you learned here apply to any Python project that needs configuration management.

For the complete TOML specification, visit https://toml.io/. The Python tomllib documentation is at https://docs.python.org/3/library/tomllib.html.

How To Build a Discord Bot With Python Using discord.py

How To Build a Discord Bot With Python Using discord.py

Last Updated: June 01, 2026

Intermediate

Discord has over 150 million monthly active users, and bots are what keep servers running smoothly. Whether you want to build a moderation bot that auto-kicks spammers, a music bot that plays audio in voice channels, or a utility bot that fetches data from APIs and posts it in a channel, Python and the discord.py library make it surprisingly approachable. If you have ever wished your Discord server could do something automatically, a bot is the answer.

The discord.py library handles all the heavy lifting — connecting to Discord’s WebSocket gateway, managing authentication, parsing events, and sending messages. You just need Python 3.8 or higher and a free Discord account. You will install discord.py with a single pip install command, and within minutes you will have a bot running in your own server responding to commands.

In this article we will walk through the entire process from start to finish. We will begin with setting up the Discord Developer Portal and creating your bot application. Then we will cover bot events, text commands, modern slash commands, and rich embed messages. Along the way we will explain how Discord’s event-driven architecture works and why it matters. Finally, we will build a complete Server Welcome Bot as a real-life project that greets new members, assigns roles, and logs activity.

Pubs - Python How To Program
Written by Pubs

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.

View all tutorials by Pubs →

Discord Bot in Python: Quick Example

Here is the simplest possible Discord bot that connects to a server and responds when someone types !hello. You can copy this code, replace the token placeholder with your actual bot token (we will show you how to get one in the next section), and have a working bot in under a minute.

# quick_example.py
import discord

intents = discord.Intents.default()
intents.message_content = True  # Required to read message text

client = discord.Client(intents=intents)

@client.event
async def on_ready():
    print(f"Bot is online as {client.user}")

@client.event
async def on_message(message):
    if message.author == client.user:
        return  # Ignore the bot's own messages
    if message.content == "!hello":
        await message.channel.send(f"Hello, {message.author.display_name}!")

client.run("YOUR_BOT_TOKEN_HERE")

Output (in your terminal):

Bot is online as MyBot#1234

When someone types !hello in any channel the bot can see, it replies with a personalized greeting. The on_ready event fires once when the bot successfully connects to Discord, and the on_message event fires every time a message is sent in a channel the bot has access to. The intents object tells Discord what types of events your bot needs — we enable message_content because reading message text requires explicit permission since 2022.

Want to learn how to set up your bot properly, use modern slash commands, and build something more substantial? Below we cover everything step by step.

Setting Up the Discord Developer Portal

Before you write any code, you need to create a bot application on Discord’s Developer Portal. This gives you a bot token (like a password for your bot) and lets you configure what permissions the bot needs. Here is the step-by-step process.

Go to https://discord.com/developers/applications and log in with your Discord account. Click “New Application” in the top right, give your application a name (this will be your bot’s display name), and click “Create.” On the left sidebar, click “Bot” to open the bot settings. Under the “Privileged Gateway Intents” section, enable Message Content Intent — this is required for your bot to read the text content of messages. You may also want to enable Server Members Intent if your bot needs to track when members join or leave.

To get your bot token, click “Reset Token” on the Bot page. Copy the token immediately — Discord only shows it once. Store it somewhere safe and never share it publicly or commit it to a Git repository. Anyone with your token can control your bot. If you accidentally expose it, go back to the Developer Portal and reset it immediately.

To invite your bot to a server, go to the “OAuth2” section in the left sidebar, then “URL Generator.” Under “Scopes” check bot and applications.commands (for slash commands). Under “Bot Permissions” select the permissions your bot needs — for this tutorial, check Send Messages, Read Message History, Manage Roles, and Embed Links. Copy the generated URL at the bottom and open it in your browser to invite the bot to your test server.

Installing discord.py

Install the library using pip. We recommend installing inside a virtual environment to keep your project dependencies isolated.

# install_discord.py
# Run these commands in your terminal (not in a Python file):
# pip install discord.py
#
# To verify the installation:
import discord
print(f"discord.py version: {discord.__version__}")

Output:

discord.py version: 2.5.2

The discord.py library installs with all its dependencies, including aiohttp for async HTTP requests and websockets for the real-time connection to Discord’s gateway. The version number may differ depending on when you install it, but any version 2.x will work with the code in this tutorial. If you need voice channel support, install discord.py[voice] instead, which includes the PyNaCl library for audio encoding.

Pyro Pete excited with chat bubbles and lightning bolts celebrating his Discord bot going online
Your bot just went online. Time to test it by talking to yourself in an empty Discord server like a normal person.

Understanding Bot Events

Discord bots are event-driven. Instead of running code in a loop, your bot sits idle and waits for Discord to send it events — a message was sent, a member joined, a reaction was added, and so on. You register handler functions for the events you care about using the @client.event decorator. Discord’s gateway sends events over a WebSocket connection, and discord.py automatically parses them into Python objects for you.

Here are the most commonly used events and when they fire:

EventWhen It FiresCommon Use
on_readyBot connects to DiscordPrint status message, initialize data
on_messageAny message is sentText commands, auto-moderation
on_member_joinA user joins the serverWelcome messages, auto-role assignment
on_member_removeA user leaves/is kickedGoodbye messages, logging
on_reaction_addSomeone reacts to a messageReaction roles, polls
on_message_deleteA message is deletedAudit logging, anti-spam

Let us see a bot that responds to multiple events. This example logs when the bot starts, when messages are sent, and when new members join the server.

# event_demo.py
import discord
from datetime import datetime

intents = discord.Intents.default()
intents.message_content = True
intents.members = True  # Required for member join/leave events

client = discord.Client(intents=intents)

@client.event
async def on_ready():
    print(f"[{datetime.now():%H:%M:%S}] {client.user} is online!")
    print(f"Connected to {len(client.guilds)} server(s)")

@client.event
async def on_message(message):
    if message.author.bot:
        return  # Ignore all bot messages
    print(f"[{datetime.now():%H:%M:%S}] {message.author}: {message.content}")

@client.event
async def on_member_join(member):
    print(f"[{datetime.now():%H:%M:%S}] {member.display_name} joined {member.guild.name}")

client.run("YOUR_BOT_TOKEN_HERE")

Output (in your terminal):

[14:30:00] MyBot#1234 is online!
Connected to 1 server(s)
[14:30:15] Alice: Hello everyone!
[14:30:22] Bob: Hey Alice!
[14:31:05] Charlie joined My Test Server

Notice that we check message.author.bot instead of comparing to client.user. This is a best practice because it prevents your bot from responding to messages from any bot, not just itself. Without this check, two bots could get into an infinite loop responding to each other. The intents.members = True line is required for the on_member_join event to work — Discord requires you to explicitly opt into member-related events for privacy reasons.

Creating Text Commands

Text commands (also called prefix commands) are the classic way to interact with a Discord bot — users type a prefix like ! followed by a command name. While Discord now recommends slash commands for new bots, text commands are still widely used and are simpler to understand for beginners. The discord.py library provides a commands.Bot class that makes text commands easy to build.

# text_commands.py
import discord
from discord.ext import commands

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

# Use commands.Bot instead of discord.Client for command support
bot = commands.Bot(command_prefix="!", intents=intents)

@bot.event
async def on_ready():
    print(f"{bot.user} is online with prefix '!'")

@bot.command(name="ping")
async def ping_command(ctx):
    """Check if the bot is responsive."""
    latency = round(bot.latency * 1000)  # Convert to milliseconds
    await ctx.send(f"Pong! Latency: {latency}ms")

@bot.command(name="say")
async def say_command(ctx, *, text: str):
    """Make the bot repeat your message."""
    await ctx.send(text)

@bot.command(name="userinfo")
async def userinfo_command(ctx, member: discord.Member = None):
    """Show information about a user."""
    member = member or ctx.author  # Default to the command caller
    joined = member.joined_at.strftime("%B %d, %Y") if member.joined_at else "Unknown"
    roles = ", ".join(role.name for role in member.roles[1:]) or "No roles"
    await ctx.send(
        f"**{member.display_name}**\n"
        f"Joined: {joined}\n"
        f"Roles: {roles}\n"
        f"Account created: {member.created_at.strftime('%B %d, %Y')}"
    )

bot.run("YOUR_BOT_TOKEN_HERE")

Output (in Discord chat):

User: !ping
Bot:  Pong! Latency: 45ms

User: !say Hello from the bot!
Bot:  Hello from the bot!

User: !userinfo
Bot:  **Alice**
      Joined: January 15, 2025
      Roles: Moderator, Developer
      Account created: March 10, 2020

The commands.Bot class extends discord.Client with a command framework. Instead of manually parsing message content in on_message, you define commands with the @bot.command() decorator. The ctx parameter (short for context) gives you access to the message, the channel, the author, and the server — everything you need to respond. The * in *, text: str tells discord.py to capture all remaining text as a single string instead of splitting on spaces. The member: discord.Member type hint enables automatic user lookup — users can mention someone or type their name and discord.py will find the matching member.

Debug Dee examining a glowing crystal orb with swirling patterns and question marks for slash commands
Slash commands: because apparently typing / is cooler than typing ! now.

Creating Slash Commands

Slash commands are Discord’s modern command system. When a user types /, Discord shows a menu of available commands with descriptions and parameter hints — no guessing what commands exist or what arguments they need. Discord strongly recommends slash commands for all new bots because they provide a better user experience and integrate with Discord’s permission system.

# slash_commands.py
import discord
from discord import app_commands

intents = discord.Intents.default()
client = discord.Client(intents=intents)
tree = app_commands.CommandTree(client)

@tree.command(name="ping", description="Check bot latency")
async def ping_slash(interaction: discord.Interaction):
    latency = round(client.latency * 1000)
    await interaction.response.send_message(f"Pong! Latency: {latency}ms")

@tree.command(name="roll", description="Roll a dice with a given number of sides")
@app_commands.describe(sides="Number of sides on the dice (default: 6)")
async def roll_slash(interaction: discord.Interaction, sides: int = 6):
    import random
    result = random.randint(1, sides)
    await interaction.response.send_message(f"You rolled a **{result}** (d{sides})")

@tree.command(name="poll", description="Create a simple yes/no poll")
@app_commands.describe(question="The question to ask")
async def poll_slash(interaction: discord.Interaction, question: str):
    message = await interaction.response.send_message(f"**Poll:** {question}")
    # Fetch the message object to add reactions
    poll_message = await interaction.original_response()
    await poll_message.add_reaction("✅")
    await poll_message.add_reaction("❌")

@client.event
async def on_ready():
    await tree.sync()  # Register commands with Discord
    print(f"{client.user} is online with slash commands!")

client.run("YOUR_BOT_TOKEN_HERE")

Output (in Discord chat):

User types: /ping
Bot:  Pong! Latency: 38ms

User types: /roll sides:20
Bot:  You rolled a **14** (d20)

User types: /poll question:Should we do movie night?
Bot:  **Poll:** Should we do movie night?
      ✅ ❌ (reactions added automatically)

Slash commands use a different API pattern than text commands. Instead of ctx, you receive an interaction object, and you must respond with interaction.response.send_message() within 3 seconds — otherwise Discord shows a “This interaction failed” error to the user. The app_commands.CommandTree manages your slash commands, and tree.sync() in on_ready registers them with Discord. Note that syncing can take up to an hour for global commands, but you can speed this up during development by syncing to a specific server using tree.sync(guild=discord.Object(id=YOUR_SERVER_ID)).

The @app_commands.describe() decorator adds parameter descriptions that Discord shows in the command menu. Type hints like sides: int tell Discord to validate the input — if a user types text instead of a number, Discord will show an error before the command even runs.

Creating Embed Messages

Plain text messages work fine, but embed messages look professional. Embeds support titles, descriptions, fields, colors, thumbnails, and footers — all rendered in a rich card format. They are perfect for displaying structured information like user profiles, search results, or status dashboards.

# embed_demo.py
import discord
from discord import app_commands
from datetime import datetime

intents = discord.Intents.default()
client = discord.Client(intents=intents)
tree = app_commands.CommandTree(client)

@tree.command(name="serverinfo", description="Show information about this server")
async def serverinfo_slash(interaction: discord.Interaction):
    guild = interaction.guild
    embed = discord.Embed(
        title=guild.name,
        description=f"Server information for **{guild.name}**",
        color=discord.Color.blue(),
        timestamp=datetime.now()
    )
    embed.add_field(name="Owner", value=guild.owner.display_name if guild.owner else "Unknown", inline=True)
    embed.add_field(name="Members", value=guild.member_count, inline=True)
    embed.add_field(name="Channels", value=len(guild.channels), inline=True)
    embed.add_field(name="Roles", value=len(guild.roles), inline=True)
    embed.add_field(name="Created", value=guild.created_at.strftime("%B %d, %Y"), inline=True)
    embed.add_field(name="Boost Level", value=f"Level {guild.premium_tier}", inline=True)

    if guild.icon:
        embed.set_thumbnail(url=guild.icon.url)
    embed.set_footer(text=f"Server ID: {guild.id}")

    await interaction.response.send_message(embed=embed)

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

client.run("YOUR_BOT_TOKEN_HERE")

Output (in Discord chat — rendered as a rich card):

┌─────────────────────────────────┐
│ My Test Server                   │
│ Server information for           │
│ **My Test Server**               │
│                                  │
│ Owner: Alice    Members: 42      │
│ Channels: 15   Roles: 8         │
│ Created: Jan 15, 2024            │
│ Boost Level: Level 2             │
│                                  │
│ Server ID: 123456789012345678    │
└─────────────────────────────────┘

The discord.Embed constructor accepts a title, description, color, and timestamp. The add_field() method adds labeled data — setting inline=True places fields side by side (up to 3 per row), while inline=False gives each field its own row. The set_thumbnail() method adds a small image in the top-right corner, and set_footer() adds gray text at the bottom. Embeds can also include set_image() for a large image and set_author() for a clickable author name with an icon. The color parameter accepts hex values, RGB tuples, or convenience methods like discord.Color.blue(), discord.Color.red(), and discord.Color.green().

Sudo Sam standing proudly next to a colorful robot he built representing a Discord bot
Cogs, error handling, logging, and a clean architecture. This bot scales. Yours should too.

Real-Life Example: Server Welcome Bot

Let us build a complete, practical bot that you can actually deploy to your Discord server. This Server Welcome Bot greets new members with a personalized embed message, automatically assigns them a default role, logs all join and leave events to a designated channel, and provides a /stats slash command that shows server statistics.

# welcome_bot.py
import discord
from discord import app_commands
from datetime import datetime

intents = discord.Intents.default()
intents.message_content = True
intents.members = True  # Required for join/leave events

client = discord.Client(intents=intents)
tree = app_commands.CommandTree(client)

# Configuration — update these for your server
WELCOME_CHANNEL_NAME = "welcome"
LOG_CHANNEL_NAME = "bot-logs"
DEFAULT_ROLE_NAME = "Member"

join_count = 0  # Track joins during this session

def find_channel(guild, name):
    """Find a text channel by name, returns None if not found."""
    return discord.utils.get(guild.text_channels, name=name)

@client.event
async def on_ready():
    await tree.sync()
    print(f"{client.user} is online!")
    print(f"Monitoring {len(client.guilds)} server(s)")

@client.event
async def on_member_join(member):
    global join_count
    join_count += 1

    # Send welcome embed
    welcome_ch = find_channel(member.guild, WELCOME_CHANNEL_NAME)
    if welcome_ch:
        embed = discord.Embed(
            title=f"Welcome, {member.display_name}!",
            description=f"{member.mention} just joined **{member.guild.name}**! "
                        f"You are member #{member.guild.member_count}.",
            color=discord.Color.green(),
            timestamp=datetime.now()
        )
        if member.avatar:
            embed.set_thumbnail(url=member.avatar.url)
        embed.set_footer(text="Enjoy your stay!")
        await welcome_ch.send(embed=embed)

    # Assign default role
    role = discord.utils.get(member.guild.roles, name=DEFAULT_ROLE_NAME)
    if role:
        try:
            await member.add_roles(role)
        except discord.Forbidden:
            print(f"Missing permission to assign {role.name}")

    # Log the event
    log_ch = find_channel(member.guild, LOG_CHANNEL_NAME)
    if log_ch:
        await log_ch.send(f"[JOIN] {member.display_name} ({member.id}) joined at {datetime.now():%H:%M:%S}")

@client.event
async def on_member_remove(member):
    log_ch = find_channel(member.guild, LOG_CHANNEL_NAME)
    if log_ch:
        await log_ch.send(f"[LEAVE] {member.display_name} ({member.id}) left at {datetime.now():%H:%M:%S}")

@tree.command(name="stats", description="Show server statistics")
async def stats_command(interaction: discord.Interaction):
    guild = interaction.guild
    embed = discord.Embed(title="Server Stats", color=discord.Color.blue())
    embed.add_field(name="Total Members", value=guild.member_count, inline=True)
    embed.add_field(name="Joins This Session", value=join_count, inline=True)
    embed.add_field(name="Channels", value=len(guild.channels), inline=True)
    embed.add_field(name="Roles", value=len(guild.roles), inline=True)
    await interaction.response.send_message(embed=embed)

client.run("YOUR_BOT_TOKEN_HERE")

Output (in Discord — welcome channel):

┌─────────────────────────────────┐
│ Welcome, Charlie!                │
│ @Charlie just joined             │
│ **My Server**! You are member    │
│ #43.                             │
│                                  │
│ Enjoy your stay!                 │
└─────────────────────────────────┘

Output (in Discord — bot-logs channel):

[JOIN] Charlie (987654321098765432) joined at 14:30:00

Output (in Discord — /stats command):

┌─────────────────────────────────┐
│ Server Stats                     │
│ Total Members: 43                │
│ Joins This Session: 1            │
│ Channels: 15    Roles: 8         │
└─────────────────────────────────┘

This bot uses the discord.utils.get() helper function to find channels and roles by name, which is cleaner than looping through lists manually. The try/except discord.Forbidden block around role assignment handles the case where the bot does not have permission to manage roles — without it, the entire on_member_join handler would crash silently. To deploy this bot to your server, create channels named “welcome” and “bot-logs” and a role named “Member,” then update the configuration constants at the top. You can extend this project by adding a /setwelcome command that lets admins change the welcome channel, a database to persist join counts between restarts, or a reaction-role system where members pick their own roles by clicking emoji reactions.

Frequently Asked Questions

How do I keep my bot token secure?

Never hardcode your token directly in your Python file, especially if you push code to GitHub. Instead, store it in an environment variable and read it with os.environ["DISCORD_TOKEN"], or use a .env file with the python-dotenv library. Add .env to your .gitignore file so it never gets committed. If your token is ever exposed, go to the Discord Developer Portal immediately and click “Reset Token” to invalidate the old one.

Should I use slash commands or text commands?

Use slash commands for new bots. Discord officially recommends them, and they provide a better user experience because Discord shows a command menu with descriptions and type validation. Text commands are still supported in discord.py 2.x, but they require the Message Content Intent, which Discord may restrict further in the future. If you are maintaining an older bot that already uses text commands, you can support both by using commands.Bot and adding slash commands alongside existing prefix commands.

Why are my slash commands not showing up in Discord?

Slash commands need to be synced with Discord using tree.sync(). Global commands can take up to one hour to appear. For instant testing, sync to a specific server with tree.sync(guild=discord.Object(id=SERVER_ID)). Also make sure you invited the bot with the applications.commands scope — without it, the bot cannot register slash commands even if you call sync.

Where can I host my Discord bot?

For small bots, a Raspberry Pi or an old laptop running 24/7 works fine. For production, popular hosting options include Railway, Render, and Oracle Cloud Free Tier (which gives you a free VPS). Avoid Heroku’s free tier for bots because it sleeps after 30 minutes of inactivity, which disconnects your bot. Whatever you choose, make sure the host supports long-running processes — Discord bots need a persistent WebSocket connection, not just HTTP request handling.

How do I handle Discord API rate limits?

The discord.py library handles rate limiting automatically. It tracks the rate limit headers from Discord’s API and pauses requests when you are close to the limit. If you are still hitting rate limits, it usually means your bot is sending too many messages too quickly — add delays between bulk operations using asyncio.sleep(). The typical rate limit is 5 requests per 5 seconds per route, but some endpoints like sending messages have stricter limits.

Can my bot run on multiple servers at the same time?

Yes, a single bot instance handles all servers it is invited to. Discord’s gateway sends events from all servers, and discord.py routes them to your event handlers with the appropriate guild context. The interaction.guild or message.guild object tells you which server the event came from. You do not need separate bot instances per server — one process handles everything.

Conclusion

In this article we walked through building a Discord bot from scratch with Python and discord.py. We covered setting up the Developer Portal, understanding the event-driven architecture, building text commands with commands.Bot, creating modern slash commands with app_commands.CommandTree, and designing rich embed messages. The Server Welcome Bot project ties all of these concepts together into a practical, deployable bot that greets new members, assigns roles, and logs activity.

From here, you can extend the welcome bot with a database backend using sqlite3 or aiosqlite for persistent storage, add a moderation system with kick, ban, and mute commands, or integrate external APIs to build a weather bot, trivia bot, or music bot. The discord.py library supports almost everything the Discord API offers, including voice channels, threads, forums, and scheduled events.

For comprehensive documentation on every feature, visit the official discord.py documentation and the Discord Developer Documentation.

How To Use Python Asyncio For Concurrent Tasks With gather() and TaskGroup

How To Use Python Asyncio For Concurrent Tasks With gather() and TaskGroup

Last Updated: June 01, 2026

Intermediate

You have a Python script that needs to fetch data from five different APIs, and right now it calls them one after another. Each call takes about two seconds, so the whole thing crawls along for ten seconds total. The frustrating part is that those API calls are completely independent — there is no reason your program should sit idle waiting for one response before sending the next request. This is exactly the problem that Python’s asyncio module solves, and once you understand it, you will never look at I/O-bound code the same way again.

The good news is that asyncio is part of Python’s standard library, so there is nothing extra to install. It has been available since Python 3.4 and has matured significantly — Python 3.11 introduced TaskGroup for structured concurrency, and Python 3.12 refined the event loop internals for better performance. All you need is Python 3.11 or later to use every feature covered in this article, though most examples work on Python 3.7 and above.

In this article we will cover everything you need to know to write concurrent Python code with asyncio. We will start with the fundamentals of async and await, then explore coroutines and the event loop. From there we will dive into running multiple tasks concurrently with asyncio.gather(), handling errors gracefully, and using the modern TaskGroup API for structured concurrency. We will also cover asyncio.wait(), timeouts, semaphores for rate limiting, and finish with a real-life project that fetches data from multiple URLs concurrently. By the end, you will be writing async Python code with confidence.

Pubs - Python How To Program
Written by Pubs

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.

View all tutorials by Pubs →

Python Asyncio: Quick Example

Before diving deep, here is a taste of what asyncio can do. This example runs three simulated tasks concurrently instead of sequentially, cutting the total time from six seconds down to about two.

# quick_example.py
import asyncio
import time

async def fetch_data(name, delay):
    """Simulate an API call that takes 'delay' seconds."""
    print(f"Starting {name}...")
    await asyncio.sleep(delay)  # Non-blocking sleep
    print(f"Finished {name}!")
    return f"{name}: {delay}s of data"

async def main():
    start = time.perf_counter()
    # Run all three tasks concurrently
    results = await asyncio.gather(
        fetch_data("Users API", 2),
        fetch_data("Orders API", 2),
        fetch_data("Products API", 2),
    )
    elapsed = time.perf_counter() - start
    print(f"\nAll done in {elapsed:.2f} seconds")
    for r in results:
        print(f"  {r}")

asyncio.run(main())

Output:

Starting Users API...
Starting Orders API...
Starting Products API...
Finished Users API!
Finished Orders API!
Finished Products API!

All done in 2.00 seconds
  Users API: 2s of data
  Orders API: 2s of data
  Products API: 2s of data

Notice that all three tasks started immediately and finished at roughly the same time, even though each one waited two seconds. If we had run them sequentially with regular time.sleep(), the total would have been six seconds. The magic here is asyncio.gather() — it schedules all three coroutines to run on the event loop and waits until they all complete. The await asyncio.sleep() call is the key: it tells the event loop “I am done for now, go run something else while I wait.”

Want to go deeper? Below we cover how the event loop works under the hood, explore gather() in detail, learn about TaskGroup for safer error handling, and build a real concurrent URL fetcher you can use in your own projects.

What Is Asyncio and Why Use It?

At its core, asyncio is Python’s framework for writing concurrent code using a single thread. Instead of creating multiple threads or processes, asyncio uses an event loop — a central coordinator that switches between tasks whenever one of them is waiting for something (like a network response or a file read). Think of it like a chef in a kitchen who starts boiling water, then chops vegetables while waiting for the water to boil, then checks the oven — one person doing many things by never standing idle.

This approach is called cooperative multitasking because each task voluntarily gives up control when it hits an await expression. The event loop then picks up another task that is ready to run. This is fundamentally different from threads, where the operating system forcibly switches between them. The cooperative model is simpler to reason about because you know exactly where your code can be interrupted — only at await points.

The question most beginners ask is: when should you use asyncio versus threads versus multiprocessing? Here is a comparison table to help you decide.

Featureasynciothreadingmultiprocessing
Best forI/O-bound tasks (network, file, database)I/O-bound tasks with blocking librariesCPU-bound tasks (math, image processing)
Concurrency modelSingle thread, event loopMultiple threads, OS-scheduledMultiple processes, separate memory
GIL impactNot affected (single thread)Limited by GIL for CPU workNo GIL limitation
Memory overheadVery low (coroutines are lightweight)Moderate (each thread has a stack)High (each process has its own memory)
ComplexityModerate (async/await syntax)High (race conditions, locks)Moderate (serialization overhead)
ScalabilityThousands of concurrent tasks easilyHundreds of threads at mostLimited by CPU cores

The takeaway is simple: if your code spends most of its time waiting for external resources — API calls, database queries, file downloads — asyncio is usually the best choice. It can handle thousands of concurrent connections with minimal memory, which is why frameworks like FastAPI and aiohttp are built on top of it. Now let us look at the building blocks.

Understanding async and await

The two keywords that make asyncio work are async and await. When you put async before a function definition, it becomes a coroutine function. Calling it does not run the function immediately — it returns a coroutine object that needs to be scheduled on the event loop. The await keyword is how you actually run a coroutine and get its result, while also telling the event loop it can switch to other tasks.

# async_basics.py
import asyncio

async def greet(name, delay):
    """A coroutine that waits, then returns a greeting."""
    await asyncio.sleep(delay)
    return f"Hello, {name}!"

async def main():
    # Calling greet() returns a coroutine object, not the result
    coro = greet("Alice", 1)
    print(f"Type of coro: {type(coro)}")

    # To actually run it, we await it
    result = await greet("Alice", 1)
    print(result)

asyncio.run(main())

Output:

Type of coro: <class 'coroutine'>
Hello, Alice!

The important thing to understand here is the difference between calling a coroutine function and awaiting it. When we wrote coro = greet("Alice", 1), nothing happened — the function body did not execute. Only when we used await greet("Alice", 1) did the code inside actually run. This is a common source of bugs for beginners: forgetting to await a coroutine means it silently does nothing, and Python will even warn you about it.

The asyncio.run() function is the entry point that creates an event loop, runs your main() coroutine, and shuts everything down cleanly when it finishes. You should call asyncio.run() exactly once at the top level of your program — never from inside another coroutine.

Sudo Sam in front of event loop
The event loop is a single-threaded traffic controller. Block it and everything stops.

The Event Loop Explained

The event loop is the engine that drives all of asyncio. It runs in a single thread and continuously cycles through a queue of tasks, executing each one until it hits an await, then moving on to the next ready task. Understanding this cycle helps you write better async code because you know exactly when your code runs and when it yields control.

# event_loop_demo.py
import asyncio

async def task_a():
    print("Task A: step 1")
    await asyncio.sleep(0)  # Yield control to the event loop
    print("Task A: step 2")
    await asyncio.sleep(0)
    print("Task A: step 3")

async def task_b():
    print("Task B: step 1")
    await asyncio.sleep(0)
    print("Task B: step 2")
    await asyncio.sleep(0)
    print("Task B: step 3")

async def main():
    # Schedule both tasks to run concurrently
    await asyncio.gather(task_a(), task_b())

asyncio.run(main())

Output:

Task A: step 1
Task B: step 1
Task A: step 2
Task B: step 2
Task A: step 3
Task B: step 3

See how the tasks interleave? Each await asyncio.sleep(0) is a zero-second pause that simply says “let others run.” The event loop picks up Task B after Task A yields, then switches back. This is cooperative multitasking in action — the tasks voluntarily take turns. If Task A had a long CPU-bound computation without any await, it would block the entire event loop and prevent Task B from running at all. That is why asyncio is designed for I/O-bound work, not number crunching.

Running Tasks Concurrently With asyncio.gather()

asyncio.gather() is the workhorse function for running multiple coroutines concurrently. You pass it any number of awaitables (coroutines, tasks, or futures), and it schedules them all to run on the event loop simultaneously. It returns a list of results in the same order you passed the coroutines, regardless of which one finishes first.

# gather_example.py
import asyncio
import time

async def fetch_user(user_id):
    """Simulate fetching a user from a database."""
    await asyncio.sleep(1.5)  # Simulated DB query
    return {"id": user_id, "name": f"User_{user_id}", "active": True}

async def fetch_orders(user_id):
    """Simulate fetching orders for a user."""
    await asyncio.sleep(2.0)  # Simulated API call
    return [{"order_id": 101, "amount": 29.99}, {"order_id": 102, "amount": 59.99}]

async def fetch_preferences(user_id):
    """Simulate fetching user preferences."""
    await asyncio.sleep(1.0)  # Simulated config lookup
    return {"theme": "dark", "language": "en", "notifications": True}

async def main():
    user_id = 42
    start = time.perf_counter()

    # Fetch all three pieces of data concurrently
    user, orders, prefs = await asyncio.gather(
        fetch_user(user_id),
        fetch_orders(user_id),
        fetch_preferences(user_id),
    )

    elapsed = time.perf_counter() - start
    print(f"Fetched everything in {elapsed:.2f} seconds\n")
    print(f"User: {user}")
    print(f"Orders: {orders}")
    print(f"Preferences: {prefs}")

asyncio.run(main())

Output:

Fetched everything in 2.00 seconds

User: {'id': 42, 'name': 'User_42', 'active': True}
Orders: [{'order_id': 101, 'amount': 29.99}, {'order_id': 102, 'amount': 59.99}]
Preferences: {'theme': 'dark', 'language': 'en', 'notifications': True}

The total time was about two seconds — the duration of the slowest task (fetch_orders) — instead of 4.5 seconds if we had called them sequentially. The results list preserves the order we passed to gather(), so we can unpack them directly into variables. This pattern is incredibly common in web applications where a single page might need data from multiple microservices.

Handling Errors in gather()

By default, if any coroutine passed to gather() raises an exception, the entire gather() call raises that exception and the other tasks may or may not have completed. You can change this behavior with the return_exceptions=True parameter, which makes gather() return exception objects in the results list instead of raising them.

# gather_errors.py
import asyncio

async def safe_task(name, delay):
    await asyncio.sleep(delay)
    return f"{name} completed"

async def failing_task():
    await asyncio.sleep(0.5)
    raise ValueError("Something went wrong in the API!")

async def main():
    # With return_exceptions=True, errors become results
    results = await asyncio.gather(
        safe_task("Task A", 1),
        failing_task(),
        safe_task("Task C", 1.5),
        return_exceptions=True,  # Don't let one failure crash everything
    )

    for i, result in enumerate(results):
        if isinstance(result, Exception):
            print(f"Task {i}: FAILED - {type(result).__name__}: {result}")
        else:
            print(f"Task {i}: {result}")

asyncio.run(main())

Output:

Task 0: Task A completed
Task 1: FAILED - ValueError: Something went wrong in the API!
Task 2: Task C completed

This is a powerful pattern for building resilient applications. Instead of letting one failed API call crash your entire data-fetching pipeline, you collect all results and handle failures individually. The key line is return_exceptions=True — without it, the ValueError from the failing task would propagate up and you would lose the results from the other two tasks that completed successfully.

Debug Dee examining tangled connections
Race conditions in async code are subtle. asyncio.Lock() is not subtle. Use it.

Structured Concurrency With TaskGroup

asyncio.TaskGroup was introduced in Python 3.11 as a safer alternative to gather(). The main difference is how it handles errors: when any task in a TaskGroup fails, it automatically cancels all remaining tasks and raises an ExceptionGroup containing all the errors. This “fail fast” behavior prevents orphaned tasks from running in the background after something has already gone wrong.

# taskgroup_example.py
import asyncio

async def download_file(filename, size_mb, delay):
    """Simulate downloading a file."""
    print(f"Downloading {filename} ({size_mb}MB)...")
    await asyncio.sleep(delay)
    print(f"Finished {filename}")
    return {"file": filename, "size_mb": size_mb, "status": "complete"}

async def main():
    results = []

    async with asyncio.TaskGroup() as tg:
        # create_task schedules coroutines within the group
        task1 = tg.create_task(download_file("report.pdf", 5, 2))
        task2 = tg.create_task(download_file("data.csv", 12, 3))
        task3 = tg.create_task(download_file("image.png", 2, 1))

    # If we get here, ALL tasks succeeded
    results = [task1.result(), task2.result(), task3.result()]
    print("\nAll downloads complete:")
    for r in results:
        print(f"  {r['file']}: {r['size_mb']}MB - {r['status']}")

asyncio.run(main())

Output:

Downloading report.pdf (5MB)...
Downloading data.csv (12MB)...
Downloading image.png (2MB)...
Finished image.png
Finished report.pdf
Finished data.csv

All downloads complete:
  report.pdf: 5MB - complete
  data.csv: 12MB - complete
  image.png: 2MB - complete

The async with asyncio.TaskGroup() as tg context manager creates a scope for your concurrent tasks. You add tasks using tg.create_task(), and when the async with block exits, it waits for all tasks to complete — similar to gather(). The critical difference shows up when errors occur: TaskGroup cancels sibling tasks immediately instead of letting them run to completion with unknown state. This is what the asyncio community calls “structured concurrency” and it prevents a whole class of subtle bugs.

TaskGroup Error Handling

When a task inside a TaskGroup raises an exception, the group cancels all other running tasks and collects the exceptions into an ExceptionGroup. You catch this with the except* syntax (also new in Python 3.11), which lets you handle different exception types selectively.

# taskgroup_errors.py
import asyncio

async def reliable_task(name, delay):
    await asyncio.sleep(delay)
    return f"{name} done"

async def flaky_api_call():
    await asyncio.sleep(0.5)
    raise ConnectionError("API server is down")

async def bad_data_task():
    await asyncio.sleep(0.8)
    raise ValueError("Invalid response format")

async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(reliable_task("Backup", 2))
            tg.create_task(flaky_api_call())
            tg.create_task(bad_data_task())
    except* ConnectionError as eg:
        for exc in eg.exceptions:
            print(f"Connection error: {exc}")
    except* ValueError as eg:
        for exc in eg.exceptions:
            print(f"Value error: {exc}")

    print("Program continues after handling errors")

asyncio.run(main())

Output:

Connection error: API server is down
Value error: Invalid response format
Program continues after handling errors

Notice that the “Backup” task was cancelled even though it had not failed — that is TaskGroup‘s strict policy. When the flaky_api_call raised ConnectionError, the group immediately cancelled all remaining tasks and collected the exceptions. The except* syntax handles each exception type separately, which is much cleaner than manually iterating through results looking for errors. If you need tasks to continue even when siblings fail, use gather(return_exceptions=True) instead.

gather() vs TaskGroup: When To Use Which

Now that you have seen both approaches, here is a direct comparison to help you choose the right tool for each situation.

Featureasyncio.gather()asyncio.TaskGroup
Python version3.4+3.11+
Error behavior (default)First exception propagates, others may still runAll tasks cancelled on first error
return_exceptions optionYes — collects errors as resultsNo — always cancels on error
Error handling syntaxCheck isinstance() on resultsexcept* ExceptionGroup
Task cancellationManualAutomatic on error
Best forIndependent tasks where partial results are OKRelated tasks that should all succeed or all fail

Use gather() when your tasks are independent and you want best-effort results — for example, fetching data from five APIs where getting four out of five is still useful. Use TaskGroup when your tasks are related and a partial result is meaningless — for example, a multi-step transaction where all steps must succeed. In practice, many developers use gather(return_exceptions=True) for resilient data fetching and TaskGroup for transactional workflows.

Creating and Managing Individual Tasks

Sometimes you need more control than gather() or TaskGroup provide. The asyncio.create_task() function lets you schedule a coroutine to run in the background without immediately waiting for its result. This is useful when you want to start something, do other work, and check on it later.

# create_task_demo.py
import asyncio

async def background_sync(data):
    """Simulate syncing data to a remote server."""
    print(f"Syncing {len(data)} records in background...")
    await asyncio.sleep(3)
    print("Sync complete!")
    return len(data)

async def process_request(request_id):
    """Simulate processing an incoming request."""
    await asyncio.sleep(0.5)
    return f"Request {request_id} processed"

async def main():
    # Start background sync — don't wait for it yet
    sync_task = asyncio.create_task(background_sync(["user1", "user2", "user3"]))

    # Process requests while sync runs in background
    for i in range(1, 4):
        result = await process_request(i)
        print(result)

    # Now wait for the sync to finish
    synced_count = await sync_task
    print(f"\nBackground sync finished: {synced_count} records synced")

asyncio.run(main())

Output:

Syncing 3 records in background...
Request 1 processed
Request 2 processed
Request 3 processed
Sync complete!

Background sync finished: 3 records synced

The key here is that asyncio.create_task() returns a Task object immediately without blocking. The sync coroutine starts running in the background while we process requests in the foreground. When we finally await sync_task, it either returns the result instantly (if it already finished) or waits until it completes. This pattern is perfect for fire-and-forget operations like logging, caching, or background data synchronization.

Cache Katie organizing task cards
TaskGroup: spawn ten coroutines, guarantee all ten get cleaned up. Even if one explodes.

Fine-Grained Control With asyncio.wait()

While gather() waits for all tasks to complete, asyncio.wait() gives you more flexibility. You can wait for the first task to finish, wait until any task raises an exception, or set a timeout. It returns two sets: done (completed tasks) and pending (still running tasks).

# wait_example.py
import asyncio

async def fetch_from_mirror(mirror_name, delay):
    """Simulate fetching from different mirror servers."""
    await asyncio.sleep(delay)
    return f"Data from {mirror_name}"

async def main():
    tasks = [
        asyncio.create_task(fetch_from_mirror("US-East", 3)),
        asyncio.create_task(fetch_from_mirror("EU-West", 1)),
        asyncio.create_task(fetch_from_mirror("Asia-Pacific", 2)),
    ]

    # Wait for the FIRST task to complete
    done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)

    # Use the fastest result
    for task in done:
        print(f"First result: {task.result()}")

    # Cancel the rest — we already have what we need
    print(f"Cancelling {len(pending)} remaining tasks...")
    for task in pending:
        task.cancel()

asyncio.run(main())

Output:

First result: Data from EU-West
Cancelling 2 remaining tasks...

This pattern is called “first response wins” and it is incredibly useful for redundant requests. If you have multiple mirror servers or backup APIs, you can query all of them simultaneously and use whichever responds first, then cancel the rest. The return_when parameter accepts three values: FIRST_COMPLETED (return when any task finishes), FIRST_EXCEPTION (return when any task raises), and ALL_COMPLETED (wait for everything, the default).

Setting Timeouts With asyncio.wait_for()

When calling external services, you should always set a timeout so your program does not hang indefinitely. The asyncio.wait_for() function wraps any awaitable with a timeout — if it does not complete in time, it raises asyncio.TimeoutError and cancels the task.

# timeout_example.py
import asyncio

async def slow_database_query():
    """Simulate a database query that takes too long."""
    print("Running complex query...")
    await asyncio.sleep(10)  # This takes way too long
    return "query results"

async def main():
    try:
        # Give it 3 seconds max
        result = await asyncio.wait_for(slow_database_query(), timeout=3.0)
        print(f"Got result: {result}")
    except asyncio.TimeoutError:
        print("Query timed out after 3 seconds!")
        print("Falling back to cached data...")
        result = "cached results"

    print(f"Using: {result}")

asyncio.run(main())

Output:

Running complex query...
Query timed out after 3 seconds!
Falling back to cached data...
Using: cached results

The timeout mechanism is essential for production code. Without it, a single unresponsive service can bring down your entire application. The asyncio.wait_for() function cancels the underlying coroutine when the timeout expires, so you do not end up with zombie tasks consuming resources in the background. A good practice is to combine timeouts with a fallback strategy — cached data, default values, or a retry with exponential backoff.

Rate Limiting With Semaphores

When you have hundreds of tasks to run, launching them all at once can overwhelm the target server or hit API rate limits. An asyncio.Semaphore acts as a bouncer — it limits how many coroutines can run a particular section of code at the same time. This is essential for being a good citizen when working with external APIs.

# semaphore_example.py
import asyncio
import time

async def fetch_page(session_semaphore, page_num):
    """Fetch a page, but respect the concurrency limit."""
    async with session_semaphore:
        # Only N tasks can be inside this block at once
        print(f"  Fetching page {page_num}...")
        await asyncio.sleep(1)  # Simulated HTTP request
        return f"Page {page_num} content"

async def main():
    semaphore = asyncio.Semaphore(3)  # Max 3 concurrent requests
    start = time.perf_counter()

    # Launch 9 tasks, but only 3 run at a time
    tasks = [fetch_page(semaphore, i) for i in range(1, 10)]
    results = await asyncio.gather(*tasks)

    elapsed = time.perf_counter() - start
    print(f"\nFetched {len(results)} pages in {elapsed:.2f} seconds")
    print(f"(3 at a time, ~1 second each = ~3 batches = ~3 seconds)")

asyncio.run(main())

Output:

  Fetching page 1...
  Fetching page 2...
  Fetching page 3...
  Fetching page 4...
  Fetching page 5...
  Fetching page 6...
  Fetching page 7...
  Fetching page 8...
  Fetching page 9...

Fetched 9 pages in 3.00 seconds
(3 at a time, ~1 second each = ~3 batches = ~3 seconds)

The async with session_semaphore context manager blocks when three tasks are already inside it, making the fourth task wait until one finishes. This creates a natural batching effect: pages 1-3 run first, then 4-6, then 7-9. Without the semaphore, all nine would fire at once, which could trigger rate limiting or connection errors. A good rule of thumb is to set the semaphore value to match the API’s rate limit — if an API allows 5 requests per second, use Semaphore(5) with a one-second delay inside the critical section.

Pyro Pete directing task queue
asyncio.gather() turns sequential API calls into concurrent ones. Same result, fraction of the time.

Real-Life Example: Concurrent URL Health Checker

Let us put everything together into a practical project. This health checker takes a list of URLs, pings them all concurrently (with a semaphore to limit concurrency), measures response times, and produces a status report. It uses aiohttp for async HTTP requests and demonstrates error handling, timeouts, and semaphores working together.

# url_health_checker.py
import asyncio
import time

# Using asyncio-compatible HTTP simulation
# In production, replace with aiohttp:
# import aiohttp

async def check_url(semaphore, url, timeout_seconds=5):
    """Check a single URL's health with concurrency limiting."""
    async with semaphore:
        start = time.perf_counter()
        try:
            # Simulate HTTP GET with varying response times
            # In production: async with aiohttp.ClientSession() as session:
            #                    async with session.get(url, timeout=...) as resp:
            simulated_delays = {
                "https://httpbin.org/get": 0.3,
                "https://jsonplaceholder.typicode.com/posts/1": 0.5,
                "https://httpbin.org/delay/2": 2.0,
                "https://httpbin.org/status/500": 0.2,
                "https://nonexistent.invalid/api": None,  # Will "fail"
            }
            delay = simulated_delays.get(url, 1.0)

            if delay is None:
                raise ConnectionError(f"Cannot resolve host")

            result = await asyncio.wait_for(
                asyncio.sleep(delay),  # Simulates network I/O
                timeout=timeout_seconds,
            )

            elapsed = time.perf_counter() - start
            status = 500 if "status/500" in url else 200
            return {
                "url": url,
                "status": status,
                "response_time": round(elapsed, 3),
                "healthy": 200 <= status < 400,
            }
        except asyncio.TimeoutError:
            elapsed = time.perf_counter() - start
            return {
                "url": url,
                "status": "TIMEOUT",
                "response_time": round(elapsed, 3),
                "healthy": False,
            }
        except Exception as e:
            elapsed = time.perf_counter() - start
            return {
                "url": url,
                "status": f"ERROR: {e}",
                "response_time": round(elapsed, 3),
                "healthy": False,
            }

def print_report(results, total_time):
    """Print a formatted health check report."""
    print("\n" + "=" * 65)
    print("  URL HEALTH CHECK REPORT")
    print("=" * 65)

    healthy = [r for r in results if r["healthy"]]
    unhealthy = [r for r in results if not r["healthy"]]

    for r in results:
        icon = "[OK]" if r["healthy"] else "[FAIL]"
        print(f"  {icon} {r['url']}")
        print(f"       Status: {r['status']}  |  Time: {r['response_time']}s")

    print("-" * 65)
    print(f"  Total: {len(results)} URLs checked in {total_time:.2f}s")
    print(f"  Healthy: {len(healthy)}  |  Unhealthy: {len(unhealthy)}")
    print("=" * 65)

async def main():
    urls = [
        "https://httpbin.org/get",
        "https://jsonplaceholder.typicode.com/posts/1",
        "https://httpbin.org/delay/2",
        "https://httpbin.org/status/500",
        "https://nonexistent.invalid/api",
    ]

    semaphore = asyncio.Semaphore(3)  # Max 3 concurrent checks
    start = time.perf_counter()

    # Check all URLs concurrently with rate limiting
    tasks = [check_url(semaphore, url, timeout_seconds=5) for url in urls]
    results = await asyncio.gather(*tasks)

    total_time = time.perf_counter() - start
    print_report(results, total_time)

asyncio.run(main())

Output:

=================================================================
  URL HEALTH CHECK REPORT
=================================================================
  [OK] https://httpbin.org/get
       Status: 200  |  Time: 0.301s
  [OK] https://jsonplaceholder.typicode.com/posts/1
       Status: 200  |  Time: 0.501s
  [OK] https://httpbin.org/delay/2
       Status: 200  |  Time: 2.001s
  [FAIL] https://httpbin.org/status/500
       Status: 500  |  Time: 0.201s
  [FAIL] https://nonexistent.invalid/api
       Status: ERROR: Cannot resolve host  |  Time: 0.0s
-----------------------------------------------------------------
  Total: 5 URLs checked in 2.00s
  Healthy: 3  |  Unhealthy: 2
=================================================================

This project demonstrates the key asyncio patterns we covered: gather() runs all checks concurrently, the semaphore limits us to three concurrent requests so we do not overwhelm any server, and wait_for() ensures no single check hangs forever. The error handling inside check_url catches both timeouts and connection errors, returning a structured result either way. To use this with real HTTP requests, install aiohttp with pip install aiohttp and replace the simulated delays with actual session.get() calls — the async structure stays exactly the same.

Frequently Asked Questions

Can I call a regular (synchronous) function from an async function?

Yes, you can call synchronous functions directly from async code, but be careful. If the synchronous function is fast (like a quick calculation or string manipulation), just call it normally. If it blocks for a long time (like time.sleep() or a synchronous HTTP request), it will freeze the entire event loop. For blocking operations, use await asyncio.to_thread(blocking_function, args) to run them in a separate thread without blocking the loop. This was added in Python 3.9 and is the recommended way to bridge sync and async code.

Can I use the requests library with asyncio?

The requests library is synchronous and will block the event loop if used directly inside an async function. You have two options: use aiohttp as a drop-in async replacement (it has a similar API with session.get() and session.post()), or wrap requests calls with await asyncio.to_thread(requests.get, url) to run them in a thread pool. The aiohttp approach is more efficient because it uses the event loop natively, while the thread approach adds thread-switching overhead.

What is the difference between asyncio.run() and get_event_loop()?

The asyncio.run() function (Python 3.7+) is the modern, recommended way to start your async program. It creates a new event loop, runs your coroutine, and cleans up afterward. The older asyncio.get_event_loop() pattern requires more manual management and is deprecated for most use cases since Python 3.10. Always use asyncio.run(main()) at the top level of your program unless you are integrating with a framework like Jupyter that manages its own event loop.

Why should I not use asyncio for CPU-bound tasks?

The asyncio event loop runs in a single thread, so CPU-intensive work blocks it completely. While one coroutine is crunching numbers, no other coroutine can run — there are no await points to yield control. For CPU-bound work like image processing, scientific computation, or data transformation, use multiprocessing or concurrent.futures.ProcessPoolExecutor. You can even combine them with asyncio using loop.run_in_executor() to run CPU work in a process pool while keeping your I/O code async.

How do I debug asyncio code?

Asyncio has a built-in debug mode you can enable by setting the environment variable PYTHONASYNCIODEBUG=1 or by passing debug=True to asyncio.run(). Debug mode warns you about common mistakes like coroutines that were never awaited, callbacks that take too long, and tasks that are destroyed while still pending. You can also use the asyncio logger with logging.getLogger('asyncio').setLevel(logging.DEBUG) to see detailed event loop activity.

What are async for and async with?

These are async versions of regular for loops and context managers. async for iterates over an asynchronous iterator — useful for streaming data from a database cursor or a websocket connection. async with enters and exits an asynchronous context manager — used heavily in aiohttp for managing HTTP sessions and connections. Both allow the event loop to run other tasks between iterations or during setup and teardown, which keeps your program responsive even when working with slow data sources.

Conclusion

You now have a solid understanding of Python's asyncio module and its key tools for concurrent programming. We covered the async/await syntax for defining coroutines, the event loop that coordinates everything, asyncio.gather() for running multiple tasks concurrently, TaskGroup for structured concurrency with automatic cancellation, asyncio.wait() for fine-grained control, timeouts with wait_for(), and semaphores for rate limiting. Each of these tools solves a specific problem, and knowing when to reach for which one is what separates a beginner from an effective async programmer.

Try extending the URL health checker project we built — add real HTTP requests with aiohttp, save results to a JSON file, or schedule periodic checks with asyncio.sleep() in a loop. You could also build an async web scraper that respects rate limits, or a chat application using websockets. The patterns you learned here apply directly to all of these projects.

For the complete reference, the official Python documentation for asyncio is excellent: https://docs.python.org/3/library/asyncio.html. The aiohttp documentation is also worth reading if you plan to make real HTTP requests in your async code.

How To Use Python Dataclasses For Clean Data Structures

How To Use Python Dataclasses For Clean Data Structures

Last Updated: June 01, 2026

Intermediate

How To Use Python Dataclasses For Clean Data Structures

You’re building a model to represent a user, a product, an order—some structured data. In older Python, you’d write boilerplate code: __init__ to initialize fields, __repr__ to show a nice string representation, __eq__ to compare instances. A simple 5-field data class would require 30+ lines of code. Dataclasses, added in Python 3.7, solve this problem entirely. One decorator (@dataclass) gives you automatic __init__, __repr__, __eq__, and more. Your class definition becomes clean, readable, and type-safe. This is one of the best quality-of-life improvements in modern Python.

In this article, we’ll explore dataclasses from basics to advanced patterns. You’ll learn when to use them, how to configure them, and how to build practical systems with them. By the end, you’ll write less boilerplate and more focused business logic.

Pubs - Python How To Program
Written by Pubs

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.

View all tutorials by Pubs →

Dataclasses: Quick Example

Here’s what would normally require 20+ lines of boilerplate code, now expressed as pure declarations:

#simple_dataclass.py
from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int
    email: str

person = Person("Alice", 30, "alice@example.com")
print(person)
# Person(name='Alice', age=30, email='alice@example.com')

person2 = Person("Alice", 30, "alice@example.com")
print(person == person2)
# True

Output:

Person(name='Alice', age=30, email='alice@example.com')
True

The @dataclass decorator gave us an __init__ that accepts all three fields, a __repr__ that shows the class and values, and __eq__ that compares instances by their fields. All with zero boilerplate code. This is why dataclasses are a game-changer for Python development.

@dataclass: __init__, __repr__, __eq__ for free.
@dataclass: __init__, __repr__, __eq__ for free.

Why Dataclasses Beat Dictionaries and Simple Classes

You might think “Can’t I just use dictionaries?” or “Why not write a normal class?” Let’s compare the approaches side by side. Each approach has tradeoffs, and dataclasses hit the sweet spot for most scenarios:

#comparison.py
from dataclasses import dataclass

# Dictionary approach (old way)
person_dict = {
    'name': 'Bob',
    'age': 28,
    'email': 'bob@example.com'
}
print(person_dict['name'])  # Must use quotes, typos aren't caught
print(person_dict.get('phone'))  # Missing keys return None silently

# Regular class (lots of boilerplate)
class PersonClass:
    def __init__(self, name, age, email):
        self.name = name
        self.age = age
        self.email = email

    def __repr__(self):
        return f'PersonClass(name={self.name}, age={self.age}, email={self.email})'

person_class = PersonClass('Bob', 28, 'bob@example.com')
print(person_class.name)  # Attribute access is nicer

# Dataclass approach (best of both worlds)
@dataclass
class Person:
    name: str
    age: int
    email: str

person = Person('Bob', 28, 'bob@example.com')
print(person.name)  # Attribute access like classes
print(person)  # Nice repr for free
# Person(name='Bob', age=28, email='bob@example.com')

Output:

Bob
None
Bob
Person(name='Bob', age=28, email='bob@example.com')

Dataclasses give you attribute access (cleaner than dict[‘key’]), automatic __repr__ (debug-friendly output), type hints (IDE support, safety), and zero boilerplate. Compared to dictionaries, you get type safety and better tooling. Compared to regular classes, you lose nothing but lines of code. They’re the right choice for modeling data.

Basic Dataclass Syntax With Methods

Dataclasses aren’t just data containers—they can have methods too. This makes them useful for modeling entities with both state and behavior:

#dataclass_with_methods.py
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

p = Point(3.5, 4.2)
print(f"Point coordinates: ({p.x}, {p.y})")

@dataclass
class Rectangle:
    width: float
    height: float

    def area(self):
        # Calculate area using width and height
        return self.width * self.height

    def perimeter(self):
        # Calculate perimeter
        return 2 * (self.width + self.height)

rect = Rectangle(5, 3)
print(f"Area: {rect.area()}")
print(f"Perimeter: {rect.perimeter()}")

Output:

Point coordinates: (3.5, 4.2)
Area: 15
Perimeter: 16

Dataclasses combine data (the fields) with behavior (the methods). This is object-oriented programming the right way—encapsulation of related data and operations.

Default Values and Field Configuration

Often your fields have default values, or they need special handling (like lists that start empty). The field() function from dataclasses gives you fine-grained control:

#dataclass_defaults.py
from dataclasses import dataclass, field
from datetime import datetime
from typing import List

@dataclass
class Article:
    title: str
    content: str
    author: str = "Anonymous"  # Simple default
    created_at: datetime = field(default_factory=datetime.now)  # Factory for mutable defaults
    tags: List[str] = field(default_factory=list)  # Always use field for lists
    views: int = 0
    is_published: bool = False

article1 = Article("Python Tips", "Learn about dataclasses...")
print(article1)

article2 = Article(
    "Flask Tutorial",
    "Build APIs with Flask...",
    author="John Doe",
    is_published=True
)
print(article2)

Output:

Article(title='Python Tips', content='Learn...', author='Anonymous', created_at=datetime.datetime(2026, 3, 12, 10, 15, 30), tags=[], views=0, is_published=False)
Article(title='Flask Tutorial', content='Build...', author='John Doe', created_at=datetime.datetime(2026, 3, 12, 10, 16, 45), tags=[], views=0, is_published=True)

Notice that created_at uses field(default_factory=…). This is important: mutable defaults (lists, dicts, datetime.now()) must use default_factory, otherwise all instances share the same list. Without field(), if you created two articles, both would share the same tags list (a common bug). The field() function prevents this.

Frozen Dataclasses: Immutability When You Need It

Sometimes you want data that can’t be changed after creation. Colors, coordinates, configuration objects—these are often immutable for good reason. The frozen=True parameter makes dataclass instances immutable:

#frozen_dataclass.py
from dataclasses import dataclass

@dataclass(frozen=True)
class Color:
    red: int
    green: int
    blue: int

sky_blue = Color(135, 206, 235)
print(f"Sky blue: {sky_blue}")

try:
    sky_blue.red = 100  # Try to modify
except Exception as e:
    print(f"Error: {e}")

darker_blue = Color(115, 186, 215)
print(f"Darker blue: {darker_blue}")

Output:

Sky blue: Color(red=135, green=206, blue=235)
Error: cannot assign to field 'red'
Darker blue: Color(red=115, green=186, blue=215)

Immutable objects are safer—they can’t be modified accidentally, they’re thread-safe, and they work as dictionary keys. Use frozen=True for configuration objects, constants, and anything that should never change.

Inheritance: Building Class Hierarchies

Dataclasses work beautifully with inheritance. Child classes inherit parent fields and can add their own:

#dataclass_inheritance.py
from dataclasses import dataclass

@dataclass
class Employee:
    name: str
    employee_id: int
    salary: float

@dataclass
class Manager(Employee):
    department: str
    team_size: int

emp = Employee("Alice", 101, 50000)
mgr = Manager("Bob", 102, 80000, "Engineering", 5)

print(emp)
print(mgr)
print(f"Manager {mgr.name} manages {mgr.team_size} people")

Output:

Employee(name='Alice', employee_id=101, salary=50000)
Manager(name='Bob', employee_id=102, salary=80000, department='Engineering', team_size=5)
Manager Bob manages 5 people

Manager inherits name, employee_id, and salary from Employee, then adds department and team_size. The __init__ signature includes all fields in the right order. This is how you model real-world hierarchies—employee types, vehicle types, etc.

slots=True: smaller, faster, no surprises.
slots=True: smaller, faster, no surprises.

Dataclasses vs NamedTuple: Understanding the Differences

NamedTuple is similar to dataclasses but makes different tradeoffs. Both are useful, but for different scenarios. Let’s compare them so you can choose wisely:

#dataclass_vs_namedtuple.py
from dataclasses import dataclass
from typing import NamedTuple

@dataclass
class PersonDataclass:
    name: str
    age: int

class PersonNamedTuple(NamedTuple):
    name: str
    age: int

p1 = PersonDataclass("Charlie", 35)
p2 = PersonNamedTuple("Charlie", 35)

print(p1)
print(p2)

p1.age = 36  # Dataclass is mutable
print(f"Updated dataclass: {p1}")

try:
    p2.age = 36  # NamedTuple is immutable
except Exception as e:
    print(f"NamedTuple error: {e}")

name, age = p2  # NamedTuple supports unpacking
print(f"Unpacked: {name}, {age}")

Output:

PersonDataclass(name='Charlie', age=35)
PersonNamedTuple(name='Charlie', age=35)
Updated dataclass: PersonDataclass(name='Charlie', age=36)
NamedTuple error: can't set attribute
Unpacked: Charlie, 35

Key differences:

  • Dataclasses: Mutable (can change fields), more flexible, better for modeling evolving objects
  • NamedTuple: Immutable, lighter weight, can be unpacked like tuples, good for fixed data

Use dataclasses when you might modify objects or want methods. Use NamedTuple when you want immutable, lightweight data containers. For most modern Python code, dataclasses are the default choice.

Real-Life Example: Product Inventory System

Let’s build a practical inventory management system using dataclasses. This demonstrates real-world patterns: enums for categories, computed properties, and business logic:

#inventory_system.py
from dataclasses import dataclass, field
from datetime import datetime
from typing import List
from enum import Enum

class Category(Enum):
    ELECTRONICS = "Electronics"
    CLOTHING = "Clothing"
    BOOKS = "Books"

@dataclass
class Product:
    sku: str
    name: str
    category: Category
    price: float
    quantity_in_stock: int
    reorder_level: int = 10
    last_restocked: datetime = field(default_factory=datetime.now)

    def needs_restock(self) -> bool:
        return self.quantity_in_stock <= self.reorder_level

    def total_value(self) -> float:
        return self.price * self.quantity_in_stock

    def sell(self, quantity: int) -> bool:
        if quantity > self.quantity_in_stock:
            return False
        self.quantity_in_stock -= quantity
        return True

    def restock(self, quantity: int) -> None:
        self.quantity_in_stock += quantity
        self.last_restocked = datetime.now()

@dataclass
class Inventory:
    products: List[Product] = field(default_factory=list)

    def add_product(self, product: Product) -> None:
        self.products.append(product)

    def get_product(self, sku: str) -> Product:
        for product in self.products:
            if product.sku == sku:
                return product
        return None

    def low_stock_items(self) -> List[Product]:
        return [p for p in self.products if p.needs_restock()]

    def total_value(self) -> float:
        return sum(p.total_value() for p in self.products)

    def inventory_report(self) -> None:
        print("=== INVENTORY REPORT ===")
        for product in self.products:
            status = "LOW STOCK" if product.needs_restock() else "OK"
            print(f"{product.sku}: {product.name} - {product.quantity_in_stock} units ({status})")
        print(f"Total Inventory Value: ${self.total_value():.2f}")
        print(f"Items needing restock: {len(self.low_stock_items())}")

inventory = Inventory()
laptop = Product(sku="LAPTOP001",name="MacBook Pro 14 inch",category=Category.ELECTRONICS,price=1999.99,quantity_in_stock=5,reorder_level=3)
book = Product(sku="BOOK001",name="Python Mastery",category=Category.BOOKS,price=29.99,quantity_in_stock=2,reorder_level=10)
inventory.add_product(laptop)
inventory.add_product(book)
laptop.sell(2)
book.sell(1)
inventory.inventory_report()
book.restock(15)
print(f"\nAfter restocking: {book}")

Output:

=== INVENTORY REPORT ===
LAPTOP001: MacBook Pro 14 inch - 3 units (OK)
BOOK001: Python Mastery - 1 units (LOW STOCK)
Total Inventory Value: $5999.97
Items needing restock: 1

After restocking: Product(sku='BOOK001', name='Python Mastery', category=, price=29.99, quantity_in_stock=16, reorder_level=10, last_restocked=datetime.datetime(2026, 3, 12, 10, 30, 45))

This inventory system uses dataclasses effectively: Product holds item data with methods for business logic (sell, restock, total_value). Inventory aggregates products and provides reports. Enums ensure category values are consistent. Timestamps track when restocking happened. This is maintainable, testable code that scales from a small store to enterprise inventory systems.

Debug Dee comparing messy __init__ methods with clean dataclass definitions
@dataclass does in one line what takes three dunder methods and twenty lines of boilerplate.

Frequently Asked Questions

Q: When should I use dataclasses vs regular classes?

Use dataclasses when your class is primarily a data container. Use regular classes when you have complex initialization logic, property decorators, or inheritance patterns that don’t fit the dataclass model. When in doubt, start with a dataclass and upgrade to a regular class if needed.

Q: Can I use dataclasses with type hints?

Type hints are required for dataclasses—they tell the decorator which fields to create. This is a feature, not a limitation. Type hints make your code clearer and enable better IDE support, type checking, and documentation. Python’s type system is optional, but use it with dataclasses.

Q: How do I convert a dataclass to a dictionary?

Use the asdict() function from dataclasses module: from dataclasses import asdict; my_dict = asdict(my_dataclass). This is useful for JSON serialization, logging, or comparing to dictionaries.

Q: Can I add validators to dataclasses?

Use the __post_init__ method to validate after initialization: def __post_init__(self): if self.age < 0: raise ValueError("Age must be positive"). You can also use field with validate_before for pre-validation, or external libraries like Pydantic for more complex validation.

Q: Do dataclasses work with JSON serialization?

Convert to dict first with asdict(), then to JSON: json.dumps(asdict(my_dataclass)). For automatic JSON support with validation, consider Pydantic instead. Pydantic is built on dataclasses but adds JSON schema support and validation.

Conclusion

Dataclasses are a game-changer for Python developers. They eliminate boilerplate code, add type safety, and make your code more readable. Whether you're building simple data models, complex business objects, or inventory systems, dataclasses scale from simple to sophisticated use cases. Start using them in your next project and you'll never want to go back to writing __init__ methods by hand.

Key takeaways:

  • Dataclasses eliminate __init__, __repr__, __eq__ boilerplate with one decorator
  • Use field() for mutable defaults (lists, dicts) and special handling
  • Use frozen=True for immutable objects
  • Dataclasses support inheritance naturally
  • Add methods to dataclasses for business logic—they're not just data containers
  • Dataclasses work with type hints, IDE tooling, and modern Python patterns
  • Use __post_init__ for validation after initialization
  • Choose dataclasses over dictionaries for type safety and over regular classes for less boilerplate

References

How To Create a Simple REST API With Python Flask in 2026

How To Create a Simple REST API With Python Flask in 2026

Last Updated: June 01, 2026

Intermediate

If you have ever wanted to build a backend that serves data to a mobile app, a frontend framework like React, or even another Python script, you need a REST API. REST APIs are the backbone of modern software — they let different systems communicate over HTTP using a standardized set of operations. Whether you are building a to-do app, a dashboard, or a microservice, the ability to create an API is one of the most practical skills a Python developer can have.

Flask is one of the best frameworks for building REST APIs in Python. It is lightweight, flexible, and stays out of your way — you can go from zero to a working API in under 20 lines of code. Flask does not force you into any particular project structure or ORM, which makes it perfect for learning and for small-to-medium projects. You will need Python 3.8 or later, and installing Flask is a single pip install flask command.

In this article we will build a REST API from scratch using Flask. We will start with a quick working example, then cover what REST means and how HTTP methods map to CRUD operations. From there we will build routes for creating, reading, updating, and deleting resources, add proper error handling, learn how to test our API with curl and Python’s requests library, and finish with a complete real-life project — a Bookmark Manager API that stores bookmarks in a JSON file with tagging and search. By the end, you will be ready to build any API you need.

Pubs - Python How To Program
Written by Pubs

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.

View all tutorials by Pubs →

Building a REST API With Flask: Quick Example

Part of the Python Web Frameworks Hub. See the full hub for related Python tutorials.

Here is the shortest working Flask API. It defines two endpoints — one that returns a welcome message and one that returns a list of books as JSON. You can run this and test it in your browser immediately.

# quick_api.py
from flask import Flask, jsonify

app = Flask(__name__)

books = [
    {"id": 1, "title": "Python Crash Course", "author": "Eric Matthes"},
    {"id": 2, "title": "Fluent Python", "author": "Luciano Ramalho"},
]

@app.route("/")
def home():
    return jsonify({"message": "Welcome to the Book API"})

@app.route("/api/books")
def get_books():
    return jsonify(books)

if __name__ == "__main__":
    app.run(debug=True)

Output (visiting http://127.0.0.1:5000/api/books):

[
  {"id": 1, "title": "Python Crash Course", "author": "Eric Matthes"},
  {"id": 2, "title": "Fluent Python", "author": "Luciano Ramalho"}
]

Run the script with python quick_api.py, then open http://127.0.0.1:5000/api/books in your browser. You will see the JSON list of books. The @app.route() decorator maps a URL path to a Python function, and jsonify() converts Python dictionaries and lists into proper JSON responses with the correct Content-Type header.

Want to go deeper? Below we cover what REST actually means, how to build full CRUD endpoints, handle errors properly, test your API, and build a complete Bookmark Manager project.

What Is a REST API and Why Use Flask?

REST stands for Representational State Transfer. It is an architectural style for designing networked applications. In practice, a REST API is a web service that uses HTTP methods (GET, POST, PUT, DELETE) to perform operations on resources identified by URLs. When you visit /api/books, you are requesting the “books” resource. When you send a POST request to that same URL with JSON data, you are creating a new book.

The beauty of REST is its simplicity — it maps naturally to the four CRUD operations that every application needs. Here is how HTTP methods correspond to database-style operations:

HTTP MethodCRUD OperationExample URLWhat It Does
GETRead/api/booksRetrieve all books
GETRead/api/books/1Retrieve book with ID 1
POSTCreate/api/booksCreate a new book
PUTUpdate/api/books/1Update book with ID 1
DELETEDelete/api/books/1Delete book with ID 1

Flask is ideal for building REST APIs because it gives you just enough structure without imposing decisions. Unlike Django (which includes an ORM, admin panel, and templating engine), Flask lets you pick only the pieces you need. For a REST API, that means routes, request parsing, and JSON responses — Flask handles all three beautifully out of the box.

Installing Flask and Project Setup

Flask installs in seconds and has no mandatory dependencies beyond its own core libraries. Let us install it and verify everything works.

# setup_check.py
# Install from terminal: pip install flask

# Verify the installation
import flask
print(f"Flask version: {flask.__version__}")

Output:

Flask version: 3.1.0

That is all you need. Flask includes its own development server, so you do not need to install a separate web server for development. For production, you would use a WSGI server like Gunicorn, but for learning and testing, the built-in server is perfect.

Your First Flask Route

A route in Flask is a URL pattern mapped to a Python function. When someone visits that URL, Flask calls your function and returns whatever it sends back. Let us build a slightly more detailed example that shows how routes, methods, and response codes work together.

# first_routes.py
from flask import Flask, jsonify, request

app = Flask(__name__)

@app.route("/")
def home():
    return jsonify({
        "message": "Welcome to my API",
        "version": "1.0",
        "endpoints": ["/api/hello", "/api/greet?name=YourName"]
    })

@app.route("/api/hello")
def hello():
    return jsonify({"greeting": "Hello, World!"})

@app.route("/api/greet")
def greet():
    # Read a query parameter from the URL
    name = request.args.get("name", "stranger")
    return jsonify({"greeting": f"Hello, {name}!"})

if __name__ == "__main__":
    app.run(debug=True, port=5000)

Output (visiting http://127.0.0.1:5000/api/greet?name=Alice):

{"greeting": "Hello, Alice!"}

The request.args.get() method reads query parameters from the URL. The second argument ("stranger") is the default value if the parameter is not provided. The debug=True flag enables auto-reloading — Flask will restart the server automatically whenever you save changes to your code, which makes development much faster. It also shows detailed error pages in the browser when something goes wrong.

Sudo Sam at a crossroads of glowing routes
Flask routing: because every URL deserves a function that loves it.

Building CRUD Endpoints With JSON

Now let us build a complete set of CRUD endpoints for a books resource. This is the pattern you will use for nearly every REST API — a collection endpoint (/api/books) for listing and creating, and an item endpoint (/api/books/<id>) for reading, updating, and deleting individual records.

# crud_api.py
from flask import Flask, jsonify, request

app = Flask(__name__)

# In-memory data store (replace with a database in production)
books = [
    {"id": 1, "title": "Python Crash Course", "author": "Eric Matthes", "year": 2019},
    {"id": 2, "title": "Fluent Python", "author": "Luciano Ramalho", "year": 2022},
    {"id": 3, "title": "Automate the Boring Stuff", "author": "Al Sweigart", "year": 2019},
]
next_id = 4  # Track the next available ID

@app.route("/api/books", methods=["GET"])
def get_all_books():
    return jsonify(books)

@app.route("/api/books/<int:book_id>", methods=["GET"])
def get_book(book_id):
    book = next((b for b in books if b["id"] == book_id), None)
    if book is None:
        return jsonify({"error": "Book not found"}), 404
    return jsonify(book)

@app.route("/api/books", methods=["POST"])
def create_book():
    global next_id
    data = request.get_json()

    # Validate required fields
    if not data or "title" not in data or "author" not in data:
        return jsonify({"error": "Title and author are required"}), 400

    new_book = {
        "id": next_id,
        "title": data["title"],
        "author": data["author"],
        "year": data.get("year", "Unknown")
    }
    books.append(new_book)
    next_id += 1
    return jsonify(new_book), 201  # 201 = Created

@app.route("/api/books/<int:book_id>", methods=["PUT"])
def update_book(book_id):
    book = next((b for b in books if b["id"] == book_id), None)
    if book is None:
        return jsonify({"error": "Book not found"}), 404

    data = request.get_json()
    book["title"] = data.get("title", book["title"])
    book["author"] = data.get("author", book["author"])
    book["year"] = data.get("year", book["year"])
    return jsonify(book)

@app.route("/api/books/<int:book_id>", methods=["DELETE"])
def delete_book(book_id):
    global books
    book = next((b for b in books if b["id"] == book_id), None)
    if book is None:
        return jsonify({"error": "Book not found"}), 404

    books = [b for b in books if b["id"] != book_id]
    return jsonify({"message": f"Book {book_id} deleted"})

if __name__ == "__main__":
    app.run(debug=True)

Output (POST request to create a book):

# Request: POST /api/books with {"title": "Clean Code", "author": "Robert Martin", "year": 2008}

# Response (201 Created):
{"id": 4, "title": "Clean Code", "author": "Robert Martin", "year": 2008}

There are several important patterns in this code. The methods parameter on @app.route() restricts which HTTP methods a route accepts — without it, Flask only allows GET. The request.get_json() method parses the request body as JSON. We return appropriate HTTP status codes: 201 for successful creation, 404 when a resource is not found, and 400 for bad requests. The <int:book_id> URL parameter tells Flask to extract an integer from the URL and pass it to your function — if someone sends a non-integer, Flask automatically returns a 404.

Error Handling in Flask APIs

A well-designed API needs consistent error responses. Flask lets you register custom error handlers that return JSON instead of the default HTML error pages. This is critical for APIs — your clients are programs, not browsers, and they need machine-readable error messages.

# error_handling.py
from flask import Flask, jsonify, request

app = Flask(__name__)

# Custom error handlers for common HTTP errors
@app.errorhandler(404)
def not_found(error):
    return jsonify({"error": "Resource not found", "status": 404}), 404

@app.errorhandler(405)
def method_not_allowed(error):
    return jsonify({"error": "Method not allowed", "status": 405}), 405

@app.errorhandler(400)
def bad_request(error):
    return jsonify({"error": "Bad request", "status": 400}), 400

@app.errorhandler(500)
def internal_error(error):
    return jsonify({"error": "Internal server error", "status": 500}), 500

# Example route with input validation
@app.route("/api/calculate", methods=["POST"])
def calculate():
    data = request.get_json()
    if not data:
        return jsonify({"error": "Request body must be JSON"}), 400

    a = data.get("a")
    b = data.get("b")
    operation = data.get("operation", "add")

    if a is None or b is None:
        return jsonify({"error": "Fields 'a' and 'b' are required"}), 400

    try:
        a, b = float(a), float(b)
    except (ValueError, TypeError):
        return jsonify({"error": "Fields 'a' and 'b' must be numbers"}), 400

    operations = {
        "add": a + b,
        "subtract": a - b,
        "multiply": a * b,
    }

    if operation == "divide":
        if b == 0:
            return jsonify({"error": "Cannot divide by zero"}), 400
        result = a / b
    elif operation in operations:
        result = operations[operation]
    else:
        return jsonify({"error": f"Unknown operation: {operation}"}), 400

    return jsonify({"result": result, "operation": operation})

if __name__ == "__main__":
    app.run(debug=True)

Output:

# Request: POST /api/calculate with {"a": 10, "b": 3, "operation": "multiply"}
# Response: {"result": 30.0, "operation": "multiply"}

# Request: POST /api/calculate with {"a": 10, "b": 0, "operation": "divide"}
# Response (400): {"error": "Cannot divide by zero"}

# Request: GET /api/nonexistent
# Response (404): {"error": "Resource not found", "status": 404}

The @app.errorhandler() decorator registers functions that handle specific HTTP error codes globally. Every 404 in your entire application will now return JSON instead of an HTML page. This is important because API clients like mobile apps and JavaScript frontends expect JSON responses for everything, including errors. The calculate endpoint demonstrates thorough input validation — checking for missing fields, wrong types, and edge cases like division by zero before processing the request.

Debug Dee examining a cracked error symbol
404: Resource not found. 200: Everything is fine. 500: Run.

Testing Your API With curl and requests

Building an API is only half the job — you also need to test it. The two most common tools for testing REST APIs from the command line are curl (built into most operating systems) and Python’s requests library. Let us see both approaches for testing our books API.

Testing With curl

The curl command is the fastest way to test an endpoint from your terminal. Here are the commands for each CRUD operation:

# testing_curl.sh
# GET all books
curl http://127.0.0.1:5000/api/books

# GET a single book
curl http://127.0.0.1:5000/api/books/1

# POST - create a new book
curl -X POST http://127.0.0.1:5000/api/books \
  -H "Content-Type: application/json" \
  -d '{"title": "Clean Code", "author": "Robert Martin", "year": 2008}'

# PUT - update an existing book
curl -X PUT http://127.0.0.1:5000/api/books/1 \
  -H "Content-Type: application/json" \
  -d '{"title": "Python Crash Course (3rd Ed)", "year": 2023}'

# DELETE - remove a book
curl -X DELETE http://127.0.0.1:5000/api/books/3

Output (from the POST command):

{"author":"Robert Martin","id":4,"title":"Clean Code","year":2008}

The -X flag specifies the HTTP method, -H sets headers, and -d sends the request body. Always include Content-Type: application/json when sending JSON data — without it, Flask’s request.get_json() returns None.

Testing With Python’s requests Library

For more complex testing or when you want to automate API tests, Python’s requests library is more convenient than curl:

# test_api.py
import requests

BASE_URL = "http://127.0.0.1:5000/api/books"

# GET all books
response = requests.get(BASE_URL)
print(f"GET all: {response.status_code}")
print(f"Books: {response.json()}\n")

# POST a new book
new_book = {"title": "Clean Code", "author": "Robert Martin", "year": 2008}
response = requests.post(BASE_URL, json=new_book)
print(f"POST: {response.status_code}")
print(f"Created: {response.json()}\n")

# GET a single book
response = requests.get(f"{BASE_URL}/1")
print(f"GET one: {response.status_code}")
print(f"Book: {response.json()}\n")

# PUT update
update_data = {"title": "Python Crash Course (3rd Ed)"}
response = requests.put(f"{BASE_URL}/1", json=update_data)
print(f"PUT: {response.status_code}")
print(f"Updated: {response.json()}\n")

# DELETE
response = requests.delete(f"{BASE_URL}/3")
print(f"DELETE: {response.status_code}")
print(f"Result: {response.json()}")

Output:

GET all: 200
Books: [{"author": "Eric Matthes", "id": 1, ...}, ...]

POST: 201
Created: {"author": "Robert Martin", "id": 4, "title": "Clean Code", "year": 2008}

GET one: 200
Book: {"author": "Eric Matthes", "id": 1, "title": "Python Crash Course", "year": 2019}

PUT: 200
Updated: {"author": "Eric Matthes", "id": 1, "title": "Python Crash Course (3rd Ed)", "year": 2019}

DELETE: 200
Result: {"message": "Book 3 deleted"}

The requests library is much easier to work with programmatically. The json= parameter automatically serializes your dictionary to JSON and sets the correct Content-Type header. The response.json() method parses the response body back into a Python dictionary. This makes it trivial to write automated test scripts that verify your API behaves correctly after every code change.

Real-Life Example: Bookmark Manager API

Pyro Pete launching a colorful rocket ship
Your Flask API just launched into production. Time to celebrate!

Let us build something practical — a Bookmark Manager API that stores website bookmarks with titles, URLs, tags, and timestamps. It supports full CRUD operations, searching by tag, and persists data to a JSON file so bookmarks survive server restarts. This project uses every concept from the article.

# bookmark_api.py
import os
import json
from datetime import datetime
from flask import Flask, jsonify, request

app = Flask(__name__)
DATA_FILE = "bookmarks.json"

def load_data():
    """Load bookmarks from JSON file."""
    if os.path.exists(DATA_FILE):
        with open(DATA_FILE, "r") as f:
            return json.load(f)
    return {"bookmarks": [], "next_id": 1}

def save_data(data):
    """Save bookmarks to JSON file."""
    with open(DATA_FILE, "w") as f:
        json.dump(data, f, indent=2)

@app.route("/api/bookmarks", methods=["GET"])
def get_bookmarks():
    data = load_data()
    tag = request.args.get("tag")  # Optional tag filter
    bookmarks = data["bookmarks"]
    if tag:
        bookmarks = [b for b in bookmarks if tag.lower() in [t.lower() for t in b["tags"]]]
    return jsonify(bookmarks)

@app.route("/api/bookmarks/<int:bookmark_id>", methods=["GET"])
def get_bookmark(bookmark_id):
    data = load_data()
    bookmark = next((b for b in data["bookmarks"] if b["id"] == bookmark_id), None)
    if not bookmark:
        return jsonify({"error": "Bookmark not found"}), 404
    return jsonify(bookmark)

@app.route("/api/bookmarks", methods=["POST"])
def create_bookmark():
    data = load_data()
    body = request.get_json()

    if not body or "url" not in body:
        return jsonify({"error": "URL is required"}), 400

    bookmark = {
        "id": data["next_id"],
        "title": body.get("title", "Untitled"),
        "url": body["url"],
        "tags": body.get("tags", []),
        "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
    }
    data["bookmarks"].append(bookmark)
    data["next_id"] += 1
    save_data(data)
    return jsonify(bookmark), 201

@app.route("/api/bookmarks/<int:bookmark_id>", methods=["PUT"])
def update_bookmark(bookmark_id):
    data = load_data()
    bookmark = next((b for b in data["bookmarks"] if b["id"] == bookmark_id), None)
    if not bookmark:
        return jsonify({"error": "Bookmark not found"}), 404

    body = request.get_json()
    bookmark["title"] = body.get("title", bookmark["title"])
    bookmark["url"] = body.get("url", bookmark["url"])
    bookmark["tags"] = body.get("tags", bookmark["tags"])
    save_data(data)
    return jsonify(bookmark)

@app.route("/api/bookmarks/<int:bookmark_id>", methods=["DELETE"])
def delete_bookmark(bookmark_id):
    data = load_data()
    original_count = len(data["bookmarks"])
    data["bookmarks"] = [b for b in data["bookmarks"] if b["id"] != bookmark_id]

    if len(data["bookmarks"]) == original_count:
        return jsonify({"error": "Bookmark not found"}), 404

    save_data(data)
    return jsonify({"message": f"Bookmark {bookmark_id} deleted"})

@app.errorhandler(404)
def not_found(error):
    return jsonify({"error": "Resource not found"}), 404

@app.errorhandler(400)
def bad_request(error):
    return jsonify({"error": "Bad request"}), 400

if __name__ == "__main__":
    app.run(debug=True)

Output (testing the API):

# POST - Create bookmarks
curl -X POST http://127.0.0.1:5000/api/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"title": "Python Docs", "url": "https://docs.python.org", "tags": ["python", "docs"]}'
# Response: {"id": 1, "title": "Python Docs", "url": "https://docs.python.org", "tags": ["python", "docs"], "created_at": "2026-03-13 15:30:00"}

curl -X POST http://127.0.0.1:5000/api/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"title": "Flask Docs", "url": "https://flask.palletsprojects.com", "tags": ["python", "flask", "web"]}'
# Response: {"id": 2, "title": "Flask Docs", ...}

# GET - Filter by tag
curl http://127.0.0.1:5000/api/bookmarks?tag=flask
# Response: [{"id": 2, "title": "Flask Docs", ...}]

# PUT - Update tags
curl -X PUT http://127.0.0.1:5000/api/bookmarks/1 \
  -H "Content-Type: application/json" \
  -d '{"tags": ["python", "docs", "reference"]}'
# Response: {"id": 1, "title": "Python Docs", "tags": ["python", "docs", "reference"], ...}

# DELETE
curl -X DELETE http://127.0.0.1:5000/api/bookmarks/1
# Response: {"message": "Bookmark 1 deleted"}

This project demonstrates a complete, production-style API pattern. Every endpoint validates its input, returns appropriate status codes, and handles edge cases like missing resources. The tag filtering on the GET endpoint shows how to support query parameters for searching and filtering. The JSON file persistence means bookmarks survive server restarts without needing a database. You could extend this with pagination (limit and offset parameters), full-text search across titles, import/export from browser bookmark files, or a simple HTML frontend.

Frequently Asked Questions

Should I use Flask or Django for my API?

Flask is better for small-to-medium APIs and microservices where you want full control over your stack. Django REST Framework is better when you need an admin panel, ORM, authentication, and other batteries-included features out of the box. If you are building a simple API or learning, start with Flask. If you are building a large application with user accounts and a database, Django might save you time.

How do I connect Flask to a database?

For SQLite (great for learning and small apps), use Python’s built-in sqlite3 module directly. For production, Flask-SQLAlchemy is the most popular choice — it provides an ORM that works with PostgreSQL, MySQL, and SQLite. Install it with pip install flask-sqlalchemy and define your models as Python classes. For simple projects, a JSON file (like we used in the Bookmark Manager) works fine and avoids the complexity of database setup.

Why do I get CORS errors when calling my Flask API from JavaScript?

Browsers block JavaScript from making requests to a different domain than the page was loaded from — this is called Cross-Origin Resource Sharing (CORS). To fix it, install flask-cors with pip install flask-cors, then add CORS(app) to your Flask app. This enables all origins by default. For production, configure it to only allow your specific frontend domain.

How do I deploy a Flask API to production?

Never use Flask’s built-in development server in production — it is single-threaded and not secure. Instead, use a WSGI server like Gunicorn (pip install gunicorn, then gunicorn app:app). Popular deployment platforms include Railway, Render, Heroku, and AWS. For a simple setup, a DigitalOcean droplet with Gunicorn behind Nginx works well and can handle thousands of requests per second.

How do I add authentication to my Flask API?

The simplest approach is API key authentication — check for a key in the request headers. For token-based auth, use Flask-JWT-Extended (pip install flask-jwt-extended), which handles JWT token creation, validation, and refresh. For OAuth and social login, Flask-Login combined with Flask-Dance works well. Start with API keys for internal tools and move to JWT when you need user accounts.

How do I write automated tests for a Flask API?

Flask includes a built-in test client that simulates HTTP requests without starting a server. Use app.test_client() in your test functions with pytest: call client.get("/api/books"), then assert response.status_code == 200 and check response.get_json() for the expected data. This lets you test every endpoint and edge case automatically as part of your CI pipeline.

Conclusion

You now know how to build a complete REST API with Flask. We covered the fundamentals: routing with @app.route(), handling JSON with request.get_json() and jsonify(), building full CRUD endpoints for creating, reading, updating, and deleting resources, returning proper HTTP status codes, implementing custom error handlers, and testing with both curl and Python’s requests library. We tied it all together with a Bookmark Manager API that includes tag-based filtering and JSON file persistence.

The Bookmark Manager is a solid foundation for your own projects. Try extending it with pagination, user authentication, a SQLite database, or a React frontend that consumes the API. The REST patterns you learned here are universal — they apply whether you are building a personal project or a production microservice.

For more advanced Flask features like blueprints, middleware, and extensions, check out the official Flask documentation. For REST API design best practices, the RESTful API tutorial is an excellent reference.

How To Schedule Python Scripts To Run Automatically With cron and schedule

How To Schedule Python Scripts To Run Automatically With cron and schedule

Last Updated: June 01, 2026

Intermediate

Pubs - Python How To Program
Written by Pubs

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.

View all tutorials by Pubs →

How To Schedule Python Scripts To Run Automatically With cron and schedule

If you’re building Python applications, sooner or later you’ll want them to run on a schedule without you having to sit at your computer and trigger them manually. Whether it’s a daily backup, sending reports, checking for updates, or cleaning up temporary files, automation is a game-changer. In this tutorial, we’ll explore how to schedule Python scripts to run automatically using both Linux cron and the Python schedule library, plus we’ll touch on Windows Task Scheduler for our Windows users.

Quick Example (TLDR)

Here’s the fastest way to schedule a Python script using the schedule library:


# Install: pip install schedule
import schedule
import time

def my_job():
    # This code runs on schedule
    print("Job executed at", time.strftime("%Y-%m-%d %H:%M:%S"))

# Schedule the job to run every day at 10:00 AM
schedule.every().day.at("10:00").do(my_job)

# Keep the scheduler running
while True:
    schedule.run_pending()
    time.sleep(60)  # Check every minute if a job should run

Output:


Job executed at 2026-03-12 10:00:15
Job executed at 2026-03-13 10:00:12

Why Automate Your Python Scripts?

Let’s think about some real-world scenarios where automation saves you time and money:

  • Backups: Automatically backup your database every night without remembering
  • Reports: Generate and email reports every Monday morning at 9 AM
  • Data Processing: Process new files as they arrive, every hour
  • Monitoring: Check server health every 5 minutes and alert if something’s wrong
  • Cleanup: Delete temporary files older than 30 days, weekly

When you automate these tasks, you free up mental energy to focus on more important things. Plus, computers don’t sleep, so they can work 24/7 without complaining!

Method 1: Linux cron Jobs

If you’re running on Linux or macOS, cron is a powerful and built-in scheduler. Let’s walk through how to set up a cron job.

Step 1: Create Your Python Script

First, let’s create a simple backup script:


# backup_script.py
import shutil
import os
from datetime import datetime

def backup_database():
    # Get current date and time
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    
    # Source and destination paths
    source = "/home/user/myapp/data.db"
    destination = f"/home/user/backups/data_{timestamp}.db"
    
    try:
        # Copy the database file
        shutil.copy2(source, destination)
        print(f"Backup successful: {destination}")
    except Exception as e:
        # Log errors for troubleshooting
        print(f"Backup failed: {e}")

if __name__ == "__main__":
    backup_database()

Output:


Backup successful: /home/user/backups/data_20260312_100000.db

Step 2: Make It Executable


chmod +x /home/user/backup_script.py

Step 3: Edit Your Crontab

Open your cron configuration by running:


crontab -e

Add this line to run the backup every day at 2 AM:


# Run backup script every day at 2:00 AM
0 2 * * * /usr/bin/python3 /home/user/backup_script.py >> /var/log/backup.log 2>&1

The cron syntax is: minute hour day month weekday command

  • 0 2 * * * = Every day at 2:00 AM
  • /usr/bin/python3 = Full path to Python interpreter
  • /home/user/backup_script.py = Your script path
  • >> /var/log/backup.log 2>&1 = Log output to a file

Method 2: Python schedule Library (Recommended for Cross-Platform)

If you need more control or want your scheduler to run within your Python application, the schedule library is excellent:


# job_scheduler.py
import schedule
import time
import logging
from datetime import datetime

# Setup logging to track what happens
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(message)s'
)

def backup_job():
    # This runs every day
    logging.info("Starting daily backup...")
    # Your backup code here
    logging.info("Backup complete!")

def send_report():
    # This runs every Monday at 9 AM
    logging.info("Sending weekly report...")
    # Your email code here
    logging.info("Report sent!")

def cleanup_temp_files():
    # This runs every hour
    logging.info("Cleaning temporary files...")
    # Your cleanup code here
    logging.info("Cleanup complete!")

# Schedule the jobs
schedule.every().day.at("02:00").do(backup_job)
schedule.every().monday.at("09:00").do(send_report)
schedule.every().hour.do(cleanup_temp_files)

# Keep the scheduler running
if __name__ == "__main__":
    print("Scheduler started. Press Ctrl+C to stop.")
    while True:
        schedule.run_pending()
        time.sleep(60)  # Check every minute

Output (simulated run):


2026-03-12 02:00:00 - Starting daily backup...
2026-03-12 02:00:15 - Backup complete!
2026-03-12 09:00:00 - Sending weekly report...
2026-03-12 09:00:25 - Report sent!
2026-03-12 13:00:00 - Cleaning temporary files...
2026-03-12 13:00:05 - Cleanup complete!

Method 3: Windows Task Scheduler

Windows users can use Task Scheduler to run Python scripts automatically:

  1. Press Win + R and type taskschd.msc to open Task Scheduler
  2. Click “Create Basic Task”
  3. Give it a name: “My Python Backup”
  4. Set trigger (when to run): Daily at 2:00 AM
  5. Set action: Start a program
    • Program: C:Python312python.exe
    • Arguments: C:\Users\YourName\backup_script.py
  6. Click Finish

Error Handling in Scheduled Tasks

When your script runs automatically, you won’t be there to see errors. That’s why logging is critical:


# robust_scheduler.py
import schedule
import time
import logging
import traceback
from datetime import datetime

# Configure detailed logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler('/var/log/my_jobs.log'),
        logging.StreamHandler()
    ]
)

def safe_job_wrapper(job_func, job_name):
    # Wrapper function that catches and logs errors
    def wrapper():
        try:
            logging.info(f"Starting: {job_name}")
            job_func()
            logging.info(f"Completed: {job_name}")
        except Exception as e:
            # Log the full error trace for debugging
            logging.error(f"Failed: {job_name}")
            logging.error(traceback.format_exc())
    return wrapper

def my_risky_job():
    # This might fail sometimes
    result = 10 / 1  # Would be 10 / 0 to cause error
    print(f"Calculation result: {result}")

# Schedule with error handling
safe_wrapper = safe_job_wrapper(my_risky_job, "my_risky_job")
schedule.every().hour.do(safe_wrapper)

if __name__ == "__main__":
    while True:
        schedule.run_pending()
        time.sleep(60)

Log Output (when error occurs):


2026-03-12 15:00:00 - INFO - Starting: my_risky_job
2026-03-12 15:00:00 - ERROR - Failed: my_risky_job
2026-03-12 15:00:00 - ERROR - Traceback (most recent call last):
  File "scheduler.py", line 15, in wrapper
    job_func()
  File "scheduler.py", line 28, in my_risky_job
    result = 10 / 0
ZeroDivisionError: division by zero

Real-Life Example: Automated Daily Backup Script

Let’s build a complete backup system that you can use right away:


# daily_backup.py
import schedule
import time
import os
import shutil
import logging
from datetime import datetime, timedelta
import gzip
import json

# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    filename='/var/log/backup.log'
)

class BackupManager:
    def __init__(self, source_dir, backup_dir, keep_days=7):
        # Initialize backup settings
        self.source_dir = source_dir
        self.backup_dir = backup_dir
        self.keep_days = keep_days
        
        # Create backup directory if it doesn't exist
        os.makedirs(backup_dir, exist_ok=True)
    
    def backup_files(self):
        # Create timestamped backup
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        backup_path = os.path.join(self.backup_dir, f"backup_{timestamp}.tar.gz")
        
        try:
            # Compress the entire directory
            shutil.make_archive(
                backup_path.replace('.tar.gz', ''),
                'gztar',
                self.source_dir
            )
            logging.info(f"Backup created: {backup_path}")
            
            # Clean old backups
            self.cleanup_old_backups()
            
        except Exception as e:
            logging.error(f"Backup failed: {e}")
    
    def cleanup_old_backups(self):
        # Remove backups older than keep_days
        cutoff_date = datetime.now() - timedelta(days=self.keep_days)
        
        for filename in os.listdir(self.backup_dir):
            filepath = os.path.join(self.backup_dir, filename)
            file_time = datetime.fromtimestamp(os.path.getmtime(filepath))
            
            if file_time < cutoff_date:
                try:
                    os.remove(filepath)
                    logging.info(f"Deleted old backup: {filename}")
                except Exception as e:
                    logging.error(f"Failed to delete {filename}: {e}")

# Create backup manager instance
backup = BackupManager("/home/user/myapp", "/home/user/backups")

# Schedule daily backup at 2 AM
schedule.every().day.at("02:00").do(backup.backup_files)

# Keep scheduler running
if __name__ == "__main__":
    logging.info("Backup scheduler started")
    while True:
        schedule.run_pending()
        time.sleep(60)

Output:


2026-03-12 02:00:00 - INFO - Backup created: /home/user/backups/backup_20260312_020000.tar.gz
2026-03-13 02:00:00 - INFO - Backup created: /home/user/backups/backup_20260313_020000.tar.gz
2026-03-15 02:00:00 - INFO - Deleted old backup: backup_20260305_020000.tar.gz

FAQ

Q1: How do I know if my scheduled job ran?

Use logging! Redirect output to a log file as shown in our examples. Check the log file to verify execution.

Q2: Can I schedule a job for every 30 minutes?

Yes! With schedule library: schedule.every(30).minutes.do(my_job). With cron: */30 * * * * command

Q3: What's the difference between cron and the schedule library?

Cron is system-level, always running. Schedule library runs inside your Python process. Cron is better for production servers; schedule is better for local testing and development.

Q4: How do I stop a scheduled task?

For cron: crontab -e and delete the line. For schedule: Stop the Python process (Ctrl+C). For Windows: Open Task Scheduler and disable the task.

Q5: What if my Python script takes longer to run than scheduled?

Use a queuing system like Celery for production workloads. For simple scripts, the schedule library won't run overlapping instances by default.

Conclusion

Scheduling Python scripts is one of the most practical skills you can develop. Whether you use cron for server environments, the schedule library for cross-platform applications, or Windows Task Scheduler for local automation, you now have the tools to build reliable automated systems. Start small with a simple daily task, add logging to track what happens, and expand from there. Your future self will thank you for automating those repetitive tasks!

References

Cache Katie setting multiple alarm clocks
schedule.every().monday.at('09:00') — cron syntax for people who value their sanity.

cron Basics for Python Scripts

On Linux and macOS, cron is the system-level scheduler. Run crontab -e to open your user's crontab; each line is a schedule plus a command. The five fields before the command are minute hour day-of-month month day-of-week:

# Run every day at 2:30 AM
30 2 * * * /usr/bin/python3 /home/me/scripts/cleanup.py

# Every 15 minutes
*/15 * * * * /home/me/.venv/bin/python /home/me/scripts/poll.py

# Every Monday at 9 AM
0 9 * * 1 /home/me/.venv/bin/python /home/me/scripts/weekly-report.py

# Twice an hour on weekdays
0,30 * * * 1-5 /home/me/.venv/bin/python /home/me/scripts/business-hours.py

Three rules that save hours of debugging:

  • Use absolute paths. Cron runs with a minimal PATH. python3 alone may not resolve. Use /usr/bin/python3 or your venv's full path.
  • Redirect stdout / stderr. Append >> /var/log/cleanup.log 2>&1 so you can debug. Cron-by-default emails errors to the system mail spool, which you'll never see.
  • Test with a 'run in one minute' schedule first. Set the job to run in 1-2 minutes from now while you're watching the log — much faster than waiting until 2:30 AM.

The schedule Library: Cron in Pure Python

For schedules that should travel with your code (containerized apps, cross-platform scripts), the schedule library gives you a Pythonic API:

# pip install schedule

import schedule
import time

def cleanup_temp_files():
    print("Cleaning up...")

def send_daily_report():
    print("Sending report")

schedule.every(15).minutes.do(cleanup_temp_files)
schedule.every().day.at("09:00").do(send_daily_report)
schedule.every().monday.at("08:00").do(send_daily_report)
schedule.every(2).hours.until("18:00").do(cleanup_temp_files)

while True:
    schedule.run_pending()
    time.sleep(60)

The advantage over cron: the schedule travels with the code, no system-level setup. The disadvantage: your script has to stay running. Pair it with systemd on Linux or supervisord to handle restarts.

APScheduler — Production-Grade Scheduling

For real applications, APScheduler beats both cron and schedule: persistent jobs (survive restart), missed-job handling, multiple triggers per job, async support. Three job stores cover most use cases — memory (default), SQLAlchemy (persistent), Redis (distributed):

# pip install apscheduler

from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger

def daily_etl():
    print("Running ETL")

def hourly_sync():
    print("Syncing...")

scheduler = BackgroundScheduler()
scheduler.add_job(
    daily_etl,
    trigger=CronTrigger(hour=2, minute=30),
    id="daily-etl",
    replace_existing=True,
)
scheduler.add_job(hourly_sync, "interval", hours=1)
scheduler.start()

# Keep the main thread alive
import time
while True:
    time.sleep(60)

The replace_existing=True idiom is essential — without it, restarting your app fails with "job already exists" against a persistent job store.

Celery Beat — Distributed Scheduled Tasks

For microservices, Celery Beat schedules tasks across a distributed worker pool. The beat process emits scheduled tasks into the queue; any worker consumes them. Survives any single node restart, scales horizontally:

# celery_app.py
from celery import Celery
from celery.schedules import crontab

app = Celery("tasks", broker="redis://localhost:6379")

app.conf.beat_schedule = {
    "daily-report": {
        "task": "tasks.send_daily_report",
        "schedule": crontab(hour=9, minute=0),
    },
    "every-15-min": {
        "task": "tasks.cleanup",
        "schedule": 900.0,  # 15 minutes in seconds
    },
}

@app.task
def send_daily_report():
    print("Daily report sent")

@app.task
def cleanup():
    print("Cleanup done")

# Run beat alongside the workers:
# celery -A celery_app beat
# celery -A celery_app worker

Common Pitfalls

  • Time zone confusion. Cron uses the system's local time. APScheduler defaults to UTC. schedule uses local time. Be explicit about which one you intend, or you'll be off by hours after the next DST change.
  • Long-running jobs blocking the scheduler. If a 15-minute job kicks off every 15 minutes, the next instance fires while the first is still running. Use max_instances=1 in APScheduler or queue jobs through Celery.
  • Forgetting the venv. Cron's python3 isn't your venv's python. Use the full venv path: /home/me/.venv/bin/python.
  • Silent failures. Cron emails errors to /var/mail by default. If you've never seen those emails, you're missing failures. Pipe to a log file and tail it occasionally.
  • Race conditions on overlap. Two cron jobs that need exclusive file access will collide if their schedules overlap. Use file locks (flock) or a job queue.

FAQ

Q: Cron, schedule, APScheduler, or Celery Beat?
A: Cron for one-off OS-level scripts. schedule for in-process scheduling in small apps. APScheduler when you need persistence or async. Celery Beat for distributed multi-worker setups.

Q: How do I make sure my script doesn't run twice if it overlaps?
A: File lock at the top: fd = open("/tmp/myscript.lock"); fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB). Exits cleanly if another instance holds the lock.

Q: How do I run a Python script on a schedule on Windows?
A: Use Task Scheduler. Create a basic task, set the trigger (daily, weekly, on event), set the action to C:\Python312\python.exe C:\scripts\myscript.py. Or use APScheduler in-process — works identically on Windows.

Q: How do I see what cron jobs are scheduled?
A: crontab -l for the current user, sudo crontab -l -u username for another user. System-level cron lives in /etc/cron.d/* and /etc/cron.{hourly,daily,weekly,monthly}/*.

Q: How do I retry a failed scheduled job?
A: APScheduler doesn't retry out of the box. Wrap your job in a try/except + sleep, or use Celery's built-in retry: @app.task(autoretry_for=(Exception,), retry_backoff=True).

Wrapping Up

Pick the right tool for the scale. A single Python script that should run at 2 AM? Plain cron. A small app with a dozen recurring jobs? schedule in-process. Real production app with persistent jobs and async support? APScheduler. Microservices fleet? Celery Beat. The complexity of each tool tracks the complexity of the use case — don't reach for Celery Beat when cron will do.

Continue Learning Python

Tutorials you might also find useful:

How To Use Python Decorators To Add Logging and Timing to Functions

How To Use Python Decorators To Add Logging and Timing to Functions

Last Updated: June 01, 2026

Intermediate

You have written a function that works perfectly. Then one day it starts running slowly in production, and you have no idea which function is the bottleneck. Or maybe you need to log every time a critical function gets called, but you do not want to litter every function body with print() statements. These are the exact problems that Python decorators solve — they let you wrap extra behavior around your functions without touching the function code itself.

The good news is that decorators are built into Python’s core syntax. You do not need to install anything — just standard Python 3. The concept uses closures and first-class functions, which sounds intimidating, but the pattern is surprisingly simple once you see it in action. By the end of this article you will be writing your own decorators from scratch with confidence.

In this article we will start with a quick working example so you can see the payoff immediately. Then we will break down what decorators actually are and how they work under the hood. From there we will build a logging decorator, a timing decorator, a retry decorator, and learn how to stack multiple decorators together. We will also cover functools.wraps — a small but critical detail that most tutorials skip. Finally, we will tie everything together with a real-life performance monitoring toolkit you can drop into any project.

Pubs - Python How To Program
Written by Pubs

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.

View all tutorials by Pubs →

Python Decorators for Logging: Quick Example

Let us jump straight into a working example. Here is a decorator that automatically logs every time a function is called, including the arguments it received and the value it returned. You can copy this code, run it, and see the result immediately.

# quick_example.py
import functools

def log_calls(func):
    """Decorator that logs function calls with arguments and return values."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"CALL: {func.__name__}({args}, {kwargs})")
        result = func(*args, **kwargs)
        print(f"RETURN: {func.__name__} -> {result}")
        return result
    return wrapper

@log_calls
def add(a, b):
    return a + b

print(add(3, 5))
print(add(10, 20))

Output:

CALL: add((3, 5), {})
RETURN: add -> 8
8
CALL: add((10, 20), {})
RETURN: add -> 30
30

With just the @log_calls line above the function definition, every call to add() now prints a log message automatically. The function itself has no idea it is being logged — it just does its job and returns the sum. That separation of concerns is the entire point of decorators. The functools.wraps call inside the decorator preserves the original function’s name and docstring, which we will explain in detail later.

Want to go deeper? Below we cover exactly how this pattern works, how to build timing and retry decorators, and how to combine multiple decorators on a single function.

What Are Python Decorators and Why Use Them?

A decorator is a function that takes another function as input, adds some behavior to it, and returns a new function. Think of it like gift wrapping — the present inside (your original function) stays exactly the same, but the wrapping paper (the decorator) adds something extra on the outside. When someone opens the gift, they get both the wrapping experience and the present itself.

In Python, functions are first-class objects. That means you can pass a function as an argument to another function, return a function from a function, and assign a function to a variable. Decorators take advantage of all three of these capabilities. The @decorator_name syntax above a function definition is just shorthand — writing @log_calls above def add() is exactly the same as writing add = log_calls(add) after the function definition.

Here is when decorators are the right tool for the job versus when you should use something else:

Use CaseDecorator?Why
Add logging to multiple functionsYesSame behavior applied to many functions without repeating code
Measure execution timeYesTiming logic stays separate from business logic
Retry on failureYesRetry policy wraps around the function cleanly
Input validationYesValidate arguments before the function runs
Change what a function returnsMaybeCan work, but modifying return values can be confusing to callers
Complex state managementNoUse a class instead — decorators should be stateless or nearly so

The key principle is that decorators work best when you want to add the same cross-cutting behavior to multiple functions. If you find yourself copying the same three lines of logging code into every function, that is a strong signal that a decorator would clean things up. Let us start building decorators from scratch.

Basic Decorator Syntax in Python

Before we build useful decorators, let us understand the basic pattern. A decorator is a function that accepts a function as its argument, defines an inner function (called a wrapper) that adds behavior, and returns the wrapper. Here is the simplest possible decorator that does nothing except call the original function.

# basic_decorator.py
def my_decorator(func):
    """A minimal decorator that wraps a function without changing behavior."""
    def wrapper(*args, **kwargs):
        # You could add behavior here (before the call)
        result = func(*args, **kwargs)
        # You could add behavior here (after the call)
        return result
    return wrapper

@my_decorator
def greet(name):
    return f"Hello, {name}!"

print(greet("Alice"))
print(greet("Bob"))

Output:

Hello, Alice!
Hello, Bob!

The wrapper function uses *args and **kwargs to accept any combination of positional and keyword arguments. This is important because the decorator needs to work with functions that have different signatures — a function with two parameters, a function with five parameters, or a function with keyword-only parameters should all work the same way. The result = func(*args, **kwargs) line calls the original function with whatever arguments were passed in, and return result passes the return value back to the caller.

Now let us replace those placeholder comments with real, useful behavior. We will start with a logging decorator.

Building a Logging Decorator

A logging decorator captures information about function calls as they happen. This is incredibly useful for debugging — instead of sprinkling print() statements throughout your code and then removing them later, you simply add or remove the @log_calls decorator. Here is a robust version that formats the output nicely and handles both positional and keyword arguments.

# logging_decorator.py
import functools
from datetime import datetime

def log_calls(func):
    """Log every call to the decorated function with timestamp, args, and result."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        # Format arguments for readable output
        arg_parts = [repr(a) for a in args]
        kwarg_parts = [f"{k}={v!r}" for k, v in kwargs.items()]
        all_args = ", ".join(arg_parts + kwarg_parts)

        print(f"[{timestamp}] CALL: {func.__name__}({all_args})")
        result = func(*args, **kwargs)
        print(f"[{timestamp}] RETURN: {func.__name__} -> {result!r}")
        return result
    return wrapper

@log_calls
def calculate_discount(price, discount_percent=10):
    """Calculate the discounted price."""
    discount_amount = price * (discount_percent / 100)
    return round(price - discount_amount, 2)

# Test with positional and keyword arguments
print(calculate_discount(99.99))
print()
print(calculate_discount(49.99, discount_percent=25))

Output:

[2026-03-13 10:30:00] CALL: calculate_discount(99.99)
[2026-03-13 10:30:00] RETURN: calculate_discount -> 89.99
89.99

[2026-03-13 10:30:00] CALL: calculate_discount(49.99, discount_percent=25)
[2026-03-13 10:30:00] RETURN: calculate_discount -> 37.49
37.49

Notice how the decorator captures the function name, the exact arguments passed (including keyword arguments like discount_percent=25), and the return value. The !r format specifier uses repr() so strings show up with quotes, making it easy to distinguish "hello" from hello in your logs. The timestamp tells you exactly when each call happened, which is essential for debugging timing-related issues in production. You can easily swap print() for Python’s built-in logging module to write these messages to a file instead of the console.

Sudo Sam standing confidently with arms crossed next to a giant stopwatch
Clean code isn’t written — it’s decorated. One @wrapper and your function gains superpowers.

Building a Timing Decorator

Performance matters, and the first step to improving performance is measuring it. A timing decorator wraps a function and records how long it takes to execute. This is far more convenient than manually adding time.time() calls at the start and end of every function you want to profile.

# timing_decorator.py
import functools
import time

def timer(func):
    """Measure and print the execution time of the decorated function."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start_time = time.perf_counter()  # High-precision timer
        result = func(*args, **kwargs)
        end_time = time.perf_counter()
        elapsed = end_time - start_time
        print(f"⏱ {func.__name__} took {elapsed:.4f} seconds")
        return result
    return wrapper

@timer
def slow_operation():
    """Simulate a slow operation."""
    total = 0
    for i in range(1_000_000):
        total += i * i
    return total

@timer
def fast_operation():
    """Simulate a fast operation."""
    return sum(range(1000))

print(f"Result: {slow_operation()}")
print(f"Result: {fast_operation()}")

Output:

⏱ slow_operation took 0.0892 seconds
Result: 333332833333500000
⏱ fast_operation took 0.0000 seconds
Result: 499500

We use time.perf_counter() instead of time.time() because it provides the highest resolution timer available on your operating system. This matters when measuring fast functions — time.time() might show 0.0 seconds for something that actually takes 0.3 milliseconds. The :.4f format gives us four decimal places, which is enough precision for most profiling needs. If you need sub-millisecond accuracy, change it to :.6f for microsecond resolution.

Using functools.wraps for Metadata Preservation

You may have noticed that every decorator we have written includes @functools.wraps(func) on the wrapper function. This is not optional — without it, the decorated function loses its identity. Let us see exactly what goes wrong when you skip it.

# without_wraps.py
def bad_decorator(func):
    """Decorator WITHOUT functools.wraps — demonstrates the problem."""
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@bad_decorator
def calculate_tax(amount, rate=0.08):
    """Calculate sales tax on a purchase amount."""
    return round(amount * rate, 2)

# Check the function's identity
print(f"Function name: {calculate_tax.__name__}")
print(f"Docstring: {calculate_tax.__doc__}")
print(f"Help output:")
help(calculate_tax)

Output:

Function name: wrapper
Docstring: None
Help output:
Help on function wrapper in module __main__:

wrapper(*args, **kwargs)

The function thinks its name is wrapper and its docstring is None. This breaks debugging tools, documentation generators, and any code that inspects function metadata. Now let us see the same decorator with functools.wraps applied.

# with_wraps.py
import functools

def good_decorator(func):
    """Decorator WITH functools.wraps — preserves the original function's metadata."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@good_decorator
def calculate_tax(amount, rate=0.08):
    """Calculate sales tax on a purchase amount."""
    return round(amount * rate, 2)

# Check the function's identity
print(f"Function name: {calculate_tax.__name__}")
print(f"Docstring: {calculate_tax.__doc__}")
print(f"Help output:")
help(calculate_tax)

Output:

Function name: calculate_tax
Docstring: Calculate sales tax on a purchase amount.
Help output:
Help on function calculate_tax in module __main__:

calculate_tax(amount, rate=0.08)
    Calculate sales tax on a purchase amount.

With @functools.wraps(func), the decorated function retains its original name, docstring, and even its parameter signature in the help output. This is a one-line addition that prevents hours of confusion. Every decorator you write should include it — no exceptions. The rule is simple: if your decorator defines a wrapper function, put @functools.wraps(func) on the line directly above it.

Loop Larry tangled up in colorful ribbons surrounded by gift boxes
Stack too many decorators and suddenly nobody knows what the function actually does. Including you.

Decorators That Accept Arguments

Sometimes you want your decorator to be configurable. For example, a logging decorator where you can set the log level, or a retry decorator where you can specify the number of retries. This requires an extra layer of nesting — a function that returns a decorator, which returns a wrapper. It sounds complicated, but the pattern is consistent once you see it.

# decorator_with_args.py
import functools

def log_with_level(level="INFO"):
    """Decorator factory that accepts a log level argument."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            arg_parts = [repr(a) for a in args]
            kwarg_parts = [f"{k}={v!r}" for k, v in kwargs.items()]
            all_args = ", ".join(arg_parts + kwarg_parts)
            print(f"[{level}] Calling {func.__name__}({all_args})")
            result = func(*args, **kwargs)
            print(f"[{level}] {func.__name__} returned {result!r}")
            return result
        return wrapper
    return decorator

@log_with_level("DEBUG")
def fetch_user(user_id):
    """Simulate fetching a user from a database."""
    return {"id": user_id, "name": "Alice", "email": "alice@example.com"}

@log_with_level("WARNING")
def delete_user(user_id):
    """Simulate deleting a user — dangerous operation."""
    return f"User {user_id} deleted"

print(fetch_user(42))
print()
print(delete_user(7))

Output:

[DEBUG] Calling fetch_user(42)
[DEBUG] fetch_user returned {'id': 42, 'name': 'Alice', 'email': 'alice@example.com'}
{'id': 42, 'name': 'Alice', 'email': 'alice@example.com'}

[WARNING] Calling delete_user(7)
[WARNING] delete_user returned 'User 7 deleted'
User 7 deleted

The key difference is that log_with_level("DEBUG") is not the decorator itself — it is a decorator factory that returns the actual decorator. When Python sees @log_with_level("DEBUG"), it first calls log_with_level("DEBUG"), which returns the decorator function. Then Python applies that decorator function to fetch_user. This three-layer structure (factory → decorator → wrapper) is the standard pattern for any decorator that needs configuration.

Building a Retry Decorator

Network calls fail. APIs time out. Databases hiccup. A retry decorator automatically re-runs a function when it raises an exception, with a configurable number of attempts and delay between retries. This is one of the most practical decorators you will ever write.

# retry_decorator.py
import functools
import time
import random

def retry(max_attempts=3, delay=1.0):
    """Retry a function up to max_attempts times with a delay between retries."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_exception = None
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    last_exception = e
                    if attempt < max_attempts:
                        print(f"⚠ {func.__name__} failed (attempt {attempt}/{max_attempts}): {e}")
                        print(f"  Retrying in {delay}s...")
                        time.sleep(delay)
                    else:
                        print(f"✗ {func.__name__} failed after {max_attempts} attempts: {e}")
            raise last_exception
        return wrapper
    return decorator

@retry(max_attempts=3, delay=0.5)
def unreliable_api_call():
    """Simulate an API call that fails randomly."""
    if random.random() < 0.7:  # 70% chance of failure
        raise ConnectionError("Server unavailable")
    return {"status": "ok", "data": [1, 2, 3]}

# Set seed for reproducible output
random.seed(42)
try:
    result = unreliable_api_call()
    print(f"Success: {result}")
except ConnectionError as e:
    print(f"Final failure: {e}")

Output:

⚠ unreliable_api_call failed (attempt 1/3): Server unavailable
  Retrying in 0.5s...
⚠ unreliable_api_call failed (attempt 2/3): Server unavailable
  Retrying in 0.5s...
✗ unreliable_api_call failed after 3 attempts: Server unavailable
Final failure: Server unavailable

The retry decorator stores the last exception in last_exception so it can re-raise it if all attempts fail. This way the caller still gets the original exception type (ConnectionError) rather than a generic error. The time.sleep(delay) adds a pause between retries, which is important for network operations — hammering a server that just failed with immediate retries usually makes things worse. In production, you would typically use exponential backoff (doubling the delay each time) instead of a fixed delay.

Stacking Multiple Decorators

One of the most powerful features of decorators is that you can stack them. When you put multiple @decorator lines above a function, they are applied from bottom to top — the decorator closest to the function definition runs first, and the outermost decorator wraps everything. This lets you combine logging, timing, and retry logic on a single function.

# stacking_decorators.py
import functools
import time

def log_calls(func):
    """Log function calls."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"LOG: Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"LOG: {func.__name__} returned {result!r}")
        return result
    return wrapper

def timer(func):
    """Time function execution."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"⏱ {func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@log_calls   # Applied second (outermost)
@timer       # Applied first (innermost)
def process_data(items):
    """Process a list of items with a simulated delay."""
    time.sleep(0.1)  # Simulate work
    return [item.upper() for item in items]

result = process_data(["hello", "world", "python"])

Output:

LOG: Calling process_data
⏱ process_data took 0.1003s
LOG: process_data returned ['HELLO', 'WORLD', 'PYTHON']

The execution order matters. Because @timer is closest to the function, it wraps process_data first. Then @log_calls wraps the already-timed version. When you call process_data(), the log decorator runs first (printing the "Calling" message), then the timer starts, then the actual function runs, then the timer stops (printing the elapsed time), and finally the log decorator prints the return value. If you reversed the order, the timer would measure the time including the logging overhead, which is usually not what you want.

Cache Katie racing with a stopwatch optimizing for speed
@lru_cache — turning O(2^n) into O(n) with one line. Your CPU sends its regards.

Real-Life Example: Performance Monitoring Toolkit

Pyro Pete excitedly stacking colorful building blocks
Decorators are just functions with ambition. Stack them right and your code writes itself.

Let us tie everything together into a practical project. This performance monitoring toolkit gives you three decorators that you can drop into any Python project: @monitor for combined logging and timing, @retry for fault tolerance, and @validate_types for runtime type checking. Together they form a lightweight monitoring layer for any application.

# performance_toolkit.py
import functools
import time
from datetime import datetime

def monitor(func):
    """Combined logging and timing decorator for production monitoring."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        timestamp = datetime.now().strftime("%H:%M:%S")
        arg_parts = [repr(a) for a in args]
        kwarg_parts = [f"{k}={v!r}" for k, v in kwargs.items()]
        signature = ", ".join(arg_parts + kwarg_parts)

        print(f"[{timestamp}] → {func.__name__}({signature})")
        start = time.perf_counter()

        try:
            result = func(*args, **kwargs)
            elapsed = time.perf_counter() - start
            print(f"[{timestamp}] ← {func.__name__} returned {result!r} ({elapsed:.4f}s)")
            return result
        except Exception as e:
            elapsed = time.perf_counter() - start
            print(f"[{timestamp}] ✗ {func.__name__} raised {type(e).__name__}: {e} ({elapsed:.4f}s)")
            raise
    return wrapper

def validate_types(**expected_types):
    """Validate argument types at runtime before the function executes."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            # Check keyword arguments against expected types
            for param_name, expected_type in expected_types.items():
                if param_name in kwargs:
                    value = kwargs[param_name]
                    if not isinstance(value, expected_type):
                        raise TypeError(
                            f"{param_name} must be {expected_type.__name__}, "
                            f"got {type(value).__name__}"
                        )
            return func(*args, **kwargs)
        return wrapper
    return decorator

# --- Use the toolkit ---

@monitor
def fetch_user_profile(user_id):
    """Simulate fetching a user profile from a database."""
    time.sleep(0.05)  # Simulate database query
    users = {1: "Alice", 2: "Bob", 3: "Charlie"}
    if user_id not in users:
        raise ValueError(f"User {user_id} not found")
    return {"id": user_id, "name": users[user_id], "active": True}

@monitor
@validate_types(amount=float, description=str)
def process_payment(amount=0.0, description=""):
    """Process a payment transaction."""
    time.sleep(0.02)  # Simulate payment processing
    return {"status": "completed", "amount": amount, "ref": "TXN-001"}

# Run the toolkit
print("=== Performance Monitoring Toolkit Demo ===\n")

# Successful call
profile = fetch_user_profile(1)
print(f"Got profile: {profile}\n")

# Successful payment
payment = process_payment(amount=29.99, description="Monthly subscription")
print(f"Payment result: {payment}\n")

# Failed call (user not found)
try:
    fetch_user_profile(999)
except ValueError:
    print("(Error handled gracefully)\n")

# Type validation failure
try:
    process_payment(amount="not a number", description="Bad payment")
except TypeError as e:
    print(f"Type error caught: {e}")

Output:

=== Performance Monitoring Toolkit Demo ===

[10:30:00] → fetch_user_profile(1)
[10:30:00] ← fetch_user_profile returned {'id': 1, 'name': 'Alice', 'active': True} (0.0503s)
Got profile: {'id': 1, 'name': 'Alice', 'active': True}

[10:30:00] → process_payment(amount=29.99, description='Monthly subscription')
[10:30:00] ← process_payment returned {'status': 'completed', 'amount': 29.99, 'ref': 'TXN-001'} (0.0201s)
Payment result: {'status': 'completed', 'amount': 29.99, 'ref': 'TXN-001'}

[10:30:00] → fetch_user_profile(999)
[10:30:00] ✗ fetch_user_profile raised ValueError: User 999 not found (0.0501s)
(Error handled gracefully)

Type error caught: amount must be float, got str

This toolkit demonstrates how decorators keep your business logic clean. The fetch_user_profile function only cares about looking up users — it knows nothing about logging, timing, or error formatting. All of that cross-cutting behavior lives in the decorators. You could add @monitor to every function in your application with a single line per function, giving you instant visibility into how your code performs. To extend this project, try adding a decorator that caches results (memoization) or one that limits how many times a function can be called per minute (rate limiting).

Frequently Asked Questions

What is the difference between a decorator and a regular function call?

A decorator wraps a function at definition time, not at call time. When you put @timer above a function, the wrapping happens once when Python loads the file. After that, every call to the function automatically goes through the wrapper. A regular function call like timer(my_func)(args) does the same thing but only for that one call. Decorators give you permanent, reusable wrapping with clean syntax.

Can you use a class as a decorator instead of a function?

Yes. Any callable object can be a decorator, and classes are callable (calling a class creates an instance). You define __init__ to accept the function and __call__ to act as the wrapper. Class-based decorators are useful when you need to maintain state between calls, such as counting how many times a function has been called. For stateless decorators like logging and timing, function-based decorators are simpler and more common.

How do you debug a decorated function?

Always use @functools.wraps(func) in your decorators — this is the single most important step. Without it, tracebacks show the wrapper function name instead of your actual function name, making debugging nearly impossible. If you need to temporarily remove a decorator for debugging, just comment out the @decorator line. You can also access the original unwrapped function via decorated_func.__wrapped__, which functools.wraps sets automatically.

Do decorators add performance overhead?

Yes, but it is typically negligible. Each decorated call adds the overhead of one extra function call plus whatever your wrapper does. For a simple logging decorator, this is microseconds. The overhead only matters if you are decorating a function that gets called millions of times in a tight loop. In that case, measure with time.perf_counter() and decide whether the convenience is worth the cost. For most applications — web servers, CLI tools, data processing scripts — the overhead is invisible.

Can decorators work with async functions?

Yes, but you need to make the wrapper function async too. Use async def wrapper(*args, **kwargs) and result = await func(*args, **kwargs) inside the decorator. If you want a single decorator that works with both sync and async functions, use import asyncio and check asyncio.iscoroutinefunction(func) to decide whether to use await. The functools.wraps pattern works the same way for async decorators.

Does the order of stacked decorators matter?

Absolutely. Decorators are applied bottom-to-top (closest to the function first), but they execute top-to-bottom when the function is called. If you stack @log_calls on top of @timer, the logging will record the total time including timer overhead. If you reverse them, the timer will only measure the function itself. The general rule is: put the decorator whose behavior you want to see first (outermost) on top, and put the decorator that should run closest to the actual function on the bottom.

Conclusion

In this article we covered everything you need to start using Python decorators effectively in your own projects. We started with the basic decorator pattern — a function that takes a function and returns a wrapper function. We built a logging decorator that captures function calls with timestamps and arguments, a timing decorator that measures execution time with time.perf_counter(), and a retry decorator that gracefully handles transient failures. We also covered functools.wraps (always use it), decorator factories for configurable decorators, and how stacking multiple decorators affects execution order.

The performance monitoring toolkit in the real-life example gives you a foundation you can build on. Try extending it with a caching decorator using functools.lru_cache, a rate limiter that tracks calls per time window, or an authentication decorator for a web application. Decorators are one of Python's most elegant features — once you get comfortable with the pattern, you will find uses for them everywhere.

For a deeper dive into decorators, closures, and first-class functions, check out the official Python documentation on decorators and the functools module documentation.

How To Set Up Python Logging With Rotating File Handlers

How To Set Up Python Logging With Rotating File Handlers

Last Updated: June 01, 2026

Intermediate

Every Python developer starts with print() for debugging. It works fine when you are learning, but the moment your code runs in production — on a server, in a scheduled task, or as a background service — print statements become useless. They disappear when the terminal closes, they have no timestamps, no severity levels, and no way to separate important messages from noise. That is where Python’s built-in logging module comes in, and when you combine it with rotating file handlers, you get a production-grade logging system that manages itself.

The good news is that Python ships with everything you need. The logging module is part of the standard library, so there is nothing to install. It supports multiple log levels (DEBUG through CRITICAL), custom formatting, and a variety of handlers that control where your logs go — console, files, network sockets, email, and more. The RotatingFileHandler and TimedRotatingFileHandler are especially useful because they automatically manage log file sizes and rotation, preventing your disk from filling up.

In this article we will set up a complete logging system from scratch. We will start with a quick working example, then explain why logging beats print(), walk through log levels and formatting, set up size-based rotation with RotatingFileHandler, time-based rotation with TimedRotatingFileHandler, combine multiple handlers for simultaneous console and file logging, add structured JSON logging, and finish with a real-life project — a Production Application Logger class you can drop into any project.

Pubs - Python How To Program
Written by Pubs

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.

View all tutorials by Pubs →

Python Logging With Rotation: Quick Example

Here is a minimal setup that logs messages to both the console and a rotating file. The file automatically rolls over when it hits 1 MB, keeping the last 3 backups.

# quick_logging.py
import logging
from logging.handlers import RotatingFileHandler

# Create logger
logger = logging.getLogger("myapp")
logger.setLevel(logging.DEBUG)

# Console handler
console = logging.StreamHandler()
console.setLevel(logging.INFO)

# Rotating file handler (1 MB max, keep 3 backups)
file_handler = RotatingFileHandler("app.log", maxBytes=1_000_000, backupCount=3)
file_handler.setLevel(logging.DEBUG)

# Formatter
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
console.setFormatter(formatter)
file_handler.setFormatter(formatter)

# Add handlers
logger.addHandler(console)
logger.addHandler(file_handler)

# Test it
logger.debug("This goes to the file only")
logger.info("This goes to both console and file")
logger.warning("Something might be wrong")
logger.error("Something is definitely wrong")

Output (console):

2026-03-13 14:30:00,123 - myapp - INFO - This goes to both console and file
2026-03-13 14:30:00,124 - myapp - WARNING - Something might be wrong
2026-03-13 14:30:00,124 - myapp - ERROR - Something is definitely wrong

Notice that the DEBUG message only appears in the file, not the console — because we set the console handler to INFO level. The file handler captures everything from DEBUG up. When app.log reaches 1 MB, it automatically renames to app.log.1, creates a fresh app.log, and deletes the oldest backup beyond 3 files. Zero maintenance required.

Want to go deeper? Below we cover why logging beats print, all five log levels, custom formatting, both types of rotation handlers, and a production-ready logger class you can reuse in any project.

Why Not Just Use print()?

The print() function is great for quick debugging, but it falls apart in any serious application. Understanding its limitations helps explain why the logging module exists and why every production codebase uses it.

Here is a side-by-side comparison of what you get with each approach:

Featureprint()logging
TimestampsManual (you add them yourself)Automatic with formatters
Severity levelsNoneDEBUG, INFO, WARNING, ERROR, CRITICAL
Output destinationConsole only (stdout)Console, files, email, network, etc.
File rotationNot possibleBuilt-in with handlers
Easy to disableMust delete or comment outChange one level setting
Thread safetyNot guaranteedBuilt-in thread safety
Source trackingManualAutomatic (module, line number, function)
Production readyNoYes

The most important difference is control. With logging, you can set your production server to WARNING level (ignoring all DEBUG and INFO messages) without changing a single line of code. You can send errors to a file while keeping info messages in the console. You can add email alerts for CRITICAL failures. None of this is possible with print().

Basic Logging Setup

The simplest way to start logging is with logging.basicConfig(), which configures the root logger with a single function call. This is fine for scripts and small programs, though for larger applications you will want the more flexible approach we show later.

# basic_setup.py
import logging

# Configure the root logger
logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s - %(levelname)s - %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S"
)

# These all use the root logger
logging.debug("Detailed information for diagnosing problems")
logging.info("Confirmation that things are working as expected")
logging.warning("Something unexpected happened, but the program still works")
logging.error("A more serious problem - something failed")
logging.critical("The program may not be able to continue")

Output:

2026-03-13 14:30:00 - DEBUG - Detailed information for diagnosing problems
2026-03-13 14:30:00 - INFO - Confirmation that things are working as expected
2026-03-13 14:30:00 - WARNING - Something unexpected happened, but the program still works
2026-03-13 14:30:00 - ERROR - A more serious problem - something failed
2026-03-13 14:30:00 - CRITICAL - The program may not be able to continue

The basicConfig() function is a convenience wrapper. The level parameter sets the minimum severity to capture — anything below this level is silently ignored. The format string controls what each log line looks like, using placeholder variables like %(asctime)s for the timestamp and %(levelname)s for the severity. The datefmt parameter controls the timestamp format.

Sudo Sam writing in a glowing journal
print() is a debugging crutch. logging.getLogger() is a debugging exoskeleton.

Understanding Log Levels

Log levels are the filtering mechanism that makes logging so powerful. Each level has a numeric value, and the logger only processes messages at or above the configured threshold. Understanding when to use each level is critical for writing logs that are actually useful when you need them.

# log_levels.py
import logging

logging.basicConfig(level=logging.DEBUG, format="%(levelname)-8s %(message)s")

# DEBUG (10) - Detailed diagnostic info, only useful during development
logging.debug(f"Processing user_id=42, payload_size=1024 bytes")

# INFO (20) - Routine operational messages
logging.info("Server started on port 8080")
logging.info("User 'alice' logged in successfully")

# WARNING (30) - Something unexpected but not broken
logging.warning("Disk usage at 85% - consider cleanup")
logging.warning("API response took 4.2s (threshold: 3.0s)")

# ERROR (40) - Something failed, but the app continues
logging.error("Failed to connect to database: Connection refused")
logging.error("Payment processing failed for order #1234")

# CRITICAL (50) - The app may crash or is in an unrecoverable state
logging.critical("Out of memory - shutting down worker process")
logging.critical("Security breach detected: unauthorized admin access")

Output:

DEBUG    Processing user_id=42, payload_size=1024 bytes
INFO     Server started on port 8080
INFO     User 'alice' logged in successfully
WARNING  Disk usage at 85% - consider cleanup
WARNING  API response took 4.2s (threshold: 3.0s)
ERROR    Failed to connect to database: Connection refused
ERROR    Payment processing failed for order #1234
CRITICAL Out of memory - shutting down worker process
CRITICAL Security breach detected: unauthorized admin access

A common production strategy is to log DEBUG and INFO to files (for post-mortem analysis) while only showing WARNING and above in the console (to avoid drowning operators in noise). The %-8s in the format string left-aligns the level name in an 8-character field, making the output easier to scan visually.

RotatingFileHandler for Size-Based Rotation

The RotatingFileHandler automatically creates a new log file when the current one reaches a specified size. Old log files are renamed with numeric suffixes (.1, .2, etc.) and the oldest files beyond your backup count are deleted automatically. This prevents log files from growing unbounded and filling up your disk.

# rotating_handler.py
import logging
from logging.handlers import RotatingFileHandler

logger = logging.getLogger("rotating_demo")
logger.setLevel(logging.DEBUG)

# Create rotating handler: 500 KB max, keep 5 backups
handler = RotatingFileHandler(
    filename="demo.log",
    maxBytes=500_000,       # 500 KB per file
    backupCount=5,          # Keep demo.log.1 through demo.log.5
    encoding="utf-8"        # Always specify encoding
)

formatter = logging.Formatter(
    "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S"
)
handler.setFormatter(formatter)
logger.addHandler(handler)

# Simulate logging activity
for i in range(1000):
    logger.info(f"Processing record {i}: status=OK, duration=0.{i % 100:02d}s")
    if i % 100 == 0:
        logger.warning(f"Batch {i // 100} checkpoint reached")

logger.info("Processing complete")

Output (in demo.log):

2026-03-13 14:30:00 | INFO     | rotating_demo | Processing record 0: status=OK, duration=0.00s
2026-03-13 14:30:00 | WARNING  | rotating_demo | Batch 0 checkpoint reached
2026-03-13 14:30:00 | INFO     | rotating_demo | Processing record 1: status=OK, duration=0.01s
...

After running this, you will see files like demo.log, demo.log.1, demo.log.2, and so on in your directory. The demo.log file is always the current, active log. When it hits 500 KB, the handler renames it to demo.log.1 (pushing the previous .1 to .2, etc.) and starts writing to a fresh demo.log. Files beyond demo.log.5 are automatically deleted. The total maximum disk usage is maxBytes x (backupCount + 1) — in this case, about 3 MB.

TimedRotatingFileHandler for Time-Based Rotation

Sometimes you want logs rotated by time rather than size — a new file every day, every hour, or every week. The TimedRotatingFileHandler handles this automatically. This is especially useful for daily log files that you can easily search by date.

# timed_handler.py
import logging
from logging.handlers import TimedRotatingFileHandler

logger = logging.getLogger("timed_demo")
logger.setLevel(logging.DEBUG)

# Create timed rotating handler: rotate at midnight, keep 30 days
handler = TimedRotatingFileHandler(
    filename="daily.log",
    when="midnight",        # Rotate at midnight
    interval=1,             # Every 1 day
    backupCount=30,         # Keep 30 days of logs
    encoding="utf-8",
    utc=False               # Use local time, not UTC
)

# Customize the backup file suffix to include the date
handler.suffix = "%Y-%m-%d"

formatter = logging.Formatter(
    "%(asctime)s | %(levelname)-8s | %(funcName)s | %(message)s"
)
handler.setFormatter(formatter)
logger.addHandler(handler)

# Example usage
def process_order(order_id, amount):
    logger.info(f"Processing order #{order_id} for ${amount:.2f}")
    if amount > 1000:
        logger.warning(f"High-value order #{order_id}: ${amount:.2f}")
    logger.debug(f"Order #{order_id} details sent to payment gateway")

process_order(1001, 49.99)
process_order(1002, 1500.00)
process_order(1003, 25.50)

Output (in daily.log):

2026-03-13 14:30:00,123 | INFO     | process_order | Processing order #1001 for $49.99
2026-03-13 14:30:00,123 | DEBUG    | process_order | Order #1001 details sent to payment gateway
2026-03-13 14:30:00,124 | INFO     | process_order | Processing order #1002 for $1500.00
2026-03-13 14:30:00,124 | WARNING  | process_order | High-value order #1002: $1500.00
2026-03-13 14:30:00,124 | DEBUG    | process_order | Order #1002 details sent to payment gateway
2026-03-13 14:30:00,125 | INFO     | process_order | Processing order #1003 for $25.50
2026-03-13 14:30:00,125 | DEBUG    | process_order | Order #1003 details sent to payment gateway

At midnight, the handler renames daily.log to daily.log.2026-03-13 and creates a fresh daily.log. The when parameter accepts several values: "S" for seconds, "M" for minutes, "H" for hours, "D" for days, "midnight" for midnight rotation, and "W0" through "W6" for specific weekdays (Monday through Sunday). The %(funcName)s formatter variable automatically includes the function name where the log call was made — extremely useful for tracing issues across a large codebase.

Loop Larry overwhelmed by unorganized papers
print(‘debug here’) is not logging. It never was. It never will be.

Multiple Handlers: Console AND File Logging

In practice, you almost always want both console output (for real-time monitoring) and file output (for historical records). Python’s logging module makes this easy — a single logger can have multiple handlers, each with its own level and format.

# multi_handler.py
import logging
from logging.handlers import RotatingFileHandler

def setup_logger(name, log_file="app.log", console_level=logging.INFO, file_level=logging.DEBUG):
    """Create a logger with both console and rotating file handlers."""
    logger = logging.getLogger(name)
    logger.setLevel(logging.DEBUG)  # Capture everything; handlers filter

    # Console handler - concise format, higher threshold
    console_handler = logging.StreamHandler()
    console_handler.setLevel(console_level)
    console_fmt = logging.Formatter("%(levelname)-8s %(message)s")
    console_handler.setFormatter(console_fmt)

    # File handler - detailed format, captures everything
    file_handler = RotatingFileHandler(
        log_file, maxBytes=5_000_000, backupCount=5, encoding="utf-8"
    )
    file_handler.setLevel(file_level)
    file_fmt = logging.Formatter(
        "%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d | %(message)s"
    )
    file_handler.setFormatter(file_fmt)

    logger.addHandler(console_handler)
    logger.addHandler(file_handler)
    return logger

# Usage
logger = setup_logger("myapp")

def connect_to_database(host, port):
    logger.debug(f"Attempting connection to {host}:{port}")
    logger.info(f"Connected to database at {host}:{port}")
    return True

def fetch_users():
    logger.debug("Executing SELECT * FROM users")
    logger.info("Fetched 42 users from database")
    logger.warning("Query took 2.3 seconds (threshold: 1.0s)")

connect_to_database("localhost", 5432)
fetch_users()

Output (console — concise):

INFO     Connected to database at localhost:5432
INFO     Fetched 42 users from database
WARNING  Query took 2.3 seconds (threshold: 1.0s)

Output (app.log — detailed):

2026-03-13 14:30:00,123 | DEBUG    | myapp:connect_to_database:30 | Attempting connection to localhost:5432
2026-03-13 14:30:00,124 | INFO     | myapp:connect_to_database:31 | Connected to database at localhost:5432
2026-03-13 14:30:00,125 | DEBUG    | myapp:fetch_users:35 | Executing SELECT * FROM users
2026-03-13 14:30:00,125 | INFO     | myapp:fetch_users:36 | Fetched 42 users from database
2026-03-13 14:30:00,126 | WARNING  | myapp:fetch_users:37 | Query took 2.3 seconds (threshold: 1.0s)

The key insight is that the logger level must be set to the lowest level you want to capture (DEBUG), and each handler then filters independently. The console handler only shows INFO and above, keeping the terminal clean. The file handler captures everything including DEBUG messages, giving you full diagnostic detail when you need to investigate an issue after the fact. The file format includes the function name and line number (%(funcName)s:%(lineno)d), which makes tracing bugs significantly faster.

Real-Life Example: Production Application Logger

Cache Katie spinning a giant colorful dial
Log rotation: because even your log files deserve a fresh start every now and then.

Let us build a reusable AppLogger class that you can drop into any project. It combines everything we have covered — console and file handlers, rotating files, structured formatting, and exception logging — into a clean, configurable package.

# app_logger.py
import os
import logging
import traceback
from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler
from datetime import datetime

class AppLogger:
    """Production-ready logger with console and rotating file output."""

    def __init__(self, name, log_dir="logs", console_level="INFO",
                 file_level="DEBUG", max_bytes=10_000_000, backup_count=10):
        # Create log directory if it doesn't exist
        os.makedirs(log_dir, exist_ok=True)

        self.logger = logging.getLogger(name)
        self.logger.setLevel(logging.DEBUG)

        # Prevent duplicate handlers if called multiple times
        if self.logger.handlers:
            return

        # Console handler - human-readable, colored by level
        console = logging.StreamHandler()
        console.setLevel(getattr(logging, console_level.upper()))
        console_fmt = logging.Formatter(
            "%(asctime)s | %(levelname)-8s | %(message)s",
            datefmt="%H:%M:%S"
        )
        console.setFormatter(console_fmt)

        # Rotating file handler - detailed, size-based rotation
        log_path = os.path.join(log_dir, f"{name}.log")
        file_handler = RotatingFileHandler(
            log_path, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
        )
        file_handler.setLevel(getattr(logging, file_level.upper()))
        file_fmt = logging.Formatter(
            "%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d | %(message)s"
        )
        file_handler.setFormatter(file_fmt)

        # Error-only file handler - quick access to errors
        error_path = os.path.join(log_dir, f"{name}_errors.log")
        error_handler = RotatingFileHandler(
            error_path, maxBytes=max_bytes, backupCount=5, encoding="utf-8"
        )
        error_handler.setLevel(logging.ERROR)
        error_handler.setFormatter(file_fmt)

        self.logger.addHandler(console)
        self.logger.addHandler(file_handler)
        self.logger.addHandler(error_handler)

    def debug(self, msg): self.logger.debug(msg)
    def info(self, msg): self.logger.info(msg)
    def warning(self, msg): self.logger.warning(msg)
    def error(self, msg): self.logger.error(msg)
    def critical(self, msg): self.logger.critical(msg)

    def exception(self, msg):
        """Log an error with full traceback."""
        self.logger.error(f"{msg}\n{traceback.format_exc()}")


# Demo usage
if __name__ == "__main__":
    log = AppLogger("myapp")

    log.info("Application started")
    log.debug("Loading configuration from config.json")
    log.info("Database connection established")

    # Simulate processing
    for i in range(5):
        log.info(f"Processing batch {i + 1} of 5")
        if i == 2:
            log.warning("Batch 3 had 12 skipped records")

    # Simulate an error with traceback
    try:
        result = 1 / 0
    except ZeroDivisionError:
        log.exception("Math operation failed")

    log.info("Application shutdown complete")

Output (console):

14:30:00 | INFO     | Application started
14:30:00 | INFO     | Database connection established
14:30:00 | INFO     | Processing batch 1 of 5
14:30:00 | INFO     | Processing batch 2 of 5
14:30:00 | INFO     | Processing batch 3 of 5
14:30:00 | WARNING  | Batch 3 had 12 skipped records
14:30:00 | INFO     | Processing batch 4 of 5
14:30:00 | INFO     | Processing batch 5 of 5
14:30:00 | ERROR    | Math operation failed
                      Traceback (most recent call last):
                        File "app_logger.py", line 74, in <module>
                          result = 1 / 0
                      ZeroDivisionError: division by zero
14:30:00 | INFO     | Application shutdown complete

This logger class gives you three outputs: a clean console for real-time monitoring, a detailed log file for everything, and a separate error-only file for quick problem diagnosis. The exception() method automatically captures the full Python traceback, which is invaluable for debugging production errors. The duplicate handler check (if self.logger.handlers) prevents the common bug where creating multiple instances of the same logger adds duplicate handlers, causing each message to appear multiple times.

You could extend this logger with JSON-formatted output for log aggregation tools like ELK Stack, email alerts for CRITICAL messages using SMTPHandler, or Slack notifications via a custom handler.

Frequently Asked Questions

When should I use basicConfig vs manual handler setup?

Use basicConfig() for quick scripts, one-file programs, and learning. It is a single function call that handles the most common case. Switch to manual handler setup (creating Logger, Handler, and Formatter objects explicitly) when you need multiple handlers with different levels, custom formatting per output, or when building a library or larger application. The manual approach gives you complete control.

How do I silence noisy logs from third-party libraries?

Third-party libraries like requests, urllib3, and boto3 often produce verbose DEBUG logs. Set their logger level to WARNING: logging.getLogger("urllib3").setLevel(logging.WARNING). This silences their DEBUG and INFO messages without affecting your own logging. You can also use logging.getLogger("urllib3").propagate = False to completely stop their messages from reaching your root logger.

What format variables are available in log formatters?

The most useful ones are: %(asctime)s for timestamp, %(levelname)s for level, %(name)s for logger name, %(funcName)s for function name, %(lineno)d for line number, %(filename)s for file name, %(message)s for the actual message, and %(process)d for process ID. You can combine them in any order. For production, include at minimum the timestamp, level, and message.

How do I log in JSON format for tools like ELK Stack?

Install the python-json-logger package (pip install python-json-logger) and use its JsonFormatter. Replace your standard formatter with JsonFormatter("%(asctime)s %(levelname)s %(name)s %(message)s"). This outputs each log line as a JSON object with those fields as keys, which tools like Elasticsearch, Splunk, and CloudWatch can parse automatically without custom regex patterns.

Is Python logging thread-safe?

Yes. The logging module uses locks internally to ensure that log messages from different threads do not interleave or corrupt each other. Each handler has its own lock. This means you can safely use the same logger from multiple threads without any additional synchronization. For multi-process applications, however, you need to be more careful — RotatingFileHandler can have issues when multiple processes write to the same file. Use QueueHandler with a separate logging process in that case.

Does logging slow down my application?

Logging has minimal overhead when configured correctly. The biggest performance tip is to use lazy formatting: write logger.debug("Processing %d items", count) instead of logger.debug(f"Processing {count} items"). With the first form, the string formatting only happens if DEBUG level is enabled. With the f-string, the formatting happens every time regardless of level. For most applications, logging overhead is negligible compared to I/O, network calls, or database queries.

Conclusion

You now have a complete understanding of Python’s logging system. We covered why print() falls short in production, how to use basicConfig() for quick setup, the five log levels and when to use each one, custom formatting with Formatter, size-based rotation with RotatingFileHandler, time-based rotation with TimedRotatingFileHandler, combining multiple handlers for simultaneous console and file output, and a reusable AppLogger class for production applications.

The AppLogger class from the real-life example is ready to use in your own projects. Try extending it with JSON output for log aggregation, email alerts for critical errors, or integration with monitoring tools like Sentry. Proper logging is one of those investments that pays for itself the first time you need to debug a production issue at 2 AM.

For the complete reference on handlers, formatters, filters, and configuration, check out the official Python logging documentation and the Logging Cookbook for advanced patterns.

How To Build a Telegram Bot With Python Using python-telegram-bot

How To Build a Telegram Bot With Python Using python-telegram-bot

Last Updated: June 01, 2026

Intermediate

Telegram bots are everywhere — they moderate group chats, send weather alerts, track crypto prices, manage to-do lists, and even run entire customer support workflows. If you have ever wanted to build your own bot that responds to commands, sends images, presents clickable buttons, or holds multi-step conversations, Python and the python-telegram-bot library make it surprisingly straightforward.

The python-telegram-bot library is the most popular Python wrapper for the Telegram Bot API. It handles all the low-level HTTP communication, provides clean handler classes for different message types, and supports both simple command-response patterns and complex multi-turn conversations. You will need Python 3.9 or later and a free Telegram account to follow along. Installation is a single pip install command, and creating a bot token through Telegram’s BotFather takes about two minutes.

In this article we will walk through every step of building a Telegram bot from scratch. We will start by creating a bot token with BotFather, then install the library and build a basic echo bot. From there we will cover command handlers, sending text and media, inline keyboard buttons, conversation handlers for multi-step flows, error handling, and finally a complete real-life project — a Personal Expense Tracker bot that stores expenses in a JSON file and can summarize your spending by category. By the end, you will have the skills to build and deploy any Telegram bot you can imagine.

Pubs - Python How To Program
Written by Pubs

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.

View all tutorials by Pubs →

Building a Telegram Bot in Python: Quick Example

Before we dive into the details, here is a minimal working bot that responds to the /start command and echoes back any text message the user sends. This gives you a working bot in under 15 lines of code.

# quick_bot.py
from telegram import Update
from telegram.ext import ApplicationBuilder, CommandHandler, MessageHandler, filters, ContextTypes

async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text("Hello! I am your Python bot. Send me any message and I will echo it back.")

async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text(f"You said: {update.message.text}")

app = ApplicationBuilder().token("YOUR_BOT_TOKEN").build()
app.add_handler(CommandHandler("start", start))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo))
app.run_polling()

Output (in Telegram chat):

User: /start
Bot: Hello! I am your Python bot. Send me any message and I will echo it back.

User: Python is awesome!
Bot: You said: Python is awesome!

This tiny script creates a fully functional Telegram bot. The CommandHandler listens for the /start command and calls our start function. The MessageHandler with filters.TEXT catches every regular text message and echoes it back. The run_polling() method keeps the bot running, continuously checking Telegram’s servers for new messages.

Want to go deeper? Below we cover how to get your bot token, handle multiple commands, send photos and documents, create interactive button menus, build multi-step conversations, and put it all together in a real expense tracker project.

What Is a Telegram Bot and Why Build One?

A Telegram bot is a special account on Telegram that is controlled by software rather than a human. When someone sends a message to your bot, Telegram forwards that message to your server (or your script running locally via polling), your code processes it, and sends a response back through the Telegram Bot API. The user experience feels like chatting with a person, but behind the scenes it is your Python code making the decisions.

Bots are useful for automation, notifications, data collection, and interactive tools. Unlike building a full web application with a frontend, a Telegram bot gives you a polished chat interface for free — Telegram handles the UI, push notifications, media rendering, and cross-platform support. You just write the logic.

The python-telegram-bot library abstracts the raw HTTP API into a clean, Pythonic interface. Here is how its key concepts map to what you will be building:

ConceptWhat It DoesExample Use
ApplicationThe main bot engine that manages handlers and pollingStarting and running the bot
CommandHandlerResponds to slash commands like /start, /helpGreeting users, showing menus
MessageHandlerResponds to regular messages (text, photos, etc.)Echoing text, processing uploads
CallbackQueryHandlerResponds to inline button pressesInteractive menus, confirmations
ConversationHandlerManages multi-step conversation flowsForms, surveys, step-by-step input
filtersNarrows which messages trigger a handlerOnly text, only photos, only from groups

Now that you understand the building blocks, let us set up BotFather and get a token so we can start coding.

Setting Up BotFather and Getting Your Bot Token

Every Telegram bot needs a unique token — a long string that authenticates your code with the Telegram API. You get this token by talking to BotFather, which is itself a Telegram bot that manages bot creation. This is a one-time setup that takes about two minutes.

Open Telegram and search for @BotFather. Start a conversation and send the /newbot command. BotFather will ask you for two things: a display name for your bot (anything you like, such as “My Python Bot”) and a username that must end in bot (such as my_python_tutorial_bot). Once you provide both, BotFather responds with your bot token.

# Your token will look something like this (this is a fake example):
# 7891234567:AAF_example-token-string-here-abc123

# IMPORTANT: Never share your real token publicly.
# Store it in an environment variable:

# In your terminal:
# export TELEGRAM_BOT_TOKEN="7891234567:AAF_your-real-token-here"

# In your Python code:
import os
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN")

The token is essentially the password to your bot. Anyone who has it can control your bot, so never commit it to a public repository or paste it in shared documents. The safest approach is to store it in an environment variable and read it with os.environ.get() as shown above. For development, you can also use a .env file with the python-dotenv library.

Sudo Sam placing a golden key into a vault guarded by Python snakes
Treat your bot token like your Netflix password. Actually, treat it better.

Installing python-telegram-bot

With your token ready, the next step is installing the library. The python-telegram-bot library is available on PyPI and supports Python 3.9 and above. A single pip command installs everything you need.

# install_check.py
# Install from terminal: pip install python-telegram-bot

# Verify the installation
import telegram
print(f"python-telegram-bot version: {telegram.__version__}")

Output:

python-telegram-bot version: 21.10

If the import works and prints a version number, you are ready to go. The library includes everything — HTTP handling, handler classes, inline keyboards, and conversation management. No additional packages are required for the tutorials in this article.

Handling Commands with CommandHandler

Commands are messages that start with a forward slash, like /start, /help, or /weather. They are the primary way users interact with bots. The CommandHandler class lets you register a Python function for each command your bot supports.

Let us build a bot that responds to three commands: /start for a welcome message, /help for a list of available commands, and /about for information about the bot.

# command_bot.py
import os
from telegram import Update
from telegram.ext import ApplicationBuilder, CommandHandler, ContextTypes

BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN")

async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user_name = update.effective_user.first_name
    await update.message.reply_text(
        f"Welcome, {user_name}! I am a demo bot.\n"
        f"Use /help to see what I can do."
    )

async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    help_text = (
        "Here are my commands:\n\n"
        "/start - Start the bot\n"
        "/help - Show this help message\n"
        "/about - Learn about this bot"
    )
    await update.message.reply_text(help_text)

async def about(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text(
        "I was built with Python and python-telegram-bot v21.\n"
        "Tutorial: pythonhowtoprogram.com"
    )

app = ApplicationBuilder().token(BOT_TOKEN).build()
app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("help", help_command))
app.add_handler(CommandHandler("about", about))
app.run_polling()

Output (in Telegram chat):

User: /start
Bot: Welcome, Alice! I am a demo bot.
     Use /help to see what I can do.

User: /help
Bot: Here are my commands:

     /start - Start the bot
     /help - Show this help message
     /about - Learn about this bot

Each CommandHandler takes two arguments: the command name (without the slash) and the async function to call. The update object contains everything about the incoming message — who sent it, the chat ID, the message text, and more. The context object provides access to bot-level data and the bot instance itself. Notice how we access the user’s first name with update.effective_user.first_name to personalize the greeting.

Sending Messages and Media

Text replies are just the beginning. Telegram bots can send photos, documents, audio files, locations, and formatted messages. The python-telegram-bot library provides dedicated methods for each media type, all accessible through the bot instance or the message reply methods.

Here is a bot that demonstrates sending a photo from a URL, a document, and a message with HTML formatting:

# media_bot.py
import os
from telegram import Update
from telegram.ext import ApplicationBuilder, CommandHandler, ContextTypes

BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN")

async def send_photo(update: Update, context: ContextTypes.DEFAULT_TYPE):
    # Send a photo from a public URL
    photo_url = "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c3/Python-logo-notext.svg/800px-Python-logo-notext.svg.png"
    await update.message.reply_photo(
        photo=photo_url,
        caption="The Python logo - beautiful, isn't it?"
    )

async def send_formatted(update: Update, context: ContextTypes.DEFAULT_TYPE):
    # Send a message with HTML formatting
    formatted_text = (
        "<b>Bold text</b>\n"
        "<i>Italic text</i>\n"
        "<code>inline_code()</code>\n\n"
        "<pre># Code block\nprint('Hello from a code block!')</pre>"
    )
    await update.message.reply_text(formatted_text, parse_mode="HTML")

async def send_document(update: Update, context: ContextTypes.DEFAULT_TYPE):
    # Create and send a text file on the fly
    content = "This file was generated by your Telegram bot!"
    file_bytes = content.encode("utf-8")
    await update.message.reply_document(
        document=file_bytes,
        filename="bot_message.txt",
        caption="Here is your generated document."
    )

app = ApplicationBuilder().token(BOT_TOKEN).build()
app.add_handler(CommandHandler("photo", send_photo))
app.add_handler(CommandHandler("format", send_formatted))
app.add_handler(CommandHandler("doc", send_document))
app.run_polling()

Output (in Telegram chat):

User: /photo
Bot: [Sends Python logo image with caption "The Python logo - beautiful, isn't it?"]

User: /format
Bot: Bold text
     Italic text
     inline_code()

     # Code block
     print('Hello from a code block!')

User: /doc
Bot: [Sends bot_message.txt file with caption "Here is your generated document."]

The reply_photo() method accepts a URL or a file path. For formatted text, Telegram supports both HTML and Markdown — we use parse_mode="HTML" because it handles nested formatting more reliably. The reply_document() method can send any file, including ones you generate dynamically from bytes. This is incredibly useful for bots that create reports, export data, or generate files on demand.

Loop Larry juggling glowing multimedia orbs while Python snakes catch dropped ones
Sending photos, docs, and audio in one bot. We call that the multimedia flex.

Using Inline Keyboard Buttons for User Interaction

One of the most powerful features in Telegram bots is inline keyboards — rows of clickable buttons that appear directly below a message. These buttons can trigger actions, navigate menus, or collect user choices without the user typing anything. They make your bot feel like a polished application rather than a text-only chat.

Here is a bot that presents a menu with inline buttons and responds when the user clicks one:

# button_bot.py
import os
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import ApplicationBuilder, CommandHandler, CallbackQueryHandler, ContextTypes

BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN")

async def menu(update: Update, context: ContextTypes.DEFAULT_TYPE):
    # Create a 2x2 grid of buttons
    keyboard = [
        [
            InlineKeyboardButton("Python Basics", callback_data="basics"),
            InlineKeyboardButton("Web Scraping", callback_data="scraping"),
        ],
        [
            InlineKeyboardButton("APIs", callback_data="apis"),
            InlineKeyboardButton("Automation", callback_data="automation"),
        ],
    ]
    reply_markup = InlineKeyboardMarkup(keyboard)
    await update.message.reply_text("What topic interests you?", reply_markup=reply_markup)

async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    await query.answer()  # Acknowledge the button press

    topics = {
        "basics": "Python Basics: Start with variables, loops, and functions!",
        "scraping": "Web Scraping: Use BeautifulSoup and Selenium to extract data.",
        "apis": "APIs: Learn requests, REST APIs, and authentication.",
        "automation": "Automation: Automate files, emails, and workflows.",
    }
    response = topics.get(query.data, "Unknown topic selected.")
    await query.edit_message_text(text=f"Great choice!\n\n{response}")

app = ApplicationBuilder().token(BOT_TOKEN).build()
app.add_handler(CommandHandler("menu", menu))
app.add_handler(CallbackQueryHandler(button_callback))
app.run_polling()

Output (in Telegram chat):

User: /menu
Bot: What topic interests you?
     [Python Basics] [Web Scraping]
     [APIs]          [Automation]

User: [clicks "Web Scraping"]
Bot: Great choice!

     Web Scraping: Use BeautifulSoup and Selenium to extract data.

Each InlineKeyboardButton has a visible label and a callback_data string that gets sent to your bot when the user clicks it. The CallbackQueryHandler catches these clicks and routes them to your callback function. Always call query.answer() first to remove the loading spinner from the button — without this, Telegram shows a progress indicator that never goes away. Then use query.edit_message_text() to update the original message in place, which gives a smooth, app-like experience.

Building Multi-Step Conversations

Simple command-response bots are useful, but many real-world bots need to collect information across multiple messages — like a form where you ask for the user’s name, then their email, then their preference. The ConversationHandler manages these multi-step flows by tracking which “state” each user is in and routing their messages to the appropriate handler function.

Here is a bot that collects a user’s name, favorite programming language, and experience level through a guided conversation:

# conversation_bot.py
import os
from telegram import Update, ReplyKeyboardMarkup, ReplyKeyboardRemove
from telegram.ext import (
    ApplicationBuilder, CommandHandler, MessageHandler,
    ConversationHandler, ContextTypes, filters
)

BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN")

# Define conversation states
NAME, LANGUAGE, EXPERIENCE = range(3)

async def start_survey(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text(
        "Welcome to the developer survey! What is your name?",
        reply_markup=ReplyKeyboardRemove()  # Remove any previous keyboard
    )
    return NAME  # Move to the NAME state

async def get_name(update: Update, context: ContextTypes.DEFAULT_TYPE):
    context.user_data["name"] = update.message.text
    languages = [["Python", "JavaScript"], ["Go", "Rust"]]
    await update.message.reply_text(
        f"Nice to meet you, {update.message.text}! "
        f"What is your favorite programming language?",
        reply_markup=ReplyKeyboardMarkup(languages, one_time_keyboard=True)
    )
    return LANGUAGE  # Move to the LANGUAGE state

async def get_language(update: Update, context: ContextTypes.DEFAULT_TYPE):
    context.user_data["language"] = update.message.text
    levels = [["Beginner", "Intermediate", "Advanced"]]
    await update.message.reply_text(
        f"Great choice! What is your experience level?",
        reply_markup=ReplyKeyboardMarkup(levels, one_time_keyboard=True)
    )
    return EXPERIENCE  # Move to the EXPERIENCE state

async def get_experience(update: Update, context: ContextTypes.DEFAULT_TYPE):
    context.user_data["experience"] = update.message.text
    # Summarize the collected data
    data = context.user_data
    summary = (
        f"Survey complete! Here is your profile:\n\n"
        f"Name: {data['name']}\n"
        f"Language: {data['language']}\n"
        f"Experience: {data['experience']}\n\n"
        f"Thanks for participating!"
    )
    await update.message.reply_text(summary, reply_markup=ReplyKeyboardRemove())
    return ConversationHandler.END  # End the conversation

async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text("Survey cancelled.", reply_markup=ReplyKeyboardRemove())
    return ConversationHandler.END

# Build the conversation handler
survey_handler = ConversationHandler(
    entry_points=[CommandHandler("survey", start_survey)],
    states={
        NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, get_name)],
        LANGUAGE: [MessageHandler(filters.TEXT & ~filters.COMMAND, get_language)],
        EXPERIENCE: [MessageHandler(filters.TEXT & ~filters.COMMAND, get_experience)],
    },
    fallbacks=[CommandHandler("cancel", cancel)],
)

app = ApplicationBuilder().token(BOT_TOKEN).build()
app.add_handler(survey_handler)
app.run_polling()

Output (in Telegram chat):

User: /survey
Bot: Welcome to the developer survey! What is your name?

User: Alice
Bot: Nice to meet you, Alice! What is your favorite programming language?
     [Python]     [JavaScript]
     [Go]         [Rust]

User: Python
Bot: Great choice! What is your experience level?
     [Beginner] [Intermediate] [Advanced]

User: Intermediate
Bot: Survey complete! Here is your profile:

     Name: Alice
     Language: Python
     Experience: Intermediate

     Thanks for participating!

The ConversationHandler is the most complex handler in the library, but the pattern is straightforward once you understand it. You define numbered states (we used range(3) to create NAME=0, LANGUAGE=1, EXPERIENCE=2). Each handler function returns the next state to transition to. The context.user_data dictionary persists across the conversation, letting you accumulate responses step by step. The ReplyKeyboardMarkup shows a custom keyboard with predefined choices, which reduces typos and makes the experience smoother. Always include a /cancel fallback so users can exit the conversation at any point.

Debug Dee following a trail of glowing footprints with her magnifying glass
ConversationHandler tracks state so your bot remembers where users left off. Unlike your memory at 3am.

Error Handling and Logging

Production bots need to handle errors gracefully. Network timeouts, invalid user input, and API rate limits can all cause exceptions. The python-telegram-bot library provides a built-in error handler that catches any uncaught exception from your handlers, so your bot keeps running even when something goes wrong.

# error_handling_bot.py
import os
import logging
from telegram import Update
from telegram.ext import ApplicationBuilder, CommandHandler, ContextTypes

# Set up logging to see what is happening
logging.basicConfig(
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
    level=logging.INFO
)
logger = logging.getLogger(__name__)

BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN")

async def risky_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    # This will raise an error if the user doesn't provide a number
    user_input = context.args[0] if context.args else None
    if user_input is None:
        await update.message.reply_text("Please provide a number: /divide 10")
        return

    result = 100 / int(user_input)  # Could raise ValueError or ZeroDivisionError
    await update.message.reply_text(f"100 / {user_input} = {result}")

async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE):
    logger.error(f"Exception while handling an update: {context.error}")

    if update and hasattr(update, "message") and update.message:
        await update.message.reply_text(
            "Something went wrong. Please try again with valid input."
        )

app = ApplicationBuilder().token(BOT_TOKEN).build()
app.add_handler(CommandHandler("divide", risky_command))
app.add_error_handler(error_handler)
app.run_polling()

Output (in Telegram chat):

User: /divide 5
Bot: 100 / 5 = 20.0

User: /divide 0
Bot: Something went wrong. Please try again with valid input.

User: /divide abc
Bot: Something went wrong. Please try again with valid input.

The add_error_handler() method registers a function that catches any unhandled exception from any handler in your bot. The context.error attribute contains the actual exception. We log it for debugging and send a friendly message to the user. The logging module is configured at the top to output timestamped messages — this is essential for debugging issues in production. In the real world, you would also want to log the full traceback and possibly send error notifications to yourself via a separate Telegram message.

Real-Life Example: Personal Expense Tracker Bot

Cache Katie surrounded by floating receipts with Python snakes sorting them
A whole expense tracker in under 60 lines. Your finance app is sweating right now.

Now let us put everything together into a practical project. This expense tracker bot lets users add expenses with a category and amount, view their spending by category, and clear their data. It stores everything in a JSON file so expenses persist between bot restarts. This project uses command handlers, formatted messages, context storage, and file I/O — all the concepts we covered in this article.

# expense_tracker_bot.py
import os
import json
from datetime import datetime
from telegram import Update
from telegram.ext import ApplicationBuilder, CommandHandler, ContextTypes

BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN")
DATA_FILE = "expenses.json"

def load_expenses():
    """Load expenses from JSON file, return empty dict if file missing."""
    if os.path.exists(DATA_FILE):
        with open(DATA_FILE, "r") as f:
            return json.load(f)
    return {}

def save_expenses(data):
    """Save expenses dictionary to JSON file."""
    with open(DATA_FILE, "w") as f:
        json.dump(data, f, indent=2)

async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text(
        "Welcome to Expense Tracker!\n\n"
        "Commands:\n"
        "/add <category> <amount> - Add an expense\n"
        "/summary - View spending by category\n"
        "/history - View recent expenses\n"
        "/clear - Clear all expenses"
    )

async def add_expense(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if len(context.args) < 2:
        await update.message.reply_text("Usage: /add food 12.50")
        return

    category = context.args[0].lower()
    try:
        amount = float(context.args[1])
    except ValueError:
        await update.message.reply_text("Amount must be a number. Example: /add food 12.50")
        return

    user_id = str(update.effective_user.id)
    expenses = load_expenses()

    if user_id not in expenses:
        expenses[user_id] = []

    expenses[user_id].append({
        "category": category,
        "amount": amount,
        "date": datetime.now().strftime("%Y-%m-%d %H:%M")
    })
    save_expenses(expenses)

    total = sum(e["amount"] for e in expenses[user_id] if e["category"] == category)
    await update.message.reply_text(
        f"Added ${amount:.2f} to {category}.\n"
        f"Total in {category}: ${total:.2f}"
    )

async def summary(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user_id = str(update.effective_user.id)
    expenses = load_expenses()
    user_expenses = expenses.get(user_id, [])

    if not user_expenses:
        await update.message.reply_text("No expenses yet. Use /add category amount.")
        return

    # Group by category
    categories = {}
    for expense in user_expenses:
        cat = expense["category"]
        categories[cat] = categories.get(cat, 0) + expense["amount"]

    grand_total = sum(categories.values())
    lines = [f"  {cat}: ${amt:.2f}" for cat, amt in sorted(categories.items())]
    text = f"Spending Summary:\n\n" + "\n".join(lines) + f"\n\nTotal: ${grand_total:.2f}"
    await update.message.reply_text(text)

async def history(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user_id = str(update.effective_user.id)
    expenses = load_expenses()
    user_expenses = expenses.get(user_id, [])

    if not user_expenses:
        await update.message.reply_text("No expenses yet.")
        return

    recent = user_expenses[-10:]  # Show last 10 expenses
    lines = [f"  {e['date']} | {e['category']}: ${e['amount']:.2f}" for e in recent]
    text = "Recent Expenses:\n\n" + "\n".join(lines)
    await update.message.reply_text(text)

async def clear(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user_id = str(update.effective_user.id)
    expenses = load_expenses()
    expenses[user_id] = []
    save_expenses(expenses)
    await update.message.reply_text("All expenses cleared.")

app = ApplicationBuilder().token(BOT_TOKEN).build()
app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("add", add_expense))
app.add_handler(CommandHandler("summary", summary))
app.add_handler(CommandHandler("history", history))
app.add_handler(CommandHandler("clear", clear))
app.run_polling()

Output (in Telegram chat):

User: /start
Bot: Welcome to Expense Tracker!

     Commands:
     /add <category> <amount> - Add an expense
     /summary - View spending by category
     /history - View recent expenses
     /clear - Clear all expenses

User: /add food 12.50
Bot: Added $12.50 to food.
     Total in food: $12.50

User: /add transport 35.00
Bot: Added $35.00 to transport.
     Total in transport: $35.00

User: /add food 8.75
Bot: Added $8.75 to food.
     Total in food: $21.25

User: /summary
Bot: Spending Summary:

       food: $21.25
       transport: $35.00

     Total: $56.25

User: /history
Bot: Recent Expenses:

       2026-03-13 14:30 | food: $12.50
       2026-03-13 14:31 | transport: $35.00
       2026-03-13 14:32 | food: $8.75

This project ties together almost every concept from the article. It uses CommandHandler for five different commands, context.args to parse user input, JSON file I/O for persistence, input validation with helpful error messages, and formatted text output. The expenses are stored per user (keyed by Telegram user ID), so multiple people can use the same bot without their data mixing together. You could extend this by adding inline buttons for quick category selection, a /export command that sends a CSV file, or monthly budget limits with alerts.

Frequently Asked Questions

What is the difference between polling and webhooks?

Polling means your bot continuously asks Telegram "any new messages?" in a loop — this is what run_polling() does and it works great for development and small bots. Webhooks are the opposite: you give Telegram a URL, and Telegram sends new messages directly to your server. Webhooks are more efficient for production bots because they eliminate the constant polling overhead. For most tutorials and personal projects, polling is simpler and works perfectly fine.

How do I keep my bot running 24/7?

During development, running the script on your local machine is fine, but it stops when you close the terminal. For production, deploy your bot to a cloud server. Popular free or low-cost options include Railway, Render, PythonAnywhere, and a small VPS from providers like DigitalOcean. You can also use a Raspberry Pi at home. The key is that the Python process needs to stay running continuously — tools like systemd, screen, or pm2 can manage this for you.

Can I run multiple bots from one Python script?

Yes, but it is generally better to run each bot as a separate script or process. Each Application instance manages its own polling loop and handlers, and mixing them in one script can make debugging harder. If you need bots to share data, use a shared database or file rather than trying to run them in the same process.

Does Telegram have rate limits for bots?

Yes. Telegram limits bots to about 30 messages per second overall, and 1 message per second to the same chat. If your bot sends too many messages too quickly, Telegram returns a 429 error with a retry_after value telling you how long to wait. The python-telegram-bot library handles basic rate limiting automatically, but for high-volume bots you should implement message queuing and respect the limits explicitly.

How do I make my bot work in group chats?

By default, bots in groups only see messages that start with a slash command or explicitly mention the bot. To see all messages, you need to disable "Group Privacy" mode in BotFather using the /setprivacy command. In your code, you can filter group messages using filters.ChatType.GROUP or filters.ChatType.SUPERGROUP to create group-specific behavior.

What is the best way to store data for a Telegram bot?

For simple bots with small amounts of data, JSON files work well (as we used in the expense tracker). For anything more complex, use SQLite (built into Python via the sqlite3 module) for structured data without needing an external database server. For production bots with many users, PostgreSQL or MongoDB are popular choices. The python-telegram-bot library also has built-in persistence classes that can automatically save conversation state and user data.

Conclusion

You now have a solid foundation for building Telegram bots with Python. We covered the entire workflow: setting up a bot through BotFather, installing python-telegram-bot, handling commands with CommandHandler, sending text, photos, and documents, creating interactive inline keyboard buttons with CallbackQueryHandler, building multi-step conversations with ConversationHandler, implementing error handling and logging, and tying it all together with a Personal Expense Tracker project.

The expense tracker is a great starting point for your own projects. Try extending it with inline buttons for quick category selection, a monthly budget limit with warnings, or a /export command that generates a CSV report of your spending. The patterns you learned here — handlers, filters, context data, and conversation states — apply to any bot you want to build, from a simple notification bot to a full customer service assistant.

For the complete API reference, advanced topics like job queues and custom filters, and deployment guides, check out the official python-telegram-bot documentation and the Telegram Bot API reference.

How To Handle API Rate Limits and Retry Logic in Python 3

How To Handle API Rate Limits and Retry Logic in Python 3

Last Updated: June 01, 2026

Intermediate

You are building a script that pulls data from an external API — maybe it fetches weather data, processes payment transactions, or syncs records from a third-party service. It works perfectly in testing, but when you deploy it to production and it starts making hundreds of requests, everything falls apart. The API starts returning 429 Too Many Requests errors, your script crashes, and you lose data. This is the rate limit problem, and every developer who works with APIs will hit it eventually.

The good news is that handling rate limits is a solved problem. Python’s requests library combined with a few simple patterns — exponential backoff, jitter, and retry logic — can make your API calls resilient and well-behaved. For more complex scenarios, the tenacity library (pip install tenacity) provides a powerful decorator-based retry system. You will also want the requests library if you do not have it already: pip install requests.

In this article we will cover everything you need to handle API rate limits like a professional. We will start with a quick example that adds retry logic to a simple API call, then explain what rate limits are and why APIs enforce them. From there we will build retry logic from scratch using exponential backoff, learn the tenacity library for production-grade retries, handle the Retry-After header that many APIs send, implement request throttling to stay under limits proactively, and finish with a real-life project that builds a reusable API client with built-in rate limit handling. By the end, your API calls will never crash from a 429 again.

Pubs - Python How To Program
Written by Pubs

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.

View all tutorials by Pubs →

Handling API Rate Limits: Quick Example

Here is the simplest way to add retry logic to an API call. This example retries failed requests with increasing delays between attempts, which is all you need for basic rate limit handling.

# quick_example.py
import requests
import time

def fetch_with_retry(url, max_retries=3):
    """Fetch a URL with automatic retry on failure."""
    for attempt in range(max_retries):
        response = requests.get(url)
        if response.status_code == 200:
            return response.json()
        elif response.status_code == 429:
            wait = 2 ** attempt  # Exponential backoff: 1s, 2s, 4s
            print(f"Rate limited! Waiting {wait}s before retry {attempt + 1}...")
            time.sleep(wait)
        else:
            response.raise_for_status()
    raise Exception(f"Failed after {max_retries} retries")

# Test with a real API
data = fetch_with_retry("https://jsonplaceholder.typicode.com/posts/1")
print(f"Title: {data['title']}")
print(f"User ID: {data['userId']}")

Output:

Title: sunt aut facere repellat provident occaecati excepturi optio reprehenderit
User ID: 1

This simple function checks the response status code after each request. If it gets a 429, it waits progressively longer before retrying — 1 second, then 2, then 4. This is called exponential backoff and it is the foundation of all rate limit handling. The real API at jsonplaceholder.typicode.com does not rate limit us in this case, so the request succeeds on the first try, but the retry logic is ready for when it matters.

Want to go deeper? Below we explain the different retry strategies, build a production-grade solution with the tenacity library, and create a reusable API client you can drop into any project.

What Are API Rate Limits and Why Do They Exist?

A rate limit is a restriction that an API server places on how many requests a client can make within a specific time window. When you exceed that limit, the server responds with HTTP status code 429 Too Many Requests instead of processing your request. This is not an error in your code — it is the API telling you to slow down.

APIs enforce rate limits for several practical reasons. First, they protect the server from being overwhelmed by a single client making thousands of requests per second. Second, they ensure fair usage — if one user monopolizes the server, other users get slow or no responses. Third, they manage infrastructure costs — every request costs the API provider compute time, bandwidth, and money. Most API documentation clearly states the rate limits, and many include rate limit headers in their responses so you can track your usage.

Here is how different APIs typically communicate their rate limits.

HeaderMeaningExample
X-RateLimit-LimitMaximum requests allowed per window100
X-RateLimit-RemainingRequests left in current window23
X-RateLimit-ResetWhen the window resets (Unix timestamp)1710360000
Retry-AfterSeconds to wait before retrying (on 429)30

The most important header is Retry-After — when an API sends a 429 response with this header, it is telling you exactly how long to wait. Always respect this value because the API knows when your rate limit window resets. If you keep hammering the server, some APIs will escalate to longer blocks or even ban your API key. Now let us build proper retry logic.

Building Retry Logic With Exponential Backoff

Exponential backoff means waiting longer after each failed attempt. Instead of retrying immediately (which would just get rate-limited again), you wait 1 second, then 2, then 4, then 8, and so on. This gives the API server time to recover and your rate limit window time to reset. Adding random jitter (a small random delay) prevents the “thundering herd” problem where multiple clients all retry at the exact same moment.

# exponential_backoff.py
import requests
import time
import random

def fetch_with_backoff(url, max_retries=5, base_delay=1):
    """Fetch a URL with exponential backoff and jitter."""
    for attempt in range(max_retries):
        try:
            response = requests.get(url, timeout=10)

            if response.status_code == 200:
                return response.json()

            if response.status_code == 429:
                # Check for Retry-After header first
                retry_after = response.headers.get("Retry-After")
                if retry_after:
                    wait = int(retry_after)
                    print(f"  Server says wait {wait}s (Retry-After header)")
                else:
                    # Exponential backoff with jitter
                    wait = base_delay * (2 ** attempt) + random.uniform(0, 1)
                    print(f"  Rate limited. Backing off {wait:.1f}s (attempt {attempt + 1})")
                time.sleep(wait)
                continue

            if response.status_code >= 500:
                # Server errors are also retryable
                wait = base_delay * (2 ** attempt) + random.uniform(0, 1)
                print(f"  Server error {response.status_code}. Retrying in {wait:.1f}s")
                time.sleep(wait)
                continue

            # Client errors (4xx except 429) should not be retried
            response.raise_for_status()

        except requests.exceptions.Timeout:
            wait = base_delay * (2 ** attempt)
            print(f"  Timeout. Retrying in {wait}s (attempt {attempt + 1})")
            time.sleep(wait)
        except requests.exceptions.ConnectionError:
            wait = base_delay * (2 ** attempt)
            print(f"  Connection failed. Retrying in {wait}s (attempt {attempt + 1})")
            time.sleep(wait)

    raise Exception(f"Failed after {max_retries} attempts")

# Test with a real API
print("Fetching user data...")
user = fetch_with_backoff("https://jsonplaceholder.typicode.com/users/1")
print(f"Name: {user['name']}")
print(f"Email: {user['email']}")
print(f"Company: {user['company']['name']}")

Output:

Fetching user data...
Name: Leanne Graham
Email: Sincere@april.biz
Company: Romaguera-Crona

This function handles three categories of retryable failures: rate limits (429), server errors (500+), and network issues (timeouts and connection errors). The random.uniform(0, 1) adds jitter so that if you have 10 scripts running in parallel, they do not all retry at the exact same second and trigger another rate limit. Notice that we check for the Retry-After header first — if the server tells us how long to wait, we trust that over our own backoff calculation.

Debug Dee holding a stop shield
Retry-After: the header that tells you exactly when to try again. Most developers ignore it. Don’t.

Production-Grade Retries With tenacity

Writing retry logic from scratch works, but the tenacity library makes it dramatically cleaner. It provides a @retry decorator that handles exponential backoff, jitter, conditional retries, and more — all in a single line. Install it with pip install tenacity.

# tenacity_example.py
import requests
from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential,
    retry_if_exception_type,
    before_sleep_log,
)
import logging

# Set up logging to see retry activity
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

@retry(
    stop=stop_after_attempt(5),              # Max 5 attempts
    wait=wait_exponential(multiplier=1, max=30),  # 1s, 2s, 4s, 8s... up to 30s
    retry=retry_if_exception_type(requests.exceptions.RequestException),
    before_sleep=before_sleep_log(logger, logging.WARNING),
)
def fetch_data(url):
    """Fetch data from an API with automatic retries."""
    response = requests.get(url, timeout=10)
    if response.status_code == 429:
        raise requests.exceptions.RequestException(
            f"Rate limited (429). Retry-After: {response.headers.get('Retry-After', 'unknown')}"
        )
    response.raise_for_status()
    return response.json()

# Use it just like a normal function
print("Fetching posts...")
posts = fetch_data("https://jsonplaceholder.typicode.com/posts?_limit=3")
for post in posts:
    print(f"  [{post['id']}] {post['title'][:50]}...")

Output:

Fetching posts...
  [1] sunt aut facere repellat provident occaecati exc...
  [2] qui est esse...
  [3] ea molestias quasi exercitationem repellat qui i...

The @retry decorator adds all the retry behavior without cluttering your function with loops and sleep calls. The stop_after_attempt(5) sets the maximum retries, wait_exponential(multiplier=1, max=30) implements exponential backoff capped at 30 seconds, and retry_if_exception_type ensures we only retry on network-related errors. The before_sleep_log callback logs each retry attempt so you can monitor what is happening in production. Your actual function stays clean — it just makes the request and raises if something goes wrong.

Custom Retry Conditions With tenacity

Sometimes you need more control over when to retry. The tenacity library lets you write custom retry conditions using callback functions. This is useful when you want to retry based on the response content, not just the HTTP status code.

# tenacity_custom.py
import requests
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_result

def is_rate_limited(response):
    """Return True if the response indicates rate limiting."""
    if response is None:
        return True
    return response.status_code == 429 or response.status_code >= 500

@retry(
    stop=stop_after_attempt(4),
    wait=wait_exponential(multiplier=1, max=16),
    retry=retry_if_result(is_rate_limited),
)
def make_api_call(url):
    """Make an API call, returning the response object."""
    try:
        response = requests.get(url, timeout=10)
        if response.status_code == 429:
            retry_after = response.headers.get("Retry-After")
            print(f"  429 received. Retry-After: {retry_after}")
        return response
    except requests.exceptions.RequestException as e:
        print(f"  Network error: {e}")
        return None

# Make the call — tenacity handles retries automatically
print("Making API call...")
response = make_api_call("https://httpbin.org/get")
if response and response.status_code == 200:
    data = response.json()
    print(f"Success! Origin: {data.get('origin', 'unknown')}")
    print(f"Headers received: {len(data.get('headers', {}))}")
else:
    print("All retries exhausted")

Output:

Making API call...
Success! Origin: 203.0.113.42
Headers received: 5

The retry_if_result condition is powerful because it lets you inspect the actual response, not just catch exceptions. The is_rate_limited() function returns True for 429 responses and server errors (500+), telling tenacity to retry. For successful responses (200-399) or client errors (400-499 except 429), it returns False and the function returns normally. This gives you fine-grained control over exactly which responses trigger a retry.

Respecting the Retry-After Header

Many well-designed APIs include a Retry-After header in their 429 responses. This header tells you exactly how many seconds to wait before your next request will be accepted. Always use this value when available — it is more accurate than your own backoff calculation because the API server knows when your rate limit window actually resets.

# retry_after.py
import requests
import time

def fetch_with_retry_after(url, max_retries=5):
    """Fetch a URL, respecting the Retry-After header."""
    for attempt in range(max_retries):
        response = requests.get(url, timeout=10)

        if response.status_code == 200:
            # Log rate limit headers if present
            remaining = response.headers.get("X-RateLimit-Remaining")
            limit = response.headers.get("X-RateLimit-Limit")
            if remaining and limit:
                print(f"  Rate limit: {remaining}/{limit} requests remaining")
            return response.json()

        if response.status_code == 429:
            retry_after = response.headers.get("Retry-After")

            if retry_after:
                # Retry-After can be seconds or an HTTP date
                try:
                    wait = int(retry_after)
                except ValueError:
                    # It's a date string — parse and calculate seconds
                    wait = 60  # Fallback
                print(f"  Rate limited. Server says wait {wait}s.")
            else:
                wait = 2 ** attempt  # Fallback to exponential backoff
                print(f"  Rate limited. No Retry-After. Backing off {wait}s.")

            time.sleep(wait)
            continue

        # Non-retryable error
        response.raise_for_status()

    raise Exception(f"Failed after {max_retries} attempts")

# Test with httpbin (won't actually rate limit, but demonstrates the pattern)
print("Fetching data with rate limit awareness...")
data = fetch_with_retry_after("https://httpbin.org/get")
print(f"Origin: {data.get('origin', 'unknown')}")

Output:

Fetching data with rate limit awareness...
Origin: 203.0.113.42

This function checks for rate limit headers on every successful response too — not just on 429s. The X-RateLimit-Remaining header tells you how many requests you have left in the current window. If you see this number getting low, you can proactively slow down your requests before hitting the limit. This is called proactive throttling and it is much better than waiting for 429 errors to react.

Sudo Sam building a defense wall
Exponential backoff without jitter is just synchronized DDoS with extra steps. Add the randomness.

Proactive Request Throttling

Instead of waiting for rate limit errors and then reacting, you can throttle your requests proactively to stay under the limit. This approach is cleaner, faster, and more respectful to the API provider. The simplest way is to add a delay between requests, but a more sophisticated approach uses a token bucket or sliding window to allow bursts while maintaining an average rate.

# throttling.py
import requests
import time

class ThrottledClient:
    """An HTTP client that limits requests per second."""

    def __init__(self, requests_per_second=5):
        self.min_interval = 1.0 / requests_per_second
        self.last_request_time = 0

    def get(self, url, **kwargs):
        """Make a throttled GET request."""
        # Calculate how long to wait
        elapsed = time.time() - self.last_request_time
        if elapsed < self.min_interval:
            wait = self.min_interval - elapsed
            time.sleep(wait)

        self.last_request_time = time.time()
        return requests.get(url, timeout=10, **kwargs)

# Demo: fetch 5 posts at a controlled rate
client = ThrottledClient(requests_per_second=2)  # Max 2 requests/sec

print("Fetching posts with throttling (2 req/sec max):\n")
start = time.time()
for post_id in range(1, 6):
    response = client.get(f"https://jsonplaceholder.typicode.com/posts/{post_id}")
    data = response.json()
    elapsed = time.time() - start
    print(f"  [{elapsed:5.2f}s] Post {post_id}: {data['title'][:40]}...")

total = time.time() - start
print(f"\nFetched 5 posts in {total:.2f}s (throttled to 2/sec)")

Output:

Fetching posts with throttling (2 req/sec max):

  [ 0.31s] Post 1: sunt aut facere repellat provident occ...
  [ 0.82s] Post 2: qui est esse...
  [ 1.33s] Post 3: ea molestias quasi exercitationem repel...
  [ 1.82s] Post 4: eum et est occaecati...
  [ 2.35s] Post 5: nesciunt quas odio...

Fetched 5 posts in 2.35s (throttled to 2/sec)

The ThrottledClient class tracks when the last request was made and adds a delay if needed to maintain the target rate. With requests_per_second=2, it ensures at least 0.5 seconds between requests. This is much better than adding a flat time.sleep(0.5) after every request because it accounts for the actual time the request takes — if a request takes 0.3 seconds, it only sleeps for 0.2 more seconds. This maximizes throughput while staying within limits.

Using requests.Session With HTTPAdapter for Retries

The requests library has a built-in retry mechanism through the urllib3.Retry class and HTTPAdapter. This is useful when you want retry behavior on all requests made through a session without modifying each individual call.

# session_retry.py
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

def create_retry_session(
    retries=3,
    backoff_factor=1,
    status_forcelist=(429, 500, 502, 503, 504),
):
    """Create a requests session with built-in retry logic."""
    session = requests.Session()

    retry_strategy = Retry(
        total=retries,
        backoff_factor=backoff_factor,  # 1s, 2s, 4s between retries
        status_forcelist=status_forcelist,
        allowed_methods=["GET", "POST", "PUT", "DELETE"],
        raise_on_status=False,
    )

    adapter = HTTPAdapter(max_retries=retry_strategy)
    session.mount("https://", adapter)
    session.mount("http://", adapter)

    return session

# Create a session with retry logic baked in
session = create_retry_session(retries=3, backoff_factor=1)

# Now every request through this session has automatic retries
print("Fetching with retry session...")
response = session.get("https://jsonplaceholder.typicode.com/users/1")
user = response.json()
print(f"Name: {user['name']}")
print(f"Email: {user['email']}")

# Fetch multiple resources — all have retry protection
print("\nFetching comments...")
response = session.get("https://jsonplaceholder.typicode.com/posts/1/comments")
comments = response.json()
print(f"Found {len(comments)} comments on post 1")
for comment in comments[:2]:
    print(f"  - {comment['name'][:40]}...")

Output:

Fetching with retry session...
Name: Leanne Graham
Email: Sincere@april.biz

Fetching comments...
Found 5 comments on post 1
  - id labore ex et quam laborum...
  - quo vero reiciendis velit similique ear...

The HTTPAdapter approach is elegant because you configure retry behavior once on the session and it applies to every request automatically. The status_forcelist parameter specifies which HTTP status codes should trigger a retry — here we include 429 (rate limited) and the common server error codes (500-504). The backoff_factor=1 means retries happen at 1s, 2s, 4s intervals. This is the approach most production Python applications use because it requires zero changes to individual API calls.

Cache Katie racing past an hourglass
asyncio.Semaphore(5) — five concurrent requests, zero 429s, maximum throughput.

Real-Life Example: Resilient API Data Fetcher

Let us build a complete, production-ready API client that combines everything we have covered: exponential backoff, Retry-After handling, proactive throttling, and comprehensive logging. This is a class you can drop into any project that needs to pull data from rate-limited APIs.

# resilient_api_client.py
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
import time
import random

class ResilientAPIClient:
    """A production-ready API client with rate limit handling."""

    def __init__(self, base_url, requests_per_second=5, max_retries=5):
        self.base_url = base_url.rstrip("/")
        self.min_interval = 1.0 / requests_per_second
        self.max_retries = max_retries
        self.last_request_time = 0
        self.request_count = 0
        self.retry_count = 0

        # Set up session with built-in retry for server errors
        self.session = requests.Session()
        retry_strategy = Retry(
            total=2,
            backoff_factor=0.5,
            status_forcelist=(500, 502, 503, 504),
        )
        adapter = HTTPAdapter(max_retries=retry_strategy)
        self.session.mount("https://", adapter)
        self.session.mount("http://", adapter)

    def _throttle(self):
        """Enforce rate limiting between requests."""
        elapsed = time.time() - self.last_request_time
        if elapsed < self.min_interval:
            time.sleep(self.min_interval - elapsed)
        self.last_request_time = time.time()

    def get(self, endpoint, params=None):
        """Make a GET request with full retry and throttling."""
        url = f"{self.base_url}/{endpoint.lstrip('/')}"
        self._throttle()

        for attempt in range(self.max_retries):
            self.request_count += 1
            response = self.session.get(url, params=params, timeout=15)

            if response.status_code == 200:
                return response.json()

            if response.status_code == 429:
                self.retry_count += 1
                retry_after = response.headers.get("Retry-After")
                if retry_after:
                    wait = int(retry_after) + random.uniform(0, 1)
                else:
                    wait = (2 ** attempt) + random.uniform(0, 1)
                print(f"  [429] Rate limited on {endpoint}. Waiting {wait:.1f}s...")
                time.sleep(wait)
                continue

            response.raise_for_status()

        raise Exception(f"Failed to fetch {endpoint} after {self.max_retries} retries")

    def get_stats(self):
        """Return usage statistics."""
        return {
            "total_requests": self.request_count,
            "retries": self.retry_count,
            "success_rate": f"{((self.request_count - self.retry_count) / max(self.request_count, 1)) * 100:.1f}%",
        }

# --- Demo: Fetch data from JSONPlaceholder API ---
if __name__ == "__main__":
    client = ResilientAPIClient(
        base_url="https://jsonplaceholder.typicode.com",
        requests_per_second=3,
        max_retries=5,
    )

    print("=== Resilient API Client Demo ===\n")

    # Fetch multiple users
    print("Fetching users...")
    users = client.get("/users")
    for user in users[:3]:
        print(f"  {user['name']} ({user['email']})")

    # Fetch posts for a user
    print(f"\nFetching posts for user 1...")
    posts = client.get("/posts", params={"userId": 1})
    print(f"  Found {len(posts)} posts")
    for post in posts[:3]:
        print(f"  [{post['id']}] {post['title'][:45]}...")

    # Fetch comments on first post
    print(f"\nFetching comments on post 1...")
    comments = client.get("/posts/1/comments")
    print(f"  Found {len(comments)} comments")

    # Fetch todos
    print(f"\nFetching todos...")
    todos = client.get("/todos", params={"userId": 1, "_limit": 5})
    completed = sum(1 for t in todos if t["completed"])
    print(f"  {completed}/{len(todos)} completed")

    # Print stats
    stats = client.get_stats()
    print(f"\n=== Client Stats ===")
    print(f"  Total requests: {stats['total_requests']}")
    print(f"  Retries: {stats['retries']}")
    print(f"  Success rate: {stats['success_rate']}")

Output:

=== Resilient API Client Demo ===

Fetching users...
  Leanne Graham (Sincere@april.biz)
  Ervin Howell (Shanna@melissa.tv)
  Clementine Bauch (Nathan@yesenia.net)

Fetching posts for user 1...
  Found 10 posts
  [1] sunt aut facere repellat provident occaec...
  [2] qui est esse...
  [3] ea molestias quasi exercitationem repella...

Fetching comments on post 1...
  Found 5 comments

Fetching todos...
  2/5 completed

=== Client Stats ===
  Total requests: 4
  Retries: 0
  Success rate: 100.0%

This ResilientAPIClient class is designed for real-world use. It combines proactive throttling (the _throttle method ensures you never exceed your target rate), reactive retry logic (exponential backoff with jitter on 429 responses), and built-in statistics tracking. The session-level HTTPAdapter handles server errors (500s) automatically, while the manual retry loop in get() handles rate limits specifically. You can extend this class with authentication headers, POST/PUT methods, pagination support, or async capabilities using aiohttp for higher throughput.

Frequently Asked Questions

How many retries should I set?

Three to five retries is the standard range for most APIs. With exponential backoff starting at 1 second, five retries means a maximum total wait of about 31 seconds (1 + 2 + 4 + 8 + 16). If an API is still rate-limiting you after 30 seconds of waiting, either your rate is far too high or the API is experiencing an outage. For batch processing jobs that can tolerate longer waits, you might go up to 7-10 retries with a backoff cap of 60 seconds.

What is jitter and why does it matter?

Jitter adds a small random delay to your backoff interval. Without it, if 100 clients all get rate-limited at the same time, they will all retry at exactly 1 second, get limited again, retry at 2 seconds, and so on — creating synchronized waves of traffic. Adding random.uniform(0, 1) spreads out the retries so not everyone hits the server at the same instant. This is especially important in distributed systems where multiple workers or servers are calling the same API.

What is the difference between 429 and 503 errors?

A 429 Too Many Requests means you specifically have exceeded your rate limit — the server is healthy but refusing your requests because you are making too many. A 503 Service Unavailable means the server itself is overloaded or down for maintenance — it is not specific to you. Both are retryable, but 429 usually comes with a Retry-After header telling you exactly when to try again, while 503 is more unpredictable. Treat 429 as "slow down" and 503 as "try again later."

Can I use retry logic with async/await?

Yes. The tenacity library works with async functions out of the box — just decorate your async def function with @retry and it handles everything. For the requests session approach, switch to aiohttp which is the async equivalent. You can also use asyncio.sleep() instead of time.sleep() in your manual retry loops so the event loop can process other tasks during the backoff period.

How do I handle rate limits for multiple different APIs?

Create separate client instances for each API, each with its own rate limit configuration. For example, if the GitHub API allows 5,000 requests per hour and a weather API allows 60 requests per minute, create two ResilientAPIClient instances with different requests_per_second values. This keeps each API's rate limiting independent and prevents one slow API from blocking requests to another.

How do I test retry logic without hitting a real API?

Use the responses library (pip install responses) or unittest.mock to mock HTTP responses. You can simulate a sequence of 429 → 429 → 200 responses to verify your backoff logic works correctly. For integration testing, httpbin.org/status/429 returns a real 429 response that you can use to test your retry handling against an actual HTTP server.

Conclusion

You now have a complete toolkit for handling API rate limits in Python. We covered the fundamentals of what rate limits are and why APIs use them, built retry logic with exponential backoff and jitter from scratch, used the tenacity library for clean decorator-based retries, learned to respect the Retry-After header, implemented proactive request throttling, configured the requests session with HTTPAdapter for automatic retries, and built a production-ready ResilientAPIClient class that combines all these techniques.

Try extending the ResilientAPIClient with authentication support, pagination handling, or aiohttp integration for async requests. These are the natural next steps when building data pipelines or API integrations that need to be both fast and reliable.

For more on the retry patterns covered here, the tenacity documentation is an excellent reference. The requests library documentation covers session management and adapters in detail.

How To Store Data Separately For Testing and Production in Python 3

How To Store Data Separately For Testing and Production in Python 3

Last Updated: June 01, 2026

Intermediate

Pubs - Python How To Program
Written by Pubs

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.

View all tutorials by Pubs →

How To Store Data Separately For Testing and Production in Python 3

Quick Example (TLDR)

Use environment variables to switch between test and production settings:

# config.py
import os

ENVIRONMENT = os.getenv('ENV', 'development')

if ENVIRONMENT == 'production':
    DATABASE_URL = 'postgresql://prod.example.com:5432/maindb'
    DEBUG = False
else:
    DATABASE_URL = 'sqlite:///test.db'
    DEBUG = True

# main.py
from config import DATABASE_URL, DEBUG
print(f"Database: {DATABASE_URL}, Debug: {DEBUG}")

Output (development):

Database: sqlite:///test.db, Debug: True

Output (production):

Database: postgresql://prod.example.com:5432/maindb, Debug: False

Why Separate Configurations Matter

Your test environment should be completely isolated from your production data. Testing on production is a recipe for disaster: you could delete real data, send test emails to real customers, or charge real credit cards. The solution is environment-specific configuration.

Using Environment Variables

Environment variables are the simplest and most secure way to manage different configs:

import os

# Read environment variable, use default if not set
DATABASE_HOST = os.getenv('DB_HOST', 'localhost')
DATABASE_PORT = int(os.getenv('DB_PORT', '5432'))
API_KEY = os.getenv('API_KEY')  # No default - must be set
DEBUG_MODE = os.getenv('DEBUG', 'False').lower() == 'true'

print(f"Host: {DATABASE_HOST}")
print(f"Port: {DATABASE_PORT}")
print(f"Debug: {DEBUG_MODE}")

Output:

Host: localhost
Port: 5432
Debug: False

Using .env Files with python-dotenv

For development, load environment variables from a .env file:

# .env (development)
DATABASE_URL=sqlite:///dev.db
API_KEY=dev-key-12345
DEBUG=True
MAIL_SERVICE=fake

# .env.production
DATABASE_URL=postgresql://prod.example.com/maindb
API_KEY=prod-key-secret
DEBUG=False
MAIL_SERVICE=sendgrid

Load them in Python:

from dotenv import load_dotenv
import os

# Load from .env file
load_dotenv('.env')

DATABASE_URL = os.getenv('DATABASE_URL')
API_KEY = os.getenv('API_KEY')
print(f"Database: {DATABASE_URL}")

Output:

Database: sqlite:///dev.db

ConfigParser for Multi-Environment Files

For complex configurations, use ConfigParser with separate .ini files:

# config_development.ini
[database]
host = localhost
port = 5432
name = testdb
user = testuser
password = testpass

[email]
service = console
debug = True

[api]
key = dev-key-123
timeout = 10

Read it in Python:

import configparser
import os

# Determine which config to load
env = os.getenv('ENV', 'development')
config_file = f'config_{env}.ini'

# Parse the config file
config = configparser.ConfigParser()
config.read(config_file)

# Access values
db_host = config.get('database', 'host')
db_port = config.getint('database', 'port')
email_debug = config.getboolean('email', 'debug')

print(f"Database: {db_host}:{db_port}")
print(f"Email debug: {email_debug}")

Output:

Database: localhost:5432
Email debug: True

The Settings Pattern: Dev, Staging, and Production

Create a flexible settings module that supports multiple environments:

# settings/base.py - Common settings for all environments
import os

class Settings:
    # Common to all environments
    SECRET_KEY = os.getenv('SECRET_KEY', 'dev-key')
    ALLOWED_HOSTS = ['localhost', '127.0.0.1']
    
    # Default values
    DATABASE_URL = 'sqlite:///db.sqlite3'
    API_TIMEOUT = 30
    DEBUG = False
    LOG_LEVEL = 'INFO'

# settings/development.py
from .base import Settings

class DevelopmentSettings(Settings):
    DEBUG = True
    DATABASE_URL = 'sqlite:///dev.db'
    API_TIMEOUT = 60  # Longer timeout for debugging
    LOG_LEVEL = 'DEBUG'

# settings/production.py
from .base import Settings

class ProductionSettings(Settings):
    DEBUG = False
    DATABASE_URL = os.getenv('DATABASE_URL')  # From env vars
    API_TIMEOUT = 10  # Strict timeout in production
    LOG_LEVEL = 'WARNING'

# settings/staging.py
from .base import Settings

class StagingSettings(Settings):
    DEBUG = False  # But with more logging than prod
    DATABASE_URL = os.getenv('STAGING_DATABASE_URL')
    LOG_LEVEL = 'INFO'

Use it in your app:

# main.py
import os
from settings.development import DevelopmentSettings
from settings.production import ProductionSettings
from settings.staging import StagingSettings

# Load appropriate settings
env = os.getenv('ENV', 'development')

if env == 'production':
    settings = ProductionSettings()
elif env == 'staging':
    settings = StagingSettings()
else:
    settings = DevelopmentSettings()

# Use the settings
print(f"Debug mode: {settings.DEBUG}")
print(f"Database: {settings.DATABASE_URL}")
print(f"Log level: {settings.LOG_LEVEL}")

Output (development):

Debug mode: True
Database: sqlite:///dev.db
Log level: DEBUG

Output (production):

Debug mode: False
Database: postgresql://prod.example.com/maindb
Log level: WARNING

Real-Life Example: Flask App With Environment-Based Config

Here’s a complete Flask application setup with separate environments:

# config.py
import os

class Config:
    # Common
    SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key')
    SQLALCHEMY_TRACK_MODIFICATIONS = False

class DevelopmentConfig(Config):
    DEBUG = True
    SQLALCHEMY_DATABASE_URI = 'sqlite:///dev.db'
    SQLALCHEMY_ECHO = True  # Log all SQL
    MAIL_BACKEND = 'console'  # Print emails to console

class TestingConfig(Config):
    TESTING = True
    SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'  # In-memory DB
    MAIL_BACKEND = 'testing'  # Don't send emails

class ProductionConfig(Config):
    DEBUG = False
    SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL')
    SQLALCHEMY_ECHO = False  # Don't log SQL
    MAIL_BACKEND = 'sendgrid'  # Send real emails

# app.py
from flask import Flask
import os
from config import DevelopmentConfig, TestingConfig, ProductionConfig

def create_app():
    app = Flask(__name__)
    
    # Load config based on environment
    env = os.getenv('FLASK_ENV', 'development')
    
    if env == 'production':
        app.config.from_object(ProductionConfig)
    elif env == 'testing':
        app.config.from_object(TestingConfig)
    else:
        app.config.from_object(DevelopmentConfig)
    
    @app.route('/status')
    def status():
        return {
            'environment': env,
            'debug': app.config['DEBUG'],
            'database': app.config['SQLALCHEMY_DATABASE_URI']
        }
    
    return app

# Run with: FLASK_ENV=production python app.py
if __name__ == '__main__':
    app = create_app()
    app.run()

Output (development):

{
  "environment": "development",
  "debug": true,
  "database": "sqlite:///dev.db"
}

FAQ

Q: Should I commit .env files to git?

A: Never! Add .env to .gitignore. Commit .env.example with dummy values so others know what variables are needed. This keeps secrets out of version control.

Q: Can I use environment variables for all settings?

A: Yes, but config files are often easier for complex setups. Use environment variables for secrets (API keys, passwords) and config files for regular settings.

Q: How do I test with a test database without affecting production?

A: Use an in-memory database or a separate test database for testing. Set ENV=testing to automatically use test configuration with no risk to production.

Q: What if I forget to set an environment variable?

A: Use defaults wisely. For critical values like DATABASE_URL, don’t provide defaults so the app fails loudly. For optional values, provide sensible defaults.

Q: How do I know which environment my code is running in?

A: Always have a way to check: print(os.getenv(‘ENV’)) or check your settings object. In Flask: app.config[‘DEBUG’] or app.config[‘ENV’]

Conclusion

Separating configuration by environment is a fundamental practice in professional software development. It protects your production data, makes testing safer, and allows different teams to work without stepping on each other’s toes. Use environment variables for secrets and configuration files for complex settings, and your code will be more flexible and secure.

References

Debug Dee switching between control panels
One if-statement to rule them all: if os.getenv(‘ENV’) == ‘production’. Config switching, done.

The Pattern: Env Var Says Which Config to Load

The most reliable pattern across frameworks: read a single environment variable (APP_ENV or PYTHON_ENV) at startup, branch on it to load the right config file. Everything else flows from that one decision:

# File: config/__init__.py
import os
from importlib import import_module

env = os.environ.get("APP_ENV", "development")
config = import_module(f"config.{env}").Config

# config/development.py
class Config:
    DEBUG = True
    DATABASE_URL = "sqlite:///dev.db"
    CACHE_TYPE = "SimpleCache"
    LOG_LEVEL = "DEBUG"

# config/testing.py
class Config:
    DEBUG = True
    TESTING = True
    DATABASE_URL = "sqlite:///:memory:"
    CACHE_TYPE = "NullCache"

# config/production.py
class Config:
    DEBUG = False
    DATABASE_URL = os.environ["DATABASE_URL"]  # require in prod
    CACHE_TYPE = "RedisCache"
    LOG_LEVEL = "WARNING"

The trick: development and testing have safe defaults inline, but production REQUIRES the env var. os.environ["DATABASE_URL"] raises KeyError at startup if missing — fail-fast is exactly what you want in production.

Pydantic Settings — The Modern Approach

For real applications, hand-rolled config classes get unwieldy fast. pydantic-settings (the modern replacement for pydantic.BaseSettings) gives you type-checked config with automatic env-var loading, validation, and .env file support:

# pip install pydantic-settings

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

    database_url: str
    secret_key: str = Field(min_length=32)
    debug: bool = False
    log_level: str = "INFO"
    redis_url: str | None = None

settings = Settings()
print(settings.database_url)

It loads from .env in dev, from real env vars in production, and the type annotations get validated at startup. secret_key with min_length=32 catches misconfigured staging where someone copy-pasted a short test value.

Secrets vs Config

Treat secrets (API keys, DB passwords, signing keys) differently from configuration (feature flags, timeouts, hostnames). Three rules:

  • Never commit secrets. Put them in .env (gitignored), in a secrets manager (AWS Secrets Manager, Vault, GCP Secret Manager), or in your platform’s encrypted-env-var feature.
  • Never log secrets. Add a __repr__ override that returns " for any field marked as a secret. Pydantic v2 supports SecretStr for exactly this.
  • Rotate periodically. Secrets that never change are secrets that have been leaked. Build rotation into your config-loading code (multiple valid keys at once, with a deprecation window).

Common Pitfalls

  • Default-to-production. os.environ.get("APP_ENV", "production") is the opposite of what you want. Default to development so a forgotten env var in CI doesn’t accidentally point at the prod database.
  • Config drift between environments. A new feature flag added in dev but not staging means staging tests pass, prod breaks. Use a single Settings class with the same fields across envs; only the values differ.
  • Config loaded at module import time. If your settings = Settings() runs at import, you can’t override env vars in tests without re-importing the module. Wrap config in a function (get_settings()) and cache it lazily.
  • Reading env vars throughout the codebase. Scattered os.environ.get(...) calls make config hard to audit. Centralize all env-var access in one Settings class; the rest of the code imports from there.
  • No validation. A typo in a numeric setting (e.g., PORT="abc") shouldn’t fail when the first request arrives — it should fail at startup. Pydantic Settings gives you this for free.

FAQ

Q: .env file or real environment variables?
A: Both. .env for local development (never committed), real env vars in production via your platform’s secrets management. pydantic-settings reads from both transparently.

Q: How do I share config between Python and other services in my stack?
A: Use a portable format — env vars, JSON, or YAML — not a Python module. Then each service loads from the shared source. Resist the temptation to import config across language boundaries.

Q: What about feature flags?
A: Same Settings class can hold feature flags as booleans. For dynamic flags that change without redeployment, use a flag service like LaunchDarkly or Unleash — they’re worth it once you have more than 5-10 flags.

Q: How do I test config-dependent code?
A: Inject the config object rather than importing it. Tests pass a custom Settings instance with the values they need. pytest fixtures make this easy with autouse=True overrides.

Q: 12-factor app — do I have to do all twelve?
A: Config-in-environment is factor 3 and the one that matters most. The other eleven are great guidelines, but most teams gain 80% of the benefit just by getting config and secrets out of code.

Wrapping Up

Environment-driven config is one of those infrastructure habits that compounds: small now, life-saving when you’re trying to debug a production outage at 2 AM. Start with the APP_ENV pattern, move to pydantic-settings when the codebase grows, and never let secrets touch your git history. The setup cost is an hour; the lifetime cost of getting it wrong is incalculable.

Related Articles
How To Work With CSV Files in Python Using the csv Module and Pandas

How To Work With CSV Files in Python Using the csv Module and Pandas

Last Updated: June 01, 2026

Beginner

Pubs - Python How To Program
Written by Pubs

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.

View all tutorials by Pubs →

How To Work With CSV Files in Python Using the csv Module and Pandas

Part of the Python Data Stack Hub. See the full hub for related Python tutorials.

Quick Example (TLDR)

Reading a CSV file with Python is simple. Here’s the fastest way using pandas:

# The quick way: pandas
import pandas as pd

# Read CSV file
df = pd.read_csv('data.csv')

# Access data
print(df.head())
print(df['column_name'].mean())

Output:

   name   age  salary
0  Alice   28   65000
1    Bob   34   72000
2  Carol   29   68000
3  David   45   95000
4   Eve   31   71000

Understanding CSV Files

CSV stands for “Comma-Separated Values.” It’s the simplest way to store tabular data: each row is a line, columns are separated by commas. Here’s what a CSV file looks like inside:

name,age,salary,department
Alice,28,65000,Engineering
Bob,34,72000,Sales
Carol,29,68000,Engineering
David,45,95000,Management

Reading CSV with the csv Module

Python’s built-in csv module is lightweight and doesn’t require external dependencies:

import csv

# Open and read CSV file
with open('employees.csv', 'r') as file:
    # csv.reader returns an iterator over rows
    csv_reader = csv.reader(file)
    
    # Get the header row
    headers = next(csv_reader)
    print("Headers:", headers)
    
    # Process each data row
    for row in csv_reader:
        print(f"Name: {row[0]}, Age: {row[1]}, Salary: {row[2]}")

Output:

Headers: ['name', 'age', 'salary', 'department']
Name: Alice, Age: 28, Salary: 65000
Name: Bob, Age: 34, Salary: 72000
Name: Carol, Age: 29, Salary: 68000

Using DictReader for Cleaner Code

DictReader automatically treats the first row as headers and returns dictionaries instead of lists:

import csv

# DictReader uses first row as keys
with open('employees.csv', 'r') as file:
    dict_reader = csv.DictReader(file)
    
    for row in dict_reader:
        # Access by column name instead of index
        print(f"{row['name']} earns $" + row['salary'] + " in " + row['department'])

Output:

Alice earns $65000 in Engineering
Bob earns $72000 in Sales
Carol earns $68000 in Engineering
David earns $95000 in Management

Writing CSV Files

Creating a CSV file is equally straightforward:

import csv

# Data to write
employees = [
    {'name': 'Alice', 'age': 28, 'salary': 65000},
    {'name': 'Bob', 'age': 34, 'salary': 72000},
    {'name': 'Carol', 'age': 29, 'salary': 68000},
]

# Write to CSV
with open('new_employees.csv', 'w', newline='') as file:
    fieldnames = ['name', 'age', 'salary']
    writer = csv.DictWriter(file, fieldnames=fieldnames)
    
    # Write header row
    writer.writeheader()
    
    # Write data rows
    writer.writerows(employees)

print("File written successfully!")

Output:

File written successfully!

Working with Pandas for Advanced Operations

Pandas makes it much easier to filter, transform, and analyze data:

import pandas as pd

# Read CSV into DataFrame
df = pd.read_csv('employees.csv')

# Basic info about the data
print(f"Total rows: {len(df)}")
print(f"Average salary: ${df['salary'].mean():.2f}")

# Filter data: salaries above 70000
high_earners = df[df['salary'] > 70000]
print("
High earners:")
print(high_earners)

# Group by department
print("
Average salary by department:")
print(df.groupby('department')['salary'].mean())

Output:

Total rows: 4
Average salary: $75000.00

High earners:
   name  age  salary department
1   Bob   34   72000     Sales
3 David   45   95000 Management

Average salary by department:
department
Engineering    66500.0
Management     95000.0
Sales          72000.0
Name: salary, dtype: float64

Handling Large CSV Files Efficiently

For massive files that don’t fit in memory, use chunking with pandas:

import pandas as pd

# Read large file in chunks
chunk_size = 10000

# Process file in batches
for chunk in pd.read_csv('huge_file.csv', chunksize=chunk_size):
    # Process each chunk
    print(f"Processing chunk with {len(chunk)} rows")
    
    # Do something with the chunk
    high_value = chunk[chunk['amount'] > 1000]
    print(f"Found {len(high_value)} high-value transactions")

Output:

Processing chunk with 10000 rows
Found 2345 high-value transactions
Processing chunk with 10000 rows
Found 2412 high-value transactions
Processing chunk with 5234 rows
Found 1123 high-value transactions

Real-Life Example: Cleaning and Merging Sales Reports

Here’s a practical example of reading, cleaning, and merging sales data from multiple CSV files:

import pandas as pd

# Read sales data from multiple sources
sales_q1 = pd.read_csv('sales_q1.csv')
sales_q2 = pd.read_csv('sales_q2.csv')

# Combine the datasets
all_sales = pd.concat([sales_q1, sales_q2], ignore_index=True)

# Data cleaning: remove duplicates
all_sales = all_sales.drop_duplicates(subset=['order_id'])
print(f"After removing duplicates: {len(all_sales)} records")

# Clean: remove rows with missing values
all_sales = all_sales.dropna(subset=['customer_id', 'amount'])
print(f"After removing null values: {len(all_sales)} records")

# Transform: add new column for commission (5% of amount)
all_sales['commission'] = all_sales['amount'] * 0.05

# Filter: only successful orders (status='completed')
completed_sales = all_sales[all_sales['status'] == 'completed']

# Analysis: sales by region
print("
Sales by region:")
region_totals = completed_sales.groupby('region')['amount'].sum()
print(region_totals)

# Sort by amount and show top 10
top_sales = completed_sales.nlargest(5, 'amount')
print("
Top 5 sales:")
print(top_sales[['order_id', 'customer_name', 'amount', 'region']])

# Save cleaned data
all_sales.to_csv('cleaned_sales.csv', index=False)
print("
Cleaned data saved to cleaned_sales.csv")

Output:

After removing duplicates: 1997 records
After removing null values: 1985 records

Sales by region:
region
North    45230.50
South    38920.75
East     52340.25
West     41230.00
Name: amount, dtype: float64

Top 5 sales:
  order_id customer_name  amount  region
5    ORD005   Acme Corp   8500.00   East
12   ORD012   TechStart   7200.50   North
18   ORD018   GlobalCo    6950.25   West
24   ORD024   InnovateLabs 6800.00   East
31   ORD031   CloudSys    6550.75   North

Cleaned data saved to cleaned_sales.csv

FAQ

Q: Should I use csv module or pandas?

A: Use csv for simple operations and to avoid dependencies. Use pandas when you need analysis, filtering, or complex transformations. Pandas makes data manipulation much easier and faster to code.

Q: How do I handle CSV files with different delimiters?

A: With csv module: csv.reader(file, delimiter=’;’) or with pandas: pd.read_csv(‘file.csv’, sep=’;’)

Q: What if my CSV has special characters or encoding issues?

A: Specify encoding: pd.read_csv(‘file.csv’, encoding=’utf-8′) or pd.read_csv(‘file.csv’, encoding=’latin-1′)

Q: Can I read CSV directly from a URL?

A: Yes! df = pd.read_csv(‘https://example.com/data.csv’) works directly with pandas.

Q: How do I export a pandas DataFrame to different formats?

A: DataFrame has methods for many formats: to_csv(), to_excel(), to_json(), to_html(), and more.

Conclusion

CSV files are everywhere in data work, and Python makes handling them simple. Start with the built-in csv module for basic needs, then graduate to pandas when you need real analysis power. The combination of these tools covers everything from simple data reading to complex transformations.

References

Loop Larry tangled in data ribbons
line.split(‘,’) works until a field contains a comma. Then you learn why csv.reader exists.
How To Use Python subprocess To Run Shell Commands Safely

How To Use Python subprocess To Run Shell Commands Safely

Last Updated: June 01, 2026

Intermediate

Pubs - Python How To Program
Written by Pubs

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.

View all tutorials by Pubs →

Introduction: Why You Need to Know subprocess

Whether you’re automating a deployment pipeline, running system diagnostics, managing file compression, or orchestrating complex DevOps workflows, you’ll eventually need to run shell commands from Python. The subprocess module is your safe passage to that world. It’s one of the most powerful—and most misused—tools in Python’s standard library. Many developers reach for quick fixes like os.system() or shell injection vulnerable approaches, not realizing that subprocess exists to solve exactly this problem with security, control, and elegance.

Here’s the great news: you don’t need to pip install anything. The subprocess module is part of Python’s standard library, available in every Python 3.x installation. This means you can use it immediately in any environment, whether it’s your laptop, a Docker container, or a cloud function. It’s been battle-tested for decades and is the recommended way to spawn child processes from Python code.

In this article, we’ll walk through everything you need to know—from your first simple command all the way to advanced patterns like piping, timeouts, and deployment automation. By the end, you’ll understand not just how to use subprocess, but when to use each function, how to handle errors gracefully, and how to avoid the security pitfalls that catch even experienced developers.

Quick Example: Your First subprocess Command

Let’s get something working right away so you can see how simple it is:

# quick_example.py
import subprocess

result = subprocess.run(['ls', '-la'], capture_output=True, text=True)
print(result.stdout)
print(f"Return code: {result.returncode}")

Output:

total 48
drwxr-xr-x  5 user staff   160 Mar 14 10:42 .
drwxr-xr-x 12 user staff   384 Mar 14 09:15 ..
-rw-r--r--  1 user staff  1234 Mar 14 10:40 quick_example.py
-rw-r--r--  1 user staff  5678 Mar 14 10:35 deployment.py
Return code: 0

That’s it. You’ve just executed a shell command from Python. The subprocess.run() function took a list of arguments (the command and its flags), captured the output as text, and returned a result object with the stdout and return code. A return code of 0 means success. If the command had failed, you’d see a non-zero value. In the sections below, we’ll explore what each parameter does, how to handle errors, and when you need more advanced tools like Popen.

What is subprocess and Why Use It?

The subprocess module is Python’s official way to spawn and manage child processes. It replaces older, less safe approaches like os.system() and os.popen(). When you call a subprocess function, Python creates a new process that runs independently, typically executing a shell command or external program. This is essential when you need to integrate with system utilities, run compiled binaries, execute scripts in other languages, or automate tasks that are more natural to express as shell commands than Python code.

Why not just use os.system()? Because os.system() invokes the shell directly, making your code vulnerable to shell injection attacks if user input isn’t carefully sanitized. subprocess lets you pass arguments as a list, bypassing the shell entirely by default. This means you can safely use untrusted input without worrying about command injection vulnerabilities.

Let’s compare the three main approaches:

Feature subprocess.run() Popen os.system()
Simple, one-shot commands ✓ Perfect Overkill Unsafe
Capture output ✓ Easy Manual pipes Workaround needed
Shell injection safe ✓ By default ✓ By default Only with shlex
Advanced features (pipes, streaming) Limited ✓ Full control N/A
Timeout support ✓ Built-in Manual logic No
Return code access ✓ Direct ✓ Direct ✓ Direct

For 90% of your use cases, subprocess.run() is exactly what you need. It’s simple, secure, and handles the common scenarios. Popen is for situations where you need more control—like streaming large outputs or keeping a process alive. And os.system()? You can almost always skip it.

Running Your First Command with subprocess.run()

The subprocess.run() function is your primary tool. It takes a command as a list, executes it, waits for it to complete, and returns a CompletedProcess object with information about the result. Let’s look at the anatomy of this function with a practical example.

The first argument to subprocess.run() is always a list of strings representing the command and its arguments. Never pass a single string—that’s a common mistake that leads to subtle bugs. Each element of the list is one token: the program name, then each flag, then each argument.

# run_commands.py
import subprocess

# Run a simple command that lists directory contents
result = subprocess.run(['echo', 'Hello from subprocess'], text=True, capture_output=True)
print("STDOUT:", result.stdout)
print("Return code:", result.returncode)

# Run a command that lists files
result2 = subprocess.run(['pwd'], text=True, capture_output=True)
print("\nCurrent directory:", result2.stdout.strip())

Output:

STDOUT: Hello from subprocess
Return code: 0

Current directory: /home/developer/project

Notice the text=True parameter—this tells subprocess to return stdout and stderr as strings instead of bytes. Without it, you’d get bytes objects that you’d need to decode manually. The capture_output=True parameter captures both stdout and stderr into the result object. These two parameters together create the most convenient API for most situations. The returncode attribute tells you whether the command succeeded (0) or failed (non-zero).

Capturing Command Output

Often you don’t just want to run a command—you want to see what it produced. The capture_output and text parameters work together to make this straightforward. When you set capture_output=True, subprocess collects the command’s output instead of letting it print to your console. The text=True parameter ensures that output comes back as a string you can work with easily.

Let’s capture the output of a real command and process it:

# capture_output.py
import subprocess

# Get the list of files and process the output
result = subprocess.run(['ls', '-l', '/tmp'], capture_output=True, text=True)

lines = result.stdout.split('\n')
print(f"Directory listing has {len(lines) - 1} entries")

# Print just the first few lines
for line in lines[:5]:
    if line.strip():
        print(line)

# Check if there were any errors
if result.stderr:
    print(f"Warnings: {result.stderr}")

Output:

Directory listing has 12 entries
total 48
drwxrwxrwt 12 root root 4096 Mar 14 10:42 .
drwxr-xr-x 15 root root 4096 Mar 14 09:15 ..
-rw-r--r--  1 user user 1024 Mar 14 09:10 temp_file.txt

Here we’ve captured the output from ls -l /tmp, split it into lines, and processed it as Python strings. Notice we also checked the stderr attribute—this contains any error messages the command produced. Many commands send warnings or non-critical messages to stderr while their main output goes to stdout. Separating them gives you the flexibility to handle each independently.

Handling Errors and Return Codes

Every command returns a status code that tells you whether it succeeded or failed. A return code of 0 means success; any non-zero value indicates an error. By default, subprocess.run() doesn’t raise an exception when a command fails—it just gives you the return code. This is actually a design feature because sometimes a non-zero exit code doesn’t mean failure in the user-facing sense. But usually, you want to know when something goes wrong.

Let’s see how to check return codes and decide what to do about failures:

# check_return_codes.py
import subprocess

# Try to run a command that will fail
result = subprocess.run(['grep', 'nonexistent_pattern', '/etc/hostname'],
                       capture_output=True, text=True)

print(f"Return code: {result.returncode}")
print(f"STDOUT: {result.stdout}")
print(f"STDERR: {result.stderr}")

# Manual error handling
if result.returncode != 0:
    print(f"Command failed with exit code {result.returncode}")

# For grep, non-zero means "pattern not found" which is normal
if result.returncode == 0:
    print(f"Pattern found: {result.stdout}")
else:
    print("Pattern not found (exit code 1 is normal for grep)")

Output:

Return code: 1
STDOUT:
STDERR:
Command failed with exit code 1
Pattern not found (exit code 1 is normal for grep)

This example shows that grep returns 1 when the pattern isn’t found. Your code needs to understand what each return code means for the specific command you’re running. For some commands (like grep), non-zero codes aren’t really errors. For others, they indicate genuine problems. The next section shows a more automatic way to handle this.

Using check=True for Automatic Error Handling

Most of the time, if a command fails (returns non-zero), you want your Python script to stop immediately rather than continue with bad data. The check=True parameter makes this automatic. When you set check=True, subprocess will raise a CalledProcessError exception if the command returns a non-zero exit code. This lets you use Python’s normal exception handling:

# error_handling.py
import subprocess

try:
    # This command will fail
    result = subprocess.run(['false'], check=True, text=True, capture_output=True)
except subprocess.CalledProcessError as error:
    print(f"Command failed with exit code {error.returncode}")
    print(f"Command was: {error.cmd}")

try:
    # This command succeeds
    result = subprocess.run(['echo', 'Success!'], check=True, text=True, capture_output=True)
    print(f"Output: {result.stdout.strip()}")
except subprocess.CalledProcessError as error:
    print(f"Unexpected failure: {error}")

Output:

Command failed with exit code 1
Command was: ['false']
Output: Success!

The check=True parameter transforms subprocess.run() into a fail-fast tool. If anything goes wrong, an exception is raised immediately, stopping your script. This is the safer default for most scripts. You can wrap it in a try-except block when you expect certain commands might fail and you want to handle that gracefully. This is much cleaner than manually checking result.returncode every single time.

Understanding Shell Mode and Security

By default, subprocess does not invoke the shell—it passes your command directly to the operating system. This is a security feature. However, some tasks genuinely need shell features like wildcards, environment variable expansion, or pipes. You can enable the shell with shell=True, but you must understand the security implications. If you ever use shell=True with user input, you’re vulnerable to shell injection attacks.

Let’s see the difference between shell and non-shell mode:

# shell_vs_noshell.py
import subprocess

# Without shell=True, the wildcard is literal
try:
    result = subprocess.run(['echo', '*.py'], capture_output=True, text=True)
    print("Without shell:")
    print(result.stdout)
except Exception as e:
    print(f"Error: {e}")

# With shell=True, the wildcard is expanded by the shell
result = subprocess.run('echo *.py', shell=True, capture_output=True, text=True)
print("\nWith shell=True:")
print(result.stdout)

Output:

Without shell:
*.py

With shell=True:
script.py utils.py main.py config.py

Notice the difference: without the shell, the wildcard is treated as a literal string. With the shell, the shell expands the wildcard to actual filenames. Now let’s see why this matters for security:

# shell_injection.py
import subprocess

# UNSAFE: Never do this with untrusted input
user_input = "test.txt; rm -rf /"
dangerous_command = f"echo {user_input}"
# If we ran this with shell=True, it would try to delete everything!
print(f"Dangerous command would be: {dangerous_command}")

# SAFE: Always use list form without shell=True when user input is involved
safe_result = subprocess.run(['echo', user_input], capture_output=True, text=True)
print(f"Safe output: {safe_result.stdout}")

Output:

Dangerous command would be: echo test.txt; rm -rf /
Safe output: test.txt; rm -rf /

When you use the list form without shell=True, the user input is treated as a literal argument to the command. The shell never sees the semicolon as a command separator. This is the safe way. Use shell=True only when you need shell features and all the input comes from you, not from users.

Developer standing behind a glowing energy shield deflecting attacks in an industrial factory setting
shell=False is your force field. Drop it and you’re dodging injection attacks bare-handed.

Working with Popen for Advanced Control

The subprocess.run() function is convenient, but sometimes you need more control. Maybe you want to keep a process running and interact with it in real-time, or you need to handle stdout and stderr separately in advanced ways. This is where Popen comes in. Popen (short for “pipe open”) is the underlying class that run() actually uses. When you call subprocess.run(), Python creates a Popen object, waits for it to finish, and returns the result.

Here’s how to use Popen directly for situations where you need that extra control:

# popen_example.py
import subprocess

# Create a Popen object without waiting
process = subprocess.Popen(
    ['ping', '-c', '4', 'google.com'],
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    text=True
)

print(f"Process started with PID: {process.pid}")

# Do other work while the process runs...
print("Doing other work while ping runs...")

# Wait for the process to finish
stdout_data, stderr_data = process.communicate()

print(f"Process finished with return code: {process.returncode}")
print(f"\nPing results (first 200 chars):\n{stdout_data[:200]}")

Output:

Process started with PID: 24531
Doing other work while ping runs...
Process finished with return code: 0

Ping results (first 200 chars):
PING google.com (172.217.16.46): 56 data bytes
64 bytes from 172.217.16.46: icmp_seq=0 ttl=119 time=25.432 ms
64 bytes from 172.217.16.46: icmp_seq=1 ttl=119 time=24.891 ms

The Popen constructor returns immediately without waiting for the process to finish. You can check the process’s status, read output as it becomes available, or even send input to the process. The communicate() method waits for the process to finish and returns both stdout and stderr. You can also use poll() to check if the process has finished without blocking, or wait() to block until it finishes. Popen gives you the raw power when subprocess.run() doesn’t provide enough flexibility.

Piping Commands Together

In the shell, you often pipe one command’s output to another command’s input using the pipe operator (|). Subprocess makes this possible too. You can connect the stdout of one process to the stdin of another, letting you build sophisticated command chains directly from Python. This is useful when you want the efficiency and power of composing Unix utilities without shelling out to a shell script.

Let’s pipe two commands together:

# piping_commands.py
import subprocess

# Pipe 'ls -la' output to 'grep' to find Python files
# This is equivalent to: ls -la | grep .py
list_process = subprocess.Popen(
    ['ls', '-la'],
    stdout=subprocess.PIPE,
    text=True
)

grep_process = subprocess.Popen(
    ['grep', '.py'],
    stdin=list_process.stdout,
    stdout=subprocess.PIPE,
    text=True
)

# Close list_process.stdout to signal EOF to grep
list_process.stdout.close()

# Get the piped output
output, _ = grep_process.communicate()
print("Python files found:")
print(output)

Output:

Python files found:
-rw-r--r--  1 user staff  2048 Mar 14 10:15 main.py
-rw-r--r--  1 user staff  1234 Mar 14 09:42 utils.py
-rw-r--r--  1 user staff   890 Mar 14 08:30 config.py

This example chains two commands: ls -la feeds its output to grep .py, which filters for lines containing “.py”. Notice that we must close list_process.stdout after connecting it—this signals EOF (end of file) to the grep process so it knows there’s no more input coming. Without this, grep would wait forever. Piping is powerful when you need to compose multiple command-line tools, though for complex logic, you might consider doing the filtering in Python itself for clarity.

Setting Timeouts, Environment Variables, and Working Directories

Real-world scripts often need to control how long a command runs, set environment variables that the command needs, or run commands in specific directories. The subprocess module provides parameters for all of these. The timeout parameter lets you specify a maximum time (in seconds) that a command can run before being killed. The env parameter lets you pass a dictionary of environment variables. The cwd parameter sets the working directory for the command.

Here’s a practical example using all three:

# advanced_control.py
import subprocess
import os

# Set custom environment variables
custom_env = os.environ.copy()
custom_env['MY_VAR'] = 'hello from python'
custom_env['DEBUG'] = '1'

try:
    # Run a command with a timeout, in a specific directory, with custom env
    result = subprocess.run(
        ['bash', '-c', 'echo "MY_VAR is: $MY_VAR"; pwd; sleep 1'],
        cwd='/tmp',
        env=custom_env,
        timeout=5,
        capture_output=True,
        text=True,
        check=True
    )
    print("Command succeeded:")
    print(result.stdout)
except subprocess.TimeoutExpired:
    print("Command took too long and was killed")
except subprocess.CalledProcessError as e:
    print(f"Command failed: {e}")

# Example with a timeout that expires
try:
    subprocess.run(['sleep', '10'], timeout=2, check=True)
except subprocess.TimeoutExpired:
    print("\nThis command timed out (as expected - we told it to sleep 10 seconds with a 2 second timeout)")

Output:

Command succeeded:
MY_VAR is: hello from python
/tmp

This command timed out (as expected - we told it to sleep 10 seconds with a 2 second timeout)

Notice that we copy os.environ and then modify the copy—this preserves all the existing environment variables while adding our custom ones. Without this, the command wouldn’t have access to essential variables like PATH, which would prevent most commands from running. The timeout parameter is especially important for preventing your script from hanging if a command gets stuck. The cwd parameter is useful when you need to run commands in directories without using cd first.

Energetic character smashing a giant clock with a hammer in an industrial factory with gears exploding
When your subprocess decides to take an extended vacation, timeout brings the hammer down.

Real-Life Example: Automated Deployment Script

Let’s bring everything together with a practical, real-world example: a deployment automation script. Imagine you’re deploying a Python web application to production. You need to pull the latest code, install dependencies, run tests, and restart the service—all safely and with proper error handling. Here’s how you’d do it with subprocess:

# deployment_script.py
import subprocess
import os
import sys
from pathlib import Path

class DeploymentManager:
    def __init__(self, project_dir, timeout=300):
        self.project_dir = Path(project_dir)
        self.timeout = timeout
        self.deploy_log = []

    def log_output(self, stage, output):
        """Store deployment output for later review"""
        self.deploy_log.append(f"[{stage}] {output}")
        print(f"[{stage}] {output}")

    def run_command(self, command, stage_name):
        """Run a command and handle errors gracefully"""
        try:
            self.log_output(stage_name, f"Running: {' '.join(command)}")
            result = subprocess.run(
                command,
                cwd=self.project_dir,
                capture_output=True,
                text=True,
                timeout=self.timeout,
                check=True
            )
            self.log_output(stage_name, f"Success. Output: {result.stdout[:100]}")
            return True
        except subprocess.TimeoutExpired:
            self.log_output(stage_name, "ERROR: Command timed out")
            return False
        except subprocess.CalledProcessError as e:
            self.log_output(stage_name, f"ERROR: {e.stderr}")
            return False

    def deploy(self):
        """Execute the full deployment pipeline"""
        print(f"Starting deployment from {self.project_dir}...\n")

        # Stage 1: Pull latest code
        if not self.run_command(['git', 'pull', 'origin', 'main'], 'GIT PULL'):
            print("Deployment failed: Could not pull code")
            return False

        # Stage 2: Install dependencies
        if not self.run_command(['pip', 'install', '-r', 'requirements.txt'], 'PIP INSTALL'):
            print("Deployment failed: Could not install dependencies")
            return False

        # Stage 3: Run tests
        if not self.run_command(['pytest', 'tests/', '-v'], 'PYTEST'):
            print("Deployment failed: Tests did not pass")
            return False

        # Stage 4: Check static files
        if not self.run_command(['python', 'manage.py', 'collectstatic', '--noinput'], 'STATIC FILES'):
            print("Deployment warning: Static files collection had issues")
            # Don't fail deployment for this

        # Stage 5: Restart the service
        if not self.run_command(['sudo', 'systemctl', 'restart', 'myapp'], 'SERVICE RESTART'):
            print("Deployment failed: Could not restart service")
            return False

        print("\nDeployment completed successfully!")
        return True

# Usage
if __name__ == '__main__':
    deployer = DeploymentManager('/var/www/myapp')
    success = deployer.deploy()

    # Save deployment log
    log_file = Path('/var/log/deployment.log')
    log_file.write_text('\n'.join(deployer.deploy_log))

    sys.exit(0 if success else 1)

Sample Output:

Starting deployment from /var/www/myapp...

[GIT PULL] Running: git pull origin main
[GIT PULL] Success. Output: Already up to date.
[PIP INSTALL] Running: pip install -r requirements.txt
[PIP INSTALL] Success. Output: Collecting django==4.2
...
[PYTEST] Running: pytest tests/ -v
[PYTEST] Success. Output: test_user_login PASSED
test_api_endpoints PASSED
...
[SERVICE RESTART] Running: sudo systemctl restart myapp
[SERVICE RESTART] Success. Output:

Deployment completed successfully!

This example demonstrates several real-world patterns: running multiple sequential commands with proper error handling, logging output for auditing, using different working directories, capturing output for reporting, and failing fast when critical steps fail while allowing non-critical steps to have warnings. The check=True parameter makes sure any command failure is caught immediately, preventing partial deployments. The timeout ensures that if a command hangs, the deployment doesn’t wait forever.

Frequently Asked Questions

Why does my output look like b’…’ instead of normal text?

You’re getting bytes instead of strings, which happens when you don’t set text=True. By default, subprocess returns bytes because not all command output is valid text. Set text=True to tell subprocess to decode the bytes as UTF-8 strings automatically. If you need a different encoding, use encoding='latin-1' (or whatever encoding applies). This is the most common gotcha when starting with subprocess.

Can I send input to a process interactively?

Yes, using Popen. Set stdin=subprocess.PIPE, then write to process.stdin. However, if you need true interactive control (like responding to prompts), consider using the pexpect library instead, which handles terminal interaction better. For simple stdin/stdout piping, subprocess works fine. Be careful with buffering—you may need to flush process.stdin after writing.

How do I run a command in a different directory?

Use the cwd parameter: subprocess.run(['make', 'build'], cwd='/home/user/project'). This changes the working directory for that specific command without affecting your Python script’s working directory. This is safer than using os.chdir() because it doesn’t change the global state.

Why can’t my Python script find the command I’m trying to run?

Either the command isn’t in your PATH, or you need to use the full path. On Linux/Mac, try which command_name to find where it’s installed. On Windows, try where command_name. If the command exists but isn’t in PATH, use the full path: subprocess.run(['/usr/local/bin/mycommand']). If you inherit environment variables from os.environ (the default), PATH should be set correctly, but if you provide a custom env dictionary, make sure it includes PATH.

What if a command produces huge amounts of output?

Using capture_output=True stores everything in memory, which can be problematic for very large outputs. Instead, use Popen and stream the output. You can read from process.stdout in chunks or use process.communicate() with a generator pattern. For very large files, consider writing output to a file directly instead of capturing it in Python: subprocess.run(['bigcommand'], stdout=open('/tmp/output.txt', 'w')).

Does subprocess work on Windows?

Yes, but Windows has some differences. Command lists work the same way, but the commands themselves might be different. For example, use dir instead of ls, and type instead of cat. On Windows, you can also pass a string to subprocess.run() without setting shell=True if you want—it’s less safe, but Windows handles it differently than Unix shells. For cross-platform scripts, use libraries like pathlib and tools like shutil instead of shelling out when possible.

Conclusion: Master subprocess and Automate Safely

The subprocess module is one of Python’s most important tools for interacting with the system. Whether you’re deploying applications, automating routine tasks, or orchestrating complex workflows, subprocess gives you a safe, reliable way to run shell commands from Python code. The key principles are straightforward: use subprocess.run() for simple commands, pass arguments as a list to avoid shell injection, set check=True for fail-fast behavior, use capture_output=True and text=True for convenient output handling, and reach for Popen only when you need advanced control.

The security lessons matter deeply. Never use shell=True with untrusted input. Always separate your command and arguments into a list rather than constructing a command string. Remember that subprocess isn’t just about convenience—it’s about building reliable, secure systems that can interact with external tools without introducing vulnerabilities. The patterns we’ve covered here—timeouts, error handling, logging, and environment control—are the building blocks of production-grade automation.

As you build more complex scripts, you’ll discover edge cases and needs that require diving deeper into the documentation. The official Python documentation at docs.python.org/3/library/subprocess.html is comprehensive and authoritative. Start with the patterns in this article, experiment with your own scripts, and refer back to the docs as you encounter new challenges. With subprocess in your toolkit, you’re equipped to automate almost anything your system can do.

Senior developer standing confidently with arms crossed in front of a smooth-running industrial pipeline with green energy orbs
subprocess.run() — because sometimes Python needs to phone a friend and actually get a clean answer back.
How To Use Argv and Argc Command Line Parameters in Python

How To Use Argv and Argc Command Line Parameters in Python

Last Updated: June 01, 2026

Beginner

You have written a Python script that works perfectly when you run it with hardcoded values. But now you need to make it flexible — different filenames, different options, different modes — and you realize you cannot keep editing the source code every time. This is exactly the problem command line arguments solve, and every professional Python script uses them.

The good news is that Python gives you two built-in tools for handling command line arguments, and neither one requires installing anything extra. sys.argv gives you raw access to whatever the user typed after the script name, while argparse from the standard library builds a complete command line interface with help text, type validation, and error messages — all automatically.

In this tutorial, we will start with a quick working example, then cover how sys.argv works under the hood, build up to argparse for real-world CLI tools, and finish with a complete project that ties everything together. By the end, you will be able to turn any Python script into a proper command line tool that other people (and future you) can actually use without reading the source code.

Pubs - Python How To Program
Written by Pubs

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.

View all tutorials by Pubs →

Command Line Arguments in Python: Quick Example

Part of the Python CLI Tools Hub. See the full hub for related Python tutorials.

Here is the fastest way to accept a command line argument in Python. Create this file and run it from your terminal:

# greet.py
import sys

if len(sys.argv) > 1:
    name = sys.argv[1]
else:
    name = "World"

print(f"Hello, {name}!")

Output:

$ python greet.py Alice
Hello, Alice!

$ python greet.py
Hello, World!

sys.argv is a list where the first element (sys.argv[0]) is always the script name, and everything after that is what the user typed. We check if there is at least one extra argument with len(sys.argv) > 1, and if so, we use it as the name. If the user does not provide a name, we fall back to a default.

This works fine for simple scripts, but what if you need multiple arguments, optional flags, type checking, and help text? That is where argparse comes in — keep reading to see how it handles all of that automatically.

Character catching colorful orbs from a conveyor belt into numbered slots representing sys.argv
sys.argv[0] is always the script name. sys.argv[1] is where the fun begins.

What Are Command Line Arguments and Why Use Them?

Command line arguments are the extra words you type after a program name when you run it from a terminal. When you type python greet.py Alice, the string "Alice" is a command line argument. The operating system captures everything you typed, splits it by spaces, and hands the pieces to your program as a list of strings.

Think of it like ordering food at a restaurant. The script name is choosing the restaurant (you always need it), and the arguments are your specific order — what dish, how spicy, with or without sides. Without arguments, every customer gets the same default meal.

Here is how the two main approaches compare:

Featuresys.argvargparse
Importimport sysimport argparse
Type conversionManual (everything is a string)Automatic (type=int)
Help textYou write it yourselfGenerated automatically
Error handlingManual checks and messagesAutomatic with usage hints
Optional flagsParse them yourselfBuilt-in (--verbose, -v)
Best forQuick one-off scriptsTools others will use

The rule of thumb is simple: use sys.argv when you are writing a script just for yourself and need one or two quick inputs. Switch to argparse the moment anyone else will run your script, or when you need more than two arguments. Let us start with sys.argv to understand the fundamentals.

How sys.argv Works in Python

sys.argv is a plain Python list that gets populated automatically when your script starts. Every element is a string, regardless of what the user typed. The first element is always the script name (or an empty string in interactive mode), and the rest are the arguments in the order they were typed.

# inspect_args.py
import sys

print(f"Script name: {sys.argv[0]}")
print(f"Number of arguments: {len(sys.argv) - 1}")
print(f"All arguments: {sys.argv[1:]}")

for i, arg in enumerate(sys.argv):
    print(f"  sys.argv[{i}] = {arg!r}")

Output:

$ python inspect_args.py hello 42 --verbose
Script name: inspect_args.py
Number of arguments: 3
All arguments: ['hello', '42', '--verbose']
  sys.argv[0] = 'inspect_args.py'
  sys.argv[1] = 'hello'
  sys.argv[2] = '42'
  sys.argv[3] = '--verbose'

Notice that 42 shows up as the string '42', not the integer 42. This is the most common gotcha with sys.argv — you must convert types yourself. If you try sys.argv[2] + 10 you will get a TypeError because Python will not automatically convert a string to a number.

Converting Argument Types Safely

Since every argument arrives as a string, you need to convert numbers, booleans, and other types explicitly. Always wrap conversions in a try/except block so your script does not crash with an ugly traceback when someone types the wrong thing:

# add_numbers.py
import sys

if len(sys.argv) != 3:
    print("Usage: python add_numbers.py <num1> <num2>")
    sys.exit(1)

try:
    num1 = float(sys.argv[1])
    num2 = float(sys.argv[2])
except ValueError:
    print("Error: Both arguments must be numbers.")
    sys.exit(1)

result = num1 + num2
print(f"{num1} + {num2} = {result}")

Output:

$ python add_numbers.py 3.5 2.1
3.5 + 2.1 = 5.6

$ python add_numbers.py three two
Error: Both arguments must be numbers.

$ python add_numbers.py 5
Usage: python add_numbers.py <num1> <num2>

The pattern here is important: check the argument count first, then try to convert types, and give the user a clear error message if anything goes wrong. sys.exit(1) tells the operating system that the script failed, which matters when your script is part of a larger automation pipeline.

Industrial stamping machine converting shapes representing string type conversion
Everything in sys.argv is a string. Type conversion is your job.

Getting Started with argparse

Once your script needs more than two arguments, or anyone besides you will run it, sys.argv becomes painful to maintain. You end up writing custom validation, usage messages, and flag parsing that argparse handles automatically. It is part of Python’s standard library, so there is nothing to install.

# greeter_v2.py
import argparse

parser = argparse.ArgumentParser(
    description="Greet someone with a customizable message."
)
parser.add_argument("name", help="The name of the person to greet")
parser.add_argument(
    "--greeting",
    default="Hello",
    help="The greeting to use (default: Hello)"
)
parser.add_argument(
    "--shout",
    action="store_true",
    help="Print the greeting in uppercase"
)

args = parser.parse_args()
message = f"{args.greeting}, {args.name}!"

if args.shout:
    message = message.upper()

print(message)

Output:

$ python greeter_v2.py Alice
Hello, Alice!

$ python greeter_v2.py Bob --greeting "Good morning" --shout
GOOD MORNING, BOB!

$ python greeter_v2.py --help
usage: greeter_v2.py [-h] [--greeting GREETING] [--shout] name

Greet someone with a customizable message.

positional arguments:
  name                 The name of the person to greet

options:
  -h, --help           show this help message and exit
  --greeting GREETING  The greeting to use (default: Hello)
  --shout              Print the greeting in uppercase

With just a few lines of setup, you get automatic help text (--help), clear error messages for missing arguments, and a clean namespace object (args) instead of raw string parsing. The add_argument method handles positional arguments (required, no dashes), optional arguments (prefixed with --), and boolean flags (action="store_true").

Specifying Argument Types and Defaults

argparse can validate types automatically. Instead of manually wrapping everything in try/except, you tell the parser what type each argument should be and it handles the conversion and error messaging for you:

# power_calc.py
import argparse

parser = argparse.ArgumentParser(
    description="Calculate base raised to a power."
)
parser.add_argument("base", type=float, help="The base number")
parser.add_argument("exponent", type=int, help="The exponent (integer)")
parser.add_argument(
    "--precision",
    type=int,
    default=2,
    help="Decimal places in output (default: 2)"
)

args = parser.parse_args()
result = args.base ** args.exponent
print(f"{args.base} ^ {args.exponent} = {result:.{args.precision}f}")

Output:

$ python power_calc.py 2.5 3
2.5 ^ 3 = 15.62

$ python power_calc.py 2.5 3 --precision 5
2.5 ^ 3 = 15.62500

$ python power_calc.py abc 3
usage: power_calc.py [-h] [--precision PRECISION] base exponent
power_calc.py: error: argument base: invalid float value: 'abc'

When you set type=float, argparse converts the string automatically and prints a helpful error if the conversion fails. You never have to write try/except for type validation again. The default parameter sets what value to use when the user does not provide an optional argument.

Limiting Choices and Adding Constraints

Sometimes you want the user to pick from a fixed set of options. The choices parameter restricts what values are accepted, and nargs controls how many values an argument takes:

# file_converter.py
import argparse

parser = argparse.ArgumentParser(
    description="Convert files between formats."
)
parser.add_argument(
    "files",
    nargs="+",
    help="One or more input files to convert"
)
parser.add_argument(
    "--format",
    choices=["csv", "json", "xml"],
    default="json",
    help="Output format (default: json)"
)
parser.add_argument(
    "-v", "--verbose",
    action="count",
    default=0,
    help="Increase output verbosity (-v, -vv, -vvv)"
)

args = parser.parse_args()
print(f"Converting {len(args.files)} file(s) to {args.format}")
print(f"Verbosity level: {args.verbose}")
for filename in args.files:
    print(f"  Processing: {filename}")

Output:

$ python file_converter.py report.txt data.txt --format csv -vv
Converting 2 file(s) to csv
Verbosity level: 2
  Processing: report.txt
  Processing: data.txt

$ python file_converter.py report.txt --format yaml
usage: file_converter.py [-h] [--format {csv,json,xml}] [-v] files [files ...]
file_converter.py: error: argument --format: invalid choice: 'yaml' (choose from 'csv', 'json', 'xml')

The nargs="+" means “one or more values” — the user can pass multiple filenames and they all get collected into a list. The choices parameter rejects anything not in the list. And action="count" lets users stack flags (-v, -vv, -vvv) for different verbosity levels, which is a common CLI pattern.

Character at an elegant control panel with organized switches and dials representing argparse
argparse turns your script into a proper control panel. –help is the user manual.

Building CLI Tools with Subcommands

Professional CLI tools like git, pip, and docker use subcommands — git commit, pip install, docker build. Each subcommand has its own set of arguments. argparse supports this pattern natively with subparsers:

# notes_cli.py
import argparse
import json
from pathlib import Path

NOTES_FILE = Path("notes.json")


def load_notes():
    if NOTES_FILE.exists():
        return json.loads(NOTES_FILE.read_text())
    return []


def save_notes(notes):
    NOTES_FILE.write_text(json.dumps(notes, indent=2))


def cmd_add(args):
    notes = load_notes()
    note = {"title": args.title, "body": args.body, "tag": args.tag}
    notes.append(note)
    save_notes(notes)
    print(f"Added note: {args.title}")


def cmd_list(args):
    notes = load_notes()
    if args.tag:
        notes = [n for n in notes if n.get("tag") == args.tag]
    if not notes:
        print("No notes found.")
        return
    for i, note in enumerate(notes, 1):
        tag_str = f" [{note['tag']}]" if note.get("tag") else ""
        print(f"{i}. {note['title']}{tag_str}")


def cmd_search(args):
    notes = load_notes()
    query = args.query.lower()
    matches = [
        n for n in notes
        if query in n["title"].lower() or query in n["body"].lower()
    ]
    print(f"Found {len(matches)} note(s) matching '{args.query}':")
    for note in matches:
        print(f"  - {note['title']}")


# Build the argument parser
parser = argparse.ArgumentParser(
    description="A simple command-line notes manager."
)
subparsers = parser.add_subparsers(dest="command", required=True)

# 'add' subcommand
add_parser = subparsers.add_parser("add", help="Add a new note")
add_parser.add_argument("title", help="Note title")
add_parser.add_argument("body", help="Note body text")
add_parser.add_argument("--tag", default="", help="Optional tag")

# 'list' subcommand
list_parser = subparsers.add_parser("list", help="List all notes")
list_parser.add_argument("--tag", help="Filter notes by tag")

# 'search' subcommand
search_parser = subparsers.add_parser("search", help="Search notes")
search_parser.add_argument("query", help="Search term")

args = parser.parse_args()

# Dispatch to the right function
commands = {"add": cmd_add, "list": cmd_list, "search": cmd_search}
commands[args.command](args)

Output:

$ python notes_cli.py add "Buy groceries" "Milk, eggs, bread" --tag shopping
Added note: Buy groceries

$ python notes_cli.py add "Fix login bug" "Users getting 403 on /dashboard" --tag work
Added note: Fix login bug

$ python notes_cli.py list
1. Buy groceries [shopping]
2. Fix login bug [work]

$ python notes_cli.py list --tag work
1. Fix login bug [work]

$ python notes_cli.py search groceries
Found 1 note(s) matching 'groceries':
  - Buy groceries

$ python notes_cli.py --help
usage: notes_cli.py [-h] {add,list,search} ...

A simple command-line notes manager.

positional arguments:
  {add,list,search}
    add              Add a new note
    list             List all notes
    search           Search notes

Each subcommand gets its own parser with its own arguments, and the dest="command" tells argparse to store which subcommand was chosen. The dispatch dictionary at the bottom routes to the right function. This is the same pattern that tools like pip and docker use internally.

Glowing crossroads with branching colorful pathways representing CLI subcommands
One entry point, multiple subcommands. The CLI equivalent of a Swiss Army knife.

When to Use sys.argv vs argparse

Now that you have seen both approaches, here is a practical decision guide. The answer depends on who is running your script and how many arguments it needs:

Use sys.argv when: You are writing a quick personal script with 1-2 inputs, you want zero setup overhead, or you are doing something temporary like a one-time data migration script. It is also fine for scripts embedded in larger systems where the calling code always passes the right arguments.

Use argparse when: Anyone else will run your script, you need more than 2 arguments, you want --help to work automatically, you need type validation or choices, or your tool has subcommands. Once you have the pattern memorized, argparse adds maybe 10 extra lines of setup and saves you hours of debugging wrong inputs.

# decision_example.py
import sys
import argparse

# Quick sys.argv approach — fine for personal scripts
def quick_approach():
    """Simple: just grab the argument or use a default."""
    filename = sys.argv[1] if len(sys.argv) > 1 else "data.txt"
    print(f"Processing: {filename}")

# argparse approach — better for shared tools
def robust_approach():
    """Robust: automatic help, type checking, error messages."""
    parser = argparse.ArgumentParser(
        description="Process a data file with options."
    )
    parser.add_argument("filename", help="Path to the data file")
    parser.add_argument(
        "--limit", type=int, default=100,
        help="Maximum rows to process (default: 100)"
    )
    args = parser.parse_args()
    print(f"Processing: {args.filename} (limit: {args.limit} rows)")

# Uncomment the one you want to test:
# quick_approach()
robust_approach()

Output:

$ python decision_example.py data.csv --limit 50
Processing: data.csv (limit: 50 rows)

Both approaches work. The difference is what happens when something goes wrong — argparse gives the user a clear path forward, while raw sys.argv leaves them guessing.

Real-Life Example: Building a File Organizer CLI

Character rapidly sorting folders into filing cabinets at lightning speed
Ten lines of argparse config replace a hundred lines of manual string parsing.

Let us build a practical tool that organizes files in a directory by their extension. This combines everything we have covered — positional arguments, optional flags, type validation, and real file system operations:

# organize_files.py
import argparse
import shutil
from pathlib import Path
from collections import defaultdict

# Map extensions to category folder names
CATEGORIES = {
    ".jpg": "Images", ".jpeg": "Images", ".png": "Images",
    ".gif": "Images", ".svg": "Images", ".webp": "Images",
    ".pdf": "Documents", ".doc": "Documents", ".docx": "Documents",
    ".txt": "Documents", ".xlsx": "Documents", ".csv": "Documents",
    ".py": "Code", ".js": "Code", ".html": "Code", ".css": "Code",
    ".zip": "Archives", ".tar": "Archives", ".gz": "Archives",
    ".mp4": "Videos", ".mov": "Videos", ".avi": "Videos",
    ".mp3": "Music", ".wav": "Music", ".flac": "Music",
}


def organize(directory, dry_run=False, verbose=False):
    """Move files into category subfolders based on extension."""
    source = Path(directory)
    if not source.is_dir():
        print(f"Error: '{directory}' is not a valid directory.")
        return

    moved = defaultdict(list)

    for filepath in source.iterdir():
        if filepath.is_file():
            ext = filepath.suffix.lower()
            category = CATEGORIES.get(ext, "Other")
            target_dir = source / category

            if dry_run:
                print(f"  [DRY RUN] {filepath.name} -> {category}/")
                moved[category].append(filepath.name)
            else:
                target_dir.mkdir(exist_ok=True)
                destination = target_dir / filepath.name
                shutil.move(str(filepath), str(destination))
                moved[category].append(filepath.name)
                if verbose:
                    print(f"  Moved {filepath.name} -> {category}/")

    # Print summary
    total = sum(len(files) for files in moved.values())
    print(f"\n{'[DRY RUN] ' if dry_run else ''}Organized {total} files:")
    for category, files in sorted(moved.items()):
        print(f"  {category}: {len(files)} file(s)")


parser = argparse.ArgumentParser(
    description="Organize files in a directory by type."
)
parser.add_argument(
    "directory",
    help="Path to the directory to organize"
)
parser.add_argument(
    "--dry-run",
    action="store_true",
    help="Show what would happen without moving files"
)
parser.add_argument(
    "-v", "--verbose",
    action="store_true",
    help="Print each file as it is moved"
)

args = parser.parse_args()
organize(args.directory, dry_run=args.dry_run, verbose=args.verbose)

Output:

$ python organize_files.py ~/Downloads --dry-run
  [DRY RUN] report.pdf -> Documents/
  [DRY RUN] photo.jpg -> Images/
  [DRY RUN] script.py -> Code/
  [DRY RUN] data.csv -> Documents/
  [DRY RUN] archive.zip -> Archives/

[DRY RUN] Organized 5 files:
  Archives: 1 file(s)
  Code: 1 file(s)
  Documents: 2 file(s)
  Images: 1 file(s)

$ python organize_files.py ~/Downloads -v
  Moved report.pdf -> Documents/
  Moved photo.jpg -> Images/
  Moved script.py -> Code/
  Moved data.csv -> Documents/
  Moved archive.zip -> Archives/

Organized 5 files:
  Archives: 1 file(s)
  Code: 1 file(s)
  Documents: 2 file(s)
  Images: 1 file(s)

This tool uses argparse for clean argument handling, pathlib for cross-platform file paths, and a dictionary-based category system that is easy to extend. The --dry-run flag is especially important — it lets the user preview what will happen before any files actually move. You can extend this by adding a --category flag to organize only specific types, or a --recursive flag to handle nested folders.

Frequently Asked Questions

What is the difference between argv and argc in Python?

In C, argv is the array of argument strings and argc is the count of arguments. Python combines both into sys.argv — it is a list, so you get the count with len(sys.argv). There is no separate argc variable in Python because lists already know their own length.

What does sys.argv[0] contain?

sys.argv[0] is always the script name or path, depending on how you ran it. If you run python myscript.py, it will be 'myscript.py'. If you run python /home/user/myscript.py, it will be '/home/user/myscript.py'. In an interactive Python session or with -c, it will be an empty string or '-c'.

How do I pass arguments that contain spaces?

Wrap the argument in quotes when calling the script: python script.py "hello world". The shell treats everything inside quotes as a single argument, so sys.argv[1] will be 'hello world' (one string, not two). This works with both single and double quotes on most systems.

How do I make an argparse argument required?

Positional arguments (no dashes) are required by default. For optional arguments (with --), add required=True to add_argument(): parser.add_argument("--config", required=True). However, if an argument is truly required, consider making it positional instead — that is the conventional approach for CLI tools.

How do I handle boolean flags with argparse?

Use action="store_true" for flags that default to False and become True when present: parser.add_argument("--verbose", action="store_true"). The user just types --verbose with no value. For the opposite pattern (default True, flag turns it off), use action="store_false" with a name like --no-color.

Can I make two arguments mutually exclusive?

Yes, use parser.add_mutually_exclusive_group(). Add the conflicting arguments to the group instead of directly to the parser. If the user passes both, argparse will print an error. This is useful for flags like --json vs --csv where only one output format should be active.

Conclusion

You now have two solid tools for handling command line arguments in Python. sys.argv gives you raw, immediate access for quick scripts — just remember that everything is a string and you need to handle errors yourself. argparse gives you a complete CLI framework with automatic help text, type validation, choices, subcommands, and clean error messages, all from the standard library.

Try extending the file organizer project with new features: add a --undo subcommand that moves files back to the parent directory, or a --config flag that loads custom category mappings from a JSON file. These are the kinds of incremental improvements that turn a tutorial exercise into a tool you actually use every day.

For the complete reference, see the official argparse documentation and the sys.argv documentation.

Continue Learning Python

Tutorials you might also find useful:

How To Manage Python Environment Variables With dotenv and os.environ

How To Manage Python Environment Variables With dotenv and os.environ

Last Updated: June 01, 2026

Beginner

Pubs - Python How To Program

Written by Pubs

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.

View all tutorials by Pubs →

Python Environment Variables: Quick Example

Environment variables keep sensitive data like API keys and database passwords out of your code. Python’s os.environ reads them, and python-dotenv loads them from a .env file.

#quick_example.py
import os
from dotenv import load_dotenv  # pip install python-dotenv

load_dotenv()  # reads .env file into environment variables

api_key = os.environ.get('API_KEY', 'not-set')  # get with a fallback
db_host = os.environ.get('DB_HOST', 'localhost')
print(f"API Key: {api_key[:8]}...")  # only show first 8 chars
print(f"DB Host: {db_host}")

Output:

API Key: sk-abc12...
DB Host: db.example.com

The load_dotenv() function reads key-value pairs from a .env file and makes them available through os.environ. Your secrets stay out of your codebase.

Want more? Below we cover creating .env files, keeping secrets out of Git, and a real-life database connection manager.

Why Environment Variables Matter for Python Developers

Hardcoding secrets into your source code is one of the most common security mistakes developers make. Push your code to GitHub with an API key embedded and bots will find it within minutes — that’s not an exaggeration. Environment variables solve this by keeping configuration separate from code. Different environments (dev, staging, production) can use different values without changing a single line of Python.

Using os.environ to Read Environment Variables in Python

Python’s built-in os module gives you direct access to environment variables through os.environ, which behaves like a dictionary.

#os_environ.py
import os

# Read an environment variable (raises KeyError if missing)
# home = os.environ['HOME']

# Safer: use .get() with a default value
home = os.environ.get('HOME', '/tmp')
user = os.environ.get('USER', 'unknown')
path = os.environ.get('PATH', '')

print(f"Home: {home}")
print(f"User: {user}")
print(f"PATH entries: {len(path.split(':'))}")

# Check if a variable exists
if 'API_KEY' in os.environ:
    print("API_KEY is set")
else:
    print("API_KEY is NOT set — using defaults")

Output:

Home: /home/user
User: user
PATH entries: 8
API_KEY is NOT set — using defaults

Always use .get() with a default value instead of direct dictionary access. If the variable doesn’t exist, os.environ['KEY'] throws a KeyError that will crash your script.

Creating a .env File for Your Python Project

A .env file is a simple text file with key-value pairs. Create it in your project root:

# .env
# Database settings
DB_HOST=db.example.com
DB_PORT=5432
DB_NAME=myapp
DB_USER=admin
DB_PASSWORD=supersecretpassword123

# API keys
API_KEY=sk-abc123def456ghi789
STRIPE_SECRET=sk_test_abcdefgh

# App settings
DEBUG=True
LOG_LEVEL=INFO

Note: Lines starting with # are comments. No quotes needed around values unless they contain spaces. No spaces around the = sign.

Installing and Using python-dotenv

pip install python-dotenv

Once installed, load_dotenv() reads your .env file and loads each variable into os.environ:

#using_dotenv.py
import os
from dotenv import load_dotenv

# Load .env file from the current directory (or specify a path)
load_dotenv()  # looks for .env in current dir and parent dirs

# Now all .env variables are available via os.environ
db_config = {
    'host': os.environ.get('DB_HOST'),
    'port': int(os.environ.get('DB_PORT', 5432)),
    'name': os.environ.get('DB_NAME'),
    'user': os.environ.get('DB_USER'),
    'password': os.environ.get('DB_PASSWORD'),
}

print(f"Connecting to {db_config['name']}@{db_config['host']}:{db_config['port']}")
print(f"Debug mode: {os.environ.get('DEBUG')}")

Output:

Connecting to myapp@db.example.com:5432
Debug mode: True

By default, load_dotenv() won’t overwrite existing environment variables. If you need to override them (for testing), pass override=True.

Keeping Secrets Out of Git With .gitignore

The whole point of using .env files is to keep secrets out of version control. Add .env to your .gitignore immediately:

# .gitignore
.env
.env.local
.env.production
*.env

Create a .env.example file that shows the required variables without actual values. Commit this to Git so other developers know what to set up:

# .env.example — copy to .env and fill in your values
DB_HOST=
DB_PORT=5432
DB_NAME=
DB_USER=
DB_PASSWORD=
API_KEY=
DEBUG=False

Validating Environment Variables at Startup

Don’t wait until your app crashes halfway through to discover a missing variable. Validate everything at startup.

#validate_env.py
import os
import sys
from dotenv import load_dotenv

load_dotenv()

REQUIRED_VARS = ['DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'API_KEY']

missing = [var for var in REQUIRED_VARS if not os.environ.get(var)]

if missing:
    print(f"ERROR: Missing required environment variables: {', '.join(missing)}")
    print("Copy .env.example to .env and fill in the values")
    sys.exit(1)

print("All required environment variables are set")

Output (when variables are missing):

ERROR: Missing required environment variables: API_KEY
Copy .env.example to .env and fill in the values

Real-Life Example: A Database Connection Manager

Here’s a practical example that combines everything — loading config from .env, validating required variables, and creating a reusable database configuration class.

#db_manager.py
import os
import sys
from dotenv import load_dotenv
from dataclasses import dataclass

load_dotenv()

@dataclass
class DatabaseConfig:
    host: str
    port: int
    name: str
    user: str
    password: str
    ssl: bool = True

    @classmethod
    def from_env(cls):
        """Create config from environment variables"""
        required = ['DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASSWORD']
        missing = [v for v in required if not os.environ.get(v)]
        if missing:
            print(f"Missing DB config: {', '.join(missing)}")
            sys.exit(1)

        return cls(
            host=os.environ['DB_HOST'],
            port=int(os.environ.get('DB_PORT', 5432)),
            name=os.environ['DB_NAME'],
            user=os.environ['DB_USER'],
            password=os.environ['DB_PASSWORD'],
            ssl=os.environ.get('DB_SSL', 'true').lower() == 'true'
        )

    @property
    def connection_string(self):
        ssl_param = '?sslmode=require' if self.ssl else ''
        return f"postgresql://{self.user}:{self.password}@{self.host}:{self.port}/{self.name}{ssl_param}"

# Usage
config = DatabaseConfig.from_env()
print(f"Database: {config.name}")
print(f"Host: {config.host}:{config.port}")
print(f"SSL: {config.ssl}")
# In production, you'd pass config.connection_string to your ORM
print(f"Connection string ready (password hidden)")

Output:

Database: myapp
Host: db.example.com:5432
SSL: True
Connection string ready (password hidden)

This pattern gives you type-safe configuration, validation at startup, sensible defaults, and a clean connection string builder — all powered by a simple .env file.

Cache Katie switching between different environment configurations
load_dotenv() in dev, real env vars in prod. Same code, zero config changes.

Frequently Asked Questions

What is the difference between os.environ and os.getenv() in Python?

os.environ.get('KEY') and os.getenv('KEY') are functionally identical — both return None if the variable is missing. The only difference is os.environ['KEY'] (without .get) raises a KeyError, while os.getenv always returns the default.

Can I use .env files in production?

You can, but most production deployments set environment variables directly through the hosting platform (Heroku config vars, AWS Parameter Store, Docker environment). The .env file is primarily a development convenience.

Does python-dotenv work with Django and Flask?

Yes. Flask has built-in .env support with python-dotenv. For Django, call load_dotenv() at the top of your settings.py before referencing any os.environ calls.

Conclusion

Environment variables are the right way to manage configuration and secrets in Python. Use python-dotenv for local development, validate required variables at startup, never commit .env to Git, and provide a .env.example for your team. It takes five minutes to set up and saves you from a world of security headaches.

Reference

python-dotenv documentation: https://pypi.org/project/python-dotenv/

12-Factor App config: https://12factor.net/config

Further Reading: For more details, see the official Python os.environ documentation.

How To Use Python Requests To Call REST APIs With Authentication

How To Use Python Requests To Call REST APIs With Authentication

Last Updated: June 01, 2026

Beginner

Pubs - Python How To Program
Written by Pubs

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.

View all tutorials by Pubs →

Calling REST APIs in Python: Quick Example

Python’s requests library makes calling REST APIs dead simple. Install it with pip install requests and you can make HTTP calls in one line.

#quick_example.py
import requests  # pip install requests

# Make a GET request to a public API
response = requests.get('https://jsonplaceholder.typicode.com/posts/1')

data = response.json()  # parse the JSON response into a dict
print(data['title'])     # access specific fields
print(response.status_code)  # check the HTTP status code

Output:

sunt aut facere repellat provident occaecati excepturi optio reprehenderit
200

The response.json() method converts the API’s JSON response directly into a Python dictionary. The status code 200 means everything went smoothly.

Want to go deeper? Below we cover sending POST requests, authentication methods, and a real-life weather dashboard project.

Auth tokens in headers. Never in URLs. Never in code.
Auth tokens in headers. Never in URLs. Never in code.

What is a REST API and Why Should You Care

A REST API is how two programs talk to each other over the internet. When you check the weather on your phone, your app is calling a weather API behind the scenes. When you log into a website using Google, that’s an API call too. As a Python developer, knowing how to call APIs opens up a world of data — weather, stock prices, social media, payment processing, you name it.

Python’s requests library is the gold standard for making HTTP calls. It wraps all the complexity of HTTP into a clean, readable interface.

Installing the Requests Library

The requests library doesn’t come with Python — you need to install it:

pip install requests

Or if you’re on Linux/Mac and need sudo:

sudo pip3 install requests

Making GET Requests With Query Parameters

GET requests are for fetching data. Most APIs accept query parameters to filter or customize the response. You can pass them as a dictionary using the params argument instead of manually building the URL string.

#get_with_params.py
import requests

# Pass query parameters as a dictionary — much cleaner than building the URL
params = {
    'userId': 1,
    'completed': 'false'
}

response = requests.get(
    'https://jsonplaceholder.typicode.com/todos',
    params=params  # requests builds the URL for you
)

todos = response.json()  # list of todo items
print(f"Found {len(todos)} incomplete todos")
print(f"First todo: {todos[0]['title']}")

Output:

Found 11 incomplete todos
First todo: delectus aut autem

The params dictionary gets converted into a query string like ?userId=1&completed=false and appended to the URL automatically. This is safer and cleaner than string concatenation.

POST Requests With JSON Body in Python

POST requests send data to an API — creating new records, submitting forms, or triggering actions. Use the json parameter to send a Python dictionary as a JSON body.

#post_request.py
import requests

# Data to send — requests will serialize this to JSON automatically
new_post = {
    'title': 'My API Post',
    'body': 'This was created with Python requests',
    'userId': 1
}

response = requests.post(
    'https://jsonplaceholder.typicode.com/posts',
    json=new_post  # automatically sets Content-Type: application/json
)

print(f"Status: {response.status_code}")  # 201 = created
print(f"New post ID: {response.json()['id']}")

Output:

Status: 201
New post ID: 101

Status code 201 means the resource was created successfully. The API returns the newly created object with its assigned ID.

Authentication Methods for Python API Calls

Most real-world APIs require authentication. Here are the three most common methods you’ll encounter.

API Key in Headers

#api_key_auth.py
import requests

headers = {
    'X-API-Key': 'your_api_key_here'  # some APIs use different header names
}

response = requests.get('https://api.example.com/data', headers=headers)
print(response.status_code)

Bearer Token Authentication

#bearer_token.py
import requests

token = 'your_access_token_here'
headers = {
    'Authorization': f'Bearer {token}'  # standard OAuth2 format
}

response = requests.get('https://api.example.com/user', headers=headers)
print(response.json())

Basic Authentication

#basic_auth.py
import requests

# requests has built-in support for Basic Auth
response = requests.get(
    'https://api.example.com/account',
    auth=('username', 'password')  # tuple of (user, pass)
)
print(response.status_code)

Note: Never hardcode API keys or tokens directly in your code. Use environment variables or a .env file instead. Check out our article on managing environment variables with dotenv for the proper approach.

Bearer vs Basic vs OAuth. Pick one, document it.
Bearer vs Basic vs OAuth. Pick one, document it.

Handling API Errors and Status Codes in Python

APIs don’t always return what you expect. Network issues, invalid data, rate limits — things go wrong. Proper error handling separates production code from tutorial code.

#error_handling.py
import requests

def safe_api_call(url):
    try:
        response = requests.get(url, timeout=10)  # always set a timeout
        response.raise_for_status()  # raises exception for 4xx/5xx codes
        return response.json()
    except requests.exceptions.Timeout:
        print("Request timed out — the server took too long to respond")
    except requests.exceptions.HTTPError as e:
        print(f"HTTP error: {e.response.status_code} - {e.response.reason}")
    except requests.exceptions.ConnectionError:
        print("Connection failed — check your internet or the URL")
    except requests.exceptions.JSONDecodeError:
        print("Response wasn't valid JSON")
    return None

# Test with a valid URL
data = safe_api_call('https://jsonplaceholder.typicode.com/posts/1')
if data:
    print(f"Got: {data['title'][:40]}...")

# Test with a URL that returns 404
data = safe_api_call('https://jsonplaceholder.typicode.com/posts/99999')

Output:

Got: sunt aut facere repellat provident MDash...
HTTP error: 404 - Not Found

The raise_for_status() method is your best friend. It throws an exception for any 4xx or 5xx status code, so you don’t accidentally process error responses as valid data.

Working With Response Headers and Pagination

Many APIs return data in pages. You need to check the response headers or body for pagination info and loop through all pages to get the complete dataset.

#pagination.py
import requests

def get_all_posts(base_url):
    all_posts = []
    page = 1

    while True:
        response = requests.get(base_url, params={'_page': page, '_limit': 10})
        posts = response.json()

        if not posts:  # empty list means no more pages
            break

        all_posts.extend(posts)
        print(f"Page {page}: got {len(posts)} posts")
        page += 1

    return all_posts

posts = get_all_posts('https://jsonplaceholder.typicode.com/posts')
print(f"\nTotal posts collected: {len(posts)}")

Output:

Page 1: got 10 posts
Page 2: got 10 posts
...
Page 10: got 10 posts

Total posts collected: 100

Real-Life Example: Building a Weather Dashboard Script

Let’s put it all together with a practical script that fetches weather data from the Open-Meteo API (free, no API key needed) and displays a simple dashboard.

#weather_dashboard.py
import requests
from datetime import datetime

def get_weather(city_lat, city_lon, city_name):
    """Fetch current weather for a location using Open-Meteo API"""
    url = 'https://api.open-meteo.com/v1/forecast'
    params = {
        'latitude': city_lat,
        'longitude': city_lon,
        'current_weather': True,  # get current conditions
        'timezone': 'auto'        # detect timezone from coordinates
    }

    try:
        response = requests.get(url, params=params, timeout=10)
        response.raise_for_status()
        data = response.json()

        weather = data['current_weather']
        return {
            'city': city_name,
            'temp': weather['temperature'],
            'wind': weather['windspeed'],
            'time': weather['time']
        }
    except requests.exceptions.RequestException as e:
        print(f"Failed to get weather for {city_name}: {e}")
        return None

# Define cities with their coordinates
cities = [
    (-33.87, 151.21, 'Sydney'),
    (51.51, -0.13, 'London'),
    (40.71, -74.01, 'New York'),
    (35.68, 139.69, 'Tokyo'),
]

# Fetch and display weather for all cities
print("=" * 45)
print("  WEATHER DASHBOARD")
print("=" * 45)

for lat, lon, name in cities:
    w = get_weather(lat, lon, name)
    if w:
        print(f"  {w['city']:12s} | {w['temp']:5.1f} C | Wind: {w['wind']} km/h")

print("=" * 45)
print(f"  Updated: {datetime.now().strftime('%Y-%m-%d %H:%M')}")

Output:

=============================================
  WEATHER DASHBOARD
=============================================
  Sydney       |  22.3 C | Wind: 15.2 km/h
  London       |   8.1 C | Wind: 20.5 km/h
  New York     |  11.7 C | Wind: 12.8 km/h
  Tokyo        |  16.4 C | Wind: 8.3 km/h
=============================================
  Updated: 2026-03-13 09:15

This script demonstrates GET requests with query parameters, response parsing, error handling with timeouts, and looping through multiple API calls. You could easily extend it with a scheduler to run every hour or save results to a CSV for tracking trends over time.

Debug Dee examining a cracked response sphere for errors
response.raise_for_status() — one line between you and a silent 404 ruining everything.

Frequently Asked Questions

What is the difference between requests.get() and requests.post() in Python?

GET fetches data from a server without changing anything. POST sends data to create or update a resource. Use GET when you’re reading, POST when you’re writing. Some APIs also use PUT for updates and DELETE for removals.

How do I send form data instead of JSON with Python requests?

Use the data parameter instead of json: requests.post(url, data={'key': 'value'}). This sends the data as application/x-www-form-urlencoded, which is what HTML forms use.

Should I use requests or urllib for API calls in Python?

requests is almost always the better choice. While urllib is built-in, its API is verbose and harder to use. The requests library handles cookies, sessions, redirects, and encoding automatically.

How do I handle API rate limits with Python requests?

Check the response headers for rate limit info (usually X-RateLimit-Remaining and Retry-After). If you get a 429 status code, wait the specified time before retrying. For robust solutions, use exponential backoff with the tenacity library.

Conclusion

The requests library gives you everything you need to interact with REST APIs in Python — from simple GET calls to authenticated POST requests with error handling. The key patterns to remember are: always set a timeout, use raise_for_status() for error detection, and never hardcode credentials. With these fundamentals, you can integrate almost any web service into your Python projects.

Reference

Official requests documentation: https://docs.python-requests.org/

Python urllib documentation: https://docs.python.org/3/library/urllib.html