How To Use Celery for Background Tasks in Python

How To Use Celery for Background Tasks in Python

Last Updated: June 01, 2026

How To Use Celery for Background Tasks in Python

Quick Answer: Celery is a distributed task queue library that allows you to run time-consuming Python functions asynchronously in background workers. Install it with pip install celery redis, define tasks using the @app.task decorator, and call them with .delay() or .apply_async() for non-blocking execution. Use Redis or RabbitMQ as your message broker, and monitor tasks with Flower web interface.
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 →

Understanding Celery and Background Tasks

Celery is a powerful asynchronous task queue system for Python that enables you to execute long-running operations without blocking your main application. Instead of making users wait for resource-intensive tasks to complete, you can defer them to background workers that process jobs independently.

Background tasks are essential for modern web applications. Without them, uploading a large file, sending emails, or processing videos would freeze your user interface. Celery solves this by allowing tasks to run in separate processes and even on different machines.

Installing and Setting Up Celery with Redis

First, install Celery along with Redis, which serves as the message broker:

pip install celery redis

Next, install and run Redis server. On macOS with Homebrew:

brew install redis
redis-server

On Ubuntu/Debian:

sudo apt-get install redis-server
sudo systemctl start redis-server

Verify Redis is running by connecting with the Redis CLI:

redis-cli ping
# Output: PONG
Celery: send the work somewhere else, get back to the user immediately.
Celery: send the work somewhere else, get back to the user immediately.

Creating Your First Celery Application

Create a file called celery_app.py to define your Celery application and tasks:

from celery import Celery

# Create Celery instance
app = Celery('myapp')

# Configure Celery to use Redis as broker
app.conf.update(
    broker_url='redis://localhost:6379/0',
    result_backend='redis://localhost:6379/0',
    task_serializer='json',
    accept_content=['json'],
    result_serializer='json',
    timezone='UTC',
    enable_utc=True,
)

# Define a simple task
@app.task
def add(x, y):
    return x + y

# Define a task that takes longer
@app.task
def send_email(email, subject, body):
    import time
    time.sleep(2)  # Simulate email sending delay
    print(f"Email sent to {email}: {subject}")
    return {"status": "sent", "email": email}

Calling Tasks Asynchronously

Now create a main application file that uses these tasks:

from celery_app import add, send_email

# Method 1: Using .delay() for simple calls
result = add.delay(4, 6)
print(f"Task ID: {result.id}")
print(f"Task state: {result.state}")

# Method 2: Using .apply_async() for advanced options
result = send_email.apply_async(
    args=('user@example.com', 'Hello', 'Welcome!'),
    countdown=10  # Execute after 10 seconds
)

# Method 3: Get task result (blocking)
print(f"Result: {result.get()}")  # Waits until task completes

# Method 4: Non-blocking result check
if result.ready():
    print(f"Task completed: {result.result}")
else:
    print("Task still processing...")

The .delay() method is shorthand for .apply_async() with immediate execution. For more control, use .apply_async() with parameters like:

  • countdown: Delay execution by N seconds
  • expires: Task expires after N seconds if not executed
  • priority: Task priority (higher numbers execute first)
  • retry: Retry failed tasks
Workers consume the queue. The web request never waits.
Workers consume the queue. The web request never waits.

Starting Celery Workers

Celery workers are separate processes that consume and execute tasks from the queue. Start a worker with:

celery -A celery_app worker --loglevel=info

You should see output like:

-------------- celery@MacBook-Pro.local v5.3.0 (emerald-rush)
--- ***** -----
-- ******* ----
- *** --- * ---
- ** ---------- [config]
- ** ----------
- *** --- * --- celery@MacBook-Pro.local
-- ******* ---- Linux-5.15.0-1020-aws
--- ***** -----
 -------------- [queues]
                .> celery           exchange=celery(direct) key=celery

[tasks]
  . celery_app.add
  . celery_app.send_email

[2024-01-15 10:30:45,123: WARNING/MainProcess] celery@MacBook-Pro.local ready.

For production, run multiple workers with concurrency:

celery -A celery_app worker --loglevel=info --concurrency=4

Task Chains, Groups, and Chords

Celery provides powerful constructs for composing complex workflows:

from celery import chain, group, chord
from celery_app import add

# Chain: Execute tasks sequentially
workflow = chain(add.s(2, 2), add.s(4), add.s(8))
result = workflow.apply_async()
print(result.get())  # Output: 16 (((2+2)+4)+8)

# Group: Execute tasks in parallel
job = group(add.s(2, 2), add.s(4, 4), add.s(8, 8))
result = job.apply_async()
print(result.get())  # Output: [4, 8, 16]

# Chord: Parallel tasks then callback
callback = add.s(0)
header = group(add.s(2, 2), add.s(4, 4), add.s(8, 8))
result = chord(header)(callback)
print(result.get())  # Output: 28 (sum of all)

These constructs allow you to:

  • Chain: Pass results between tasks sequentially
  • Group: Execute multiple tasks in parallel
  • Chord: Run parallel tasks, then process combined results
Monitor your queue depth. A growing queue is a smoking gun.
Monitor your queue depth. A growing queue is a smoking gun.

Error Handling and Retries

Celery provides built-in retry mechanisms for fault tolerance:

from celery import Celery
from celery.exceptions import MaxRetriesExceededError
import requests

app = Celery('myapp')
app.conf.broker_url = 'redis://localhost:6379/0'

@app.task(bind=True, max_retries=3)
def fetch_data(self, url):
    try:
        response = requests.get(url, timeout=5)
        response.raise_for_status()
        return response.json()
    except requests.ConnectionError as exc:
        # Retry with exponential backoff
        raise self.retry(exc=exc, countdown=2 ** self.request.retries)
    except requests.Timeout:
        if self.request.retries < self.max_retries:
            raise self.retry(countdown=60)
        else:
            raise MaxRetriesExceededError()

# Call with automatic retries
result = fetch_data.delay('https://api.example.com/data')

Key retry features:

  • max_retries: Maximum number of retry attempts
  • countdown: Seconds to wait before retrying
  • autoretry_for: Tuple of exception types to auto-retry
  • bind=True: Gives task access to self for retry logic

Periodic Tasks with Celery Beat

Schedule recurring tasks using Celery Beat:

from celery import Celery
from celery.schedules import crontab
import datetime

app = Celery('myapp')
app.conf.broker_url = 'redis://localhost:6379/0'

# Configure periodic tasks
app.conf.beat_schedule = {
    'send-report-every-hour': {
        'task': 'tasks.send_report',
        'schedule': 3600.0,  # Every 3600 seconds (1 hour)
    },
    'cleanup-database-daily': {
        'task': 'tasks.cleanup_old_data',
        'schedule': crontab(hour=2, minute=0),  # Daily at 2 AM
    },
    'sync-data-every-30-minutes': {
        'task': 'tasks.sync_external_api',
        'schedule': 1800.0,  # Every 30 minutes
    },
}

@app.task
def send_report():
    print(f"Report generated at {datetime.datetime.now()}")
    return "Report sent"

@app.task
def cleanup_old_data():
    print("Cleaning up old database records")
    # Delete old records
    return "Cleanup complete"

@app.task
def sync_external_api():
    print("Syncing with external API")
    return "Sync complete"

Start Celery Beat scheduler:

celery -A celery_app beat --loglevel=info

Run both worker and beat together:

celery -A celery_app worker --beat --loglevel=info

Monitoring Tasks with Flower

Flower is a real-time web-based monitoring tool for Celery. Install and run it:

pip install flower
celery -A celery_app flower

Access Flower at http://localhost:5555 to see:

  • Active tasks and their progress
  • Worker status and capacity
  • Task execution history and statistics
  • Real-time task graphs
  • Task failure details

You can also programmatically inspect tasks:

from celery.app.control import Inspect

app = Celery('myapp')
app.conf.broker_url = 'redis://localhost:6379/0'

# Get inspection object
i = Inspect(app=app)

# Get active tasks
active = i.active()
print(f"Active tasks: {active}")

# Get registered tasks
registered = i.registered()
print(f"Registered tasks: {registered}")

# Get worker stats
stats = i.stats()
print(f"Worker stats: {stats}")

Troubleshooting Common Celery Issues

Issue Cause Solution
Worker not processing tasks Worker not running or Redis connection failed Verify Redis is running: redis-cli ping. Start worker: celery -A celery_app worker --loglevel=info
Task remains in PENDING state Task never executed or worker crashed Check worker logs, verify broker connection, ensure task is properly registered
Redis connection error Redis not running or wrong connection string Check Redis is running, verify broker_url in Celery config
Task results not stored Result backend not configured Set result_backend in Celery config to Redis or other backend
High memory usage Too many tasks queued or workers consuming memory Limit prefetch with --prefetch-multiplier=1, adjust concurrency
Tasks timing out Task execution exceeds time limit Increase time_limit or optimize task code

Real-Life Example: Email Sending Queue

Here’s a production-ready example of an email sending queue system:

from celery import Celery, group
from celery.exceptions import MaxRetriesExceededError
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

app = Celery('email_app')
app.conf.update(
    broker_url='redis://localhost:6379/0',
    result_backend='redis://localhost:6379/0',
    task_serializer='json',
    accept_content=['json'],
    result_serializer='json',
)

@app.task(bind=True, max_retries=5)
def send_email_task(self, recipient, subject, body, html=None):
    """Send email with automatic retries"""
    try:
        sender = 'noreply@example.com'
        password = 'your_app_password'

        msg = MIMEMultipart('alternative')
        msg['Subject'] = subject
        msg['From'] = sender
        msg['To'] = recipient

        # Attach text and HTML versions
        msg.attach(MIMEText(body, 'plain'))
        if html:
            msg.attach(MIMEText(html, 'html'))

        # Send email
        with smtplib.SMTP_SSL('smtp.gmail.com', 465) as server:
            server.login(sender, password)
            server.sendmail(sender, recipient, msg.as_string())

        return {
            'status': 'success',
            'recipient': recipient,
            'subject': subject
        }

    except smtplib.SMTPException as exc:
        # Retry with exponential backoff
        raise self.retry(
            exc=exc,
            countdown=min(600, 2 ** self.request.retries)
        )

@app.task
def send_bulk_emails(recipients, subject, body):
    """Send emails to multiple recipients in parallel"""
    job = group(
        send_email_task.s(recipient, subject, body)
        for recipient in recipients
    )
    result = job.apply_async()
    return result.id

# Usage example
if __name__ == '__main__':
    # Send single email
    result = send_email_task.delay(
        'user@example.com',
        'Welcome!',
        'Thanks for signing up!'
    )
    print(f"Task ID: {result.id}")

    # Send bulk emails
    recipients = [
        'user1@example.com',
        'user2@example.com',
        'user3@example.com'
    ]
    bulk_result = send_bulk_emails.delay(
        recipients,
        'Newsletter',
        'Check out our latest updates!'
    )
    print(f"Bulk send task ID: {bulk_result.id}")

This example demonstrates:

  • Email sending with automatic retries and exponential backoff
  • Both single and bulk email sending
  • Error handling for SMTP failures
  • Parallel task execution for multiple recipients
  • Task result tracking and retrieval

Best Practices for Celery in Production

When deploying Celery to production, follow these guidelines:

  • Use a robust broker: RabbitMQ for mission-critical tasks, Redis for caching-heavy workloads
  • Set result TTL: Configure result_expires to automatically clean up old results
  • Monitor workers: Use Flower or external monitoring to track worker health
  • Use task routing: Route different tasks to different workers for better resource allocation
  • Implement task timeout: Set task_soft_time_limit and task_time_limit to prevent hanging tasks
  • Version your API: Use task_protocol to handle backward compatibility
  • Log comprehensively: Configure proper logging for debugging production issues
  • Test retries: Verify retry logic works correctly before production deployment

FAQ

Q: What is the difference between Celery and threading in Python?

A: Celery is a distributed task queue system that runs tasks in separate processes and can scale across multiple machines, while threading runs tasks in the same process and is limited by Python’s GIL. Celery is better for I/O-bound and CPU-intensive tasks that require true parallelism.

Q: Can Celery run tasks on different machines?

A: Yes, Celery is designed for distributed computing. You can run workers on different servers, and they will all consume tasks from the same message broker for true scalability.

Q: What message brokers does Celery support?

A: Celery supports RabbitMQ, Redis, AWS SQS, and others. RabbitMQ is recommended for production, while Redis is suitable for development and lighter workloads.

Q: How do I handle task failures in Celery?

A: Use the @app.task decorator with max_retries and implement retry logic using self.retry(). You can also use error callbacks with apply_async() to execute functions when tasks fail.

Q: Is Celery suitable for real-time processing?

A: Celery is better for background tasks with some latency tolerance. For true real-time processing, consider message streams like Kafka alongside Celery.

Setting Up Celery

Celery needs three pieces — the Celery app, a broker (Redis or RabbitMQ), and a worker process. Minimal setup:

# pip install celery redis

# File: tasks.py
from celery import Celery

app = Celery("myapp", broker="redis://localhost:6379/0", backend="redis://localhost:6379/1")

@app.task
def send_email(to: str, subject: str, body: str):
    # Pretend this does real SMTP work
    print(f"Sending email to {to}: {subject}")
    return {"status": "sent", "to": to}

# In your web app:
from tasks import send_email
result = send_email.delay("alice@example.com", "Welcome", "Hi there")
print(result.id)        # job ID for later status checks
print(result.get(timeout=10))   # block until done

Start the worker in a separate process: celery -A tasks worker --loglevel=info. Now .delay() drops the task into the queue; the worker picks it up and runs it; the result is stored in the backend for retrieval.

Retries, Timeouts, and Error Handling

Production tasks need to handle failure. Use autoretry on specific exceptions with exponential backoff:

from celery import Celery
import requests

app = Celery("myapp", broker="redis://localhost:6379")

@app.task(
    autoretry_for=(requests.RequestException,),
    retry_backoff=True,
    retry_backoff_max=600,
    retry_jitter=True,
    max_retries=5,
    soft_time_limit=30,
    time_limit=60,
)
def fetch_url(url: str):
    response = requests.get(url, timeout=10)
    response.raise_for_status()
    return response.text

Each retry doubles the wait (with jitter), up to 10 minutes between tries. soft_time_limit raises SoftTimeLimitExceeded that your code can catch and clean up; time_limit hard-kills the task.

Periodic Tasks with Celery Beat

Beat is Celery’s built-in scheduler — replaces cron for background work. Run it alongside the worker:

from celery.schedules import crontab

app.conf.beat_schedule = {
    "daily-report": {
        "task": "tasks.send_daily_report",
        "schedule": crontab(hour=9, minute=0),
    },
    "every-5-min": {
        "task": "tasks.health_check",
        "schedule": 300.0,
    },
}

# Run beat:    celery -A tasks beat --loglevel=info
# Run worker:  celery -A tasks worker --loglevel=info

Common Pitfalls

  • Tasks that aren’t picklable. Celery serializes arguments. Database session objects, file handles, and lambdas can’t cross the wire — pass IDs/strings instead.
  • Forgetting result_backend. Without a backend, .get() hangs forever waiting for a result that’s never stored. Redis backend is fine for most cases.
  • Long-running tasks blocking workers. Set --concurrency appropriately and consider --max-tasks-per-child=1000 to recycle workers periodically (helps with memory leaks).
  • Beat running twice. Two beat processes = double-firing schedules. Use a singleton beat or the celery-beat-django database scheduler with a lock.
  • Logging surprises. Worker logs go to stdout in the worker process. Configure Python logging in celeryconfig.py so your task code’s logs end up in the right place.

FAQ

Q: Celery, RQ, or Dramatiq?
A: Celery for most cases — most features, biggest ecosystem. RQ if you want simplicity and you’re already on Redis. Dramatiq if you’ve outgrown RQ but Celery feels too heavy.

Q: Redis or RabbitMQ as broker?
A: Redis is fine for under 1000 tasks/sec. RabbitMQ handles higher volume and offers stronger durability guarantees. For most apps, start with Redis.

Q: How do I see what tasks are running?
A: celery -A tasks inspect active shows current jobs. Flower (pip install flower) gives you a web UI for monitoring, retrying failed jobs, and inspecting queues.

Q: Async tasks in async views (FastAPI / Django async)?
A: send_email.delay(...) is fire-and-forget — works fine in async views. To await a result, use asyncio.to_thread(result.get, timeout=10).

Q: How do I prevent thundering herd on Celery beat after a long downtime?
A: app.conf.beat_max_loop_interval = 60 and use --max-interval on beat. For tasks with misfire_grace_time, set it small to skip overdue tasks rather than running 50 backlogged copies.

Wrapping Up

Celery’s worth the setup once you have any of: emails to send, reports to generate, webhook deliveries, image processing, or any work that shouldn’t block a web request. Start with one task type and one worker; add retries, beat scheduling, and Flower monitoring as the load grows. The complexity scales with your needs — you don’t have to learn everything before you can use it.

How To Build Web Apps with Django in Python

How To Build Web Apps with Django in Python

Last Updated: June 01, 2026

Quick Answer:

To build web apps with Django, install it with pip install django, create a project with django-admin startproject mysite, define URL patterns in urls.py, write views in views.py, create templates in HTML, and define data models in models.py. Run python manage.py runserver to start your development server.

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 Django Web Development

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

Django is the most popular Python web framework, powering sites like Instagram, Pinterest, and Mozilla. It follows the “batteries included” philosophy, providing everything you need to build production-ready web applications out of the box — from an ORM and authentication system to an admin panel and form handling.

If you have been writing Python scripts and want to move into web development, Django gives you a structured, well-documented path. This tutorial walks you through building a complete web application from scratch, covering project setup, URL routing, views, templates, models, and forms. By the end, you will have a working task manager application and a solid understanding of how Django ties everything together.

Setting Up Your Django Project

Start by creating a virtual environment and installing Django. This keeps your project dependencies isolated from your system Python.

# Create and activate a virtual environment
python -m venv myenv
# On macOS/Linux:
source myenv/bin/activate
# On Windows:
myenv\Scripts\activate

# Install Django
pip install django

# Verify the installation
python -m django --version
# Output: 5.1.3

Now create a new Django project and an app within it. A project is the overall configuration container, while apps are modular components that handle specific functionality.

# Create the project
django-admin startproject taskmanager

# Navigate into the project
cd taskmanager

# Create an app called 'tasks'
python manage.py startapp tasks

Your project structure now looks like this:

taskmanager/
    manage.py
    taskmanager/
        __init__.py
        settings.py      # Project configuration
        urls.py           # Root URL routing
        asgi.py
        wsgi.py
    tasks/
        __init__.py
        admin.py          # Admin panel configuration
        apps.py
        models.py         # Database models
        tests.py
        views.py          # Request handlers
        migrations/       # Database migrations

Register your new app in settings.py by adding it to INSTALLED_APPS:

# taskmanager/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'tasks',  # Add your app here
]
Django: batteries included, opinions included, productivity included.
Django: batteries included, opinions included, productivity included.

Understanding URL Routing

Django uses URL patterns to map incoming HTTP requests to the correct view function. Think of it as a switchboard that directs each request to the right handler.

# taskmanager/urls.py (root URL configuration)
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('tasks.urls')),  # Delegate to app URLs
]

Create a urls.py file inside your tasks app:

# tasks/urls.py
from django.urls import path
from . import views

app_name = 'tasks'

urlpatterns = [
    path('', views.task_list, name='task_list'),
    path('create/', views.task_create, name='task_create'),
    path('<int:pk>/update/', views.task_update, name='task_update'),
    path('<int:pk>/delete/', views.task_delete, name='task_delete'),
    path('<int:pk>/', views.task_detail, name='task_detail'),
]

The <int:pk> syntax captures an integer from the URL and passes it to the view as the pk parameter. Django provides several path converters: str, int, slug, uuid, and path.

Writing Views

Views are Python functions (or classes) that receive HTTP requests and return HTTP responses. They contain the logic for what happens when a user visits a particular URL.

# tasks/views.py
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib import messages
from .models import Task
from .forms import TaskForm


def task_list(request):
    """Display all tasks, with optional filtering."""
    status_filter = request.GET.get('status', '')
    
    if status_filter:
        tasks = Task.objects.filter(status=status_filter)
    else:
        tasks = Task.objects.all()
    
    tasks = tasks.order_by('-created_at')
    
    context = {
        'tasks': tasks,
        'current_filter': status_filter,
        'status_choices': Task.STATUS_CHOICES,
    }
    return render(request, 'tasks/task_list.html', context)


def task_detail(request, pk):
    """Display a single task's details."""
    task = get_object_or_404(Task, pk=pk)
    return render(request, 'tasks/task_detail.html', {'task': task})


def task_create(request):
    """Handle task creation with a form."""
    if request.method == 'POST':
        form = TaskForm(request.POST)
        if form.is_valid():
            task = form.save()
            messages.success(request, f'Task "{task.title}" created successfully!')
            return redirect('tasks:task_list')
    else:
        form = TaskForm()
    
    return render(request, 'tasks/task_form.html', {
        'form': form,
        'action': 'Create',
    })


def task_update(request, pk):
    """Handle task editing."""
    task = get_object_or_404(Task, pk=pk)
    
    if request.method == 'POST':
        form = TaskForm(request.POST, instance=task)
        if form.is_valid():
            form.save()
            messages.success(request, f'Task "{task.title}" updated!')
            return redirect('tasks:task_detail', pk=task.pk)
    else:
        form = TaskForm(instance=task)
    
    return render(request, 'tasks/task_form.html', {
        'form': form,
        'action': 'Update',
        'task': task,
    })


def task_delete(request, pk):
    """Handle task deletion with confirmation."""
    task = get_object_or_404(Task, pk=pk)
    
    if request.method == 'POST':
        title = task.title
        task.delete()
        messages.success(request, f'Task "{title}" deleted.')
        return redirect('tasks:task_list')
    
    return render(request, 'tasks/task_confirm_delete.html', {'task': task})

The get_object_or_404 shortcut is a clean way to handle missing records — it automatically returns a 404 page if the object does not exist, so you do not need manual try/except blocks.

Models, views, templates. Django's holy trinity.
Models, views, templates. Django’s holy trinity.

Defining Models

Models define your database structure. Each model class maps to a database table, and each attribute maps to a column. Django’s ORM handles all the SQL behind the scenes.

# tasks/models.py
from django.db import models
from django.utils import timezone


class Task(models.Model):
    STATUS_CHOICES = [
        ('todo', 'To Do'),
        ('in_progress', 'In Progress'),
        ('done', 'Done'),
    ]
    
    PRIORITY_CHOICES = [
        ('low', 'Low'),
        ('medium', 'Medium'),
        ('high', 'High'),
    ]
    
    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    status = models.CharField(
        max_length=20,
        choices=STATUS_CHOICES,
        default='todo'
    )
    priority = models.CharField(
        max_length=20,
        choices=PRIORITY_CHOICES,
        default='medium'
    )
    due_date = models.DateField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    
    class Meta:
        ordering = ['-created_at']
    
    def __str__(self):
        return self.title
    
    @property
    def is_overdue(self):
        """Check if task is past its due date."""
        if self.due_date and self.status != 'done':
            return self.due_date < timezone.now().date()
        return False

After defining your model, create and apply the migration to update your database:

# Create migration files
python manage.py makemigrations tasks

# Apply migrations to the database
python manage.py migrate

Django generates the SQL for you. You can inspect it with python manage.py sqlmigrate tasks 0001 if you are curious about what is happening under the hood.

Working with the Django ORM

The ORM provides an intuitive Python API for database operations. Here are the most common patterns you will use daily:

# Creating records
task = Task.objects.create(
    title='Learn Django',
    description='Complete the tutorial',
    priority='high'
)

# Querying records
all_tasks = Task.objects.all()
high_priority = Task.objects.filter(priority='high')
first_task = Task.objects.first()
specific = Task.objects.get(pk=1)  # Raises DoesNotExist if not found

# Chaining filters
urgent = Task.objects.filter(
    priority='high',
    status='todo'
).order_by('due_date')

# Updating records
task.status = 'in_progress'
task.save()

# Bulk update
Task.objects.filter(status='todo').update(status='in_progress')

# Deleting records
task.delete()

# Aggregation
from django.db.models import Count
status_counts = Task.objects.values('status').annotate(
    count=Count('id')
)
Render templates server-side. Sometimes that's exactly what you need.
Render templates server-side. Sometimes that's exactly what you need.

Creating Templates

Templates are HTML files with Django's template language mixed in. They handle the presentation layer of your application. Create a templates directory structure inside your tasks app:

tasks/
    templates/
        tasks/
            base.html
            task_list.html
            task_detail.html
            task_form.html
            task_confirm_delete.html

Start with a base template that other templates extend:

<!-- tasks/templates/tasks/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}Task Manager{% endblock %}</title>
    <style>
        body { font-family: sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
        .nav { background: #333; padding: 10px 20px; border-radius: 8px; margin-bottom: 20px; }
        .nav a { color: white; text-decoration: none; margin-right: 15px; }
        .task-card { border: 1px solid #ddd; padding: 15px; margin: 10px 0; border-radius: 8px; }
        .priority-high { border-left: 4px solid #e74c3c; }
        .priority-medium { border-left: 4px solid #f39c12; }
        .priority-low { border-left: 4px solid #27ae60; }
        .btn { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; }
        .btn-primary { background: #3498db; color: white; }
        .btn-danger { background: #e74c3c; color: white; }
        .message { padding: 10px 15px; margin: 10px 0; border-radius: 4px; background: #d4edda; color: #155724; }
    </style>
</head>
<body>
    <nav class="nav">
        <a href="{% url 'tasks:task_list' %}">All Tasks</a>
        <a href="{% url 'tasks:task_create' %}">New Task</a>
    </nav>

    {% if messages %}
        {% for message in messages %}
            <div class="message">{{ message }}</div>
        {% endfor %}
    {% endif %}

    {% block content %}{% endblock %}
</body>
</html>

Then create the task list template:

<!-- tasks/templates/tasks/task_list.html -->
{% extends 'tasks/base.html' %}

{% block title %}My Tasks{% endblock %}

{% block content %}
<h1>My Tasks</h1>

<div>
    <strong>Filter:</strong>
    <a href="{% url 'tasks:task_list' %}">All</a>
    {% for value, label in status_choices %}
        | <a href="?status={{ value }}">{{ label }}</a>
    {% endfor %}
</div>

{% for task in tasks %}
    <div class="task-card priority-{{ task.priority }}">
        <h3><a href="{% url 'tasks:task_detail' task.pk %}">{{ task.title }}</a></h3>
        <p>Status: {{ task.get_status_display }} | Priority: {{ task.get_priority_display }}</p>
        {% if task.is_overdue %}
            <p style="color: red;">Overdue! Due: {{ task.due_date }}</p>
        {% elif task.due_date %}
            <p>Due: {{ task.due_date }}</p>
        {% endif %}
    </div>
{% empty %}
    <p>No tasks found. <a href="{% url 'tasks:task_create' %}">Create one!</a></p>
{% endfor %}
{% endblock %}

Building Forms

Django forms handle validation, rendering, and security (CSRF protection) automatically. Model forms are especially useful because they generate form fields directly from your model definition.

# tasks/forms.py
from django import forms
from .models import Task


class TaskForm(forms.ModelForm):
    class Meta:
        model = Task
        fields = ['title', 'description', 'status', 'priority', 'due_date']
        widgets = {
            'title': forms.TextInput(attrs={
                'class': 'form-control',
                'placeholder': 'Enter task title'
            }),
            'description': forms.Textarea(attrs={
                'class': 'form-control',
                'rows': 4,
                'placeholder': 'Describe the task...'
            }),
            'due_date': forms.DateInput(attrs={
                'type': 'date',
                'class': 'form-control'
            }),
        }
    
    def clean_title(self):
        """Custom validation for the title field."""
        title = self.cleaned_data['title']
        if len(title) < 3:
            raise forms.ValidationError('Title must be at least 3 characters.')
        return title

And the template that renders the form:

<!-- tasks/templates/tasks/task_form.html -->
{% extends 'tasks/base.html' %}

{% block title %}{{ action }} Task{% endblock %}

{% block content %}
<h1>{{ action }} Task</h1>

<form method="post">
    {% csrf_token %}
    
    {% for field in form %}
        <div style="margin: 10px 0;">
            <label for="{{ field.id_for_label }}">{{ field.label }}</label><br>
            {{ field }}
            {% if field.errors %}
                <p style="color: red;">{{ field.errors.0 }}</p>
            {% endif %}
        </div>
    {% endfor %}
    
    <button type="submit" class="btn btn-primary">{{ action }} Task</button>
</form>
{% endblock %}

Setting Up the Admin Panel

One of Django's most loved features is its automatic admin interface. With just a few lines, you get a fully functional admin panel for managing your data.

# tasks/admin.py
from django.contrib import admin
from .models import Task


@admin.register(Task)
class TaskAdmin(admin.ModelAdmin):
    list_display = ['title', 'status', 'priority', 'due_date', 'created_at']
    list_filter = ['status', 'priority']
    search_fields = ['title', 'description']
    list_editable = ['status', 'priority']
    date_hierarchy = 'created_at'

Create a superuser account to access the admin panel:

# Create admin account
python manage.py createsuperuser

# Start the server
python manage.py runserver

# Visit http://127.0.0.1:8000/admin/

Adding Static Files and Media

Most web apps need CSS, JavaScript, and user-uploaded files. Django has a built-in system for managing both static files (your code's assets) and media files (user uploads).

# taskmanager/settings.py

# Static files (CSS, JavaScript, images you ship with the app)
STATIC_URL = '/static/'
STATICFILES_DIRS = [BASE_DIR / 'static']

# Media files (user uploads)
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# Using static files in templates
{% load static %}
<link rel="stylesheet" href="{% static 'css/style.css' %}">
<img src="{% static 'images/logo.png' %}" alt="Logo">

Deploying Your Django App

When you are ready to go live, Django needs a few configuration changes. Here is a deployment checklist covering the essentials:

# taskmanager/settings.py - Production settings

import os

# SECURITY: Never expose your secret key in production
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')

# Disable debug mode
DEBUG = False

# Specify allowed hostnames
ALLOWED_HOSTS = ['yourdomain.com', 'www.yourdomain.com']

# Use a production database (PostgreSQL recommended)
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.environ.get('DB_NAME'),
        'USER': os.environ.get('DB_USER'),
        'PASSWORD': os.environ.get('DB_PASSWORD'),
        'HOST': os.environ.get('DB_HOST', 'localhost'),
        'PORT': os.environ.get('DB_PORT', '5432'),
    }
}

# Security settings
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True

For production, use Gunicorn as your WSGI server and Nginx as a reverse proxy:

# Install gunicorn
pip install gunicorn

# Collect static files for production
python manage.py collectstatic

# Run with gunicorn
gunicorn taskmanager.wsgi:application --bind 0.0.0.0:8000

Real-Life Example: Building a Team Task Board

Let us extend our task manager into a team collaboration tool. This shows how Django handles user relationships, permissions, and more complex queries in a practical scenario.

# tasks/models.py - Extended for team use
from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone


class Team(models.Model):
    name = models.CharField(max_length=100)
    members = models.ManyToManyField(User, related_name='teams')
    created_at = models.DateTimeField(auto_now_add=True)
    
    def __str__(self):
        return self.name


class Task(models.Model):
    STATUS_CHOICES = [
        ('todo', 'To Do'),
        ('in_progress', 'In Progress'),
        ('review', 'In Review'),
        ('done', 'Done'),
    ]
    
    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='todo')
    assigned_to = models.ForeignKey(
        User, on_delete=models.SET_NULL,
        null=True, blank=True, related_name='assigned_tasks'
    )
    team = models.ForeignKey(
        Team, on_delete=models.CASCADE, related_name='tasks'
    )
    due_date = models.DateField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    
    def __str__(self):
        return self.title


class Comment(models.Model):
    task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name='comments')
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    text = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    
    class Meta:
        ordering = ['created_at']


# Team dashboard view
def team_dashboard(request, team_id):
    team = get_object_or_404(Team, pk=team_id)
    
    # Get task statistics
    stats = {
        'total': team.tasks.count(),
        'todo': team.tasks.filter(status='todo').count(),
        'in_progress': team.tasks.filter(status='in_progress').count(),
        'done': team.tasks.filter(status='done').count(),
        'overdue': team.tasks.filter(
            due_date__lt=timezone.now().date()
        ).exclude(status='done').count(),
    }
    
    # Get tasks grouped by status for a kanban-style board
    columns = {}
    for value, label in Task.STATUS_CHOICES:
        columns[label] = team.tasks.filter(status=value).select_related('assigned_to')
    
    return render(request, 'tasks/team_dashboard.html', {
        'team': team,
        'stats': stats,
        'columns': columns,
    })

Troubleshooting Common Django Issues

IssueCauseSolution
TemplateDoesNotExistTemplate path or app not in INSTALLED_APPSCheck template directory structure and verify app is registered in settings.py
NoReverseMatchURL name doesn't match or missing argumentsVerify URL names in urls.py and pass all required arguments in template tags
OperationalError: no such tableMigrations not appliedRun python manage.py makemigrations then python manage.py migrate
CSRF verification failedMissing {% csrf_token %} in formAdd {% csrf_token %} inside every <form method="post"> tag
Static files not loadingMissing {% load static %} or wrong STATIC_URLAdd {% load static %} at top of template and check settings.py paths
Changes not reflectingBrowser cache or server not restartedHard refresh browser (Ctrl+Shift+R) and restart runserver

Frequently Asked Questions

What is the difference between a Django project and a Django app?

A project is the entire web application with its settings and configuration. An app is a modular component within the project that handles a specific feature. A project can contain many apps, and apps can be reused across projects. For example, a "tasks" app, a "users" app, and a "notifications" app might all live within one project.

Should I use function-based views or class-based views in Django?

Start with function-based views because they are more explicit and easier to understand. Class-based views are useful when you have repetitive patterns like CRUD operations, since Django provides generic views (ListView, CreateView, UpdateView, DeleteView) that reduce boilerplate. Most real projects use a mix of both depending on the complexity of each view.

How do I handle user authentication in Django?

Django includes a complete authentication system out of the box. Add django.contrib.auth to INSTALLED_APPS (it is there by default), use the @login_required decorator on views, and include django.contrib.auth.urls in your URL configuration for login, logout, and password reset views. For registration, create a custom view using Django's UserCreationForm.

Which database should I use with Django?

SQLite works fine for development and small projects — Django uses it by default. For production, PostgreSQL is the recommended choice because it has the best Django support, including full-text search, JSONField, and ArrayField. MySQL and MariaDB are also supported. The Django ORM makes switching databases straightforward since your Python code stays the same.

How do I deploy a Django app to production?

Set DEBUG = False, configure a production database (PostgreSQL), use environment variables for secrets, run collectstatic, and serve with Gunicorn behind Nginx. Popular hosting options include DigitalOcean, Railway, Render, and AWS. For managed platforms, services like Heroku and PythonAnywhere simplify the process significantly. Always run python manage.py check --deploy before going live.

How To Use Python Property Decorators and Descriptors

How To Use Python Property Decorators and Descriptors

Last Updated: June 01, 2026

Intermediate

You have built a Python class with a handful of attributes, and everything works fine — until a user sets age to -5 or email to an empty string. Suddenly your downstream code breaks in confusing ways because nobody validated the data at the point where it was assigned. The quick fix is writing explicit getter and setter methods like Java, but that makes your clean Python code look bloated and ugly.

Python has a built-in solution for this: the @property decorator. It lets you define methods that look and feel like regular attribute access to the caller, while secretly running validation, computation, or logging behind the scenes. Combined with descriptors — the lower-level protocol that powers @property under the hood — you can build reusable, self-validating attribute types that work across any class.

In this article, we will start with a quick example showing @property in action, then explain what properties and descriptors actually are, walk through getters, setters, and deleters, build custom descriptors, and finish with a real-life example that ties everything together. By the end, you will know how to protect your class attributes without sacrificing Python’s clean syntax.

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 Property Decorator: Quick Example

Here is the shortest useful example of @property — a Temperature class that stores Celsius internally but lets you read Fahrenheit as if it were a normal attribute.

# quick_example.py
class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def fahrenheit(self):
        return self.celsius * 9 / 5 + 32

temp = Temperature(100)
print(temp.fahrenheit)
print(temp.celsius)
temp.celsius = 0
print(temp.fahrenheit)

Output:

212.0
100
32.0

Notice how temp.fahrenheit looks like a regular attribute access — no parentheses, no method call syntax. But behind the scenes, Python is running the fahrenheit method every time you access it. This is the core idea behind properties: methods disguised as attributes.

The real power shows up when you add a setter, which we will cover in the sections below.

What Are Properties and Why Use Them?

A property in Python is a special kind of attribute that delegates access to methods. When you read the attribute, Python calls a getter method. When you write to it, Python calls a setter method. When you delete it, Python calls a deleter method. The caller never knows methods are involved — they just use normal dot notation.

This matters because it lets you start with simple public attributes and add validation or computation later without changing the API. In languages like Java, you must write getX() and setX() methods from day one “just in case”. In Python, you can freely use plain attributes until you actually need control, then switch to properties without breaking any calling code.

ApproachSyntaxWhen to Use
Plain attributeobj.x = 5No validation needed, simple storage
Propertyobj.x = 5 (calls setter)Need validation, computation, or logging
Descriptorobj.x = 5 (calls __set__)Reusable validation across multiple classes
Getter/Setter methodsobj.get_x()Avoid in Python — not idiomatic

Properties are the right choice when a single class needs managed attributes. Descriptors are the right choice when you want to reuse that management logic across many classes or attributes.

@property turns attribute access into a guarded method.
@property turns attribute access into a guarded method.

Creating a Property Getter

The simplest property is a read-only computed attribute. You decorate a method with @property and it becomes accessible as an attribute. This is useful for values that are derived from other attributes and should always stay in sync.

# property_getter.py
class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        return self._radius

    @property
    def area(self):
        return 3.14159 * self._radius ** 2

    @property
    def circumference(self):
        return 2 * 3.14159 * self._radius

circle = Circle(5)
print(f"Radius: {circle.radius}")
print(f"Area: {circle.area:.2f}")
print(f"Circumference: {circle.circumference:.2f}")

Output:

Radius: 5
Area: 78.54
Circumference: 31.42

The area and circumference properties are computed on every access from the stored _radius value. They cannot go out of sync because there is no separate stored value to drift. The underscore prefix on _radius is a Python convention meaning “this is internal, use the property instead”.

Adding a Property Setter

A getter alone makes the attribute read-only. To allow assignment with validation, add a setter using the @name.setter decorator. The setter receives the value being assigned and can validate, transform, or reject it before storing.

# property_setter.py
class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        if not isinstance(value, str) or len(value.strip()) == 0:
            raise ValueError("Name must be a non-empty string")
        self._name = value.strip()

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if not isinstance(value, int) or value < 0 or value > 150:
            raise ValueError("Age must be an integer between 0 and 150")
        self._age = value

user = User("Alice", 30)
print(f"{user.name}, age {user.age}")

user.name = "  Bob  "
print(f"Name after update: {user.name}")

user.age = 25
print(f"Age after update: {user.age}")

try:
    user.age = -5
except ValueError as e:
    print(f"Error: {e}")

try:
    user.name = ""
except ValueError as e:
    print(f"Error: {e}")

Output:

Alice, age 30
Name after update: Bob
Age after update: 25
Error: Age must be an integer between 0 and 150
Error: Name must be a non-empty string

The validation runs every time you assign to user.name or user.age — including inside __init__. This is a key benefit: the constructor uses self.name = name (not self._name = name), so the validation runs on object creation too.

Descriptors run code when attributes are touched. Magical and dangerous.
Descriptors run code when attributes are touched. Magical and dangerous.

Adding a Property Deleter

The third and least common piece of the property puzzle is the deleter. It runs when someone uses del obj.attribute. This is useful for cleanup logic or for resetting an attribute to a default state.

# property_deleter.py
class CachedData:
    def __init__(self, raw_data):
        self._raw_data = raw_data
        self._processed = None

    @property
    def processed(self):
        if self._processed is None:
            print("Processing data (expensive operation)...")
            self._processed = sorted(set(self._raw_data))
        return self._processed

    @processed.deleter
    def processed(self):
        print("Clearing cache")
        self._processed = None

data = CachedData([3, 1, 4, 1, 5, 9, 2, 6, 5])
print(data.processed)
print(data.processed)
del data.processed
print(data.processed)

Output:

Processing data (expensive operation)...
[1, 2, 3, 4, 5, 6, 9]
[1, 2, 3, 4, 5, 6, 9]
Clearing cache
Processing data (expensive operation)...
[1, 2, 3, 4, 5, 6, 9]

The first access triggers processing. The second access returns the cached result. After del data.processed, the cache clears, and the next access reprocesses. This pattern is called “lazy evaluation with cache invalidation” and is common in data pipelines.

Understanding Descriptors

Properties are actually built on top of a more fundamental Python mechanism called the descriptor protocol. A descriptor is any object that defines __get__, __set__, or __delete__ methods. When Python looks up an attribute and finds a descriptor on the class, it calls those methods instead of returning the descriptor object itself.

This is what makes descriptors powerful: you define the attribute behavior once in a separate class, then reuse it across any number of attributes in any number of classes. Properties are single-use descriptors. Custom descriptors are reusable ones.

# descriptor_basics.py
class PositiveNumber:
    def __init__(self, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__.get(self.name, 0)

    def __set__(self, obj, value):
        if not isinstance(value, (int, float)):
            raise TypeError(f"{self.name} must be a number")
        if value <= 0:
            raise ValueError(f"{self.name} must be positive")
        obj.__dict__[self.name] = value

    def __delete__(self, obj):
        del obj.__dict__[self.name]

class Product:
    price = PositiveNumber("price")
    weight = PositiveNumber("weight")

    def __init__(self, name, price, weight):
        self.name = name
        self.price = price
        self.weight = weight

item = Product("Widget", 9.99, 0.5)
print(f"{item.name}: ${item.price}, {item.weight}kg")

try:
    item.price = -10
except ValueError as e:
    print(f"Error: {e}")

try:
    item.weight = 'heavy'
except TypeError as e:
    print(f"Error: {e}")

Output:

Widget: $9.99, 0.5kg
Error: price must be positive
Error: weight must be a number

The PositiveNumber descriptor is defined once and used for both price and weight. You could use it in any class that needs positive numeric attributes.

Getters and setters without ceremony. Pythonic, finally.
Getters and setters without ceremony. Pythonic, finally.

Building Custom Descriptors

Let us build a more sophisticated descriptor that validates string attributes with configurable constraints: minimum length, maximum length, and an optional regex pattern.

# custom_descriptor.py
import re

class ValidatedString:
    def __init__(self, name, min_length=0, max_length=None, pattern=None):
        self.name = name
        self.min_length = min_length
        self.max_length = max_length
        self.pattern = re.compile(pattern) if pattern else None

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__.get(self.name, "")

    def __set__(self, obj, value):
        if not isinstance(value, str):
            raise TypeError(f"{self.name} must be a string")
        value = value.strip()
        if len(value) < self.min_length:
            raise ValueError(
                f"{self.name} must be at least {self.min_length} characters"
            )
        if self.max_length and len(value) > self.max_length:
            raise ValueError(
                f"{self.name} must be at most {self.max_length} characters"
            )
        if self.pattern and not self.pattern.match(value):
            raise ValueError(
                f"{self.name} does not match required pattern"
            )
        obj.__dict__[self.name] = value

class Registration:
    username = ValidatedString("username", min_length=3, max_length=20,
                                pattern=r"^[a-zA-Z0-9_]+$")
    email = ValidatedString("email", min_length=5,
                             pattern=r"^[^@]+@[^@]+\.[^@]+$")
    bio = ValidatedString("bio", max_length=200)

    def __init__(self, username, email, bio=""):
        self.username = username
        self.email = email
        self.bio = bio

reg = Registration("alice_dev", "alice@example.com", "Python developer")
print(f"User: {reg.username}, Email: {reg.email}")

try:
    Registration("ab", "test@test.com")
except ValueError as e:
    print(f"Error: {e}")

try:
    Registration("valid_user", "not-an-email")
except ValueError as e:
    print(f"Error: {e}")

Output:

User: alice_dev, Email: alice@example.com
Error: username must be at least 3 characters
Error: email does not match required pattern

This ValidatedString descriptor handles three different kinds of validation in one reusable class.

Property vs Descriptor: When to Use Each

Both properties and descriptors manage attribute access, but they serve different use cases.

CriteriaUse @propertyUse a Descriptor
ReusabilityLogic used in one class onlyLogic reused across multiple classes
Number of attributes1-2 managed attributesMany attributes with same rules
ComplexitySimple get/set/deleteConfigurable validation with parameters
Learning curveEasy to understandRequires understanding __get__/__set__
Typical useComputed values, basic validationForm fields, ORM columns, API models

Start with @property when you first need managed attribute access. If you find yourself copying the same property logic across classes, refactor it into a descriptor.

Real-Life Example: Building a Configuration Manager

Let us build a practical configuration manager that uses both properties and descriptors to validate settings for a web application.

# config_manager.py
class RangeValidator:
    def __init__(self, name, min_val, max_val):
        self.name = name
        self.min_val = min_val
        self.max_val = max_val

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__.get(self.name, self.min_val)

    def __set__(self, obj, value):
        if not isinstance(value, (int, float)):
            raise TypeError(f"{self.name} must be a number")
        if value < self.min_val or value > self.max_val:
            raise ValueError(
                f"{self.name} must be between {self.min_val} and {self.max_val}"
            )
        obj.__dict__[self.name] = value

class ChoiceValidator:
    def __init__(self, name, choices):
        self.name = name
        self.choices = choices

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__.get(self.name, self.choices[0])

    def __set__(self, obj, value):
        if value not in self.choices:
            raise ValueError(
                f"{self.name} must be one of {self.choices}"
            )
        obj.__dict__[self.name] = value

class AppConfig:
    port = RangeValidator("port", 1024, 65535)
    max_connections = RangeValidator("max_connections", 1, 10000)
    log_level = ChoiceValidator("log_level",
                                 ["DEBUG", "INFO", "WARNING", "ERROR"])

    def __init__(self, port=8080, max_connections=100, log_level="INFO",
                 app_name="MyApp", debug=False):
        self.port = port
        self.max_connections = max_connections
        self.log_level = log_level
        self._app_name = app_name
        self._debug = debug

    @property
    def debug(self):
        return self._debug

    @debug.setter
    def debug(self, value):
        if not isinstance(value, bool):
            raise TypeError("debug must be a boolean")
        self._debug = value
        if value:
            self.log_level = "DEBUG"
            print("Debug mode enabled -- log level set to DEBUG")

    @property
    def base_url(self):
        protocol = "http" if self._debug else "https"
        return f"{protocol}://localhost:{self.port}"

    def summary(self):
        return (
            f"App: {self._app_name}\n"
            f"URL: {self.base_url}\n"
            f"Port: {self.port}\n"
            f"Max Connections: {self.max_connections}\n"
            f"Log Level: {self.log_level}\n"
            f"Debug: {self.debug}"
        )

config = AppConfig(port=3000, app_name="TutorialApp")
print(config.summary())
print()

config.debug = True
print(f"URL changed to: {config.base_url}")
print()

try:
    config.port = 80
except ValueError as e:
    print(f"Port error: {e}")

try:
    config.log_level = "TRACE"
except ValueError as e:
    print(f"Log level error: {e}")

config.max_connections = 500
print(f"Updated max connections: {config.max_connections}")

Output:

App: TutorialApp
URL: https://localhost:3000
Port: 3000
Max Connections: 100
Log Level: INFO
Debug: False

Debug mode enabled -- log level set to DEBUG
URL changed to: http://localhost:3000

Port error: port must be between 1024 and 65535
Log level error: log_level must be one of ['DEBUG', 'INFO', 'WARNING', 'ERROR']
Updated max connections: 500

This configuration manager uses descriptors for reusable numeric and choice validation, and properties for one-off computed values and side effects.

Frequently Asked Questions

Can I use @property with __slots__?

Yes, properties work with __slots__, but you need to include the private attribute name (like _name) in your slots tuple, not the property name. The property itself lives on the class, not the instance, so it does not need a slot.

What happens if I define a property without a setter and try to assign to it?

Python raises an AttributeError with the message “property ‘x’ of ‘ClassName’ object has no setter”. This makes it clear that the attribute is read-only.

Do descriptors work with inheritance?

Yes. If a parent class defines a descriptor attribute, subclasses inherit it. You can override it by defining a new descriptor or property with the same name in the subclass. The method resolution order (MRO) determines which descriptor Python finds first.

What is the difference between a data descriptor and a non-data descriptor?

A data descriptor defines both __get__ and __set__ (or __delete__). A non-data descriptor only defines __get__. The difference matters for attribute lookup priority: data descriptors take precedence over instance __dict__ entries, while non-data descriptors do not.

Is there a performance cost to using properties?

There is a small overhead compared to direct attribute access — Python must call a function instead of looking up a dictionary value. For most applications this is negligible. If you are in a tight loop accessing a property millions of times, consider caching the value in a local variable before the loop.

Conclusion

Python properties and descriptors give you full control over attribute access without sacrificing the clean obj.attr syntax. We covered @property for getters, setters, and deleters, then built custom descriptors like PositiveNumber, ValidatedString, RangeValidator, and ChoiceValidator that can be reused across any class.

The configuration manager example showed how properties and descriptors complement each other: descriptors handle reusable validation patterns, while properties handle one-off computed values and side effects.

For more details, see the official Python documentation on Descriptor HowTo Guide and the property built-in.

How To Master Python List Comprehensions with Examples

How To Master Python List Comprehensions with Examples

Last Updated: June 01, 2026

Beginner

How To Use Python argparse for Command-Line Arguments

Command-line tools are the backbone of modern development workflows. Whether you’re building deployment scripts, data processing utilities, or automation tools, your Python scripts need to accept arguments and options from the terminal. Without a proper argument parser, you’ll end up manually processing strings from sys.argv, leading to inconsistent interfaces, missing help messages, and frustrated users. This is where Python’s argparse module transforms the experience–from chaotic string parsing to professional, user-friendly CLI tools.

The good news is that argparse comes built into Python’s standard library. You don’t need to install third-party dependencies. Whether you’re a beginner or building production tools, argparse provides everything needed to handle positional arguments, optional flags, type conversion, default values, and even complex subcommands. It automatically generates help messages, validates arguments, and gives users clear error messages when they get something wrong.

In this tutorial, we’ll walk through argparse from the ground up. You’ll learn how to build your first argument parser, understand the difference between positional and optional arguments, handle type conversion and validation, create mutually exclusive groups, and organize complex CLIs with subcommands. By the end, you’ll have the skills to build professional command-line tools that work exactly how users expect them to work.

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

Before diving into theory, here’s a working script that shows the core pattern. This is all you need to get started:

# hello_cli.py
import argparse

parser = argparse.ArgumentParser(description='A simple greeting tool')
parser.add_argument('name', help='Person to greet')
parser.add_argument('--age', type=int, help='Age of the person')
parser.add_argument('--excited', action='store_true', help='Add enthusiasm')

args = parser.parse_args()

greeting = f"Hello, {args.name}"
if args.age:
    greeting += f" (age {args.age})"
if args.excited:
    greeting += "!!!"
else:
    greeting += "."

print(greeting)

Output:

$ python hello_cli.py Alice --age 30 --excited
Hello, Alice (age 30)!!!

$ python hello_cli.py Bob
Hello, Bob.

$ python hello_cli.py --help
usage: hello_cli.py [-h] [--age AGE] [--excited] name

A simple greeting tool

positional arguments:
  name        Person to greet

optional arguments:
  -h, --help  show this help message and exit
  --age AGE   Age of the person
  --excited   Add enthusiasm
Clean code with list comprehensions
When your code is so clean, it reflects back at you.

What Is argparse?

The argparse module is Python’s standard library tool for parsing command-line arguments. It automates the tedious work of extracting and validating arguments, freeing you to focus on your application logic. When you create an argument parser, you define what arguments your script accepts, what types they should be, whether they’re required, and what help text to display. Then argparse handles everything else–parsing, validation, and generating help messages.

Before Python formalized argparse, developers used the older getopt module or even manually parsed sys.argv lists. Today, argparse is the standard choice because it’s more powerful and easier to use. For very simple scripts, sys.argv works fine. For anything more complex than a couple of arguments, argparse saves you hours of debugging and edge case handling.

Here’s how argparse compares to other approaches:

Feature argparse sys.argv click (3rd-party)
Built-in to Python Yes Yes No
Automatic help generation Yes No Yes
Type conversion Yes Manual Yes
Subcommands Yes Manual Yes
Learning curve Moderate Steep Gentle
Setup complexity Low Low Medium

For most projects, argparse strikes the perfect balance between power and simplicity. You get production-grade functionality without external dependencies.

Positional Arguments

What Are Positional Arguments?

Positional arguments are required values that the user must provide in a specific order. Think of them as the “nouns” of your command. When you see git commit -m "message", the word after “commit” is a positional argument. In argparse, positional arguments are required by default and must appear before optional arguments.

# file_reader.py
import argparse

parser = argparse.ArgumentParser(description='Read file contents')
parser.add_argument('filename', help='Path to the file to read')
parser.add_argument('encoding', help='File encoding (e.g., utf-8)')

args = parser.parse_args()

try:
    with open(args.filename, 'r', encoding=args.encoding) as f:
        print(f.read())
except FileNotFoundError:
    print(f"Error: File '{args.filename}' not found")

Output:

$ python file_reader.py data.txt utf-8
[contents of data.txt...]

$ python file_reader.py data.txt
usage: file_reader.py [-h] filename encoding
file_reader.py: error: the following arguments are required: encoding

Making Positional Arguments Optional

You can make a positional argument optional by using the nargs parameter. Setting nargs='?' means “zero or one” of this argument:

# search_tool.py
import argparse

parser = argparse.ArgumentParser(description='Search tool')
parser.add_argument('query', help='Search term')
parser.add_argument('directory', nargs='?', default='.', help='Directory to search (default: current)')

args = parser.parse_args()

print(f"Searching for '{args.query}' in '{args.directory}'")

Output:

$ python search_tool.py "python" .
Searching for 'python' in '.'

$ python search_tool.py "python"
Searching for 'python' in '.'
Converting loops to one-liners
When your loop becomes a single-liner.

Optional Arguments and Flags

Single vs Double Dashes

Optional arguments start with dashes. A single dash like -v is a “short” option (typically one letter), while double dashes like --verbose are “long” options (typically words). You can provide both:

# backup_tool.py
import argparse

parser = argparse.ArgumentParser(description='Backup files')
parser.add_argument('--verbose', '-v', action='store_true', help='Show detailed output')
parser.add_argument('--output', '-o', help='Output directory')
parser.add_argument('--compress', '-c', action='store_true', help='Compress backup')

args = parser.parse_args()

output_dir = args.output or './backups'
print(f"Backing up to: {output_dir}")
if args.verbose:
    print("Verbose mode enabled")
if args.compress:
    print("Compression enabled")

Output:

$ python backup_tool.py -v -c
Backing up to: ./backups
Verbose mode enabled
Compression enabled

$ python backup_tool.py --output /mnt/backup --verbose
Backing up to: /mnt/backup
Verbose mode enabled

Boolean Flags with action

The action='store_true' parameter turns an optional argument into a boolean flag. By default, the value is False. When the flag is present, it becomes True. Use action='store_false' for the opposite behavior:

# config_tool.py
import argparse

parser = argparse.ArgumentParser(description='Configuration tool')
parser.add_argument('--enable-logging', action='store_true', help='Enable logging')
parser.add_argument('--skip-cache', action='store_true', help='Skip cache')
parser.add_argument('--no-color', action='store_false', dest='color', help='Disable color output')

args = parser.parse_args()

print(f"Logging: {args.enable_logging}")
print(f"Skip cache: {args.skip_cache}")
print(f"Color output: {args.color}")

Output:

$ python config_tool.py --enable-logging
Logging: True
Skip cache: False
Color output: True

$ python config_tool.py --enable-logging --no-color
Logging: True
Skip cache: False
Color output: False

Type Conversion and Validation

By default, all arguments are treated as strings. Use the type parameter to convert them automatically. Python provides built-in types like int, float, and bool, and you can define custom conversion functions:

# math_cli.py
import argparse

parser = argparse.ArgumentParser(description='Math operations')
parser.add_argument('--numbers', type=float, nargs='+', help='Numbers to process')
parser.add_argument('--max-results', type=int, default=10, help='Maximum results')
parser.add_argument('--threshold', type=float, default=0.5, help='Threshold value')

args = parser.parse_args()

if args.numbers:
    total = sum(args.numbers)
    avg = total / len(args.numbers)
    print(f"Sum: {total}, Average: {avg}")
    print(f"Max results: {args.max_results}")
    print(f"Threshold: {args.threshold}")

Output:

$ python math_cli.py --numbers 5.2 3.1 7.8 --max-results 20
Sum: 16.1, Average: 5.366666666666667
Max results: 20
Threshold: 0.5

Custom Type Functions

For complex validation, write a function that takes a string and returns the converted value, or raises argparse.ArgumentTypeError if invalid:

# port_validator.py
import argparse

def valid_port(value):
    port = int(value)
    if not (1 <= port <= 65535):
        raise argparse.ArgumentTypeError(f"{value} is not a valid port (1-65535)")
    return port

parser = argparse.ArgumentParser(description='Server launcher')
parser.add_argument('--port', type=valid_port, default=8000, help='Port number')
parser.add_argument('--host', default='localhost', help='Host address')

args = parser.parse_args()

print(f"Starting server at {args.host}:{args.port}")

Output:

$ python port_validator.py --port 3000
Starting server at localhost:3000

$ python port_validator.py --port 70000
usage: port_validator.py [-h] [--port PORT] [--host HOST]
port_validator.py: error: argument --port: 70000 is not a valid port (1-65535)
Nested list comprehensions
When you nest your comprehensions just right.

Choices and Default Values

The choices parameter restricts an argument to a specific set of values. This is useful for mode selection, environment names, or any enumerated option. When combined with default, you provide sensible fallback behavior:

# deployment_tool.py
import argparse

parser = argparse.ArgumentParser(description='Deployment tool')
parser.add_argument('environment', choices=['dev', 'staging', 'prod'],
                   help='Target environment')
parser.add_argument('--log-level', choices=['debug', 'info', 'warning', 'error'],
                   default='info', help='Logging level')
parser.add_argument('--timeout', type=int, default=30, help='Timeout in seconds')
parser.add_argument('--retry-count', type=int, default=3, help='Number of retries')

args = parser.parse_args()

print(f"Deploying to {args.environment}")
print(f"Log level: {args.log_level}")
print(f"Timeout: {args.timeout}s, Retries: {args.retry_count}")

Output:

$ python deployment_tool.py staging
Deploying to staging
Log level: info
Timeout: 30s, Retries: 3

$ python deployment_tool.py prod --log-level debug --timeout 60
Deploying to prod
Log level: debug
Timeout: 60s, Retries: 3

$ python deployment_tool.py testing
usage: deployment_tool.py [-h] [--log-level {debug,info,warning,error}]
                          [--timeout TIMEOUT] [--retry-count RETRY_COUNT]
                          {dev,staging,prod}
deployment_tool.py: error: argument environment: invalid choice: 'testing'
(choose from 'dev', 'staging', 'prod')

Mutually Exclusive Groups

Sometimes you want to ensure that only one of several options can be used at a time. The add_mutually_exclusive_group() method enforces this constraint and provides helpful error messages when users violate it:

# data_converter.py
import argparse

parser = argparse.ArgumentParser(description='Data format converter')
parser.add_argument('input_file', help='Input file path')

format_group = parser.add_mutually_exclusive_group(required=True)
format_group.add_argument('--to-json', action='store_true', help='Convert to JSON')
format_group.add_argument('--to-csv', action='store_true', help='Convert to CSV')
format_group.add_argument('--to-xml', action='store_true', help='Convert to XML')

parser.add_argument('--pretty', action='store_true', help='Pretty-print output')

args = parser.parse_args()

output_format = None
if args.to_json:
    output_format = 'json'
elif args.to_csv:
    output_format = 'csv'
elif args.to_xml:
    output_format = 'xml'

print(f"Converting {args.input_file} to {output_format}")
if args.pretty:
    print("Pretty-printing enabled")

Output:

$ python data_converter.py data.txt --to-json --pretty
Converting data.txt to json
Pretty-printing enabled

$ python data_converter.py data.txt --to-json --to-csv
usage: data_converter.py [-h] (--to-json | --to-csv | --to-xml) [--pretty]
                         input_file
data_converter.py: error: argument --to-csv: not allowed with argument --to-json
Zen of Python comprehensions
Zen through Pythonic code.

Subcommands

Complex tools often have multiple "modes" like git commit, git push, git clone. Use add_subparsers() to create subcommand structures. Each subcommand gets its own set of arguments and can have different behaviors:

# package_manager.py
import argparse

parser = argparse.ArgumentParser(description='Package manager')
subparsers = parser.add_subparsers(dest='command', help='Available commands')

# Install subcommand
install_parser = subparsers.add_parser('install', help='Install a package')
install_parser.add_argument('package_name', help='Package to install')
install_parser.add_argument('--version', help='Specific version to install')
install_parser.add_argument('--upgrade', action='store_true', help='Upgrade if exists')

# Remove subcommand
remove_parser = subparsers.add_parser('remove', help='Remove a package')
remove_parser.add_argument('package_name', help='Package to remove')
remove_parser.add_argument('--force', action='store_true', help='Force removal')

# List subcommand
list_parser = subparsers.add_parser('list', help='List installed packages')
list_parser.add_argument('--outdated', action='store_true', help='Only show outdated')

args = parser.parse_args()

if args.command == 'install':
    version = args.version or 'latest'
    upgrade_msg = " (upgrading)" if args.upgrade else ""
    print(f"Installing {args.package_name} version {version}{upgrade_msg}")
elif args.command == 'remove':
    force_msg = " (forced)" if args.force else ""
    print(f"Removing {args.package_name}{force_msg}")
elif args.command == 'list':
    filter_msg = " outdated packages" if args.outdated else " packages"
    print(f"Listing{filter_msg}")
else:
    parser.print_help()

Output:

$ python package_manager.py install numpy --version 1.24
Installing numpy version 1.24

$ python package_manager.py remove requests --force
Removing requests (forced)

$ python package_manager.py list --outdated
Listing outdated packages

$ python package_manager.py --help
usage: package_manager.py [-h] {install,remove,list} ...

Package manager

positional arguments:
  {install,remove,list}  Available commands
    install              Install a package
    remove               Remove a package
    list                 List installed packages

optional arguments:
  -h, --help             show this help message and exit

Real-Life Example: Building a File Organizer CLI

Let's combine everything into a practical file organization tool. This script organizes files by extension, with options for dry-run mode, custom destinations, and file type filtering:

# file_organizer.py
import argparse
import os
import shutil
from pathlib import Path

def valid_directory(value):
    if not os.path.isdir(value):
        raise argparse.ArgumentTypeError(f"'{value}' is not a valid directory")
    return value

parser = argparse.ArgumentParser(
    description='Organize files in a directory by extension',
    formatter_class=argparse.RawDescriptionHelpFormatter,
    epilog='''Examples:
  python file_organizer.py ~/Downloads
  python file_organizer.py ~/Downloads --extensions txt pdf --dry-run
  python file_organizer.py ~/Downloads --output ~/Organized --clean
'''
)

parser.add_argument('source_dir', type=valid_directory, help='Directory to organize')
parser.add_argument('--output', '-o', type=valid_directory, default=None,
                   help='Output directory (default: same as source)')
parser.add_argument('--extensions', '-e', nargs='+', default=None,
                   help='Only organize these file types (e.g., txt pdf)')
parser.add_argument('--dry-run', action='store_true',
                   help='Show what would happen without making changes')
parser.add_argument('--clean', action='store_true',
                   help='Remove empty subdirectories after organizing')

args = parser.parse_args()

source = Path(args.source_dir)
output = Path(args.output) if args.output else source

file_count = 0
for file_path in source.glob('*'):
    if file_path.is_file():
        ext = file_path.suffix[1:].lower() or 'no_extension'

        if args.extensions and ext not in args.extensions:
            continue

        target_dir = output / ext

        if args.dry_run:
            print(f"Would move: {file_path.name} -> {ext}/")
        else:
            target_dir.mkdir(exist_ok=True)
            shutil.move(str(file_path), str(target_dir / file_path.name))
            print(f"Moved: {file_path.name} -> {ext}/")

        file_count += 1

print(f"\nTotal files processed: {file_count}")

if args.clean and not args.dry_run:
    removed = 0
    for subdir in source.iterdir():
        if subdir.is_dir() and not list(subdir.iterdir()):
            subdir.rmdir()
            removed += 1
    if removed > 0:
        print(f"Removed {removed} empty directories")

Output:

$ python file_organizer.py ~/Downloads --dry-run
Would move: report.pdf -> pdf/
Would move: script.py -> py/
Would move: image.jpg -> jpg/

Total files processed: 3

$ python file_organizer.py ~/Downloads --extensions txt py --clean
Moved: notes.txt -> txt/
Moved: script.py -> py/
Removed 2 empty directories

Total files processed: 2
When comprehensions get too clever
When your comprehension is just a little too clever.

Frequently Asked Questions

What does nargs do?

The nargs parameter controls how many values an argument accepts. Use nargs='+' for one or more values, nargs='*' for zero or more, nargs=N for exactly N values, and nargs='?' for zero or one. This is essential for accepting variable-length lists of inputs.

What is the dest parameter?

The dest parameter specifies the attribute name where the parsed argument value will be stored. By default, argparse converts the argument name to a valid Python identifier (e.g., --my-option becomes args.my_option). Use dest to override this: parser.add_argument('--my-option', dest='custom_name') stores the value in args.custom_name.

How do I customize help text formatting?

Pass formatter_class=argparse.RawDescriptionHelpFormatter to preserve formatting in description text, or use argparse.RawTextHelpFormatter for help text. Use epilog to add text at the end of the help message. The help parameter for each argument becomes part of the auto-generated help output.

How do I make optional arguments required?

Pass required=True to add_argument(). For example: parser.add_argument('--api-key', required=True). This forces users to provide the argument, even though it uses the dash syntax of optional arguments. It's useful when you need to maintain consistent naming but require the value.

Can argparse read from environment variables?

Yes, use env_var (Python 3.10+) or manually check environment variables in your code. For older Python versions, use: parser.add_argument('--api-key', default=os.getenv('API_KEY')). This provides flexibility for users who prefer environment variables over command-line arguments.

Conclusion

You now have a solid foundation in Python's argparse module. You've learned to create positional and optional arguments, handle type conversion and validation, organize options with mutually exclusive groups, and structure complex CLIs with subcommands. The patterns shown here scale from simple scripts to sophisticated command-line applications used by thousands of developers.

The best way to internalize these concepts is to build something. Start with a simple script that needs two or three arguments, then gradually add complexity. Reference the official argparse documentation when you need advanced features like custom formatters or argument groups. Your future self will thank you for building tools with clear, well-documented interfaces.

List Comprehension Syntax

The basic form: [expression for variable in iterable]. Optionally add a filter: [expression for variable in iterable if condition]:

# Simple — square each number
squares = [x * x for x in range(10)]
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

# With filter
evens = [x for x in range(20) if x % 2 == 0]
# [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

# Transform AND filter
even_squares = [x * x for x in range(20) if x % 2 == 0]
# [0, 4, 16, 36, 64, 100, 144, 196, 256, 324]

# Equivalent to:
result = []
for x in range(20):
    if x % 2 == 0:
        result.append(x * x)

Comprehensions are faster than equivalent for-loops because Python optimizes them — no append attribute lookup per iteration.

Nested Comprehensions

You can nest comprehensions for matrix-like work:

# Flatten a 2D matrix
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [x for row in matrix for x in row]
# [1, 2, 3, 4, 5, 6, 7, 8, 9]

# Transpose a matrix
matrix = [[1, 2, 3], [4, 5, 6]]
transposed = [[row[i] for row in matrix] for i in range(len(matrix[0]))]
# [[1, 4], [2, 5], [3, 6]]

# Cartesian product
pairs = [(x, y) for x in [1, 2, 3] for y in ["a", "b"]]
# [(1, 'a'), (1, 'b'), (2, 'a'), (2, 'b'), (3, 'a'), (3, 'b')]

Dict and Set Comprehensions

Same syntax with {} instead of []:

# Dict comprehension
squares = {x: x*x for x in range(5)}
# {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}

# Build a lookup table
fruits = ["apple", "banana", "cherry"]
lookup = {f: len(f) for f in fruits}
# {'apple': 5, 'banana': 6, 'cherry': 6}

# Set comprehension — unique values only
unique_lengths = {len(word) for word in ["hi", "hello", "hey", "world"]}
# {2, 3, 5}

Generator Expressions

For large iterables where you only need to walk through once, generator expressions use () instead of [] — they yield items lazily:

# List comprehension — builds the whole list in memory
total = sum([x * x for x in range(10_000_000)])

# Generator expression — one value at a time, constant memory
total = sum(x * x for x in range(10_000_000))

# Generator inside a function call doesn't need extra parens
nums = [1, 2, 3]
print(sum(n*2 for n in nums))    # 12

# Generator chains
words = ["hello", "world", "python"]
lengths = (len(w) for w in words)
big_lengths = (l for l in lengths if l > 4)
print(list(big_lengths))

Rule of thumb: if you'll only iterate once OR the result would be huge, use a generator. Otherwise, list comprehension.

When Not to Use Comprehensions

Comprehensions are great for simple transformations. They become unreadable when nested too deep or doing too much per iteration:

# Too clever — refactor to a for-loop
result = [
    process(x, y) if validate(x) else default
    for x in iterable1
    for y in iterable2
    if filter1(x) and filter2(y)
]

# Better
result = []
for x in iterable1:
    if not filter1(x):
        continue
    for y in iterable2:
        if not filter2(y):
            continue
        result.append(process(x, y) if validate(x) else default)

If your comprehension needs scrolling to read, it's too complex. Loops are fine — Python isn't graded on density.

Common Pitfalls

  • Side effects in the expression. [print(x) for x in items] is wrong — comprehensions should produce values, not have side effects. Use a for-loop.
  • Forgetting Python 3 scope. The loop variable in a comprehension is scoped to the comprehension. x doesn't leak out in Python 3 (it did in Python 2).
  • Walrus operator confusion. [y := x*x for x in range(10)]y ends up scoped to the comprehension, not the surrounding code (mostly — there are edge cases).
  • Eager evaluation when you wanted lazy. List comprehensions materialize immediately. For lazy / pipelined work, use generator expressions.
  • Trying to break out early. Comprehensions have no break. Use a generator + next() or refactor.

FAQ

Q: List comprehension or for-loop?
A: Comprehension when the logic fits on one readable line. For-loop when you have multiple steps, side effects, or complex conditionals.

Q: Are comprehensions faster than for-loops?
A: Marginally — they avoid the append method lookup per iteration. The win is readability, not speed. For CPU-bound work, NumPy/Numba beats both.

Q: Can I use async in a comprehension?
A: Yes — async comprehensions: [x async for x in async_iter]. Requires Python 3.6+.

Q: Generator vs list — when does it matter?
A: Memory: generator is constant-memory, list grows with size. Speed: list is faster if you iterate the same data twice (no recomputation). Use a list when you need len() or indexing; generator when you stream.

Q: How do I conditionally include items?
A: Filter clause: [x for x in xs if condition]. To pick BETWEEN two expressions, use a conditional expression in the body: [x if condition else y for x in xs] — note that the filter goes after the for, the conditional value goes before.

Wrapping Up

List comprehensions are one of Python's most-loved features — concise, fast, idiomatic. Master the basic form first, add filters and nested loops as needed, and reach for generator expressions when iterating once over large data. The cardinal rule: if it's not readable, refactor to a for-loop. Comprehensions are tools for clarity, not contests in cleverness.

How To Use the Rich Library for Beautiful Terminal Output in Python

How To Use the Rich Library for Beautiful Terminal Output in Python

Last Updated: June 01, 2026

Beginner

How To Use Python argparse for Command-Line Arguments

Command-line tools are the backbone of modern development workflows. Whether you’re building deployment scripts, data processing utilities, or automation tools, your Python scripts need to accept arguments and options from the terminal. Without a proper argument parser, you’ll end up manually processing strings from sys.argv, leading to inconsistent interfaces, missing help messages, and frustrated users. This is where Python’s argparse module transforms the experience–from chaotic string parsing to professional, user-friendly CLI tools.

The good news is that argparse comes built into Python’s standard library. You don’t need to install third-party dependencies. Whether you’re a beginner or building production tools, argparse provides everything needed to handle positional arguments, optional flags, type conversion, default values, and even complex subcommands. It automatically generates help messages, validates arguments, and gives users clear error messages when they get something wrong.

In this tutorial, we’ll walk through argparse from the ground up. You’ll learn how to build your first argument parser, understand the difference between positional and optional arguments, handle type conversion and validation, create mutually exclusive groups, and organize complex CLIs with subcommands. By the end, you’ll have the skills to build professional command-line tools that work exactly how users expect them to work.

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

Part of the Python CLI Tools Hub. See the full hub for related Python tutorials.

Before diving into theory, here’s a working script that shows the core pattern. This is all you need to get started:

# hello_cli.py
import argparse

parser = argparse.ArgumentParser(description='A simple greeting tool')
parser.add_argument('name', help='Person to greet')
parser.add_argument('--age', type=int, help='Age of the person')
parser.add_argument('--excited', action='store_true', help='Add enthusiasm')

args = parser.parse_args()

greeting = f"Hello, {args.name}"
if args.age:
    greeting += f" (age {args.age})"
if args.excited:
    greeting += "!!!"
else:
    greeting += "."

print(greeting)

Output:

$ python hello_cli.py Alice --age 30 --excited
Hello, Alice (age 30)!!!

$ python hello_cli.py Bob
Hello, Bob.

$ python hello_cli.py --help
usage: hello_cli.py [-h] [--age AGE] [--excited] name

A simple greeting tool

positional arguments:
  name        Person to greet

optional arguments:
  -h, --help  show this help message and exit
  --age AGE   Age of the person
  --excited   Add enthusiasm
Colourful terminal output with Rich
When your print statements finally achieve enlightenment.

What Is argparse?

The argparse module is Python’s standard library tool for parsing command-line arguments. It automates the tedious work of extracting and validating arguments, freeing you to focus on your application logic. When you create an argument parser, you define what arguments your script accepts, what types they should be, whether they’re required, and what help text to display. Then argparse handles everything else–parsing, validation, and generating help messages.

Before Python formalized argparse, developers used the older getopt module or even manually parsed sys.argv lists. Today, argparse is the standard choice because it’s more powerful and easier to use. For very simple scripts, sys.argv works fine. For anything more complex than a couple of arguments, argparse saves you hours of debugging and edge case handling.

Here’s how argparse compares to other approaches:

Feature argparse sys.argv click (3rd-party)
Built-in to Python Yes Yes No
Automatic help generation Yes No Yes
Type conversion Yes Manual Yes
Subcommands Yes Manual Yes
Learning curve Moderate Steep Gentle
Setup complexity Low Low Medium

For most projects, argparse strikes the perfect balance between power and simplicity. You get production-grade functionality without external dependencies.

Positional Arguments

What Are Positional Arguments?

Positional arguments are required values that the user must provide in a specific order. Think of them as the “nouns” of your command. When you see git commit -m "message", the word after “commit” is a positional argument. In argparse, positional arguments are required by default and must appear before optional arguments.

# file_reader.py
import argparse

parser = argparse.ArgumentParser(description='Read file contents')
parser.add_argument('filename', help='Path to the file to read')
parser.add_argument('encoding', help='File encoding (e.g., utf-8)')

args = parser.parse_args()

try:
    with open(args.filename, 'r', encoding=args.encoding) as f:
        print(f.read())
except FileNotFoundError:
    print(f"Error: File '{args.filename}' not found")

Output:

$ python file_reader.py data.txt utf-8
[contents of data.txt...]

$ python file_reader.py data.txt
usage: file_reader.py [-h] filename encoding
file_reader.py: error: the following arguments are required: encoding

Making Positional Arguments Optional

You can make a positional argument optional by using the nargs parameter. Setting nargs='?' means “zero or one” of this argument:

# search_tool.py
import argparse

parser = argparse.ArgumentParser(description='Search tool')
parser.add_argument('query', help='Search term')
parser.add_argument('directory', nargs='?', default='.', help='Directory to search (default: current)')

args = parser.parse_args()

print(f"Searching for '{args.query}' in '{args.directory}'")

Output:

$ python search_tool.py "python" .
Searching for 'python' in '.'

$ python search_tool.py "python"
Searching for 'python' in '.'
Console printing with Rich library
Console.print() — your gateway to terminal enlightenment.

Optional Arguments and Flags

Single vs Double Dashes

Optional arguments start with dashes. A single dash like -v is a “short” option (typically one letter), while double dashes like --verbose are “long” options (typically words). You can provide both:

# backup_tool.py
import argparse

parser = argparse.ArgumentParser(description='Backup files')
parser.add_argument('--verbose', '-v', action='store_true', help='Show detailed output')
parser.add_argument('--output', '-o', help='Output directory')
parser.add_argument('--compress', '-c', action='store_true', help='Compress backup')

args = parser.parse_args()

output_dir = args.output or './backups'
print(f"Backing up to: {output_dir}")
if args.verbose:
    print("Verbose mode enabled")
if args.compress:
    print("Compression enabled")

Output:

$ python backup_tool.py -v -c
Backing up to: ./backups
Verbose mode enabled
Compression enabled

$ python backup_tool.py --output /mnt/backup --verbose
Backing up to: /mnt/backup
Verbose mode enabled

Boolean Flags with action

The action='store_true' parameter turns an optional argument into a boolean flag. By default, the value is False. When the flag is present, it becomes True. Use action='store_false' for the opposite behavior:

# config_tool.py
import argparse

parser = argparse.ArgumentParser(description='Configuration tool')
parser.add_argument('--enable-logging', action='store_true', help='Enable logging')
parser.add_argument('--skip-cache', action='store_true', help='Skip cache')
parser.add_argument('--no-color', action='store_false', dest='color', help='Disable color output')

args = parser.parse_args()

print(f"Logging: {args.enable_logging}")
print(f"Skip cache: {args.skip_cache}")
print(f"Color output: {args.color}")

Output:

$ python config_tool.py --enable-logging
Logging: True
Skip cache: False
Color output: True

$ python config_tool.py --enable-logging --no-color
Logging: True
Skip cache: False
Color output: False

Type Conversion and Validation

By default, all arguments are treated as strings. Use the type parameter to convert them automatically. Python provides built-in types like int, float, and bool, and you can define custom conversion functions:

# math_cli.py
import argparse

parser = argparse.ArgumentParser(description='Math operations')
parser.add_argument('--numbers', type=float, nargs='+', help='Numbers to process')
parser.add_argument('--max-results', type=int, default=10, help='Maximum results')
parser.add_argument('--threshold', type=float, default=0.5, help='Threshold value')

args = parser.parse_args()

if args.numbers:
    total = sum(args.numbers)
    avg = total / len(args.numbers)
    print(f"Sum: {total}, Average: {avg}")
    print(f"Max results: {args.max_results}")
    print(f"Threshold: {args.threshold}")

Output:

$ python math_cli.py --numbers 5.2 3.1 7.8 --max-results 20
Sum: 16.1, Average: 5.366666666666667
Max results: 20
Threshold: 0.5

Custom Type Functions

For complex validation, write a function that takes a string and returns the converted value, or raises argparse.ArgumentTypeError if invalid:

# port_validator.py
import argparse

def valid_port(value):
    port = int(value)
    if not (1 <= port <= 65535):
        raise argparse.ArgumentTypeError(f"{value} is not a valid port (1-65535)")
    return port

parser = argparse.ArgumentParser(description='Server launcher')
parser.add_argument('--port', type=valid_port, default=8000, help='Port number')
parser.add_argument('--host', default='localhost', help='Host address')

args = parser.parse_args()

print(f"Starting server at {args.host}:{args.port}")

Output:

$ python port_validator.py --port 3000
Starting server at localhost:3000

$ python port_validator.py --port 70000
usage: port_validator.py [-h] [--port PORT] [--host HOST]
port_validator.py: error: argument --port: 70000 is not a valid port (1-65535)
Rich text formatting and styles
When your terminal output finally achieves artistic status.

Choices and Default Values

The choices parameter restricts an argument to a specific set of values. This is useful for mode selection, environment names, or any enumerated option. When combined with default, you provide sensible fallback behavior:

# deployment_tool.py
import argparse

parser = argparse.ArgumentParser(description='Deployment tool')
parser.add_argument('environment', choices=['dev', 'staging', 'prod'],
                   help='Target environment')
parser.add_argument('--log-level', choices=['debug', 'info', 'warning', 'error'],
                   default='info', help='Logging level')
parser.add_argument('--timeout', type=int, default=30, help='Timeout in seconds')
parser.add_argument('--retry-count', type=int, default=3, help='Number of retries')

args = parser.parse_args()

print(f"Deploying to {args.environment}")
print(f"Log level: {args.log_level}")
print(f"Timeout: {args.timeout}s, Retries: {args.retry_count}")

Output:

$ python deployment_tool.py staging
Deploying to staging
Log level: info
Timeout: 30s, Retries: 3

$ python deployment_tool.py prod --log-level debug --timeout 60
Deploying to prod
Log level: debug
Timeout: 60s, Retries: 3

$ python deployment_tool.py testing
usage: deployment_tool.py [-h] [--log-level {debug,info,warning,error}]
                          [--timeout TIMEOUT] [--retry-count RETRY_COUNT]
                          {dev,staging,prod}
deployment_tool.py: error: argument environment: invalid choice: 'testing'
(choose from 'dev', 'staging', 'prod')

Mutually Exclusive Groups

Sometimes you want to ensure that only one of several options can be used at a time. The add_mutually_exclusive_group() method enforces this constraint and provides helpful error messages when users violate it:

# data_converter.py
import argparse

parser = argparse.ArgumentParser(description='Data format converter')
parser.add_argument('input_file', help='Input file path')

format_group = parser.add_mutually_exclusive_group(required=True)
format_group.add_argument('--to-json', action='store_true', help='Convert to JSON')
format_group.add_argument('--to-csv', action='store_true', help='Convert to CSV')
format_group.add_argument('--to-xml', action='store_true', help='Convert to XML')

parser.add_argument('--pretty', action='store_true', help='Pretty-print output')

args = parser.parse_args()

output_format = None
if args.to_json:
    output_format = 'json'
elif args.to_csv:
    output_format = 'csv'
elif args.to_xml:
    output_format = 'xml'

print(f"Converting {args.input_file} to {output_format}")
if args.pretty:
    print("Pretty-printing enabled")

Output:

$ python data_converter.py data.txt --to-json --pretty
Converting data.txt to json
Pretty-printing enabled

$ python data_converter.py data.txt --to-json --to-csv
usage: data_converter.py [-h] (--to-json | --to-csv | --to-xml) [--pretty]
                         input_file
data_converter.py: error: argument --to-csv: not allowed with argument --to-json
Rich progress bars and spinners
When your progress bar has a progress bar.

Subcommands

Complex tools often have multiple "modes" like git commit, git push, git clone. Use add_subparsers() to create subcommand structures. Each subcommand gets its own set of arguments and can have different behaviors:

# package_manager.py
import argparse

parser = argparse.ArgumentParser(description='Package manager')
subparsers = parser.add_subparsers(dest='command', help='Available commands')

# Install subcommand
install_parser = subparsers.add_parser('install', help='Install a package')
install_parser.add_argument('package_name', help='Package to install')
install_parser.add_argument('--version', help='Specific version to install')
install_parser.add_argument('--upgrade', action='store_true', help='Upgrade if exists')

# Remove subcommand
remove_parser = subparsers.add_parser('remove', help='Remove a package')
remove_parser.add_argument('package_name', help='Package to remove')
remove_parser.add_argument('--force', action='store_true', help='Force removal')

# List subcommand
list_parser = subparsers.add_parser('list', help='List installed packages')
list_parser.add_argument('--outdated', action='store_true', help='Only show outdated')

args = parser.parse_args()

if args.command == 'install':
    version = args.version or 'latest'
    upgrade_msg = " (upgrading)" if args.upgrade else ""
    print(f"Installing {args.package_name} version {version}{upgrade_msg}")
elif args.command == 'remove':
    force_msg = " (forced)" if args.force else ""
    print(f"Removing {args.package_name}{force_msg}")
elif args.command == 'list':
    filter_msg = " outdated packages" if args.outdated else " packages"
    print(f"Listing{filter_msg}")
else:
    parser.print_help()

Output:

$ python package_manager.py install numpy --version 1.24
Installing numpy version 1.24

$ python package_manager.py remove requests --force
Removing requests (forced)

$ python package_manager.py list --outdated
Listing outdated packages

$ python package_manager.py --help
usage: package_manager.py [-h] {install,remove,list} ...

Package manager

positional arguments:
  {install,remove,list}  Available commands
    install              Install a package
    remove               Remove a package
    list                 List installed packages

optional arguments:
  -h, --help             show this help message and exit

Real-Life Example: Building a File Organizer CLI

Let's combine everything into a practical file organization tool. This script organizes files by extension, with options for dry-run mode, custom destinations, and file type filtering:

# file_organizer.py
import argparse
import os
import shutil
from pathlib import Path

def valid_directory(value):
    if not os.path.isdir(value):
        raise argparse.ArgumentTypeError(f"'{value}' is not a valid directory")
    return value

parser = argparse.ArgumentParser(
    description='Organize files in a directory by extension',
    formatter_class=argparse.RawDescriptionHelpFormatter,
    epilog='''Examples:
  python file_organizer.py ~/Downloads
  python file_organizer.py ~/Downloads --extensions txt pdf --dry-run
  python file_organizer.py ~/Downloads --output ~/Organized --clean
'''
)

parser.add_argument('source_dir', type=valid_directory, help='Directory to organize')
parser.add_argument('--output', '-o', type=valid_directory, default=None,
                   help='Output directory (default: same as source)')
parser.add_argument('--extensions', '-e', nargs='+', default=None,
                   help='Only organize these file types (e.g., txt pdf)')
parser.add_argument('--dry-run', action='store_true',
                   help='Show what would happen without making changes')
parser.add_argument('--clean', action='store_true',
                   help='Remove empty subdirectories after organizing')

args = parser.parse_args()

source = Path(args.source_dir)
output = Path(args.output) if args.output else source

file_count = 0
for file_path in source.glob('*'):
    if file_path.is_file():
        ext = file_path.suffix[1:].lower() or 'no_extension'

        if args.extensions and ext not in args.extensions:
            continue

        target_dir = output / ext

        if args.dry_run:
            print(f"Would move: {file_path.name} -> {ext}/")
        else:
            target_dir.mkdir(exist_ok=True)
            shutil.move(str(file_path), str(target_dir / file_path.name))
            print(f"Moved: {file_path.name} -> {ext}/")

        file_count += 1

print(f"\nTotal files processed: {file_count}")

if args.clean and not args.dry_run:
    removed = 0
    for subdir in source.iterdir():
        if subdir.is_dir() and not list(subdir.iterdir()):
            subdir.rmdir()
            removed += 1
    if removed > 0:
        print(f"Removed {removed} empty directories")

Output:

$ python file_organizer.py ~/Downloads --dry-run
Would move: report.pdf -> pdf/
Would move: script.py -> py/
Would move: image.jpg -> jpg/

Total files processed: 3

$ python file_organizer.py ~/Downloads --extensions txt py --clean
Moved: notes.txt -> txt/
Moved: script.py -> py/
Removed 2 empty directories

Total files processed: 2
Rich syntax highlighting
When code formatting finally achieves inner peace.

Frequently Asked Questions

What does nargs do?

The nargs parameter controls how many values an argument accepts. Use nargs='+' for one or more values, nargs='*' for zero or more, nargs=N for exactly N values, and nargs='?' for zero or one. This is essential for accepting variable-length lists of inputs.

What is the dest parameter?

The dest parameter specifies the attribute name where the parsed argument value will be stored. By default, argparse converts the argument name to a valid Python identifier (e.g., --my-option becomes args.my_option). Use dest to override this: parser.add_argument('--my-option', dest='custom_name') stores the value in args.custom_name.

How do I customize help text formatting?

Pass formatter_class=argparse.RawDescriptionHelpFormatter to preserve formatting in description text, or use argparse.RawTextHelpFormatter for help text. Use epilog to add text at the end of the help message. The help parameter for each argument becomes part of the auto-generated help output.

How do I make optional arguments required?

Pass required=True to add_argument(). For example: parser.add_argument('--api-key', required=True). This forces users to provide the argument, even though it uses the dash syntax of optional arguments. It's useful when you need to maintain consistent naming but require the value.

Can argparse read from environment variables?

Yes, use env_var (Python 3.10+) or manually check environment variables in your code. For older Python versions, use: parser.add_argument('--api-key', default=os.getenv('API_KEY')). This provides flexibility for users who prefer environment variables over command-line arguments.

Conclusion

You now have a solid foundation in Python's argparse module. You've learned to create positional and optional arguments, handle type conversion and validation, organize options with mutually exclusive groups, and structure complex CLIs with subcommands. The patterns shown here scale from simple scripts to sophisticated command-line applications used by thousands of developers.

The best way to internalize these concepts is to build something. Start with a simple script that needs two or three arguments, then gradually add complexity. Reference the official argparse documentation when you need advanced features like custom formatters or argument groups. Your future self will thank you for building tools with clear, well-documented interfaces.

Rich Basics

from rich import print
print("[bold red]Error:[/bold red] something broke")
print("[green]Success:[/green] operation completed")
print({"status": "ok", "count": 42, "items": [1, 2, 3]})

Markup syntax — [style]text[/style] — gets parsed automatically. Colors, bold, italic, underline, and combos work.

Console Object

from rich.console import Console
console = Console()
console.print("Hello", style="bold magenta")
console.print("Warning", style="yellow on black")
console.print("Heading", style="reverse bold")
console.print("This text is long and wraps nicely", width=40)
with open("output.txt", "w") as f:
    Console(file=f).print("plain text in file")

Tables

from rich.table import Table
from rich.console import Console
console = Console()
table = Table(title="Sales Report")
table.add_column("Product", style="cyan", no_wrap=True)
table.add_column("Region", style="magenta")
table.add_column("Revenue", justify="right", style="green")
table.add_row("Widget", "NA", "$12,400")
table.add_row("Gadget", "EU", "$8,250")
table.add_row("Gizmo", "APAC", "$15,100")
console.print(table)

Rich tables auto-size columns, support row grouping, and look professional in a terminal.

Progress Bars

from rich.progress import track, Progress
import time

for item in track(range(100), description="Processing..."):
    time.sleep(0.05)

with Progress() as progress:
    task1 = progress.add_task("Downloading", total=1000)
    task2 = progress.add_task("Processing", total=1000)
    for _ in range(100):
        progress.update(task1, advance=10)
        progress.update(task2, advance=5)
        time.sleep(0.05)

Beautiful Tracebacks

from rich.traceback import install
install(show_locals=True)

def divide(a, b):
    return a / b
divide(1, 0)

Add this once at app startup and every traceback for the rest of the process gets the rich treatment. show_locals=True prints local variables — invaluable for debugging.

Markdown and Syntax Highlighting

from rich.markdown import Markdown
from rich.syntax import Syntax
console = Console()
md = Markdown("# Heading\n\n- Item 1\n- Item 2")
console.print(md)
code = "def f(x):\n    return x * 2\n"
syntax = Syntax(code, "python", theme="monokai", line_numbers=True)
console.print(syntax)

Live Displays

from rich.live import Live
from rich.table import Table
import time

with Live(refresh_per_second=4) as live:
    for i in range(20):
        table = Table()
        table.add_column("Iteration")
        table.add_column("Value")
        table.add_row(str(i), str(i ** 2))
        live.update(table)
        time.sleep(0.5)

Common Pitfalls

  • Markup in user input. Brackets in user-controlled strings can be misinterpreted as markup. Use console.print(text, markup=False) or escape with rich.markup.escape().
  • Performance in tight loops. Rich is slow if called millions of times. For high-volume logging, write to a file directly.
  • Color codes in log files. Default loggers capture ANSI codes literally. Use Rich's logging handler or strip colors.
  • install() called too late. rich.traceback.install must run before the first exception. Call it at the top of your entry point.
  • Print breaking on non-terminal stdout. When piping output, Rich falls back to plain text. Usually what you want.

FAQ

Q: Rich, prompt_toolkit, or textual?
A: Rich for console output. prompt_toolkit for interactive prompts. Textual for full TUI apps.

Q: Rich with Python logging?
A: from rich.logging import RichHandler; logging.basicConfig(handlers=[RichHandler()]).

Q: Will Rich slow down my script?
A: For typical CLI output, no. For per-message rendering of millions of items, fall back to plain print.

Q: Works in Jupyter?
A: Yes — better than Jupyter's plain print, even.

Q: Table that scrolls horizontally?
A: console.print(table, soft_wrap=True). For huge tables, paginate with console.pager().

Wrapping Up

Rich turns Python CLIs from "monospace black-and-white walls of text" into "structured, colored, readable interfaces" — with three lines of code. Install rich.traceback at startup for pleasant debugging. Use tables for tabular output, progress for long operations, and markup for emphasis.

How To Use NumPy Arrays for Scientific Computing in Python

How To Use NumPy Arrays for Scientific Computing in Python

Last Updated: June 01, 2026

Beginner

How To Use Python argparse for Command-Line Arguments

Command-line tools are the backbone of modern development workflows. Whether you’re building deployment scripts, data processing utilities, or automation tools, your Python scripts need to accept arguments and options from the terminal. Without a proper argument parser, you’ll end up manually processing strings from sys.argv, leading to inconsistent interfaces, missing help messages, and frustrated users. This is where Python’s argparse module transforms the experience–from chaotic string parsing to professional, user-friendly CLI tools.

The good news is that argparse comes built into Python’s standard library. You don’t need to install third-party dependencies. Whether you’re a beginner or building production tools, argparse provides everything needed to handle positional arguments, optional flags, type conversion, default values, and even complex subcommands. It automatically generates help messages, validates arguments, and gives users clear error messages when they get something wrong.

In this tutorial, we’ll walk through argparse from the ground up. You’ll learn how to build your first argument parser, understand the difference between positional and optional arguments, handle type conversion and validation, create mutually exclusive groups, and organize complex CLIs with subcommands. By the end, you’ll have the skills to build professional command-line tools that work exactly how users expect them to work.

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

Part of the Python Data Stack Hub. See the full hub for related Python tutorials.

Before diving into theory, here’s a working script that shows the core pattern. This is all you need to get started:

# hello_cli.py
import argparse

parser = argparse.ArgumentParser(description='A simple greeting tool')
parser.add_argument('name', help='Person to greet')
parser.add_argument('--age', type=int, help='Age of the person')
parser.add_argument('--excited', action='store_true', help='Add enthusiasm')

args = parser.parse_args()

greeting = f"Hello, {args.name}"
if args.age:
    greeting += f" (age {args.age})"
if args.excited:
    greeting += "!!!"
else:
    greeting += "."

print(greeting)

Output:

$ python hello_cli.py Alice --age 30 --excited
Hello, Alice (age 30)!!!

$ python hello_cli.py Bob
Hello, Bob.

$ python hello_cli.py --help
usage: hello_cli.py [-h] [--age AGE] [--excited] name

A simple greeting tool

positional arguments:
  name        Person to greet

optional arguments:
  -h, --help  show this help message and exit
  --age AGE   Age of the person
  --excited   Add enthusiasm
NumPy array speed and performance
Speed is NumPy superpower — leave list comprehensions in the dust.

What Is argparse?

The argparse module is Python’s standard library tool for parsing command-line arguments. It automates the tedious work of extracting and validating arguments, freeing you to focus on your application logic. When you create an argument parser, you define what arguments your script accepts, what types they should be, whether they’re required, and what help text to display. Then argparse handles everything else–parsing, validation, and generating help messages.

Before Python formalized argparse, developers used the older getopt module or even manually parsed sys.argv lists. Today, argparse is the standard choice because it’s more powerful and easier to use. For very simple scripts, sys.argv works fine. For anything more complex than a couple of arguments, argparse saves you hours of debugging and edge case handling.

Here’s how argparse compares to other approaches:

Feature argparse sys.argv click (3rd-party)
Built-in to Python Yes Yes No
Automatic help generation Yes No Yes
Type conversion Yes Manual Yes
Subcommands Yes Manual Yes
Learning curve Moderate Steep Gentle
Setup complexity Low Low Medium

For most projects, argparse strikes the perfect balance between power and simplicity. You get production-grade functionality without external dependencies.

Positional Arguments

What Are Positional Arguments?

Positional arguments are required values that the user must provide in a specific order. Think of them as the “nouns” of your command. When you see git commit -m "message", the word after “commit” is a positional argument. In argparse, positional arguments are required by default and must appear before optional arguments.

# file_reader.py
import argparse

parser = argparse.ArgumentParser(description='Read file contents')
parser.add_argument('filename', help='Path to the file to read')
parser.add_argument('encoding', help='File encoding (e.g., utf-8)')

args = parser.parse_args()

try:
    with open(args.filename, 'r', encoding=args.encoding) as f:
        print(f.read())
except FileNotFoundError:
    print(f"Error: File '{args.filename}' not found")

Output:

$ python file_reader.py data.txt utf-8
[contents of data.txt...]

$ python file_reader.py data.txt
usage: file_reader.py [-h] filename encoding
file_reader.py: error: the following arguments are required: encoding

Making Positional Arguments Optional

You can make a positional argument optional by using the nargs parameter. Setting nargs='?' means “zero or one” of this argument:

# search_tool.py
import argparse

parser = argparse.ArgumentParser(description='Search tool')
parser.add_argument('query', help='Search term')
parser.add_argument('directory', nargs='?', default='.', help='Directory to search (default: current)')

args = parser.parse_args()

print(f"Searching for '{args.query}' in '{args.directory}'")

Output:

$ python search_tool.py "python" .
Searching for 'python' in '.'

$ python search_tool.py "python"
Searching for 'python' in '.'
Creating NumPy arrays
Choose the right array generator — zeros, ones, arange, or linspace.

Optional Arguments and Flags

Single vs Double Dashes

Optional arguments start with dashes. A single dash like -v is a “short” option (typically one letter), while double dashes like --verbose are “long” options (typically words). You can provide both:

# backup_tool.py
import argparse

parser = argparse.ArgumentParser(description='Backup files')
parser.add_argument('--verbose', '-v', action='store_true', help='Show detailed output')
parser.add_argument('--output', '-o', help='Output directory')
parser.add_argument('--compress', '-c', action='store_true', help='Compress backup')

args = parser.parse_args()

output_dir = args.output or './backups'
print(f"Backing up to: {output_dir}")
if args.verbose:
    print("Verbose mode enabled")
if args.compress:
    print("Compression enabled")

Output:

$ python backup_tool.py -v -c
Backing up to: ./backups
Verbose mode enabled
Compression enabled

$ python backup_tool.py --output /mnt/backup --verbose
Backing up to: /mnt/backup
Verbose mode enabled

Boolean Flags with action

The action='store_true' parameter turns an optional argument into a boolean flag. By default, the value is False. When the flag is present, it becomes True. Use action='store_false' for the opposite behavior:

# config_tool.py
import argparse

parser = argparse.ArgumentParser(description='Configuration tool')
parser.add_argument('--enable-logging', action='store_true', help='Enable logging')
parser.add_argument('--skip-cache', action='store_true', help='Skip cache')
parser.add_argument('--no-color', action='store_false', dest='color', help='Disable color output')

args = parser.parse_args()

print(f"Logging: {args.enable_logging}")
print(f"Skip cache: {args.skip_cache}")
print(f"Color output: {args.color}")

Output:

$ python config_tool.py --enable-logging
Logging: True
Skip cache: False
Color output: True

$ python config_tool.py --enable-logging --no-color
Logging: True
Skip cache: False
Color output: False

Type Conversion and Validation

By default, all arguments are treated as strings. Use the type parameter to convert them automatically. Python provides built-in types like int, float, and bool, and you can define custom conversion functions:

# math_cli.py
import argparse

parser = argparse.ArgumentParser(description='Math operations')
parser.add_argument('--numbers', type=float, nargs='+', help='Numbers to process')
parser.add_argument('--max-results', type=int, default=10, help='Maximum results')
parser.add_argument('--threshold', type=float, default=0.5, help='Threshold value')

args = parser.parse_args()

if args.numbers:
    total = sum(args.numbers)
    avg = total / len(args.numbers)
    print(f"Sum: {total}, Average: {avg}")
    print(f"Max results: {args.max_results}")
    print(f"Threshold: {args.threshold}")

Output:

$ python math_cli.py --numbers 5.2 3.1 7.8 --max-results 20
Sum: 16.1, Average: 5.366666666666667
Max results: 20
Threshold: 0.5

Custom Type Functions

For complex validation, write a function that takes a string and returns the converted value, or raises argparse.ArgumentTypeError if invalid:

# port_validator.py
import argparse

def valid_port(value):
    port = int(value)
    if not (1 <= port <= 65535):
        raise argparse.ArgumentTypeError(f"{value} is not a valid port (1-65535)")
    return port

parser = argparse.ArgumentParser(description='Server launcher')
parser.add_argument('--port', type=valid_port, default=8000, help='Port number')
parser.add_argument('--host', default='localhost', help='Host address')

args = parser.parse_args()

print(f"Starting server at {args.host}:{args.port}")

Output:

$ python port_validator.py --port 3000
Starting server at localhost:3000

$ python port_validator.py --port 70000
usage: port_validator.py [-h] [--port PORT] [--host HOST]
port_validator.py: error: argument --port: 70000 is not a valid port (1-65535)
Reshaping and transforming NumPy arrays
Reshape, transpose, and flatten — reconfigure your data for any task.

Choices and Default Values

The choices parameter restricts an argument to a specific set of values. This is useful for mode selection, environment names, or any enumerated option. When combined with default, you provide sensible fallback behavior:

# deployment_tool.py
import argparse

parser = argparse.ArgumentParser(description='Deployment tool')
parser.add_argument('environment', choices=['dev', 'staging', 'prod'],
                   help='Target environment')
parser.add_argument('--log-level', choices=['debug', 'info', 'warning', 'error'],
                   default='info', help='Logging level')
parser.add_argument('--timeout', type=int, default=30, help='Timeout in seconds')
parser.add_argument('--retry-count', type=int, default=3, help='Number of retries')

args = parser.parse_args()

print(f"Deploying to {args.environment}")
print(f"Log level: {args.log_level}")
print(f"Timeout: {args.timeout}s, Retries: {args.retry_count}")

Output:

$ python deployment_tool.py staging
Deploying to staging
Log level: info
Timeout: 30s, Retries: 3

$ python deployment_tool.py prod --log-level debug --timeout 60
Deploying to prod
Log level: debug
Timeout: 60s, Retries: 3

$ python deployment_tool.py testing
usage: deployment_tool.py [-h] [--log-level {debug,info,warning,error}]
                          [--timeout TIMEOUT] [--retry-count RETRY_COUNT]
                          {dev,staging,prod}
deployment_tool.py: error: argument environment: invalid choice: 'testing'
(choose from 'dev', 'staging', 'prod')

Mutually Exclusive Groups

Sometimes you want to ensure that only one of several options can be used at a time. The add_mutually_exclusive_group() method enforces this constraint and provides helpful error messages when users violate it:

# data_converter.py
import argparse

parser = argparse.ArgumentParser(description='Data format converter')
parser.add_argument('input_file', help='Input file path')

format_group = parser.add_mutually_exclusive_group(required=True)
format_group.add_argument('--to-json', action='store_true', help='Convert to JSON')
format_group.add_argument('--to-csv', action='store_true', help='Convert to CSV')
format_group.add_argument('--to-xml', action='store_true', help='Convert to XML')

parser.add_argument('--pretty', action='store_true', help='Pretty-print output')

args = parser.parse_args()

output_format = None
if args.to_json:
    output_format = 'json'
elif args.to_csv:
    output_format = 'csv'
elif args.to_xml:
    output_format = 'xml'

print(f"Converting {args.input_file} to {output_format}")
if args.pretty:
    print("Pretty-printing enabled")

Output:

$ python data_converter.py data.txt --to-json --pretty
Converting data.txt to json
Pretty-printing enabled

$ python data_converter.py data.txt --to-json --to-csv
usage: data_converter.py [-h] (--to-json | --to-csv | --to-xml) [--pretty]
                         input_file
data_converter.py: error: argument --to-csv: not allowed with argument --to-json
NumPy mathematical operations
Mathematical operations are vectorised — compute on entire arrays simultaneously.

Subcommands

Complex tools often have multiple "modes" like git commit, git push, git clone. Use add_subparsers() to create subcommand structures. Each subcommand gets its own set of arguments and can have different behaviors:

# package_manager.py
import argparse

parser = argparse.ArgumentParser(description='Package manager')
subparsers = parser.add_subparsers(dest='command', help='Available commands')

# Install subcommand
install_parser = subparsers.add_parser('install', help='Install a package')
install_parser.add_argument('package_name', help='Package to install')
install_parser.add_argument('--version', help='Specific version to install')
install_parser.add_argument('--upgrade', action='store_true', help='Upgrade if exists')

# Remove subcommand
remove_parser = subparsers.add_parser('remove', help='Remove a package')
remove_parser.add_argument('package_name', help='Package to remove')
remove_parser.add_argument('--force', action='store_true', help='Force removal')

# List subcommand
list_parser = subparsers.add_parser('list', help='List installed packages')
list_parser.add_argument('--outdated', action='store_true', help='Only show outdated')

args = parser.parse_args()

if args.command == 'install':
    version = args.version or 'latest'
    upgrade_msg = " (upgrading)" if args.upgrade else ""
    print(f"Installing {args.package_name} version {version}{upgrade_msg}")
elif args.command == 'remove':
    force_msg = " (forced)" if args.force else ""
    print(f"Removing {args.package_name}{force_msg}")
elif args.command == 'list':
    filter_msg = " outdated packages" if args.outdated else " packages"
    print(f"Listing{filter_msg}")
else:
    parser.print_help()

Output:

$ python package_manager.py install numpy --version 1.24
Installing numpy version 1.24

$ python package_manager.py remove requests --force
Removing requests (forced)

$ python package_manager.py list --outdated
Listing outdated packages

$ python package_manager.py --help
usage: package_manager.py [-h] {install,remove,list} ...

Package manager

positional arguments:
  {install,remove,list}  Available commands
    install              Install a package
    remove               Remove a package
    list                 List installed packages

optional arguments:
  -h, --help             show this help message and exit

Real-Life Example: Building a File Organizer CLI

Let's combine everything into a practical file organization tool. This script organizes files by extension, with options for dry-run mode, custom destinations, and file type filtering:

# file_organizer.py
import argparse
import os
import shutil
from pathlib import Path

def valid_directory(value):
    if not os.path.isdir(value):
        raise argparse.ArgumentTypeError(f"'{value}' is not a valid directory")
    return value

parser = argparse.ArgumentParser(
    description='Organize files in a directory by extension',
    formatter_class=argparse.RawDescriptionHelpFormatter,
    epilog='''Examples:
  python file_organizer.py ~/Downloads
  python file_organizer.py ~/Downloads --extensions txt pdf --dry-run
  python file_organizer.py ~/Downloads --output ~/Organized --clean
'''
)

parser.add_argument('source_dir', type=valid_directory, help='Directory to organize')
parser.add_argument('--output', '-o', type=valid_directory, default=None,
                   help='Output directory (default: same as source)')
parser.add_argument('--extensions', '-e', nargs='+', default=None,
                   help='Only organize these file types (e.g., txt pdf)')
parser.add_argument('--dry-run', action='store_true',
                   help='Show what would happen without making changes')
parser.add_argument('--clean', action='store_true',
                   help='Remove empty subdirectories after organizing')

args = parser.parse_args()

source = Path(args.source_dir)
output = Path(args.output) if args.output else source

file_count = 0
for file_path in source.glob('*'):
    if file_path.is_file():
        ext = file_path.suffix[1:].lower() or 'no_extension'

        if args.extensions and ext not in args.extensions:
            continue

        target_dir = output / ext

        if args.dry_run:
            print(f"Would move: {file_path.name} -> {ext}/")
        else:
            target_dir.mkdir(exist_ok=True)
            shutil.move(str(file_path), str(target_dir / file_path.name))
            print(f"Moved: {file_path.name} -> {ext}/")

        file_count += 1

print(f"\nTotal files processed: {file_count}")

if args.clean and not args.dry_run:
    removed = 0
    for subdir in source.iterdir():
        if subdir.is_dir() and not list(subdir.iterdir()):
            subdir.rmdir()
            removed += 1
    if removed > 0:
        print(f"Removed {removed} empty directories")

Output:

$ python file_organizer.py ~/Downloads --dry-run
Would move: report.pdf -> pdf/
Would move: script.py -> py/
Would move: image.jpg -> jpg/

Total files processed: 3

$ python file_organizer.py ~/Downloads --extensions txt py --clean
Moved: notes.txt -> txt/
Moved: script.py -> py/
Removed 2 empty directories

Total files processed: 2
Stacking and concatenating NumPy arrays
Stack, concatenate, and split arrays — combine and reorganise data effortlessly.

Frequently Asked Questions

What does nargs do?

The nargs parameter controls how many values an argument accepts. Use nargs='+' for one or more values, nargs='*' for zero or more, nargs=N for exactly N values, and nargs='?' for zero or one. This is essential for accepting variable-length lists of inputs.

What is the dest parameter?

The dest parameter specifies the attribute name where the parsed argument value will be stored. By default, argparse converts the argument name to a valid Python identifier (e.g., --my-option becomes args.my_option). Use dest to override this: parser.add_argument('--my-option', dest='custom_name') stores the value in args.custom_name.

How do I customize help text formatting?

Pass formatter_class=argparse.RawDescriptionHelpFormatter to preserve formatting in description text, or use argparse.RawTextHelpFormatter for help text. Use epilog to add text at the end of the help message. The help parameter for each argument becomes part of the auto-generated help output.

How do I make optional arguments required?

Pass required=True to add_argument(). For example: parser.add_argument('--api-key', required=True). This forces users to provide the argument, even though it uses the dash syntax of optional arguments. It's useful when you need to maintain consistent naming but require the value.

Can argparse read from environment variables?

Yes, use env_var (Python 3.10+) or manually check environment variables in your code. For older Python versions, use: parser.add_argument('--api-key', default=os.getenv('API_KEY')). This provides flexibility for users who prefer environment variables over command-line arguments.

Conclusion

You now have a solid foundation in Python's argparse module. You've learned to create positional and optional arguments, handle type conversion and validation, organize options with mutually exclusive groups, and structure complex CLIs with subcommands. The patterns shown here scale from simple scripts to sophisticated command-line applications used by thousands of developers.

The best way to internalize these concepts is to build something. Start with a simple script that needs two or three arguments, then gradually add complexity. Reference the official argparse documentation when you need advanced features like custom formatters or argument groups. Your future self will thank you for building tools with clear, well-documented interfaces.

Vectorization: Why NumPy is Fast

NumPy's superpower is vectorization — running C loops underneath what looks like Python. A pure-Python loop over a million elements takes seconds; the NumPy equivalent runs in milliseconds:

import numpy as np
import time

# Pure Python — slow
xs = list(range(1_000_000))
t0 = time.time()
squared = [x * x for x in xs]
print(f"Python: {time.time()-t0:.3f}s")

# NumPy — fast
arr = np.arange(1_000_000)
t0 = time.time()
squared = arr ** 2
print(f"NumPy: {time.time()-t0:.4f}s")

# Typical: Python 0.04s, NumPy 0.001s — 40x speedup

The rule of thumb: any loop you can write as an array operation should be. Once you start thinking in array operations, scientific computing in Python clicks.

Array Creation and Reshaping

import numpy as np

# From a list
a = np.array([1, 2, 3, 4, 5])

# Zeros, ones, empty
z = np.zeros((3, 4))             # 3x4 matrix of zeros
o = np.ones(10, dtype=np.int32)  # explicit dtype
r = np.empty((5, 5))             # uninitialized (faster, dangerous)

# Ranges
arr = np.arange(0, 100, 5)       # 0,5,10,...,95
ls = np.linspace(0, 1, 11)       # 11 evenly-spaced values 0 to 1

# Reshape (no copy when contiguous)
flat = np.arange(12)
matrix = flat.reshape(3, 4)
matrix.shape                      # (3, 4)
matrix.T.shape                    # (4, 3) — transpose

Indexing and Slicing

NumPy indexing is far richer than Python lists — fancy indexing, boolean masks, and views:

arr = np.arange(25).reshape(5, 5)

# Slicing — returns a VIEW, not a copy
arr[1:4, 2:4]
arr[:, 0]                          # first column
arr[-1, :]                         # last row

# Boolean masks
arr[arr > 10]                      # all elements > 10

# Fancy indexing — pick specific rows/cols
arr[[0, 2, 4], :]                  # rows 0, 2, 4
arr[[0, 1, 2], [1, 2, 3]]          # diagonal-ish — (0,1), (1,2), (2,3)

# Assignment works the same way
arr[arr > 10] = -1
arr[:, 0] = 99

Critical gotcha: slicing returns a VIEW, so modifying the slice modifies the original. arr.copy() when you need an independent array.

Broadcasting

NumPy's broadcasting lets you operate on arrays of different shapes:

matrix = np.arange(12).reshape(3, 4)

# Add a scalar to every element
matrix + 100

# Add a 1D array to each row
row_offsets = np.array([10, 20, 30, 40])
matrix + row_offsets             # works — broadcasts (4,) across (3,4)

# Add column offsets
col_offsets = np.array([1, 2, 3]).reshape(-1, 1)
matrix + col_offsets             # (3,1) broadcasts to (3,4)

Broadcasting eliminates explicit loops. When in doubt, print shapes — NumPy aligns them right-to-left and broadcasts dimensions of size 1 or matching size.

Statistics and Linear Algebra

arr = np.random.normal(loc=0, scale=1, size=1000)
print(arr.mean(), arr.std(), arr.min(), arr.max())
print(np.percentile(arr, [25, 50, 75]))

# Matrix ops
A = np.random.rand(3, 3)
B = np.random.rand(3, 3)
C = A @ B                          # matrix multiply
np.linalg.inv(A)                   # inverse
eigvals, eigvecs = np.linalg.eig(A)  # eigenvalues + eigenvectors
np.linalg.solve(A, np.array([1,2,3]))  # solve Ax = b

Common Pitfalls

  • Slicing copies in Python lists; views in NumPy. Modifying a slice modifies the array. Use .copy() to detach.
  • Wrong dtype. Integer arithmetic on int8 wraps around silently at 127. Cast to int32 or float64 for large values.
  • Mixing scalars and arrays in conditionals. if arr > 5 raises "ambiguous truth value". Use .any(), .all(), or boolean masks.
  • Iterating over NumPy arrays. A Python for-loop over an array is slow. Always look for an array operation or vectorized function first.
  • Forgetting reshape returns a view. arr.reshape(...).T = something assigns to a view of arr. If you wanted a new array, use .copy().

FAQ

Q: NumPy vs Pandas vs Polars?
A: NumPy for numeric arrays and math. Pandas for tabular data with labels (rows + columns). Polars for fast tabular operations on big data. They complement each other.

Q: Why is np.array faster than Python lists?
A: NumPy stores contiguous C arrays of a single dtype, plus C-level loops without Python's per-iteration overhead. The combination is 10-100x faster for numeric work.

Q: How do I save/load NumPy arrays?
A: np.save("file.npy", arr) for one array. np.savez("file.npz", a=arr1, b=arr2) for multiple. np.load("file.npy") to load.

Q: How do I parallelize NumPy across cores?
A: Most NumPy operations are already C-threaded via BLAS/LAPACK. For explicit parallelism use multiprocessing, joblib, or Dask.

Q: What about GPU NumPy?
A: CuPy is a drop-in replacement for NumPy on NVIDIA GPUs. JAX gives you NumPy + autodiff + GPU/TPU. For most workloads NumPy on CPU is enough.

Wrapping Up

NumPy is the foundation of scientific Python — pandas, scikit-learn, SciPy, PyTorch all build on it. Learn the array creation, indexing, broadcasting, and dot product, and you have 80% of practical NumPy. For high-performance numeric work, the rule is simple: if you find yourself writing a Python for-loop over numbers, ask "is there a NumPy operation that does this?" — there usually is.

How To Create Plots and Charts with Matplotlib in Python

How To Create Plots and Charts with Matplotlib in Python

Last Updated: June 01, 2026

Beginner

How To Use Python argparse for Command-Line Arguments

Command-line tools are the backbone of modern development workflows. Whether you’re building deployment scripts, data processing utilities, or automation tools, your Python scripts need to accept arguments and options from the terminal. Without a proper argument parser, you’ll end up manually processing strings from sys.argv, leading to inconsistent interfaces, missing help messages, and frustrated users. This is where Python’s argparse module transforms the experience–from chaotic string parsing to professional, user-friendly CLI tools.

The good news is that argparse comes built into Python’s standard library. You don’t need to install third-party dependencies. Whether you’re a beginner or building production tools, argparse provides everything needed to handle positional arguments, optional flags, type conversion, default values, and even complex subcommands. It automatically generates help messages, validates arguments, and gives users clear error messages when they get something wrong.

In this tutorial, we’ll walk through argparse from the ground up. You’ll learn how to build your first argument parser, understand the difference between positional and optional arguments, handle type conversion and validation, create mutually exclusive groups, and organize complex CLIs with subcommands. By the end, you’ll have the skills to build professional command-line tools that work exactly how users expect them to work.

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

Part of the Python Data Stack Hub. See the full hub for related Python tutorials.

Before diving into theory, here’s a working script that shows the core pattern. This is all you need to get started:

# hello_cli.py
import argparse

parser = argparse.ArgumentParser(description='A simple greeting tool')
parser.add_argument('name', help='Person to greet')
parser.add_argument('--age', type=int, help='Age of the person')
parser.add_argument('--excited', action='store_true', help='Add enthusiasm')

args = parser.parse_args()

greeting = f"Hello, {args.name}"
if args.age:
    greeting += f" (age {args.age})"
if args.excited:
    greeting += "!!!"
else:
    greeting += "."

print(greeting)

Output:

$ python hello_cli.py Alice --age 30 --excited
Hello, Alice (age 30)!!!

$ python hello_cli.py Bob
Hello, Bob.

$ python hello_cli.py --help
usage: hello_cli.py [-h] [--age AGE] [--excited] name

A simple greeting tool

positional arguments:
  name        Person to greet

optional arguments:
  -h, --help  show this help message and exit
  --age AGE   Age of the person
  --excited   Add enthusiasm
Exploring data visualizations with Matplotlib
Visualising is not just about making data pretty — it is about revealing truth.

What Is argparse?

The argparse module is Python’s standard library tool for parsing command-line arguments. It automates the tedious work of extracting and validating arguments, freeing you to focus on your application logic. When you create an argument parser, you define what arguments your script accepts, what types they should be, whether they’re required, and what help text to display. Then argparse handles everything else–parsing, validation, and generating help messages.

Before Python formalized argparse, developers used the older getopt module or even manually parsed sys.argv lists. Today, argparse is the standard choice because it’s more powerful and easier to use. For very simple scripts, sys.argv works fine. For anything more complex than a couple of arguments, argparse saves you hours of debugging and edge case handling.

Here’s how argparse compares to other approaches:

Feature argparse sys.argv click (3rd-party)
Built-in to Python Yes Yes No
Automatic help generation Yes No Yes
Type conversion Yes Manual Yes
Subcommands Yes Manual Yes
Learning curve Moderate Steep Gentle
Setup complexity Low Low Medium

For most projects, argparse strikes the perfect balance between power and simplicity. You get production-grade functionality without external dependencies.

Positional Arguments

What Are Positional Arguments?

Positional arguments are required values that the user must provide in a specific order. Think of them as the “nouns” of your command. When you see git commit -m "message", the word after “commit” is a positional argument. In argparse, positional arguments are required by default and must appear before optional arguments.

# file_reader.py
import argparse

parser = argparse.ArgumentParser(description='Read file contents')
parser.add_argument('filename', help='Path to the file to read')
parser.add_argument('encoding', help='File encoding (e.g., utf-8)')

args = parser.parse_args()

try:
    with open(args.filename, 'r', encoding=args.encoding) as f:
        print(f.read())
except FileNotFoundError:
    print(f"Error: File '{args.filename}' not found")

Output:

$ python file_reader.py data.txt utf-8
[contents of data.txt...]

$ python file_reader.py data.txt
usage: file_reader.py [-h] filename encoding
file_reader.py: error: the following arguments are required: encoding

Making Positional Arguments Optional

You can make a positional argument optional by using the nargs parameter. Setting nargs='?' means “zero or one” of this argument:

# search_tool.py
import argparse

parser = argparse.ArgumentParser(description='Search tool')
parser.add_argument('query', help='Search term')
parser.add_argument('directory', nargs='?', default='.', help='Directory to search (default: current)')

args = parser.parse_args()

print(f"Searching for '{args.query}' in '{args.directory}'")

Output:

$ python search_tool.py "python" .
Searching for 'python' in '.'

$ python search_tool.py "python"
Searching for 'python' in '.'
Coding graphs and charts with Matplotlib
Real-time visualisation: where performance meets perception.

Optional Arguments and Flags

Single vs Double Dashes

Optional arguments start with dashes. A single dash like -v is a “short” option (typically one letter), while double dashes like --verbose are “long” options (typically words). You can provide both:

# backup_tool.py
import argparse

parser = argparse.ArgumentParser(description='Backup files')
parser.add_argument('--verbose', '-v', action='store_true', help='Show detailed output')
parser.add_argument('--output', '-o', help='Output directory')
parser.add_argument('--compress', '-c', action='store_true', help='Compress backup')

args = parser.parse_args()

output_dir = args.output or './backups'
print(f"Backing up to: {output_dir}")
if args.verbose:
    print("Verbose mode enabled")
if args.compress:
    print("Compression enabled")

Output:

$ python backup_tool.py -v -c
Backing up to: ./backups
Verbose mode enabled
Compression enabled

$ python backup_tool.py --output /mnt/backup --verbose
Backing up to: /mnt/backup
Verbose mode enabled

Boolean Flags with action

The action='store_true' parameter turns an optional argument into a boolean flag. By default, the value is False. When the flag is present, it becomes True. Use action='store_false' for the opposite behavior:

# config_tool.py
import argparse

parser = argparse.ArgumentParser(description='Configuration tool')
parser.add_argument('--enable-logging', action='store_true', help='Enable logging')
parser.add_argument('--skip-cache', action='store_true', help='Skip cache')
parser.add_argument('--no-color', action='store_false', dest='color', help='Disable color output')

args = parser.parse_args()

print(f"Logging: {args.enable_logging}")
print(f"Skip cache: {args.skip_cache}")
print(f"Color output: {args.color}")

Output:

$ python config_tool.py --enable-logging
Logging: True
Skip cache: False
Color output: True

$ python config_tool.py --enable-logging --no-color
Logging: True
Skip cache: False
Color output: False

Type Conversion and Validation

By default, all arguments are treated as strings. Use the type parameter to convert them automatically. Python provides built-in types like int, float, and bool, and you can define custom conversion functions:

# math_cli.py
import argparse

parser = argparse.ArgumentParser(description='Math operations')
parser.add_argument('--numbers', type=float, nargs='+', help='Numbers to process')
parser.add_argument('--max-results', type=int, default=10, help='Maximum results')
parser.add_argument('--threshold', type=float, default=0.5, help='Threshold value')

args = parser.parse_args()

if args.numbers:
    total = sum(args.numbers)
    avg = total / len(args.numbers)
    print(f"Sum: {total}, Average: {avg}")
    print(f"Max results: {args.max_results}")
    print(f"Threshold: {args.threshold}")

Output:

$ python math_cli.py --numbers 5.2 3.1 7.8 --max-results 20
Sum: 16.1, Average: 5.366666666666667
Max results: 20
Threshold: 0.5

Custom Type Functions

For complex validation, write a function that takes a string and returns the converted value, or raises argparse.ArgumentTypeError if invalid:

# port_validator.py
import argparse

def valid_port(value):
    port = int(value)
    if not (1 <= port <= 65535):
        raise argparse.ArgumentTypeError(f"{value} is not a valid port (1-65535)")
    return port

parser = argparse.ArgumentParser(description='Server launcher')
parser.add_argument('--port', type=valid_port, default=8000, help='Port number')
parser.add_argument('--host', default='localhost', help='Host address')

args = parser.parse_args()

print(f"Starting server at {args.host}:{args.port}")

Output:

$ python port_validator.py --port 3000
Starting server at localhost:3000

$ python port_validator.py --port 70000
usage: port_validator.py [-h] [--port PORT] [--host HOST]
port_validator.py: error: argument --port: 70000 is not a valid port (1-65535)
Creating scatter plots with Matplotlib
Correlation detection: where randomness reveals its secrets.

Choices and Default Values

The choices parameter restricts an argument to a specific set of values. This is useful for mode selection, environment names, or any enumerated option. When combined with default, you provide sensible fallback behavior:

# deployment_tool.py
import argparse

parser = argparse.ArgumentParser(description='Deployment tool')
parser.add_argument('environment', choices=['dev', 'staging', 'prod'],
                   help='Target environment')
parser.add_argument('--log-level', choices=['debug', 'info', 'warning', 'error'],
                   default='info', help='Logging level')
parser.add_argument('--timeout', type=int, default=30, help='Timeout in seconds')
parser.add_argument('--retry-count', type=int, default=3, help='Number of retries')

args = parser.parse_args()

print(f"Deploying to {args.environment}")
print(f"Log level: {args.log_level}")
print(f"Timeout: {args.timeout}s, Retries: {args.retry_count}")

Output:

$ python deployment_tool.py staging
Deploying to staging
Log level: info
Timeout: 30s, Retries: 3

$ python deployment_tool.py prod --log-level debug --timeout 60
Deploying to prod
Log level: debug
Timeout: 60s, Retries: 3

$ python deployment_tool.py testing
usage: deployment_tool.py [-h] [--log-level {debug,info,warning,error}]
                          [--timeout TIMEOUT] [--retry-count RETRY_COUNT]
                          {dev,staging,prod}
deployment_tool.py: error: argument environment: invalid choice: 'testing'
(choose from 'dev', 'staging', 'prod')

Mutually Exclusive Groups

Sometimes you want to ensure that only one of several options can be used at a time. The add_mutually_exclusive_group() method enforces this constraint and provides helpful error messages when users violate it:

# data_converter.py
import argparse

parser = argparse.ArgumentParser(description='Data format converter')
parser.add_argument('input_file', help='Input file path')

format_group = parser.add_mutually_exclusive_group(required=True)
format_group.add_argument('--to-json', action='store_true', help='Convert to JSON')
format_group.add_argument('--to-csv', action='store_true', help='Convert to CSV')
format_group.add_argument('--to-xml', action='store_true', help='Convert to XML')

parser.add_argument('--pretty', action='store_true', help='Pretty-print output')

args = parser.parse_args()

output_format = None
if args.to_json:
    output_format = 'json'
elif args.to_csv:
    output_format = 'csv'
elif args.to_xml:
    output_format = 'xml'

print(f"Converting {args.input_file} to {output_format}")
if args.pretty:
    print("Pretty-printing enabled")

Output:

$ python data_converter.py data.txt --to-json --pretty
Converting data.txt to json
Pretty-printing enabled

$ python data_converter.py data.txt --to-json --to-csv
usage: data_converter.py [-h] (--to-json | --to-csv | --to-xml) [--pretty]
                         input_file
data_converter.py: error: argument --to-csv: not allowed with argument --to-json
Examining pie charts with Matplotlib
Debugging visualisations: sometimes the bug is in what you are not seeing.

Subcommands

Complex tools often have multiple "modes" like git commit, git push, git clone. Use add_subparsers() to create subcommand structures. Each subcommand gets its own set of arguments and can have different behaviors:

# package_manager.py
import argparse

parser = argparse.ArgumentParser(description='Package manager')
subparsers = parser.add_subparsers(dest='command', help='Available commands')

# Install subcommand
install_parser = subparsers.add_parser('install', help='Install a package')
install_parser.add_argument('package_name', help='Package to install')
install_parser.add_argument('--version', help='Specific version to install')
install_parser.add_argument('--upgrade', action='store_true', help='Upgrade if exists')

# Remove subcommand
remove_parser = subparsers.add_parser('remove', help='Remove a package')
remove_parser.add_argument('package_name', help='Package to remove')
remove_parser.add_argument('--force', action='store_true', help='Force removal')

# List subcommand
list_parser = subparsers.add_parser('list', help='List installed packages')
list_parser.add_argument('--outdated', action='store_true', help='Only show outdated')

args = parser.parse_args()

if args.command == 'install':
    version = args.version or 'latest'
    upgrade_msg = " (upgrading)" if args.upgrade else ""
    print(f"Installing {args.package_name} version {version}{upgrade_msg}")
elif args.command == 'remove':
    force_msg = " (forced)" if args.force else ""
    print(f"Removing {args.package_name}{force_msg}")
elif args.command == 'list':
    filter_msg = " outdated packages" if args.outdated else " packages"
    print(f"Listing{filter_msg}")
else:
    parser.print_help()

Output:

$ python package_manager.py install numpy --version 1.24
Installing numpy version 1.24

$ python package_manager.py remove requests --force
Removing requests (forced)

$ python package_manager.py list --outdated
Listing outdated packages

$ python package_manager.py --help
usage: package_manager.py [-h] {install,remove,list} ...

Package manager

positional arguments:
  {install,remove,list}  Available commands
    install              Install a package
    remove               Remove a package
    list                 List installed packages

optional arguments:
  -h, --help             show this help message and exit

Real-Life Example: Building a File Organizer CLI

Let's combine everything into a practical file organization tool. This script organizes files by extension, with options for dry-run mode, custom destinations, and file type filtering:

# file_organizer.py
import argparse
import os
import shutil
from pathlib import Path

def valid_directory(value):
    if not os.path.isdir(value):
        raise argparse.ArgumentTypeError(f"'{value}' is not a valid directory")
    return value

parser = argparse.ArgumentParser(
    description='Organize files in a directory by extension',
    formatter_class=argparse.RawDescriptionHelpFormatter,
    epilog='''Examples:
  python file_organizer.py ~/Downloads
  python file_organizer.py ~/Downloads --extensions txt pdf --dry-run
  python file_organizer.py ~/Downloads --output ~/Organized --clean
'''
)

parser.add_argument('source_dir', type=valid_directory, help='Directory to organize')
parser.add_argument('--output', '-o', type=valid_directory, default=None,
                   help='Output directory (default: same as source)')
parser.add_argument('--extensions', '-e', nargs='+', default=None,
                   help='Only organize these file types (e.g., txt pdf)')
parser.add_argument('--dry-run', action='store_true',
                   help='Show what would happen without making changes')
parser.add_argument('--clean', action='store_true',
                   help='Remove empty subdirectories after organizing')

args = parser.parse_args()

source = Path(args.source_dir)
output = Path(args.output) if args.output else source

file_count = 0
for file_path in source.glob('*'):
    if file_path.is_file():
        ext = file_path.suffix[1:].lower() or 'no_extension'

        if args.extensions and ext not in args.extensions:
            continue

        target_dir = output / ext

        if args.dry_run:
            print(f"Would move: {file_path.name} -> {ext}/")
        else:
            target_dir.mkdir(exist_ok=True)
            shutil.move(str(file_path), str(target_dir / file_path.name))
            print(f"Moved: {file_path.name} -> {ext}/")

        file_count += 1

print(f"\nTotal files processed: {file_count}")

if args.clean and not args.dry_run:
    removed = 0
    for subdir in source.iterdir():
        if subdir.is_dir() and not list(subdir.iterdir()):
            subdir.rmdir()
            removed += 1
    if removed > 0:
        print(f"Removed {removed} empty directories")

Output:

$ python file_organizer.py ~/Downloads --dry-run
Would move: report.pdf -> pdf/
Would move: script.py -> py/
Would move: image.jpg -> jpg/

Total files processed: 3

$ python file_organizer.py ~/Downloads --extensions txt py --clean
Moved: notes.txt -> txt/
Moved: script.py -> py/
Removed 2 empty directories

Total files processed: 2

Frequently Asked Questions

What does nargs do?

The nargs parameter controls how many values an argument accepts. Use nargs='+' for one or more values, nargs='*' for zero or more, nargs=N for exactly N values, and nargs='?' for zero or one. This is essential for accepting variable-length lists of inputs.

What is the dest parameter?

The dest parameter specifies the attribute name where the parsed argument value will be stored. By default, argparse converts the argument name to a valid Python identifier (e.g., --my-option becomes args.my_option). Use dest to override this: parser.add_argument('--my-option', dest='custom_name') stores the value in args.custom_name.

How do I customize help text formatting?

Pass formatter_class=argparse.RawDescriptionHelpFormatter to preserve formatting in description text, or use argparse.RawTextHelpFormatter for help text. Use epilog to add text at the end of the help message. The help parameter for each argument becomes part of the auto-generated help output.

How do I make optional arguments required?

Pass required=True to add_argument(). For example: parser.add_argument('--api-key', required=True). This forces users to provide the argument, even though it uses the dash syntax of optional arguments. It's useful when you need to maintain consistent naming but require the value.

Can argparse read from environment variables?

Yes, use env_var (Python 3.10+) or manually check environment variables in your code. For older Python versions, use: parser.add_argument('--api-key', default=os.getenv('API_KEY')). This provides flexibility for users who prefer environment variables over command-line arguments.

Conclusion

You now have a solid foundation in Python's argparse module. You've learned to create positional and optional arguments, handle type conversion and validation, organize options with mutually exclusive groups, and structure complex CLIs with subcommands. The patterns shown here scale from simple scripts to sophisticated command-line applications used by thousands of developers.

The best way to internalize these concepts is to build something. Start with a simple script that needs two or three arguments, then gradually add complexity. Reference the official argparse documentation when you need advanced features like custom formatters or argument groups. Your future self will thank you for building tools with clear, well-documented interfaces.

The Two Matplotlib APIs

Matplotlib has two interfaces — pyplot (state-based, MATLAB-style) and object-oriented. They produce identical plots; the OO API scales better as plots get complex:

import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(0, 10, 100)
y = np.sin(x)

# pyplot style — fine for quick plots
plt.plot(x, y)
plt.xlabel("x")
plt.ylabel("sin(x)")
plt.title("Sine Wave")
plt.show()

# Object-oriented — better for subplots, customization
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(x, y, label="sin")
ax.plot(x, np.cos(x), label="cos")
ax.set_xlabel("x")
ax.set_ylabel("value")
ax.set_title("Trig Functions")
ax.legend()
ax.grid(True, alpha=0.3)
fig.savefig("trig.png", dpi=150)

Default rule: pyplot for quick exploration, OO for anything you'll save / share / customize.

Common Plot Types

# Line — time series, continuous data
ax.plot(x, y, "b-", linewidth=2, label="Series A")
ax.plot(x, z, "r--", linewidth=2, label="Series B")

# Scatter — relationships, classifications
ax.scatter(x, y, c=labels, s=50, alpha=0.6, cmap="viridis")

# Bar — categorical
ax.bar(categories, values, color="steelblue")
ax.barh(categories, values)             # horizontal

# Histogram — distributions
ax.hist(data, bins=30, edgecolor="black")

# Box plot — distribution summary
ax.boxplot([sample1, sample2, sample3], labels=["A", "B", "C"])

# Heatmap (via imshow)
ax.imshow(matrix, cmap="coolwarm", aspect="auto")
fig.colorbar(im, ax=ax)

Subplots and Layout

For multi-panel figures, plt.subplots() builds the grid in one call:

fig, axes = plt.subplots(2, 3, figsize=(15, 8), sharex=True)

axes[0, 0].plot(x, np.sin(x))
axes[0, 0].set_title("sin(x)")
axes[0, 1].plot(x, np.cos(x))
axes[0, 1].set_title("cos(x)")
axes[1, 0].hist(np.random.normal(size=1000), bins=30)
axes[1, 0].set_title("normal")
# ...

fig.suptitle("Multi-panel figure", fontsize=14)
fig.tight_layout()
fig.savefig("multi.png")

fig.tight_layout() is essential — it prevents labels from overlapping between panels.

Styling: Colors, Themes, Annotations

# Built-in styles
plt.style.use("seaborn-v0_8-darkgrid")    # try also "ggplot", "bmh", "fivethirtyeight"

# Color palettes
import matplotlib.cm as cm
colors = cm.viridis(np.linspace(0, 1, 5))
for i, c in enumerate(colors):
    ax.plot(x, np.sin(x + i*0.5), color=c)

# Annotations
ax.annotate(
    "Peak",
    xy=(np.pi/2, 1), xytext=(2, 0.7),
    arrowprops={"arrowstyle": "->", "color": "red"},
)

ax.axhline(y=0, color="black", linestyle="--", alpha=0.3)
ax.axvline(x=np.pi, color="green", alpha=0.5, label="x=π")

ax.text(5, 0.5, "Custom text", fontsize=12, bbox={"facecolor": "yellow"})

Saving Plots

# PNG for blog / docs
fig.savefig("plot.png", dpi=150, bbox_inches="tight")

# PDF for papers (vector, scalable)
fig.savefig("plot.pdf", bbox_inches="tight")

# SVG for web (vector, editable)
fig.savefig("plot.svg")

# Transparent background
fig.savefig("plot.png", transparent=True)

Common Pitfalls

  • Mixing pyplot and OO API. plt.plot() targets the "current axes"; if you switch around, you get plots in the wrong panel. Pick one style per figure.
  • Forgetting plt.show() in non-Jupyter. Scripts won't display plots without it. In notebooks, %matplotlib inline handles display automatically.
  • Default DPI looking blurry. Default dpi=100 looks fuzzy on Retina screens. Save with dpi=150 or 200; for print use 300.
  • Calling plt.show() multiple times. Some backends close the window after first show(). For interactive use, plt.ion() turns on interactive mode.
  • Memory leaks in loops. If you create many figures, call plt.close(fig) to free memory or use plt.close("all") after each batch.

FAQ

Q: Matplotlib, Seaborn, or Plotly?
A: matplotlib for everything (foundational). seaborn for prettier defaults + statistical plots (wraps matplotlib). plotly for interactive plots in dashboards.

Q: How do I plot pandas DataFrames?
A: df.plot(kind="line"), df.plot.scatter(x="a", y="b"), etc. — pandas has matplotlib built in. For more control, plot directly on a matplotlib axis.

Q: Plotly looks better — should I switch?
A: For interactive web dashboards, yes. For static figures (papers, reports, blogs), matplotlib is more flexible and produces publication-quality output.

Q: How do I make plots look professional?
A: Bump font size, add a grid, choose a colormap (avoid jet/rainbow), label axes with units, use tight_layout(), save at high DPI. plt.style.use("ggplot") is a quick win.

Q: 3D plots?
A: from mpl_toolkits.mplot3d import Axes3D; ax = fig.add_subplot(111, projection="3d"). Works fine for small datasets; for serious 3D viz use Plotly or Mayavi.

Wrapping Up

Matplotlib is the foundation of static plotting in Python — every other library (pandas, seaborn, scikit-learn) ends up calling matplotlib underneath. Master plt.subplots() + the OO API + tight_layout() + savefig at 150 DPI, and you can produce publication-quality figures consistently. For interactive web plots, switch to Plotly or Bokeh; for statistical defaults, layer seaborn on top.

How To Use Python argparse for Command-Line Arguments

How To Use Python argparse for Command-Line Arguments

Last Updated: June 01, 2026

Beginner

How To Use Python argparse for Command-Line Arguments

Command-line tools are the backbone of modern development workflows. Whether you’re building deployment scripts, data processing utilities, or automation tools, your Python scripts need to accept arguments and options from the terminal. Without a proper argument parser, you’ll end up manually processing strings from sys.argv, leading to inconsistent interfaces, missing help messages, and frustrated users. This is where Python’s argparse module transforms the experience–from chaotic string parsing to professional, user-friendly CLI tools.

The good news is that argparse comes built into Python’s standard library. You don’t need to install third-party dependencies. Whether you’re a beginner or building production tools, argparse provides everything needed to handle positional arguments, optional flags, type conversion, default values, and even complex subcommands. It automatically generates help messages, validates arguments, and gives users clear error messages when they get something wrong.

In this tutorial, we’ll walk through argparse from the ground up. You’ll learn how to build your first argument parser, understand the difference between positional and optional arguments, handle type conversion and validation, create mutually exclusive groups, and organize complex CLIs with subcommands. By the end, you’ll have the skills to build professional command-line tools that work exactly how users expect them to work.

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

Part of the Python CLI Tools Hub. See the full hub for related Python tutorials.

Before diving into theory, here’s a working script that shows the core pattern. This is all you need to get started:

# hello_cli.py
import argparse

parser = argparse.ArgumentParser(description='A simple greeting tool')
parser.add_argument('name', help='Person to greet')
parser.add_argument('--age', type=int, help='Age of the person')
parser.add_argument('--excited', action='store_true', help='Add enthusiasm')

args = parser.parse_args()

greeting = f"Hello, {args.name}"
if args.age:
    greeting += f" (age {args.age})"
if args.excited:
    greeting += "!!!"
else:
    greeting += "."

print(greeting)

Output:

$ python hello_cli.py Alice --age 30 --excited
Hello, Alice (age 30)!!!

$ python hello_cli.py Bob
Hello, Bob.

$ python hello_cli.py --help
usage: hello_cli.py [-h] [--age AGE] [--excited] name

A simple greeting tool

positional arguments:
  name        Person to greet

optional arguments:
  -h, --help  show this help message and exit
  --age AGE   Age of the person
  --excited   Add enthusiasm
Building CLI foundations with argparse
Every great CLI starts with a solid foundation.

What Is argparse?

The argparse module is Python’s standard library tool for parsing command-line arguments. It automates the tedious work of extracting and validating arguments, freeing you to focus on your application logic. When you create an argument parser, you define what arguments your script accepts, what types they should be, whether they’re required, and what help text to display. Then argparse handles everything else–parsing, validation, and generating help messages.

Before Python formalized argparse, developers used the older getopt module or even manually parsed sys.argv lists. Today, argparse is the standard choice because it’s more powerful and easier to use. For very simple scripts, sys.argv works fine. For anything more complex than a couple of arguments, argparse saves you hours of debugging and edge case handling.

Here’s how argparse compares to other approaches:

Feature argparse sys.argv click (3rd-party)
Built-in to Python Yes Yes No
Automatic help generation Yes No Yes
Type conversion Yes Manual Yes
Subcommands Yes Manual Yes
Learning curve Moderate Steep Gentle
Setup complexity Low Low Medium

For most projects, argparse strikes the perfect balance between power and simplicity. You get production-grade functionality without external dependencies.

Positional Arguments

What Are Positional Arguments?

Positional arguments are required values that the user must provide in a specific order. Think of them as the “nouns” of your command. When you see git commit -m "message", the word after “commit” is a positional argument. In argparse, positional arguments are required by default and must appear before optional arguments.

# file_reader.py
import argparse

parser = argparse.ArgumentParser(description='Read file contents')
parser.add_argument('filename', help='Path to the file to read')
parser.add_argument('encoding', help='File encoding (e.g., utf-8)')

args = parser.parse_args()

try:
    with open(args.filename, 'r', encoding=args.encoding) as f:
        print(f.read())
except FileNotFoundError:
    print(f"Error: File '{args.filename}' not found")

Output:

$ python file_reader.py data.txt utf-8
[contents of data.txt...]

$ python file_reader.py data.txt
usage: file_reader.py [-h] filename encoding
file_reader.py: error: the following arguments are required: encoding

Making Positional Arguments Optional

You can make a positional argument optional by using the nargs parameter. Setting nargs='?' means “zero or one” of this argument:

# search_tool.py
import argparse

parser = argparse.ArgumentParser(description='Search tool')
parser.add_argument('query', help='Search term')
parser.add_argument('directory', nargs='?', default='.', help='Directory to search (default: current)')

args = parser.parse_args()

print(f"Searching for '{args.query}' in '{args.directory}'")

Output:

$ python search_tool.py "python" .
Searching for 'python' in '.'

$ python search_tool.py "python"
Searching for 'python' in '.'
Debugging argument chains in argparse
When your args go sideways, at least you got a stack trace.

Optional Arguments and Flags

Single vs Double Dashes

Optional arguments start with dashes. A single dash like -v is a “short” option (typically one letter), while double dashes like --verbose are “long” options (typically words). You can provide both:

# backup_tool.py
import argparse

parser = argparse.ArgumentParser(description='Backup files')
parser.add_argument('--verbose', '-v', action='store_true', help='Show detailed output')
parser.add_argument('--output', '-o', help='Output directory')
parser.add_argument('--compress', '-c', action='store_true', help='Compress backup')

args = parser.parse_args()

output_dir = args.output or './backups'
print(f"Backing up to: {output_dir}")
if args.verbose:
    print("Verbose mode enabled")
if args.compress:
    print("Compression enabled")

Output:

$ python backup_tool.py -v -c
Backing up to: ./backups
Verbose mode enabled
Compression enabled

$ python backup_tool.py --output /mnt/backup --verbose
Backing up to: /mnt/backup
Verbose mode enabled

Boolean Flags with action

The action='store_true' parameter turns an optional argument into a boolean flag. By default, the value is False. When the flag is present, it becomes True. Use action='store_false' for the opposite behavior:

# config_tool.py
import argparse

parser = argparse.ArgumentParser(description='Configuration tool')
parser.add_argument('--enable-logging', action='store_true', help='Enable logging')
parser.add_argument('--skip-cache', action='store_true', help='Skip cache')
parser.add_argument('--no-color', action='store_false', dest='color', help='Disable color output')

args = parser.parse_args()

print(f"Logging: {args.enable_logging}")
print(f"Skip cache: {args.skip_cache}")
print(f"Color output: {args.color}")

Output:

$ python config_tool.py --enable-logging
Logging: True
Skip cache: False
Color output: True

$ python config_tool.py --enable-logging --no-color
Logging: True
Skip cache: False
Color output: False

Type Conversion and Validation

By default, all arguments are treated as strings. Use the type parameter to convert them automatically. Python provides built-in types like int, float, and bool, and you can define custom conversion functions:

# math_cli.py
import argparse

parser = argparse.ArgumentParser(description='Math operations')
parser.add_argument('--numbers', type=float, nargs='+', help='Numbers to process')
parser.add_argument('--max-results', type=int, default=10, help='Maximum results')
parser.add_argument('--threshold', type=float, default=0.5, help='Threshold value')

args = parser.parse_args()

if args.numbers:
    total = sum(args.numbers)
    avg = total / len(args.numbers)
    print(f"Sum: {total}, Average: {avg}")
    print(f"Max results: {args.max_results}")
    print(f"Threshold: {args.threshold}")

Output:

$ python math_cli.py --numbers 5.2 3.1 7.8 --max-results 20
Sum: 16.1, Average: 5.366666666666667
Max results: 20
Threshold: 0.5

Custom Type Functions

For complex validation, write a function that takes a string and returns the converted value, or raises argparse.ArgumentTypeError if invalid:

# port_validator.py
import argparse

def valid_port(value):
    port = int(value)
    if not (1 <= port <= 65535):
        raise argparse.ArgumentTypeError(f"{value} is not a valid port (1-65535)")
    return port

parser = argparse.ArgumentParser(description='Server launcher')
parser.add_argument('--port', type=valid_port, default=8000, help='Port number')
parser.add_argument('--host', default='localhost', help='Host address')

args = parser.parse_args()

print(f"Starting server at {args.host}:{args.port}")

Output:

$ python port_validator.py --port 3000
Starting server at localhost:3000

$ python port_validator.py --port 70000
usage: port_validator.py [-h] [--port PORT] [--host HOST]
port_validator.py: error: argument --port: 70000 is not a valid port (1-65535)
Understanding argparse type conversions
When your string is not actually a number.

Choices and Default Values

The choices parameter restricts an argument to a specific set of values. This is useful for mode selection, environment names, or any enumerated option. When combined with default, you provide sensible fallback behavior:

# deployment_tool.py
import argparse

parser = argparse.ArgumentParser(description='Deployment tool')
parser.add_argument('environment', choices=['dev', 'staging', 'prod'],
                   help='Target environment')
parser.add_argument('--log-level', choices=['debug', 'info', 'warning', 'error'],
                   default='info', help='Logging level')
parser.add_argument('--timeout', type=int, default=30, help='Timeout in seconds')
parser.add_argument('--retry-count', type=int, default=3, help='Number of retries')

args = parser.parse_args()

print(f"Deploying to {args.environment}")
print(f"Log level: {args.log_level}")
print(f"Timeout: {args.timeout}s, Retries: {args.retry_count}")

Output:

$ python deployment_tool.py staging
Deploying to staging
Log level: info
Timeout: 30s, Retries: 3

$ python deployment_tool.py prod --log-level debug --timeout 60
Deploying to prod
Log level: debug
Timeout: 60s, Retries: 3

$ python deployment_tool.py testing
usage: deployment_tool.py [-h] [--log-level {debug,info,warning,error}]
                          [--timeout TIMEOUT] [--retry-count RETRY_COUNT]
                          {dev,staging,prod}
deployment_tool.py: error: argument environment: invalid choice: 'testing'
(choose from 'dev', 'staging', 'prod')

Mutually Exclusive Groups

Sometimes you want to ensure that only one of several options can be used at a time. The add_mutually_exclusive_group() method enforces this constraint and provides helpful error messages when users violate it:

# data_converter.py
import argparse

parser = argparse.ArgumentParser(description='Data format converter')
parser.add_argument('input_file', help='Input file path')

format_group = parser.add_mutually_exclusive_group(required=True)
format_group.add_argument('--to-json', action='store_true', help='Convert to JSON')
format_group.add_argument('--to-csv', action='store_true', help='Convert to CSV')
format_group.add_argument('--to-xml', action='store_true', help='Convert to XML')

parser.add_argument('--pretty', action='store_true', help='Pretty-print output')

args = parser.parse_args()

output_format = None
if args.to_json:
    output_format = 'json'
elif args.to_csv:
    output_format = 'csv'
elif args.to_xml:
    output_format = 'xml'

print(f"Converting {args.input_file} to {output_format}")
if args.pretty:
    print("Pretty-printing enabled")

Output:

$ python data_converter.py data.txt --to-json --pretty
Converting data.txt to json
Pretty-printing enabled

$ python data_converter.py data.txt --to-json --to-csv
usage: data_converter.py [-h] (--to-json | --to-csv | --to-xml) [--pretty]
                         input_file
data_converter.py: error: argument --to-csv: not allowed with argument --to-json
Investigating argparse subcommand hierarchies
Organizing chaos, one subcommand at a time.

Subcommands

Complex tools often have multiple "modes" like git commit, git push, git clone. Use add_subparsers() to create subcommand structures. Each subcommand gets its own set of arguments and can have different behaviors:

# package_manager.py
import argparse

parser = argparse.ArgumentParser(description='Package manager')
subparsers = parser.add_subparsers(dest='command', help='Available commands')

# Install subcommand
install_parser = subparsers.add_parser('install', help='Install a package')
install_parser.add_argument('package_name', help='Package to install')
install_parser.add_argument('--version', help='Specific version to install')
install_parser.add_argument('--upgrade', action='store_true', help='Upgrade if exists')

# Remove subcommand
remove_parser = subparsers.add_parser('remove', help='Remove a package')
remove_parser.add_argument('package_name', help='Package to remove')
remove_parser.add_argument('--force', action='store_true', help='Force removal')

# List subcommand
list_parser = subparsers.add_parser('list', help='List installed packages')
list_parser.add_argument('--outdated', action='store_true', help='Only show outdated')

args = parser.parse_args()

if args.command == 'install':
    version = args.version or 'latest'
    upgrade_msg = " (upgrading)" if args.upgrade else ""
    print(f"Installing {args.package_name} version {version}{upgrade_msg}")
elif args.command == 'remove':
    force_msg = " (forced)" if args.force else ""
    print(f"Removing {args.package_name}{force_msg}")
elif args.command == 'list':
    filter_msg = " outdated packages" if args.outdated else " packages"
    print(f"Listing{filter_msg}")
else:
    parser.print_help()

Output:

$ python package_manager.py install numpy --version 1.24
Installing numpy version 1.24

$ python package_manager.py remove requests --force
Removing requests (forced)

$ python package_manager.py list --outdated
Listing outdated packages

$ python package_manager.py --help
usage: package_manager.py [-h] {install,remove,list} ...

Package manager

positional arguments:
  {install,remove,list}  Available commands
    install              Install a package
    remove               Remove a package
    list                 List installed packages

optional arguments:
  -h, --help             show this help message and exit

Real-Life Example: Building a File Organizer CLI

Let's combine everything into a practical file organization tool. This script organizes files by extension, with options for dry-run mode, custom destinations, and file type filtering:

# file_organizer.py
import argparse
import os
import shutil
from pathlib import Path

def valid_directory(value):
    if not os.path.isdir(value):
        raise argparse.ArgumentTypeError(f"'{value}' is not a valid directory")
    return value

parser = argparse.ArgumentParser(
    description='Organize files in a directory by extension',
    formatter_class=argparse.RawDescriptionHelpFormatter,
    epilog='''Examples:
  python file_organizer.py ~/Downloads
  python file_organizer.py ~/Downloads --extensions txt pdf --dry-run
  python file_organizer.py ~/Downloads --output ~/Organized --clean
'''
)

parser.add_argument('source_dir', type=valid_directory, help='Directory to organize')
parser.add_argument('--output', '-o', type=valid_directory, default=None,
                   help='Output directory (default: same as source)')
parser.add_argument('--extensions', '-e', nargs='+', default=None,
                   help='Only organize these file types (e.g., txt pdf)')
parser.add_argument('--dry-run', action='store_true',
                   help='Show what would happen without making changes')
parser.add_argument('--clean', action='store_true',
                   help='Remove empty subdirectories after organizing')

args = parser.parse_args()

source = Path(args.source_dir)
output = Path(args.output) if args.output else source

file_count = 0
for file_path in source.glob('*'):
    if file_path.is_file():
        ext = file_path.suffix[1:].lower() or 'no_extension'

        if args.extensions and ext not in args.extensions:
            continue

        target_dir = output / ext

        if args.dry_run:
            print(f"Would move: {file_path.name} -> {ext}/")
        else:
            target_dir.mkdir(exist_ok=True)
            shutil.move(str(file_path), str(target_dir / file_path.name))
            print(f"Moved: {file_path.name} -> {ext}/")

        file_count += 1

print(f"\nTotal files processed: {file_count}")

if args.clean and not args.dry_run:
    removed = 0
    for subdir in source.iterdir():
        if subdir.is_dir() and not list(subdir.iterdir()):
            subdir.rmdir()
            removed += 1
    if removed > 0:
        print(f"Removed {removed} empty directories")

Output:

$ python file_organizer.py ~/Downloads --dry-run
Would move: report.pdf -> pdf/
Would move: script.py -> py/
Would move: image.jpg -> jpg/

Total files processed: 3

$ python file_organizer.py ~/Downloads --extensions txt py --clean
Moved: notes.txt -> txt/
Moved: script.py -> py/
Removed 2 empty directories

Total files processed: 2
Optimizing argument parsing performance
Cache your help messages, save your CLI soul.

Frequently Asked Questions

What does nargs do?

The nargs parameter controls how many values an argument accepts. Use nargs='+' for one or more values, nargs='*' for zero or more, nargs=N for exactly N values, and nargs='?' for zero or one. This is essential for accepting variable-length lists of inputs.

What is the dest parameter?

The dest parameter specifies the attribute name where the parsed argument value will be stored. By default, argparse converts the argument name to a valid Python identifier (e.g., --my-option becomes args.my_option). Use dest to override this: parser.add_argument('--my-option', dest='custom_name') stores the value in args.custom_name.

How do I customize help text formatting?

Pass formatter_class=argparse.RawDescriptionHelpFormatter to preserve formatting in description text, or use argparse.RawTextHelpFormatter for help text. Use epilog to add text at the end of the help message. The help parameter for each argument becomes part of the auto-generated help output.

How do I make optional arguments required?

Pass required=True to add_argument(). For example: parser.add_argument('--api-key', required=True). This forces users to provide the argument, even though it uses the dash syntax of optional arguments. It's useful when you need to maintain consistent naming but require the value.

Can argparse read from environment variables?

Yes, use env_var (Python 3.10+) or manually check environment variables in your code. For older Python versions, use: parser.add_argument('--api-key', default=os.getenv('API_KEY')). This provides flexibility for users who prefer environment variables over command-line arguments.

Conclusion

You now have a solid foundation in Python's argparse module. You've learned to create positional and optional arguments, handle type conversion and validation, organize options with mutually exclusive groups, and structure complex CLIs with subcommands. The patterns shown here scale from simple scripts to sophisticated command-line applications used by thousands of developers.

The best way to internalize these concepts is to build something. Start with a simple script that needs two or three arguments, then gradually add complexity. Reference the official argparse documentation when you need advanced features like custom formatters or argument groups. Your future self will thank you for building tools with clear, well-documented interfaces.

Argparse Basics

argparse is in the standard library — no install. The pattern: create a parser, add arguments, parse sys.argv:

import argparse

parser = argparse.ArgumentParser(
    prog="myapp",
    description="Process some files",
)

# Positional argument (required)
parser.add_argument("input", help="Input file path")

# Optional flags
parser.add_argument("-o", "--output", default="out.txt", help="Output path")
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
parser.add_argument("--workers", type=int, default=4, help="Number of workers")

args = parser.parse_args()

print(args.input, args.output, args.verbose, args.workers)

argparse auto-generates --help with descriptions, types, defaults, and usage. Run myapp --help and you get a full reference.

Argument Types and Choices

parser.add_argument("--port", type=int, default=8080)
parser.add_argument("--threshold", type=float)
parser.add_argument("--config", type=argparse.FileType("r"))   # opens the file for you
parser.add_argument("--format", choices=["json", "yaml", "toml"])
parser.add_argument("--tags", nargs="+", help="One or more tags")    # accepts multiple
parser.add_argument("--no-cache", action="store_false", dest="cache")

choices validates against an allowed set. nargs="+" collects multiple values into a list. nargs="?" makes a positional optional.

Subcommands

For multi-command CLIs (think git commit, docker pull), use subparsers:

parser = argparse.ArgumentParser(prog="mytool")
subs = parser.add_subparsers(dest="command", required=True)

# Subcommand: deploy
deploy = subs.add_parser("deploy", help="Deploy the app")
deploy.add_argument("--env", choices=["dev", "staging", "prod"], required=True)
deploy.add_argument("--version", default="latest")

# Subcommand: rollback
rollback = subs.add_parser("rollback", help="Rollback to previous version")
rollback.add_argument("--target-version", required=True)

# Subcommand: status
status = subs.add_parser("status", help="Show deployment status")

args = parser.parse_args()

if args.command == "deploy":
    do_deploy(args.env, args.version)
elif args.command == "rollback":
    do_rollback(args.target_version)
elif args.command == "status":
    do_status()

Custom Validation

For more complex validation than type and choices can express, write a function:

def positive_int(value):
    n = int(value)
    if n <= 0:
        raise argparse.ArgumentTypeError(f"{value} is not a positive integer")
    return n

def email_address(value):
    if "@" not in value:
        raise argparse.ArgumentTypeError(f"{value} is not a valid email")
    return value.strip().lower()

parser.add_argument("--count", type=positive_int, default=1)
parser.add_argument("--admin-email", type=email_address)

Mutually Exclusive Groups

For "one of these, not both" arguments:

group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--all", action="store_true")
group.add_argument("--ids", nargs="+", type=int)
group.add_argument("--from-file", type=argparse.FileType("r"))

# myapp --all     ✓
# myapp --ids 1 2 3   ✓
# myapp --all --ids 1   ✗ argparse error
# myapp           ✗ argparse error (required group has nothing)

Common Pitfalls

  • Forgetting required=True. By default, all flags are optional. For mandatory options, set required=True explicitly.
  • Mixing positional and optional. All positional arguments must come before all optional flags. argparse handles this automatically but ordering matters in your add_argument calls.
  • store_true vs store_false. action="store_true" defaults to False; flag presence sets True. The opposite is confusing — use a different name (--no-cache) and use store_false.
  • Manual sys.argv parsing. Don't. argparse handles edge cases (quoting, escaping, -- separator) that your code won't.
  • Long help text in argument descriptions. argparse wraps help to terminal width. Use \n for line breaks; use RawDescriptionHelpFormatter to preserve formatting in epilog/description.

FAQ

Q: argparse, click, or typer?
A: argparse for stdlib-only / minimal-dependency requirements. click for richer features and ecosystem. typer for modern type-hint-based CLIs.

Q: How do I make an argument environment-variable backed?
A: parser.add_argument("--api-key", default=os.environ.get("API_KEY")). Document the env var in help text so users discover it.

Q: How do I test an argparse CLI?
A: Wrap the parse in a function: def main(argv=None): args = parser.parse_args(argv). Pass a list directly: main(["--port", "9000"]).

Q: How do I support a config file alongside flags?
A: Two patterns: (1) custom action that reads the file and updates the namespace, (2) parse args, then override with config file values, then parse args again. The latter is simpler.

Q: How do I make subcommands share common arguments?
A: Create a parent parser with the shared args (no add_help), then pass it as parents=[parent] when creating each subparser.

Wrapping Up

argparse is the workhorse of Python CLIs — no extra dependencies, in the standard library, more than capable for 90% of scripts. For one-off tools, master add_argument with positional/optional/choices/type/nargs and you've covered the vast majority of use cases. For multi-command tools, subparsers scale up cleanly. When you outgrow argparse, click or typer are the natural next steps.

Continue Learning Python

Tutorials you might also find useful:

How To Use Python Typing Protocol for Structural Subtyping

How To Use Python Typing Protocol for Structural Subtyping

Last Updated: June 01, 2026

Intermediate

You are writing a function that accepts “anything that has a read() method” — maybe a file, a StringIO buffer, or a custom stream class. You do not care about the class hierarchy. You just need it to have read(). With traditional ABCs, every class must explicitly inherit from your abstract base. With Python’s typing.Protocol, you get the same compile-time safety without requiring inheritance at all.

Protocol, introduced in Python 3.8 via PEP 544, brings structural subtyping (also called static duck typing) to Python’s type system. If a class has the right methods with the right signatures, it satisfies the Protocol — no class MyClass(MyProtocol) needed. Type checkers like mypy verify this at analysis time.

In this article, we will start with a quick example comparing Protocol to ABC, then explain structural vs nominal subtyping. We will cover defining protocols, using runtime checkable protocols, combining protocols, and practical patterns for building flexible APIs. We will finish with a real-life notification system 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 Protocol Quick Example

# quick_protocol.py
from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> str:
        ...

def render(shape: Drawable) -> None:
    print(f"Rendering: {shape.draw()}")

# No inheritance needed -- just implement draw()
class Circle:
    def __init__(self, radius: float):
        self.radius = radius
    
    def draw(self) -> str:
        return f"Circle(r={self.radius})"

class Square:
    def __init__(self, side: float):
        self.side = side
    
    def draw(self) -> str:
        return f"Square(s={self.side})"

# Both work with render() -- no inheritance required
render(Circle(5.0))
render(Square(3.0))

Output:

Rendering: Circle(r=5.0)
Rendering: Square(s=3.0)

Neither Circle nor Square inherits from Drawable. They satisfy the protocol simply by having a draw() method that returns a string. This is structural subtyping — the structure (what methods exist) matters, not the class hierarchy.

What Is Structural Subtyping?

Python has two approaches to type compatibility. Nominal subtyping (traditional inheritance) says “class B is compatible with A because B explicitly inherits from A.” Structural subtyping (protocols) says “class B is compatible with A because B has all the methods that A requires.”

Think of it like job hiring. Nominal subtyping is checking someone’s degree: “Did you graduate from our approved program?” Structural subtyping is checking their skills: “Can you do the work? Then you are hired.”

FeatureABC (Nominal)Protocol (Structural)
Inheritance requiredYesNo
Checked atRuntime (instantiation)Static analysis (mypy)
Third-party classesMust modify or registerWorks automatically
Best forYour own class hierarchyFlexible APIs, duck typing
Python version2.6+3.8+

Protocols are especially powerful when working with third-party classes you cannot modify. If a library class has a read() method, it automatically satisfies your Readable protocol — you do not need the library author to inherit from your ABC.

Structural vs nominal subtyping
Protocol checks what you can do, not who your parents are.

Defining Your Own Protocols

A protocol is a class that inherits from Protocol and defines methods (with type hints) that conforming classes must implement. The method bodies are typically ... (Ellipsis) since they serve as specifications, not implementations.

# defining_protocols.py
from typing import Protocol, runtime_checkable

class Serializable(Protocol):
    def to_dict(self) -> dict:
        ...
    
    def to_json(self) -> str:
        ...

class Persistable(Protocol):
    def save(self, path: str) -> None:
        ...
    
    def load(self, path: str) -> None:
        ...

# This class satisfies Serializable (has to_dict and to_json)
class User:
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email
    
    def to_dict(self) -> dict:
        return {"name": self.name, "email": self.email}
    
    def to_json(self) -> str:
        import json
        return json.dumps(self.to_dict())

def export_data(obj: Serializable) -> None:
    data = obj.to_dict()
    json_str = obj.to_json()
    print(f"Dict: {data}")
    print(f"JSON: {json_str}")

user = User("Alice", "alice@example.com")
export_data(user)

Output:

Dict: {'name': 'Alice', 'email': 'alice@example.com'}
JSON: {"name": "Alice", "email": "alice@example.com"}

The User class never mentions Serializable anywhere. It satisfies the protocol purely through its method signatures. If you removed to_json() from User, mypy would catch the error at static analysis time.

Runtime Checkable Protocols

# runtime_checkable.py
from typing import Protocol, runtime_checkable

@runtime_checkable
class Closeable(Protocol):
    def close(self) -> None:
        ...

# Test various objects
import io

file_like = io.StringIO("hello")
regular_list = [1, 2, 3]

print(f"StringIO is Closeable: {isinstance(file_like, Closeable)}")
print(f"list is Closeable: {isinstance(regular_list, Closeable)}")

# Custom class
class Connection:
    def close(self) -> None:
        print("Connection closed")

conn = Connection()
print(f"Connection is Closeable: {isinstance(conn, Closeable)}")

# Use in a function
def cleanup(resources: list) -> None:
    for resource in resources:
        if isinstance(resource, Closeable):
            resource.close()
            print(f"  Closed {type(resource).__name__}")

cleanup([file_like, conn, regular_list])

Output:

StringIO is Closeable: True
list is Closeable: False
Connection is Closeable: True
  Closed StringIO
  Closed Connection

The @runtime_checkable decorator lets you use isinstance() checks with your protocol. Without it, isinstance would raise a TypeError. Note that runtime checks only verify method existence, not signatures — for full signature checking, use mypy.

Runtime checkable protocol
@runtime_checkable — isinstance() for duck typing.

Combining and Extending Protocols

Protocols can inherit from other protocols to create combined interfaces. This lets you build up complex requirements from simple building blocks.

# combining_protocols.py
from typing import Protocol

class Readable(Protocol):
    def read(self) -> str:
        ...

class Writable(Protocol):
    def write(self, data: str) -> None:
        ...

# Combined protocol
class ReadWritable(Readable, Writable, Protocol):
    pass

class InMemoryBuffer:
    def __init__(self):
        self._data = ""
    
    def read(self) -> str:
        return self._data
    
    def write(self, data: str) -> None:
        self._data += data

def process(stream: ReadWritable) -> None:
    stream.write("Hello, ")
    stream.write("World!")
    print(f"Content: {stream.read()}")

buf = InMemoryBuffer()
process(buf)

Output:

Content: Hello, World!

ReadWritable combines both Readable and Writable — any class that implements both read() and write() satisfies it. This composition pattern is cleaner than creating one large protocol with many methods.

Real-Life Example: Notification System

Notification dispatch system
One Protocol, infinite notification backends. Add SMS without changing a line of dispatch code.

Let us build a notification dispatch system that accepts any backend implementing a simple protocol — no inheritance required.

# notification_system.py
from typing import Protocol, runtime_checkable
from datetime import datetime

@runtime_checkable
class NotificationBackend(Protocol):
    def send(self, recipient: str, subject: str, body: str) -> bool:
        ...
    
    @property
    def backend_name(self) -> str:
        ...

class EmailBackend:
    @property
    def backend_name(self) -> str:
        return "Email"
    
    def send(self, recipient: str, subject: str, body: str) -> bool:
        print(f"    Email to {recipient}: {subject}")
        return True

class SlackBackend:
    def __init__(self, channel: str):
        self.channel = channel
    
    @property
    def backend_name(self) -> str:
        return f"Slack(#{self.channel})"
    
    def send(self, recipient: str, subject: str, body: str) -> bool:
        print(f"    Slack #{self.channel} @{recipient}: {subject}")
        return True

class WebhookBackend:
    def __init__(self, url: str):
        self.url = url
    
    @property
    def backend_name(self) -> str:
        return "Webhook"
    
    def send(self, recipient: str, subject: str, body: str) -> bool:
        print(f"    Webhook POST to {self.url}: {subject}")
        return True

class NotificationDispatcher:
    def __init__(self):
        self.backends: list = []
        self.log: list = []
    
    def register(self, backend: NotificationBackend) -> None:
        if not isinstance(backend, NotificationBackend):
            raise TypeError(f"{type(backend).__name__} does not satisfy NotificationBackend protocol")
        self.backends.append(backend)
        print(f"  Registered: {backend.backend_name}")
    
    def notify(self, recipient: str, subject: str, body: str) -> dict:
        results = {}
        for backend in self.backends:
            success = backend.send(recipient, subject, body)
            results[backend.backend_name] = success
            self.log.append({
                "time": datetime.now().strftime("%H:%M:%S"),
                "backend": backend.backend_name,
                "recipient": recipient,
                "success": success
            })
        return results
    
    def summary(self) -> None:
        print(f"\n  Notification log: {len(self.log)} entries")
        for entry in self.log:
            status = "OK" if entry["success"] else "FAIL"
            print(f"    [{status}] {entry['time']} via {entry['backend']} to {entry['recipient']}")

# Setup
print("Registering backends:")
dispatcher = NotificationDispatcher()
dispatcher.register(EmailBackend())
dispatcher.register(SlackBackend("alerts"))
dispatcher.register(WebhookBackend("https://hooks.example.com/notify"))

print("\nSending notifications:")
dispatcher.notify("alice", "Deploy Complete", "v2.1.0 deployed to production")
dispatcher.notify("bob", "Build Failed", "CI pipeline failed on main branch")

dispatcher.summary()

Output:

Registering backends:
  Registered: Email
  Registered: Slack(#alerts)
  Registered: Webhook

Sending notifications:
    Email to alice: Deploy Complete
    Slack #alerts @alice: Deploy Complete
    Webhook POST to https://hooks.example.com/notify: Deploy Complete
    Email to bob: Build Failed
    Slack #alerts @bob: Build Failed
    Webhook POST to https://hooks.example.com/notify: Build Failed

  Notification log: 6 entries
    [OK] 09:45:30 via Email to alice
    [OK] 09:45:30 via Slack(#alerts) to alice
    [OK] 09:45:30 via Webhook to alice
    [OK] 09:45:30 via Email to bob
    [OK] 09:45:30 via Slack(#alerts) to bob
    [OK] 09:45:30 via Webhook to bob

The power here is extensibility. Anyone can write a new backend — SMSBackend, PagerDutyBackend, TeamsBackend — without inheriting from anything. As long as it has send() and backend_name, the dispatcher accepts it. The @runtime_checkable decorator catches mistakes at registration time.

Modular protocol dashboard
Three backends today, five tomorrow. Protocol makes the interface, not the inheritance.

Frequently Asked Questions

When should I use Protocol instead of ABC?

Use Protocol when you want to define an interface without forcing inheritance — especially useful with third-party classes you cannot modify. Use ABC when you control the class hierarchy and want runtime enforcement. In practice, Protocol is better for library APIs and ABC is better for internal frameworks.

Does Protocol work at runtime or only with mypy?

By default, Protocol is a static-only concept — mypy and other type checkers verify it. Add @runtime_checkable to enable isinstance() checks at runtime. However, runtime checks only verify method existence, not parameter types or return types.

Can a Protocol have default method implementations?

Technically yes — you can add method bodies to Protocol methods. However, these implementations are only available to classes that explicitly inherit from the Protocol. Classes that satisfy the protocol structurally do not get the default implementations. For shared functionality, consider a mixin class instead.

Can I use Protocol with dataclasses?

Yes. A dataclass that has the right methods and attributes satisfies a Protocol just like any other class. You can also define Protocols with attribute requirements using class variables with type annotations (without assignment), and dataclasses will satisfy them through their generated __init__.

What Python version do I need for Protocol?

Protocol was added in Python 3.8 via PEP 544. For Python 3.7, you can use typing_extensions.Protocol as a backport. Since Python 3.8 reached its minimum support baseline for most modern projects, you can safely use Protocol in any new code.

Conclusion

We covered Python’s typing.Protocol for structural subtyping: defining protocols with method specifications, using @runtime_checkable for isinstance checks, combining protocols through inheritance, and building flexible APIs that accept any object with the right methods. The notification system showed how protocols enable plugin-style architectures without inheritance.

Try converting an existing ABC-based system to use Protocol and compare the flexibility. For the complete reference, see the official Protocol documentation and PEP 544.

Why Protocols Beat ABCs (Sometimes)

Before Python 3.8 you had two options for “this object must support these methods”: runtime ABC inheritance or duck typing with no static checks. typing.Protocol is the third path — structural typing that gets enforced by type checkers like mypy without forcing inheritance:

from typing import Protocol

class SupportsClose(Protocol):
    def close(self) -> None: ...

def shutdown(resource: SupportsClose) -> None:
    resource.close()

# Any class with close() satisfies the protocol — no inheritance needed
class FileWrapper:
    def __init__(self, path):
        self.f = open(path)
    def close(self):
        self.f.close()

class HttpClient:
    def close(self):
        print("Client closed")

# Both work — mypy verifies them
shutdown(FileWrapper("data.txt"))
shutdown(HttpClient())

The third-party type doesn’t need to know about your Protocol. This is the killer feature — you can declare contracts for libraries you don’t control.

Runtime-Checkable Protocols

Static analysis is the primary use case, but you can opt into runtime isinstance checks with @runtime_checkable:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Comparable(Protocol):
    def __lt__(self, other: "Comparable") -> bool: ...

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius
    def __lt__(self, other):
        return self.celsius < other.celsius

print(isinstance(Temperature(25), Comparable))   # True
print(isinstance(42, Comparable))                # True (int has __lt__)

Runtime checks have caveats — they only verify method presence, not signatures or types. For strict validation, lean on static type checkers.

Generic Protocols

Protocols can be parameterized just like generic ABCs. The classic example is "anything supporting __getitem__":

from typing import Protocol, TypeVar

T = TypeVar("T", covariant=True)

class Container(Protocol[T]):
    def __getitem__(self, key: int) -> T: ...
    def __len__(self) -> int: ...

def first(c: Container[T]) -> T:
    return c[0]

x: int = first([1, 2, 3])         # ok
y: str = first(["a", "b"])         # ok
z: int = first(("a", "b"))         # mypy error — Container[int] required

Protocol vs ABC: Quick Decision Tree

NeedUse
Enforce at runtime (TypeError if missing)ABC
Provide default method implementationsABC
Type third-party classes you don't controlProtocol
No inheritance hierarchy neededProtocol
Plugin systems with discoveryABC
Lightweight interface declarationsProtocol

Common Pitfalls

  • Forgetting @runtime_checkable for isinstance. Without it, isinstance(obj, MyProtocol) raises TypeError. Static checks always work; runtime checks need the decorator.
  • Protocols don't enforce signatures at runtime. A class with def close(self, x, y) still passes isinstance for SupportsClose. Static type checkers catch this; runtime doesn't.
  • Mixing Protocol with regular base classes. A class that inherits from both a Protocol and a regular class can lead to MRO confusion. Most of the time, just declare the Protocol — no inheritance needed.
  • Forgetting __init__ vs __post_init__. Protocols can declare __init__ but it's awkward — usually you want the Protocol to specify behavior, not construction.
  • Overusing Protocols. For your own classes that you control, sometimes a plain ABC or a regular class is clearer. Use Protocols when you need the structural-typing flexibility.

FAQ

Q: Protocol or ABC for new code?
A: Protocol if you're typing third-party objects or want lightweight interfaces. ABC if you need runtime enforcement, default methods, or a real inheritance tree.

Q: Do Protocols work with dataclasses?
A: Yes. A dataclass automatically satisfies any Protocol whose methods/attributes it provides. @dataclass doesn't conflict with Protocol.

Q: Can a Protocol have non-abstract methods?
A: Yes — methods with bodies act as default implementations for classes that inherit from the Protocol. But classes don't need to inherit; they just need the structural shape.

Q: How do mypy and Pyright handle Protocols?
A: Both fully support them, including generic Protocols and variance. Pyright is slightly stricter on covariance/contravariance — fine, it usually catches real bugs.

Q: PEP 544 vs PEP 695?
A: PEP 544 introduced Protocols (3.8). PEP 695 added cleaner generic syntax (3.12+). They work together — PEP 695 just makes generic Protocols less verbose.

Wrapping Up

typing.Protocol is one of the most useful features added to Python in the last decade. Use it when you want to type interfaces without forcing inheritance — third-party objects, plugin shapes, structural duck typing made type-safe. For deeper enforcement and default methods, stick with ABCs. The two paradigms complement each other; pick the right tool per interface.

Continue Learning Python

Tutorials you might also find useful:

How To Use Python Pickle and Shelve for Object Serialization

How To Use Python Pickle and Shelve for Object Serialization

Last Updated: June 01, 2026

Intermediate

You have trained a machine learning model that took three hours to fit, and now you need to save it so you can load it tomorrow without retraining. Or you have a complex nested data structure — dictionaries containing lists of custom objects — and you need to persist it between script runs. JSON cannot handle custom Python objects, and writing a custom serializer for every class is tedious. This is where pickle comes in.

Python’s pickle module serializes Python objects into a byte stream and deserializes them back. Its companion module shelve builds on pickle to give you a persistent dictionary backed by a file. Both are in the standard library — no installation needed.

In this article, we will start with a quick example of saving and loading objects, then explain how pickle works and its security implications. We will cover pickling custom classes, using shelve as a persistent key-value store, and best practices for safe serialization. We will finish with a real-life project that caches expensive API results.

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 Pickle Quick Example

# quick_pickle.py
import pickle

# Save a complex data structure
data = {
    "users": ["Alice", "Bob", "Charlie"],
    "scores": {"Alice": [95, 87, 92], "Bob": [78, 85], "Charlie": [90]},
    "metadata": {"version": 2, "created": "2026-04-12"}
}

# Serialize to file
with open("data.pkl", "wb") as f:
    pickle.dump(data, f)
print("Saved to data.pkl")

# Load it back
with open("data.pkl", "rb") as f:
    loaded = pickle.load(f)

print(f"Users: {loaded['users']}")
print(f"Alice scores: {loaded['scores']['Alice']}")
print(f"Same data: {data == loaded}")

Output:

Saved to data.pkl
Users: ['Alice', 'Bob', 'Charlie']
Alice scores: [95, 87, 92]
Same data: True

Two function calls: pickle.dump() to save and pickle.load() to restore. The entire nested structure — dict, lists, strings, integers — survives the round trip perfectly. Let us explore how this works and when to use it.

What Is Pickle and When Should You Use It?

Pickle converts Python objects into a byte stream (serialization) and reconstructs them from that byte stream (deserialization). Unlike JSON, which only handles basic types (strings, numbers, lists, dicts), pickle can serialize almost any Python object: custom classes, functions, sets, datetime objects, and complex nested structures.

FeaturepickleJSONshelve
Python-specific typesYes (classes, sets, etc.)No (basic types only)Yes (uses pickle)
Human readableNo (binary)YesNo
Cross-languageNo (Python only)YesNo
SecurityUnsafe with untrusted dataSafeUnsafe with untrusted data
Key-value accessNo (full load)No (full load)Yes (dict-like)

Use pickle when: you need to save Python-specific objects (custom classes, ML models, complex structures) and the data stays within your own code. Use JSON when: you need human-readable output or cross-language compatibility. Never unpickle data from untrusted sources — pickle can execute arbitrary code during deserialization.

Pickling objects into jars
pickle.dump() — freeze any Python object. pickle.load() — thaw it back to life.

Pickling Custom Classes

One of pickle’s most powerful features is its ability to serialize custom objects. Your classes work with pickle automatically as long as their attributes are themselves picklable.

# custom_pickle.py
import pickle
from datetime import datetime

class Task:
    def __init__(self, title, priority="medium"):
        self.title = title
        self.priority = priority
        self.created = datetime.now()
        self.completed = False
    
    def complete(self):
        self.completed = True
    
    def __repr__(self):
        status = "done" if self.completed else "pending"
        return f"Task('{self.title}', {self.priority}, {status})"

# Create tasks
tasks = [
    Task("Write tests", "high"),
    Task("Update docs", "low"),
    Task("Fix bug #42", "high"),
]
tasks[0].complete()

# Save
with open("tasks.pkl", "wb") as f:
    pickle.dump(tasks, f)
print(f"Saved {len(tasks)} tasks")

# Load
with open("tasks.pkl", "rb") as f:
    loaded_tasks = pickle.load(f)

for task in loaded_tasks:
    print(f"  {task}")
print(f"\nFirst task created: {loaded_tasks[0].created}")

Output:

Saved 3 tasks
  Task('Write tests', high, done)
  Task('Update docs', low, pending)
  Task('Fix bug #42', high, pending)

First task created: 2026-04-12 09:30:15.123456

Notice that everything was preserved: the string attributes, the boolean completed state, and even the datetime object. Pickle captures the full state of each object, including the state changes we made by calling complete().

Pickle Protocols and bytes

# pickle_protocols.py
import pickle

data = {"key": "value", "numbers": [1, 2, 3]}

# Serialize to bytes (not to a file)
pickled_bytes = pickle.dumps(data)
print(f"Pickled size: {len(pickled_bytes)} bytes")
print(f"Bytes preview: {pickled_bytes[:30]}...")

# Deserialize from bytes
restored = pickle.loads(pickled_bytes)
print(f"Restored: {restored}")

# Check available protocols
print(f"\nHighest protocol: {pickle.HIGHEST_PROTOCOL}")
print(f"Default protocol: {pickle.DEFAULT_PROTOCOL}")

# Use highest protocol for best performance
fast_bytes = pickle.dumps(data, protocol=pickle.HIGHEST_PROTOCOL)
print(f"Highest protocol size: {len(fast_bytes)} bytes")

Output:

Pickled size: 52 bytes
Bytes preview: b'\x80\x05\x95*\x00\x00\x00\x00\x00\x00\x00}\x94(\x8c\x03key'...
Restored: {'key': 'value', 'numbers': [1, 2, 3]}

Highest protocol: 5
Default protocol: 5
Highest protocol size: 52 bytes

Use pickle.dumps() and pickle.loads() (note the ‘s’) for in-memory serialization to bytes — useful for sending objects over networks or storing in databases. Higher protocol numbers generally produce smaller output and deserialize faster.

Pickle protocols and bytes
pickle.dumps() for bytes, pickle.dump() for files. One letter changes everything.

Shelve: A Persistent Dictionary

shelve builds on pickle to give you a dictionary-like interface backed by a file. Instead of loading and saving entire data structures, you read and write individual keys — perfect for caching and simple data stores.

# shelve_basics.py
import shelve

# Open a shelf (creates the file if it does not exist)
with shelve.open("mydata") as db:
    db["users"] = ["Alice", "Bob", "Charlie"]
    db["config"] = {"theme": "dark", "lang": "en"}
    db["counter"] = 42
    print(f"Stored {len(db)} keys")

# Read back individual keys
with shelve.open("mydata") as db:
    print(f"Users: {db['users']}")
    print(f"Config: {db['config']}")
    print(f"Keys: {list(db.keys())}")
    
    # Check if key exists
    print(f"Has 'users': {'users' in db}")
    print(f"Has 'missing': {'missing' in db}")
    
    # Update a value
    db["counter"] = db["counter"] + 1
    print(f"Counter: {db['counter']}")

Output:

Stored 3 keys
Users: ['Alice', 'Bob', 'Charlie']
Config: {'theme': 'dark', 'lang': 'en'}
Keys: ['users', 'config', 'counter']
Has 'users': True
Has 'missing': False
Counter: 43

Shelve works exactly like a dictionary — you use square bracket syntax to get and set values, in to check membership, and keys() to list all keys. The difference is that the data persists on disk between runs. Always use the with statement to ensure the shelf is properly closed and synced.

Real-Life Example: API Response Cache

API response cache with shelve
Cache hit: 0ms. Cache miss: 2,000ms. shelve makes the difference.

Let us build an API response cache that stores results on disk to avoid making the same request twice. This is useful for development, testing, or any scenario where API calls are slow or rate-limited.

# api_cache.py
import shelve
import time
import hashlib
import json

class APICache:
    def __init__(self, cache_file="api_cache", ttl=3600):
        self.cache_file = cache_file
        self.ttl = ttl  # Time to live in seconds
    
    def _make_key(self, url, params=None):
        key_data = url + json.dumps(params or {}, sort_keys=True)
        return hashlib.md5(key_data.encode()).hexdigest()
    
    def get(self, url, params=None):
        key = self._make_key(url, params)
        with shelve.open(self.cache_file) as db:
            if key in db:
                entry = db[key]
                age = time.time() - entry["timestamp"]
                if age < self.ttl:
                    print(f"  Cache HIT for {url} (age: {age:.0f}s)")
                    return entry["data"]
                else:
                    print(f"  Cache EXPIRED for {url} (age: {age:.0f}s)")
        return None
    
    def set(self, url, data, params=None):
        key = self._make_key(url, params)
        with shelve.open(self.cache_file) as db:
            db[key] = {"data": data, "timestamp": time.time(), "url": url}
        print(f"  Cached response for {url}")
    
    def stats(self):
        with shelve.open(self.cache_file) as db:
            total = len(db)
            fresh = sum(1 for k in db if time.time() - db[k]["timestamp"] < self.ttl)
            print(f"  Cache: {total} entries, {fresh} fresh, {total - fresh} expired")

def fetch_with_cache(cache, url, params=None):
    cached = cache.get(url, params)
    if cached is not None:
        return cached
    
    # Simulate API call
    print(f"  Fetching {url}...")
    time.sleep(0.1)  # Simulate network delay
    data = {"url": url, "status": "ok", "results": [1, 2, 3]}
    
    cache.set(url, data, params)
    return data

# Demo
cache = APICache(ttl=60)

print("First requests (cache miss):")
fetch_with_cache(cache, "https://api.example.com/users")
fetch_with_cache(cache, "https://api.example.com/products")

print("\nSecond requests (cache hit):")
fetch_with_cache(cache, "https://api.example.com/users")
fetch_with_cache(cache, "https://api.example.com/products")

print("\nCache statistics:")
cache.stats()

Output:

First requests (cache miss):
  Fetching https://api.example.com/users...
  Cached response for https://api.example.com/users
  Fetching https://api.example.com/products...
  Cached response for https://api.example.com/products

Second requests (cache hit):
  Cache HIT for https://api.example.com/users (age: 0s)
  Cache HIT for https://api.example.com/products (age: 0s)

Cache statistics:
  Cache: 2 entries, 2 fresh, 0 expired

This cache uses shelve for persistent storage, MD5 hashes for unique cache keys, and TTL-based expiration. You could extend it with LRU eviction, cache size limits, or async support for real production use.

Pickle security warning
Never unpickle data you did not create. Deserialization runs arbitrary code.

Frequently Asked Questions

Is pickle safe to use?

Pickle is safe when you only load data you created yourself. Never unpickle data from untrusted sources -- a malicious pickle file can execute arbitrary code on your machine during deserialization. For data exchange with external systems, use JSON, MessagePack, or Protocol Buffers instead.

What types can pickle handle?

Pickle handles most Python types: basic types (int, float, str, bytes, None), containers (list, dict, set, tuple), custom classes, functions defined at module level, and classes defined at module level. It cannot pickle lambda functions, generators, open file handles, database connections, or thread locks.

When should I use shelve instead of pickle?

Use shelve when you need to read and write individual keys without loading the entire dataset into memory. Pickle loads everything at once. Shelve is better for caches, configuration stores, and any scenario where you access data by key rather than as a whole.

How do I handle class changes after pickling?

If you add attributes to a class after pickling objects, implement __getstate__ and __setstate__ to handle migration. Or use __reduce__ for full control over how objects are reconstructed. For simple cases, adding a __init__ default works: getattr(self, "new_attr", default_value).

Is there a size limit for pickle files?

There is no hard limit, but very large pickle files (multi-GB) can cause memory issues since the entire object must fit in RAM during deserialization. For large datasets, consider using pickle with chunked processing, or switch to formats like Parquet or HDF5 that support lazy loading.

Conclusion

We covered Python's serialization tools: pickle for saving and loading any Python object, pickle protocols for optimization, shelve for persistent key-value storage, and critical security considerations. The API cache project demonstrated how shelve powers practical caching solutions.

Try building a session manager or a simple document store with shelve. For the complete reference, see the official pickle documentation and shelve documentation.

Pickle Basics

pickle serializes any Python object to bytes — lists, dicts, custom classes, functions, even modules in some cases. The API is dump/load (file) or dumps/loads (bytes):

import pickle

data = {"users": [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}], "version": 2}

# Write to file
with open("data.pkl", "wb") as f:
    pickle.dump(data, f)

# Read it back
with open("data.pkl", "rb") as f:
    loaded = pickle.load(f)

print(loaded)  # exact reproduction of the original

Pickle preserves the object graph perfectly — even circular references, custom class instances, and unhashable nested structures. For "save the entire state of a complex object," nothing beats it.

The Security Warning You Must Read

NEVER unpickle data from an untrusted source. Pickle is a serialization format, but it's also effectively a programming language — a malicious pickle can execute arbitrary code on load:

# DON'T DO THIS
data = pickle.loads(downloaded_from_internet)  # could execute anything

# Safer alternatives for untrusted data
import json
data = json.loads(downloaded_from_internet)  # text only, no code execution

import msgpack
data = msgpack.unpackb(downloaded_from_internet)  # binary, no code execution

Pickle is for trusted, internal use only: caching computation results, saving ML models you trained yourself, swapping data between your own processes. Anything that crosses a trust boundary should use JSON, msgpack, or Protocol Buffers.

Pickle Protocols

Pickle has 6 protocol versions. Higher = faster, smaller, but less portable to older Python:

# Default — Protocol 5 in Python 3.8+
pickle.dumps(data)

# Explicit protocol
pickle.dumps(data, protocol=pickle.HIGHEST_PROTOCOL)   # always pick newest available
pickle.dumps(data, protocol=4)                          # readable by Python 3.4+
pickle.dumps(data, protocol=0)                          # ASCII, slowest, most portable

For internal use, always use HIGHEST_PROTOCOL. The size and speed difference is significant for large objects.

shelve: Pickle-Backed Persistent Dictionary

shelve wraps pickle in a dict-like persistent storage. Each value gets pickled on write, unpickled on read:

import shelve

# Open like a dict — file path replaces dict literal
with shelve.open("cache.shelf") as cache:
    cache["users:1"] = {"name": "Alice", "age": 30, "groups": ["admin", "writers"]}
    cache["users:2"] = {"name": "Bob", "age": 25, "groups": ["readers"]}
    cache["meta"] = {"last_updated": "2026-05-16"}

# Read in another process
with shelve.open("cache.shelf") as cache:
    print(cache["users:1"])
    print(list(cache.keys()))

shelve is the simplest persistent storage in Python — no schema, no connection pool, no daemon. Great for small caches, configuration storage, and prototype state.

Pickle vs JSON vs msgpack vs SQLite

Use casePick
Save trained ML modelpickle / joblib
Send data over the networkJSON (readable) or msgpack (smaller)
Save app state between runs (small)pickle / shelve
Save app state between runs (large)SQLite
Inter-process queue messagesmsgpack or pickle (trusted)
Configuration fileYAML / TOML / JSON
Anything from external/internetJSON / msgpack — NEVER pickle

Common Pitfalls

  • Unpickling untrusted data. Repeat for emphasis: pickle is a code-execution format. Treat .pkl files like .py files when deciding trust.
  • Schema evolution. A pickled class that's been refactored may fail to load. Either keep the class definitions stable, or migrate pickles at code-update time.
  • shelve write-through. By default, shelve doesn't detect in-place changes to mutable values. cache["k"].append(x) doesn't persist; you need cache["k"] = cache["k"] + [x] or open with writeback=True.
  • shelve on multiple processes. shelve is single-process — concurrent writes corrupt the file. For multi-process state, use SQLite or Redis.
  • Forgetting binary mode. open("data.pkl", "w") uses text mode and breaks pickle data. Always use "wb" / "rb".

FAQ

Q: pickle vs joblib?
A: joblib is pickle under the hood with optimizations for NumPy arrays and large objects. For ML models and numeric data, joblib is faster and produces smaller files.

Q: Is pickle cross-version compatible?
A: Across Python minor versions (3.10 → 3.11), usually yes. Across major versions (Python 2 → 3), often no. Pin Python version for long-lived pickled data.

Q: How do I make my class pickle-friendly?
A: Make sure all attributes are picklable (no file handles, no lambdas, no DB connections). For custom serialization, define __getstate__ and __setstate__.

Q: What's the alternative for large arrays?
A: NumPy's np.save / np.load for arrays alone. For DataFrames, pd.to_parquet / pd.read_parquet — faster, smaller, and language-portable.

Q: Should I gzip pickle output?
A: For large pickles, yes — with gzip.open(path, "wb") as f: pickle.dump(data, f). Combine with HIGHEST_PROTOCOL for the smallest result.

Wrapping Up

Pickle is the Swiss Army knife of Python serialization — but with one big edge: NEVER unpickle untrusted data. For internal use it's perfect: ML models, computation caches, prototype state. shelve turns pickle into a persistent dict for the simplest possible storage. For anything that crosses a trust boundary or another language, reach for JSON, msgpack, or Protocol Buffers instead.

How To Use Python Abstract Base Classes (ABC) for Interfaces

How To Use Python Abstract Base Classes (ABC) for Interfaces

Last Updated: June 01, 2026

Intermediate

You are building a plugin system where third-party developers can create their own processors, but you need to guarantee that every plugin implements certain methods. Or you have a team project where different developers build different payment gateways, and you want the code to break loudly at class definition time — not silently at runtime — if someone forgets to implement a required method. This is the exact problem Abstract Base Classes solve.

Python’s abc module lets you define abstract classes that serve as contracts. Any class that inherits from an ABC must implement all abstract methods, or Python raises a TypeError when you try to create an instance. No pip install needed — it is part of the standard library.

In this article, we will start with a quick example showing how ABCs enforce method implementation, then explain when and why you would use them instead of duck typing. We will cover creating abstract methods, abstract properties, combining ABCs with concrete methods, and using the built-in ABCs from collections.abc. We will finish with a real-life plugin system 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 ABC Quick Example

# quick_abc.py
from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass
    
    @abstractmethod
    def perimeter(self):
        pass

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    
    def area(self):
        return 3.14159 * self.radius ** 2
    
    def perimeter(self):
        return 2 * 3.14159 * self.radius

circle = Circle(5)
print(f"Area: {circle.area():.2f}")
print(f"Perimeter: {circle.perimeter():.2f}")

# This would raise TypeError:
# shape = Shape()  # Can't instantiate abstract class

Output:

Area: 78.54
Perimeter: 31.42

The Shape class cannot be instantiated directly because it has abstract methods. Any subclass must implement area() and perimeter(), or Python raises TypeError at instantiation time — not when the method is called. This catches bugs early.

What Are Abstract Base Classes and Why Use Them?

Python is famously a duck-typing language: “if it walks like a duck and quacks like a duck, it is a duck.” This flexibility is powerful, but it creates a problem — you only discover missing methods when the code actually tries to call them, which might be deep in a production workflow.

Abstract Base Classes add voluntary structure to Python’s duck typing. They let you define a contract that says “any class claiming to be a Shape must have these methods.” The check happens at class instantiation time, not method call time.

ApproachError TimingBest For
Duck typingRuntime, when method is calledSimple scripts, quick prototypes
ABCInstantiation timeFrameworks, plugin systems, team projects
Protocol (typing)Static analysis onlyType checking without inheritance

Use ABCs when you control the base class hierarchy and want to enforce a contract. Use Protocols (covered in a separate article) when you want structural subtyping without requiring inheritance.

Abstract base class blueprint
abstractmethod says implement me, or TypeError says goodbye.

Creating Abstract Base Classes

To create an ABC, inherit from ABC and decorate methods with @abstractmethod. You can mix abstract and concrete methods in the same class.

# creating_abcs.py
from abc import ABC, abstractmethod

class DataExporter(ABC):
    def __init__(self, data):
        self.data = data
    
    @abstractmethod
    def export(self, filename):
        """Export data to a file. Subclasses must implement this."""
        pass
    
    def preview(self, rows=3):
        """Concrete method -- shared by all subclasses."""
        print(f"Preview (first {rows} items):")
        for item in self.data[:rows]:
            print(f"  {item}")
    
    def validate(self):
        """Concrete method with shared validation logic."""
        if not self.data:
            raise ValueError("No data to export")
        print(f"Validated: {len(self.data)} records ready")

class CSVExporter(DataExporter):
    def export(self, filename):
        self.validate()
        with open(filename, "w") as f:
            for row in self.data:
                f.write(",".join(str(v) for v in row.values()) + "\n")
        print(f"Exported to {filename}")

class JSONExporter(DataExporter):
    def export(self, filename):
        import json
        self.validate()
        with open(filename, "w") as f:
            json.dump(self.data, f, indent=2)
        print(f"Exported to {filename}")

# Usage
records = [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]

csv_exp = CSVExporter(records)
csv_exp.preview()
csv_exp.export("people.csv")

print()
json_exp = JSONExporter(records)
json_exp.export("people.json")

Output:

Preview (first 3 items):
  {'name': 'Alice', 'age': 30}
  {'name': 'Bob', 'age': 25}
Validated: 2 records ready
Exported to people.csv

Validated: 2 records ready
Exported to people.json

The key pattern here is that DataExporter provides shared functionality (preview() and validate()) while forcing subclasses to implement the specific export() logic. This is the Template Method pattern — the base class defines the algorithm skeleton and subclasses fill in the details.

Abstract Properties

# abstract_properties.py
from abc import ABC, abstractmethod

class Vehicle(ABC):
    @property
    @abstractmethod
    def fuel_type(self):
        pass
    
    @property
    @abstractmethod
    def max_speed(self):
        pass
    
    def describe(self):
        print(f"Fuel: {self.fuel_type}, Max Speed: {self.max_speed} km/h")

class ElectricCar(Vehicle):
    @property
    def fuel_type(self):
        return "Electric"
    
    @property
    def max_speed(self):
        return 200

class DieselTruck(Vehicle):
    @property
    def fuel_type(self):
        return "Diesel"
    
    @property
    def max_speed(self):
        return 120

car = ElectricCar()
car.describe()

truck = DieselTruck()
truck.describe()

Output:

Fuel: Electric, Max Speed: 200 km/h
Fuel: Diesel, Max Speed: 120 km/h

Stack @property above @abstractmethod to create abstract properties. Subclasses must implement these as properties (not regular methods), ensuring a consistent interface.

Abstract methods as gears
Abstract properties — because sometimes you need to enforce attributes, not just methods.

Built-in ABCs from collections.abc

Python provides a rich set of ABCs in collections.abc that define standard interfaces for container types. These let you check if an object supports iteration, indexing, or other protocols without checking for specific types.

# collections_abc.py
from collections.abc import Iterable, Sequence, Mapping, Callable

# isinstance checks with ABCs
print(f"list is Iterable: {isinstance([1,2,3], Iterable)}")
print(f"dict is Mapping: {isinstance({'a': 1}, Mapping)}")
print(f"str is Sequence: {isinstance('hello', Sequence)}")
print(f"lambda is Callable: {isinstance(lambda x: x, Callable)}")
print(f"int is Iterable: {isinstance(42, Iterable)}")

# Custom class implementing Iterable
class Countdown:
    def __init__(self, start):
        self.start = start
    
    def __iter__(self):
        current = self.start
        while current > 0:
            yield current
            current -= 1

c = Countdown(3)
print(f"\nCountdown is Iterable: {isinstance(c, Iterable)}")
print(f"Countdown values: {list(c)}")

Output:

list is Iterable: True
dict is Mapping: True
str is Sequence: True
lambda is Callable: True
int is Iterable: False

Countdown is Iterable: True
Countdown values: [3, 2, 1]

The beauty of collections.abc is structural checking — your Countdown class is recognized as Iterable just because it implements __iter__, without explicitly inheriting from Iterable. This is called virtual subclassing.

Real-Life Example: Payment Gateway Plugin System

Payment gateway plugin system
One abstract class, five payment gateways. Add a sixth without touching existing code.

Let us build a payment processing system where each gateway must implement a standard interface.

# payment_system.py
from abc import ABC, abstractmethod
from datetime import datetime

class PaymentGateway(ABC):
    def __init__(self, merchant_id):
        self.merchant_id = merchant_id
        self.transactions = []
    
    @abstractmethod
    def charge(self, amount, currency, customer_id):
        """Process a payment. Returns transaction ID."""
        pass
    
    @abstractmethod
    def refund(self, transaction_id):
        """Refund a transaction. Returns True if successful."""
        pass
    
    @property
    @abstractmethod
    def gateway_name(self):
        pass
    
    def log_transaction(self, tx_type, amount, tx_id):
        entry = {
            "type": tx_type,
            "amount": amount,
            "id": tx_id,
            "time": datetime.now().strftime("%H:%M:%S"),
            "gateway": self.gateway_name
        }
        self.transactions.append(entry)
        print(f"  [{entry['gateway']}] {tx_type}: amount={amount}, id={tx_id}")
    
    def get_summary(self):
        total = sum(t["amount"] for t in self.transactions if t["type"] == "charge")
        refunds = sum(t["amount"] for t in self.transactions if t["type"] == "refund")
        print(f"  {self.gateway_name}: {len(self.transactions)} transactions, " +
              f"charged={total}, refunded={refunds}")

class StripeGateway(PaymentGateway):
    @property
    def gateway_name(self):
        return "Stripe"
    
    def charge(self, amount, currency, customer_id):
        tx_id = f"stripe_ch_{id(self)}_{len(self.transactions)}"
        self.log_transaction("charge", amount, tx_id)
        return tx_id
    
    def refund(self, transaction_id):
        self.log_transaction("refund", 0, transaction_id)
        return True

class PayPalGateway(PaymentGateway):
    @property
    def gateway_name(self):
        return "PayPal"
    
    def charge(self, amount, currency, customer_id):
        tx_id = f"pp_{customer_id}_{len(self.transactions)}"
        self.log_transaction("charge", amount, tx_id)
        return tx_id
    
    def refund(self, transaction_id):
        self.log_transaction("refund", 0, transaction_id)
        return True

# Process payments through multiple gateways
gateways = [StripeGateway("merch_001"), PayPalGateway("merch_002")]

print("Processing payments:")
tx1 = gateways[0].charge(99.99, "USD", "cust_alice")
tx2 = gateways[1].charge(49.50, "USD", "cust_bob")
tx3 = gateways[0].charge(149.99, "USD", "cust_charlie")
gateways[0].refund(tx1)

print("\nSummaries:")
for gw in gateways:
    gw.get_summary()

Output:

Processing payments:
  [Stripe] charge: amount=99.99, id=stripe_ch_140234567_0
  [PayPal] charge: amount=49.5, id=pp_cust_bob_0
  [Stripe] charge: amount=149.99, id=stripe_ch_140234567_1
  [Stripe] refund: amount=0, id=stripe_ch_140234567_0

Summaries:
  Stripe: 3 transactions, charged=249.98, refunded=0
  PayPal: 1 transactions, charged=49.5, refunded=0

This pattern is powerful because adding a new gateway (say, SquareGateway) only requires implementing three methods and one property. The rest of the infrastructure — logging, summaries, transaction tracking — comes for free from the base class. If someone forgets to implement refund(), Python catches it immediately.

Connecting interface pieces
TypeError at instantiation beats AttributeError in production. Every time.

Defining an ABC

An abstract base class declares the contract a subclass must implement. The abc module’s ABC base + @abstractmethod decorator make this enforceable at instantiation time:

from abc import ABC, abstractmethod

class Storage(ABC):
    @abstractmethod
    def save(self, key: str, value: bytes) -> None:
        ...

    @abstractmethod
    def load(self, key: str) -> bytes:
        ...

class FileStorage(Storage):
    def save(self, key, value):
        with open(key, "wb") as f:
            f.write(value)
    def load(self, key):
        with open(key, "rb") as f:
            return f.read()

# Works
fs = FileStorage()
fs.save("a.bin", b"hello")

# Won't work — missing implementation
class BrokenStorage(Storage):
    def save(self, key, value): pass
    # Forgot to implement load()

BrokenStorage()
# TypeError: Can't instantiate abstract class BrokenStorage with abstract method load

The error fires at instantiation, not at first call — much better than a hidden bug that only triggers when something tries to load. ABCs turn “trust the developer” into “the language enforces the contract”.

ABC vs Protocol vs Duck Typing

Python has three ways to express “implements this interface”:

  • ABC (nominal): Subclasses must inherit from the ABC. Enforced at runtime.
  • Protocol (structural): Any class with the right methods counts, no inheritance needed. Enforced at type-check time only.
  • Duck typing: No declaration. Just hope. Caller crashes if the method is missing.

Use ABCs when you need runtime enforcement and a clear inheritance hierarchy. Use Protocols when you want lightweight, mypy-checked interfaces without forcing inheritance (great for plugin systems, third-party integration). Use plain duck typing for one-off scripts where the type-checker overhead isn’t worth it.

from typing import Protocol

class Comparable(Protocol):
    def __lt__(self, other: "Comparable") -> bool: ...

# Any class with __lt__ satisfies Comparable, no inheritance needed
def sort_descending(items: list[Comparable]) -> list[Comparable]:
    return sorted(items, reverse=True)

Built-in ABCs You Already Use

The collections.abc module has battle-tested ABCs for the standard container types: Iterable, Iterator, Sequence, Mapping, Set, Hashable. You can subclass them to get most methods for free:

from collections.abc import Mapping

class FrozenDict(Mapping):
    def __init__(self, data):
        self._data = dict(data)
    def __getitem__(self, key):
        return self._data[key]
    def __iter__(self):
        return iter(self._data)
    def __len__(self):
        return len(self._data)

# Inherited for free: keys(), values(), items(), get(), contains, eq, etc.
fd = FrozenDict({"a": 1, "b": 2})
print("a" in fd, fd.get("a"), list(fd.items()))

Subclassing Mapping gives you 10+ methods for free with just 3 abstract methods implemented. This is the killer feature of ABCs — useful default behavior bundled with the contract.

Registering External Classes

What if you don’t control the class but want it to count as your ABC? register() adds it without modifying source:

from collections.abc import Iterable

class MyExternalThing:
    def __iter__(self):
        return iter([1, 2, 3])

Iterable.register(MyExternalThing)
print(isinstance(MyExternalThing(), Iterable))  # True

Registration is “this class virtually inherits from the ABC” — useful for adapting third-party libraries to your type system.

Common Pitfalls

  • Abstract methods that have bodies. @abstractmethod doesn’t prevent you from writing a body — you can call super().save(...) from the subclass. But ABCs can’t be instantiated, so the body only runs if explicitly called from a subclass.
  • Forgetting @classmethod / @staticmethod ordering. Stack the decorators correctly: @classmethod on top, @abstractmethod directly above the method. Reversed order silently breaks the abstract check.
  • Multiple inheritance MRO confusion. When your subclass inherits from two ABCs, Python’s MRO determines which abstract method counts. Read the MRO carefully or stick to single inheritance.
  • Mixing ABC and Protocol in the same hierarchy. Pick one paradigm per interface. Mixing them yields unpredictable runtime/type-check behavior.
  • Treating ABCs as just type hints. An ABC enforces at instantiation. A Protocol enforces at type-check. They’re not interchangeable.

FAQ

Q: ABC or Protocol — which should I default to?
A: For new code targeting Python 3.8+, Protocol is usually lighter. For libraries that need to enforce at runtime (plugin systems, framework hooks), ABC.

Q: Can an ABC have non-abstract methods?
A: Yes — and it’s a strong feature. Mix abstract method contracts with default implementations that use those abstract methods. Subclasses implement the abstract parts and inherit the rest.

Q: How do I make a property abstract?
A: @property stacked with @abstractmethod: @property\n@abstractmethod\ndef name(self): .... Same for setters and class methods.

Q: Are ABCs slow?
A: The abstract-method check runs once at instantiation. Method dispatch on ABC subclasses is identical to regular Python — zero runtime cost after the constructor.

Q: When does isinstance() work for unrelated classes?
A: After explicit register(), or if the class implements the structural methods (via __subclasshook__). The collections.abc ABCs use the hook trick — that’s why ANY iterable counts as Iterable.

Wrapping Up

Abstract base classes give Python the “interface” concept without ceremony. Use them when you genuinely need to enforce a contract at instantiation time — plugin systems, framework hooks, base classes you’ll subclass many times. For lighter-weight typing, reach for typing.Protocol instead. The combo of ABCs from collections.abc plus your own bespoke ABCs covers nearly every “I need an interface” use case in Python.

Continue Learning Python

Tutorials you might also find useful:

How To Use Python Collections Module (Counter, defaultdict, deque)

How To Use Python Collections Module (Counter, defaultdict, deque)

Last Updated: June 01, 2026

Intermediate

You are counting word frequencies in a text file, so you create a dictionary, check if the key exists, initialize it to zero if not, then increment. Or you are building a grouped structure and writing the same if key not in dict boilerplate for every new group. Python’s built-in dict is powerful, but for specialized data tasks, it makes you write too much plumbing code.

The collections module in Python’s standard library provides specialized container datatypes that solve these exact problems. Counter counts things. defaultdict eliminates key-existence checks. deque gives you a fast double-ended queue. They are all built in — no pip install required.

In this article, we will start with a quick example showing all three in action, then dive deep into each one: Counter for frequency analysis, defaultdict for automatic initialization, deque for efficient queue and stack operations, and namedtuple for lightweight data records. We will finish with a real-life log analyzer project. By the end, you will reach for collections before writing another manual counting loop.

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 Collections Quick Example

# quick_collections.py
from collections import Counter, defaultdict, deque

# Counter: count word frequencies
words = ["python", "java", "python", "rust", "python", "java"]
freq = Counter(words)
print(f"Most common: {freq.most_common(2)}")

# defaultdict: group items without key checks
scores = defaultdict(list)
for name, score in [("Alice", 90), ("Bob", 85), ("Alice", 95)]:
    scores[name].append(score)
print(f"Scores: {dict(scores)}")

# deque: efficient append/pop from both ends
history = deque(maxlen=3)
for page in ["home", "about", "blog", "contact"]:
    history.append(page)
print(f"Recent pages: {list(history)}")

Output:

Most common: [('python', 3), ('java', 2)]
Scores: {'Alice': [90, 95], 'Bob': [85]}
Recent pages: ['about', 'blog', 'contact']

Three specialized containers, each replacing several lines of manual code with a single clear expression. Counter eliminates counting loops, defaultdict removes key-existence checks, and deque with maxlen automatically drops old items. Let us explore each one in depth.

What Is the Collections Module?

The collections module provides alternatives to Python’s general-purpose built-in containers (dict, list, set, and tuple). Each specialized container is optimized for a specific use case, offering better performance or cleaner syntax than doing the same thing with built-in types.

ContainerReplacesBest For
CounterManual counting with dictFrequency analysis, histograms, voting
defaultdictDict with if-key-exists checksGrouping, accumulating, nested structures
dequeList used as queue/stackQueues, sliding windows, undo history
namedtupleTuples with index accessLightweight records, data transfer objects
OrderedDictDict (pre-3.7)Explicit ordering, move-to-end operations

Since Python 3.7, regular dictionaries maintain insertion order, so OrderedDict is less critical than it used to be. But Counter, defaultdict, and deque remain essential tools that every Python developer should know.

Collections container types
Five container types, each built for one job. Pick the right one and delete half your code.

Counter: Counting Made Simple

Counter is a dictionary subclass designed for counting hashable objects. You feed it any iterable and it counts how many times each element appears.

# counter_basics.py
from collections import Counter

# Count characters in a string
char_count = Counter("mississippi")
print(f"Character counts: {char_count}")
print(f"Most common 3: {char_count.most_common(3)}")

# Count from a list
colors = ["red", "blue", "red", "green", "blue", "red"]
color_count = Counter(colors)
print(f"\nColor counts: {color_count}")
print(f"Red count: {color_count['red']}")
print(f"Missing key: {color_count['yellow']}")  # Returns 0, not KeyError

Output:

Counter({'s': 4, 'i': 4, 'p': 2, 'm': 1})
Most common 3: [('s', 4), ('i', 4), ('p', 2)]

Color counts: Counter({'red': 3, 'blue': 2, 'green': 1})
Red count: 3
Missing key: 0

Notice that accessing a missing key returns 0 instead of raising a KeyError — this is incredibly useful because you never need to check if a key exists before reading its count.

Counter Arithmetic and Operations

# counter_operations.py
from collections import Counter

inventory_a = Counter(apples=5, oranges=3, bananas=2)
inventory_b = Counter(apples=2, oranges=4, grapes=1)

# Addition: combine counts
combined = inventory_a + inventory_b
print(f"Combined: {combined}")

# Subtraction: remove counts
diff = inventory_a - inventory_b
print(f"A minus B: {diff}")

# Intersection: minimum of counts
common = inventory_a & inventory_b
print(f"Common minimum: {common}")

# Union: maximum of counts
maximum = inventory_a | inventory_b
print(f"Maximum: {maximum}")

# Total count
print(f"Total items in A: {inventory_a.total()}")

# Elements iterator
print(f"Elements: {list(inventory_a.elements())}")

Output:

Combined: Counter({'apples': 7, 'oranges': 7, 'bananas': 2, 'grapes': 1})
A minus B: Counter({'bananas': 2, 'apples': 3})
Common minimum: Counter({'apples': 2, 'oranges': 3})
Maximum: Counter({'apples': 5, 'oranges': 4, 'bananas': 2, 'grapes': 1})
Total items in A: 10
Elements: ['apples', 'apples', 'apples', 'apples', 'apples', 'oranges', 'oranges', 'oranges', 'bananas', 'bananas']

Counter arithmetic is where this class really shines. You can add inventories, find differences, calculate overlaps — all with simple operators. The total() method (Python 3.10+) gives the sum of all counts, and elements() expands the counter back into individual items.

Counter frequency counting
Counter.most_common() — because manually sorting a frequency dict is beneath you.

defaultdict: Never Check for Missing Keys Again

A defaultdict works exactly like a regular dictionary, except that when you access a key that does not exist, it automatically creates a default value using a factory function you specify.

# defaultdict_basics.py
from collections import defaultdict

# Group words by their first letter
words = ["apple", "banana", "avocado", "cherry", "apricot", "blueberry"]
grouped = defaultdict(list)
for word in words:
    grouped[word[0]].append(word)

print("Grouped by first letter:")
for letter, group in sorted(grouped.items()):
    print(f"  {letter}: {group}")

# Count with defaultdict(int)
text = "the cat sat on the mat the cat"
word_count = defaultdict(int)
for word in text.split():
    word_count[word] += 1

print(f"\nWord counts: {dict(word_count)}")

Output:

Grouped by first letter:
  a: ['apple', 'avocado', 'apricot']
  b: ['banana', 'blueberry']
  c: ['cherry']

Word counts: {'the': 3, 'cat': 2, 'sat': 1, 'on': 1, 'mat': 1}

The key insight is the factory function you pass to defaultdict. Pass list and missing keys get an empty list. Pass int and they get 0. Pass set and they get an empty set. This eliminates the entire category of “check if key exists, initialize if not” code.

Nested defaultdict for Complex Structures

# nested_defaultdict.py
from collections import defaultdict

employees = [
    ("Engineering", "Backend", "Alice"),
    ("Engineering", "Frontend", "Bob"),
    ("Engineering", "Backend", "Charlie"),
    ("Marketing", "Content", "Diana"),
    ("Marketing", "SEO", "Eve"),
    ("Marketing", "Content", "Frank"),
]

org = defaultdict(lambda: defaultdict(list))
for dept, role, name in employees:
    org[dept][role].append(name)

print("Organization:")
for dept, roles in sorted(org.items()):
    print(f"  {dept}:")
    for role, people in sorted(roles.items()):
        print(f"    {role}: {', '.join(people)}")

Output:

Organization:
  Engineering:
    Backend: Alice, Charlie
    Frontend: Bob
  Marketing:
    Content: Diana, Frank
    SEO: Eve

The nested defaultdict pattern using lambda: defaultdict(list) lets you build multi-level groupings without any initialization code. Every level auto-creates as needed.

deque: Fast Double-Ended Queue

A deque (pronounced “deck”) is a generalization of stacks and queues. While Python lists support append and pop at the end efficiently, operations at the beginning are O(n) because every element must shift. deque gives you O(1) operations at both ends.

# deque_basics.py
from collections import deque

# Basic operations
d = deque([1, 2, 3])
d.append(4)          # Add to right
d.appendleft(0)      # Add to left
print(f"After appends: {d}")

d.pop()              # Remove from right
d.popleft()          # Remove from left
print(f"After pops: {d}")

# Rotation
d = deque([1, 2, 3, 4, 5])
d.rotate(2)
print(f"Rotated right: {d}")
d.rotate(-3)
print(f"Rotated left: {d}")

# Fixed-size deque (sliding window)
recent = deque(maxlen=3)
for i in range(6):
    recent.append(i)
    print(f"  Added {i}: {list(recent)}")

Output:

After appends: deque([0, 1, 2, 3, 4])
After pops: deque([1, 2, 3])
Rotated right: deque([4, 5, 1, 2, 3])
Rotated left: deque([2, 3, 4, 5, 1])
  Added 0: [0]
  Added 1: [0, 1]
  Added 2: [0, 1, 2]
  Added 3: [1, 2, 3]
  Added 4: [2, 3, 4]
  Added 5: [3, 4, 5]

The maxlen parameter is especially powerful — when you add an item that would exceed the maximum length, the oldest item on the opposite end is automatically discarded. This creates a natural sliding window without any manual cleanup code.

deque double-ended queue
deque(maxlen=100) — a sliding window that cleans up after itself.

namedtuple: Lightweight Data Records

A namedtuple creates a tuple subclass with named fields. It gives you the memory efficiency of tuples with the readability of accessing fields by name instead of index.

# namedtuple_basics.py
from collections import namedtuple

Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
print(f"Point: {p}")
print(f"x={p.x}, y={p.y}")
print(f"Index access: {p[0]}, {p[1]}")

Employee = namedtuple("Employee", "name department salary")
team = [
    Employee("Alice", "Engineering", 120000),
    Employee("Bob", "Marketing", 95000),
    Employee("Charlie", "Engineering", 115000),
]

engineers = [e for e in team if e.department == "Engineering"]
avg_salary = sum(e.salary for e in engineers) / len(engineers)
print(f"\nEngineers: {[e.name for e in engineers]}")
print(f"Average salary: ${avg_salary:,.0f}")
print(f"\nAs dict: {team[0]._asdict()}")

Output:

Point: Point(x=3, y=4)
x=3, y=4
Index access: 3, 4

Engineers: ['Alice', 'Charlie']
Average salary: $117,500

As dict: {'name': 'Alice', 'department': 'Engineering', 'salary': 120000}

For modern Python (3.7+), dataclasses are often preferred over namedtuple for mutable data records. But namedtuple still wins when you need immutability, tuple compatibility, or minimal memory overhead.

Real-Life Example: Server Log Analyzer

Log analyzer with collections
Counter for frequencies, defaultdict for grouping, deque for the last 100 lines. Three tools, one log analyzer.

Let us build a log analyzer that processes server access logs using all three collections types together.

# log_analyzer.py
from collections import Counter, defaultdict, deque
from datetime import datetime

log_entries = [
    "2026-04-12 08:01:15 GET /api/users 200 45ms",
    "2026-04-12 08:01:16 GET /api/products 200 120ms",
    "2026-04-12 08:01:17 POST /api/orders 201 230ms",
    "2026-04-12 08:01:18 GET /api/users 200 38ms",
    "2026-04-12 08:01:19 GET /api/products 500 5ms",
    "2026-04-12 08:01:20 GET /api/users 200 42ms",
    "2026-04-12 08:01:21 DELETE /api/orders/5 204 15ms",
    "2026-04-12 08:01:22 GET /api/products 200 110ms",
    "2026-04-12 08:01:23 POST /api/users 201 180ms",
    "2026-04-12 08:01:24 GET /api/products 500 3ms",
]

endpoint_hits = Counter()
response_times = defaultdict(list)
errors = defaultdict(list)
recent = deque(maxlen=5)

for entry in log_entries:
    parts = entry.split()
    timestamp = f"{parts[0]} {parts[1]}"
    method = parts[2]
    path = parts[3]
    status = int(parts[4])
    duration = int(parts[5].replace("ms", ""))
    key = f"{method} {path}"
    endpoint_hits[key] += 1
    response_times[key].append(duration)
    recent.append({"time": timestamp, "endpoint": key, "status": status})
    if status >= 400:
        errors[key].append({"status": status, "time": timestamp})

print("=== Endpoint Frequency ===")
for endpoint, count in endpoint_hits.most_common():
    avg_ms = sum(response_times[endpoint]) / len(response_times[endpoint])
    print(f"  {endpoint}: {count} hits, avg {avg_ms:.0f}ms")

print("\n=== Errors ===")
for endpoint, err_list in errors.items():
    print(f"  {endpoint}: {len(err_list)} errors")

print("\n=== Recent Activity ===")
for entry in recent:
    icon = "OK" if entry["status"] < 400 else "ERR"
    print(f"  [{icon}] {entry['time']} {entry['endpoint']}")

Output:

=== Endpoint Frequency ===
  GET /api/products: 4 hits, avg 60ms
  GET /api/users: 3 hits, avg 42ms
  POST /api/orders: 1 hits, avg 230ms
  DELETE /api/orders/5: 1 hits, avg 15ms
  POST /api/users: 1 hits, avg 180ms

=== Errors ===
  GET /api/products: 2 errors

=== Recent Activity ===
  [OK] 2026-04-12 08:01:20 GET /api/users
  [OK] 2026-04-12 08:01:21 DELETE /api/orders/5
  [OK] 2026-04-12 08:01:22 GET /api/products
  [OK] 2026-04-12 08:01:23 POST /api/users
  [ERR] 2026-04-12 08:01:24 GET /api/products

This analyzer demonstrates the power of combining collections types: Counter tracks hit frequency with zero boilerplate, defaultdict(list) groups response times and errors by endpoint without key-existence checks, and deque(maxlen=5) keeps a rolling window of recent activity.

Frequently Asked Questions

When should I use Counter instead of a regular dict?

Use Counter whenever you are counting occurrences -- word frequencies, vote tallies, inventory quantities. The key advantage is that missing keys return 0 instead of raising KeyError, and you get arithmetic operations and most_common() for free. If you find yourself writing dict.get(key, 0) + 1, switch to Counter.

Is defaultdict faster than dict.setdefault()?

defaultdict is slightly faster because the default value factory is called internally in C, while dict.setdefault() creates the default value on every call even if the key already exists. More importantly, defaultdict is cleaner to read.

Can I use deque as a regular list replacement?

Not exactly. deque is optimized for operations at both ends (O(1) append/pop from either side), but random access by index is O(n) compared to O(1) for lists. Use deque when you primarily add and remove from ends. Stick with lists when you need fast index-based access.

Should I use namedtuple or dataclass?

Use dataclass for mutable data with default values, methods, and type hints. Use namedtuple when you need immutability, want to use records as dictionary keys, or need the memory efficiency of tuples. In practice, dataclass is more common in modern Python code.

Can I combine multiple Counter objects?

Yes, Counter supports all standard arithmetic: a + b adds counts, a - b subtracts, a & b takes the minimum per key, and a | b takes the maximum. You can also call counter.update(iterable) to add counts in place.

Conclusion

We covered the four most useful types in Python's collections module: Counter for frequency counting, defaultdict for automatic default values, deque for O(1) double-ended queue operations, and namedtuple for lightweight immutable records. The log analyzer project showed how these tools work together in practice.

Try extending the log analyzer with time-window aggregations using deque, or build a word frequency tool for text files using Counter. For the complete reference, see the official collections documentation.

How To Use Python Pathlib for File and Directory Operations

How To Use Python Pathlib for File and Directory Operations

Last Updated: June 01, 2026

Intermediate

You have a Python script that works perfectly on your Mac, but the moment a teammate runs it on Windows, the file paths break. Maybe you have been joining paths with string concatenation and forward slashes, or juggling os.path.join(), os.path.exists(), and os.path.basename() calls scattered across your codebase. There is a better way.

Python’s pathlib module, available since Python 3.4 and now the recommended approach for file system operations, gives you an object-oriented interface for working with paths. Instead of treating paths as raw strings, you get Path objects with methods for reading, writing, searching, and navigating directories — all cross-platform by default.

In this article, we will start with a quick example to get you productive in 30 seconds, then cover what pathlib is and why it replaced os.path. From there, we will walk through creating and navigating paths, reading and writing files, searching with glob patterns, and working with file metadata. We will finish with a real-life project that ties everything together. By the end, you will never go back to string-based path manipulation.

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 Pathlib Quick Example

# quick_pathlib.py
from pathlib import Path

# Create a path and explore it
project = Path.cwd() / "my_project" / "data"
project.mkdir(parents=True, exist_ok=True)

# Write a file
config = project / "settings.txt"
config.write_text("debug=True\nport=8080")

# Read it back
print(config.read_text())
print(f"File size: {config.stat().st_size} bytes")
print(f"Parent folder: {config.parent.name}")

Output:

debug=True
port=8080
File size: 21 bytes
Parent folder: data

Notice how the / operator joins path components — no os.path.join() needed. The Path object handles platform-specific separators automatically. Methods like write_text(), read_text(), and stat() replace several lines of boilerplate file I/O code. Let us dig deeper into each of these features below.

What Is Pathlib and Why Use It?

The pathlib module provides classes representing filesystem paths with semantics appropriate for different operating systems. The main class you will use is Path, which automatically gives you a PosixPath on Linux/Mac or a WindowsPath on Windows.

Think of it like this: os.path treats paths as strings and gives you standalone functions to manipulate them. pathlib treats paths as objects that know how to manipulate themselves. It is the difference between calling len(my_string) (a function on data) and calling my_path.exists() (a method on an object that understands what it is).

Taskos.path (old way)pathlib (modern way)
Join pathsos.path.join(a, b)Path(a) / b
Check existenceos.path.exists(p)p.exists()
Get filenameos.path.basename(p)p.name
Get extensionos.path.splitext(p)[1]p.suffix
Read fileopen(p).read()p.read_text()
List directoryos.listdir(p)p.iterdir()
Find filesglob.glob(pattern)p.glob(pattern)

Since Python 3.6, Path objects work everywhere a string path is accepted: open(), json.load(), csv.reader(), shutil.copy(), and most third-party libraries. There is almost no reason to convert a Path to a string anymore.

os.path.join vs pathlib comparison
os.path.join() called 47 times in one file. There had to be a better way.

Creating and Navigating Paths

There are several ways to create Path objects depending on your starting point. Let us look at the most common patterns.

# creating_paths.py
from pathlib import Path

# From a string
config_path = Path("/etc/hosts")
print(f"From string: {config_path}")

# Current working directory
cwd = Path.cwd()
print(f"Current dir: {cwd}")

# Home directory
home = Path.home()
print(f"Home dir: {home}")

# Join with the / operator
data_file = home / "Documents" / "data.csv"
print(f"Joined path: {data_file}")

# Path components
print(f"Name: {data_file.name}")
print(f"Stem: {data_file.stem}")
print(f"Suffix: {data_file.suffix}")
print(f"Parent: {data_file.parent}")
print(f"Parts: {data_file.parts}")

Output:

From string: /etc/hosts
Current dir: /home/user/projects
Home dir: /home/user
Joined path: /home/user/Documents/data.csv
Name: data.csv
Stem: data
Suffix: .csv
Parent: /home/user/Documents
Parts: ('/', 'home', 'user', 'Documents', 'data.csv')

The / operator is the star here — it replaces os.path.join() with something that reads like an actual file path. The .name, .stem, .suffix, and .parent properties give you instant access to path components without parsing strings.

Resolving and Converting Paths

# resolving_paths.py
from pathlib import Path

# Resolve relative paths to absolute
relative = Path("../data/config.json")
absolute = relative.resolve()
print(f"Resolved: {absolute}")

# Convert to string when needed
path_str = str(Path.home() / "file.txt")
print(f"As string: {path_str}")
print(f"Type: {type(path_str)}")

# Change extension
readme = Path("docs/guide.md")
html_version = readme.with_suffix(".html")
print(f"Changed suffix: {html_version}")

# Change filename
new_name = readme.with_name("tutorial.md")
print(f"Changed name: {new_name}")

Output:

Resolved: /home/user/projects/data/config.json
As string: /home/user/file.txt
Type: <class 'str'>
Changed suffix: docs/guide.html
Changed name: docs/tutorial.md

The resolve() method is essential for turning relative paths into absolute ones — it also resolves symlinks. Use with_suffix() and with_name() to create path variations without string manipulation.

Reading and Writing Files

Path objects have built-in methods for common file I/O operations. These methods handle opening and closing files automatically, so you do not need context managers for simple read/write operations.

# file_io.py
from pathlib import Path

# Setup
demo_dir = Path.cwd() / "pathlib_demo"
demo_dir.mkdir(exist_ok=True)

# Write text
notes = demo_dir / "notes.txt"
notes.write_text("Line 1: Python is great\nLine 2: Pathlib is better")
print(f"Wrote {notes.stat().st_size} bytes")

# Read text
content = notes.read_text()
print(f"Content:\n{content}")

# Write bytes (useful for binary data)
binary_file = demo_dir / "data.bin"
binary_file.write_bytes(b"\x89PNG\r\n\x1a\n")
print(f"\nBinary file size: {binary_file.stat().st_size} bytes")

# Append text (use open() for append mode)
with notes.open("a") as f:
    f.write("\nLine 3: Appended with open()")

print(f"\nAfter append:\n{notes.read_text()}")

Output:

Wrote 49 bytes
Content:
Line 1: Python is great
Line 2: Pathlib is better

Binary file size: 8 bytes

After append:
Line 1: Python is great
Line 2: Pathlib is better
Line 3: Appended with open()

For simple read/write operations, read_text() and write_text() are all you need — they open the file, perform the operation, and close it in one call. For more complex operations like appending or reading line by line, use path.open() which works exactly like the built-in open() function.

File I/O with pathlib write_text and read_text
write_text() and read_text() — file I/O without the ceremony.

Directory Operations

Creating, listing, and removing directories are everyday tasks. pathlib makes each of these a single method call.

# directory_ops.py
from pathlib import Path

base = Path.cwd() / "project_skeleton"

# Create nested directories
(base / "src" / "utils").mkdir(parents=True, exist_ok=True)
(base / "tests").mkdir(parents=True, exist_ok=True)
(base / "docs").mkdir(parents=True, exist_ok=True)

# Create some files
(base / "src" / "__init__.py").write_text("")
(base / "src" / "main.py").write_text("print('hello')")
(base / "src" / "utils" / "helpers.py").write_text("# helpers")
(base / "tests" / "test_main.py").write_text("# tests")
(base / "README.md").write_text("# My Project")

# List immediate children
print("Top-level contents:")
for item in sorted(base.iterdir()):
    icon = "D" if item.is_dir() else "F"
    print(f"  [{icon}] {item.name}")

# Check properties
print(f"\nIs directory: {base.is_dir()}")
print(f"Is file: {(base / 'README.md').is_file()}")
print(f"Exists: {(base / 'missing.txt').exists()}")

Output:

Top-level contents:
  [F] README.md
  [D] docs
  [D] src
  [D] tests

Is directory: True
Is file: True
Exists: False

The mkdir(parents=True, exist_ok=True) combination is your best friend — it creates the full directory tree and does not raise an error if it already exists. The iterdir() method returns a generator of all items in a directory, which you can filter with is_dir() and is_file().

Searching with Glob Patterns

Finding files by pattern is one of pathlib‘s strongest features. The glob() method searches a directory, while rglob() searches recursively through all subdirectories.

# glob_search.py
from pathlib import Path

base = Path.cwd() / "project_skeleton"

# Find all Python files in src/
print("Python files in src/:")
for py_file in sorted((base / "src").glob("*.py")):
    print(f"  {py_file.name}")

# Recursive search - all .py files anywhere
print("\nAll Python files (recursive):")
for py_file in sorted(base.rglob("*.py")):
    print(f"  {py_file.relative_to(base)}")

# Find all markdown files
print("\nMarkdown files:")
for md_file in base.rglob("*.md"):
    print(f"  {md_file.name} ({md_file.stat().st_size} bytes)")

# Multiple patterns
print("\nPython and Markdown files:")
for pattern in ["*.py", "*.md"]:
    for f in base.rglob(pattern):
        print(f"  {f.relative_to(base)}")

Output:

Python files in src/:
  __init__.py
  main.py

All Python files (recursive):
  src/__init__.py
  src/main.py
  src/utils/helpers.py
  tests/test_main.py

Markdown files:
  README.md (12 bytes)

Python and Markdown files:
  README.md
  src/__init__.py
  src/main.py
  src/utils/helpers.py
  tests/test_main.py

The rglob() method is particularly powerful — rglob("*.py") is equivalent to glob("**/*.py") but shorter. Use relative_to() to display paths relative to a base directory, which is much cleaner than showing full absolute paths.

Searching files with pathlib rglob
rglob(‘*.py’) — find every Python file, no matter how deep it hides.

Working with File Metadata

Every file carries metadata — size, creation time, modification time, and permissions. pathlib gives you access to all of this through the stat() method.

# file_metadata.py
from pathlib import Path
from datetime import datetime

target = Path.cwd() / "project_skeleton" / "src" / "main.py"

# Get file stats
stats = target.stat()
print(f"File: {target.name}")
print(f"Size: {stats.st_size} bytes")
print(f"Modified: {datetime.fromtimestamp(stats.st_mtime).strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Created: {datetime.fromtimestamp(stats.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}")

# Check file type
print(f"\nIs file: {target.is_file()}")
print(f"Is symlink: {target.is_symlink()}")
print(f"Suffix: {target.suffix}")

# Get directory size (sum of all files)
project = Path.cwd() / "project_skeleton"
total_size = sum(f.stat().st_size for f in project.rglob("*") if f.is_file())
file_count = sum(1 for f in project.rglob("*") if f.is_file())
print(f"\nProject: {file_count} files, {total_size} bytes total")

Output:

File: main.py
Size: 14 bytes
Modified: 2026-04-12 09:15:30
Created: 2026-04-12 09:15:30

Is file: True
Is symlink: False
Suffix: .py

Project: 5 files, 45 bytes total

The stat() method returns the same information as os.stat() but you call it directly on the path object. Combining rglob() with stat() in a generator expression is a common pattern for calculating directory sizes or finding the largest files.

Real-Life Example: Project File Organizer

Building a file organizer with pathlib
Forty downloads, zero organization. Time for pathlib to sort this out.

Let us build a practical file organizer that sorts files in a directory by their extension into categorized subfolders. This is something you might use to clean up a messy Downloads folder.

# file_organizer.py
from pathlib import Path
from collections import defaultdict

CATEGORIES = {
    "Images": [".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp"],
    "Documents": [".pdf", ".doc", ".docx", ".txt", ".md", ".csv", ".xlsx"],
    "Code": [".py", ".js", ".html", ".css", ".json", ".yaml", ".yml"],
    "Archives": [".zip", ".tar", ".gz", ".rar", ".7z"],
    "Media": [".mp3", ".mp4", ".wav", ".avi", ".mkv"],
}

def get_category(suffix):
    for category, extensions in CATEGORIES.items():
        if suffix.lower() in extensions:
            return category
    return "Other"

def organize_directory(source_dir):
    source = Path(source_dir)
    if not source.is_dir():
        print(f"Error: {source} is not a valid directory")
        return

    moved = defaultdict(list)

    for item in source.iterdir():
        if item.is_file() and not item.name.startswith("."):
            category = get_category(item.suffix)
            target_dir = source / category
            target_dir.mkdir(exist_ok=True)

            target_file = target_dir / item.name
            if target_file.exists():
                stem = item.stem
                suffix = item.suffix
                counter = 1
                while target_file.exists():
                    target_file = target_dir / f"{stem}_{counter}{suffix}"
                    counter += 1

            item.rename(target_file)
            moved[category].append(item.name)

    print("Organization complete!")
    for category, files in sorted(moved.items()):
        print(f"  {category}: {len(files)} files")
        for f in files[:3]:
            print(f"    - {f}")
        if len(files) > 3:
            print(f"    ... and {len(files) - 3} more")

# Demo with test files
demo = Path.cwd() / "messy_folder"
demo.mkdir(exist_ok=True)
for name in ["photo.jpg", "report.pdf", "script.py", "data.csv", "notes.txt", "archive.zip", "song.mp3"]:
    (demo / name).write_text("sample content")

organize_directory(demo)

Output:

Organization complete!
  Archives: 1 files
    - archive.zip
  Code: 1 files
    - script.py
  Documents: 3 files
    - report.pdf
    - data.csv
    - notes.txt
  Images: 1 files
    - photo.jpg
  Media: 1 files
    - song.mp3

This organizer uses nearly every pathlib feature we covered: iterdir() to list files, .suffix to check extensions, mkdir() to create category folders, .exists() to handle duplicates, and rename() to move files. You could extend this by adding logging, a dry-run mode, or custom category rules loaded from a JSON config file.

Organized files with pathlib
Downloads folder: before pathlib, a landfill. After pathlib, a library.

Frequently Asked Questions

Can I use pathlib with older Python versions?

pathlib was added in Python 3.4 and has been improved in every release since. Python 3.6 added os.fspath() support so Path objects work with open() and other built-in functions. If you are on Python 3.6 or later (which you should be — 3.5 reached end of life in 2020), pathlib works everywhere.

Should I completely replace os.path with pathlib?

For new code, yes. pathlib is the recommended approach per Python’s official documentation. The only exception is if you are working in a codebase that heavily uses os.path and consistency matters more than modernization. You can mix both — Path objects accept strings, and str(path) converts back.

How do I handle permissions with pathlib?

Use path.chmod(mode) to set permissions and path.stat().st_mode to read them. For example, script.chmod(0o755) makes a file executable. The stat module helps decode permission bits: import stat; print(stat.filemode(path.stat().st_mode)) gives human-readable output like -rwxr-xr-x.

What happens if I use pathlib on Windows with forward slashes?

pathlib handles this automatically. When you write Path("src") / "utils" / "helpers.py", it produces src\utils\helpers.py on Windows and src/utils/helpers.py on Linux/Mac. You never need to worry about separator characters — that is the whole point of using Path objects instead of strings.

How do I delete files and directories with pathlib?

Use path.unlink() to delete a file and path.rmdir() to remove an empty directory. For non-empty directories, combine with shutil.rmtree(): import shutil; shutil.rmtree(path). Since Python 3.8, unlink() accepts a missing_ok=True parameter so it does not raise an error if the file is already gone.

Conclusion

We covered the full range of pathlib operations: creating and joining paths with the / operator, reading and writing files with read_text() and write_text(), navigating directories with iterdir(), searching with glob() and rglob(), and inspecting file metadata with stat(). The real-life file organizer showed how these pieces come together in a practical tool.

Try extending the file organizer with features like recursive organization, file size filtering, or a configuration file that defines custom categories. The pathlib module has even more methods we did not cover — check the official pathlib documentation for the complete reference.

Path() Is the Constructor

Every pathlib operation starts with a Path object. Build it from strings, joined parts, or environment-aware specials:

from pathlib import Path

p = Path("/tmp/data")
p = Path("data/file.txt")
p = Path.home() / "docs" / "report.pdf"
p = Path.cwd()
p = Path(__file__).parent

p = Path("C:/Users/Alice") / "Documents"
print(p)

The / operator joins paths — way more readable than os.path.join.

Common Operations

p = Path("/tmp/data/report.pdf")
p.name           # 'report.pdf'
p.stem           # 'report'
p.suffix         # '.pdf'
p.parent         # /tmp/data
p.with_suffix(".txt")
p.with_name("summary.pdf")
p.exists(); p.is_file(); p.is_dir()
p.stat().st_size
p.resolve()

Reading and Writing Files

p = Path("data.txt")
content = p.read_text(encoding="utf-8")
p.write_text("Hello", encoding="utf-8")
data = p.read_bytes()
p.write_bytes(b"\x00\x01\x02")
with p.open("r") as f:
    for line in f:
        process(line)

Iterating Directories

p = Path("./src")
for child in p.iterdir(): print(child)
for py in p.rglob("*.py"): print(py)
test_files = list(p.glob("test_*.py"))
big_pys = [f for f in p.rglob("*.py") if f.stat().st_size > 10_000]

Creating Directories and Files

p = Path("output/2026/05")
p.mkdir(parents=True, exist_ok=True)
(p / "summary.txt").touch()
src = Path("old_name.txt")
src.rename(p / "new_name.txt")
file_to_remove = p / "summary.txt"
file_to_remove.unlink(missing_ok=True)
import shutil
shutil.rmtree(p)

Common Pitfalls

  • Treating Path like a string. Path doesn’t have .split() or .upper(). Use str(path) when you need string ops.
  • Forgetting Windows path separators. Don’t hardcode /. Use Path joins; they pick the right separator per OS.
  • resolve() vs absolute(). resolve() follows symlinks; absolute() doesn’t. For path-traversal safety, resolve() first.
  • Mutating during iterdir. Modifying the directory while iterating can skip files. Walk into a list first.
  • Using glob() when you wanted recursive. Use rglob() for recursive matching.

FAQ

Q: pathlib or os.path?
A: pathlib for new code — more readable, OS-aware, methods discover via tab-complete.

Q: Get file extension without dot?
A: p.suffix.lstrip(".") — pathlib includes the dot.

Q: Relative path from another path?
A: target.relative_to(base). Raises ValueError if target isn’t a child.

Q: pathlib in pandas / NumPy / open()?
A: All accept Path objects since Python 3.6 (PEP 519).

Q: Atomic file writes?
A: Write to temp, then rename. tmp = p.with_suffix(p.suffix + ".tmp"); tmp.write_text(...); tmp.rename(p).

Wrapping Up

pathlib is the modern way to do paths in Python. The / operator alone is reason to switch from os.path.join. Master read_text/write_text/iterdir/rglob and you cover 95% of file system code.

How To Use Python Regular Expressions (re Module) with Examples

How To Use Python Regular Expressions (re Module) with Examples

Last Updated: June 01, 2026

Intermediate

You need to find, extract, or replace text patterns in strings — email addresses in a document, phone numbers in a log file, or URLs scattered across a web page. Python’s built-in re module gives you regular expressions, a powerful pattern-matching language that handles all of these tasks with concise, reusable patterns.

Regular expressions look intimidating at first, but they follow logical rules. Once you learn the core patterns, you will reach for them constantly — for data validation, log parsing, text cleanup, and search-and-replace operations. Python’s re module is part of the standard library, so there is nothing to install.

In this tutorial, you will learn the essential regex patterns, how to use re.search(), re.findall(), re.sub(), and re.compile(), work with groups and named groups, handle common real-world patterns, and build a complete log parser 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 →

Pattern Matching: Quick Example

Here is a minimal example that extracts all email addresses from a block of text.

# quick_regex.py
import re

text = "Contact us at support@example.com or sales@company.org for help."
pattern = r'[\w.+-]+@[\w-]+\.[\w.]+'

emails = re.findall(pattern, text)
for email in emails:
    print(email)

Output:

support@example.com
sales@company.org

The r prefix creates a raw string, which prevents Python from interpreting backslashes as escape characters. Always use raw strings for regex patterns.

Pattern matching with regex in Python
Pattern matching is just string searching with superpowers — and a learning curve.

What Are Regular Expressions and Why Use Them?

Regular expressions (regex) are sequences of characters that define search patterns. Think of them as advanced wildcards — where a file search might use *.txt to match all text files, regex gives you the precision to match patterns like “any string that looks like a phone number” or “every word followed by a colon.”

Pattern Matches Example
\d Any digit (0-9) \d{3} matches “123”
\w Word character (letter, digit, underscore) \w+ matches “hello_world”
\s Whitespace (space, tab, newline) \s+ matches ” “
. Any character except newline a.c matches “abc”, “a1c”
+ One or more of previous \d+ matches “42”, “1000”
* Zero or more of previous \d* matches “”, “5”, “99”
? Zero or one of previous colou?r matches “color”, “colour”
[abc] Any character in the set [aeiou] matches vowels
^ Start of string ^Hello matches “Hello world”
$ End of string world$ matches “Hello world”

Core Functions: search, match, findall, sub

The re module provides four main functions. Each serves a different use case.

# core_functions.py
import re

text = "Order #12345 was placed on 2024-03-15 for $49.99"

# search() - find first match anywhere in string
match = re.search(r'#(\d+)', text)
if match:
    print(f"Order number: {match.group(1)}")

# findall() - find all matches, return as list
prices = re.findall(r'\$[\d.]+', text)
print(f"Prices found: {prices}")

# sub() - search and replace
cleaned = re.sub(r'\$[\d.]+', '[PRICE]', text)
print(f"Cleaned: {cleaned}")

# match() - match at the START of string only
result = re.match(r'Order', text)
print(f"Starts with Order: {result is not None}")

Output:

Order number: 12345
Prices found: ['$49.99']
Cleaned: Order #12345 was placed on 2024-03-15 for [PRICE]
Starts with Order: True

Use search() when you want to find a pattern anywhere in the string. Use match() only when you specifically need to check the beginning of the string.

Groups and Named Groups

Parentheses in regex create capture groups that let you extract specific parts of a match. Named groups make your code more readable.

# groups.py
import re

log_line = '2024-03-15 14:30:22 ERROR [auth] Failed login for user admin from 192.168.1.100'

# Numbered groups
pattern = r'(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) (\w+) \[(\w+)\] (.+)'
match = re.search(pattern, log_line)
if match:
    print(f"Date: {match.group(1)}")
    print(f"Time: {match.group(2)}")
    print(f"Level: {match.group(3)}")
    print(f"Module: {match.group(4)}")
    print(f"Message: {match.group(5)}")

# Named groups (much more readable)
pattern = r'(?P<date>\d{4}-\d{2}-\d{2}) (?P<time>\d{2}:\d{2}:\d{2}) (?P<level>\w+)'
match = re.search(pattern, log_line)
if match:
    print(f"\nNamed: {match.group('date')} at {match.group('time')} [{match.group('level')}]")

Output:

Date: 2024-03-15
Time: 14:30:22
Level: ERROR
Module: auth
Message: Failed login for user admin from 192.168.1.100

Named: 2024-03-15 at 14:30:22 [ERROR]

Named groups use the (?P<name>...) syntax. They are especially valuable in complex patterns where numbered groups become confusing.

Using capture groups in Python regex
Capture groups let you grab exactly what you need from the chaos.

Compiled Patterns with re.compile()

When you use the same regex pattern multiple times, compile it first for better performance and cleaner code.

# compiled.py
import re

# Compile once, use many times
email_pattern = re.compile(r'[\w.+-]+@[\w-]+\.[\w.]+')
phone_pattern = re.compile(r'\b\d{3}[-.]?\d{3}[-.]?\d{4}\b')

contacts = [
    "Call John at 555-123-4567 or email john@example.com",
    "Reach Jane at jane@company.org or 555.987.6543",
    "No contact info here, sorry!",
]

for text in contacts:
    emails = email_pattern.findall(text)
    phones = phone_pattern.findall(text)
    print(f"Emails: {emails}, Phones: {phones}")

Output:

Emails: ['john@example.com'], Phones: ['555-123-4567']
Emails: ['jane@company.org'], Phones: ['555.987.6543']
Emails: [], Phones: []

Compiled patterns have the same methods as the re module itself — search(), findall(), sub(), etc. The performance benefit comes from compiling the regex once instead of recompiling it on every call.

Common Real-World Patterns

Here are battle-tested patterns for common data extraction tasks.

# common_patterns.py
import re

# URL extraction
text = "Visit https://example.com or http://docs.python.org/3/ for details"
urls = re.findall(r'https?://[\w./\-?=&]+', text)
print("URLs:", urls)

# Date extraction (YYYY-MM-DD)
text = "Events on 2024-03-15 and 2024-12-25"
dates = re.findall(r'\d{4}-\d{2}-\d{2}', text)
print("Dates:", dates)

# IP address
text = "Server 192.168.1.100 responded, backup at 10.0.0.1"
ips = re.findall(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', text)
print("IPs:", ips)

# HTML tag stripping
html = "<p>Hello <b>world</b></p>"
clean = re.sub(r'<[^>]+>', '', html)
print("Stripped:", clean)

Output:

URLs: ['https://example.com', 'http://docs.python.org/3/']
Dates: ['2024-03-15', '2024-12-25']
IPs: ['192.168.1.100', '10.0.0.1']
Stripped: Hello world

These patterns handle the most common extraction tasks. For production use, consider edge cases — the email pattern above works for most addresses but does not cover every valid format defined in the RFC.

Advanced regex methods in Python
re.sub(), re.split(), re.findall() — pick your weapon wisely.

Real-Life Example: Building a Log File Parser

# log_parser.py
import re
from collections import Counter

# Sample log data
log_data = """
2024-03-15 14:30:22 ERROR [auth] Failed login for user admin from 192.168.1.100
2024-03-15 14:30:25 INFO [auth] Successful login for user alice from 10.0.0.50
2024-03-15 14:31:00 WARNING [db] Slow query detected: 2.5s
2024-03-15 14:31:05 ERROR [api] Timeout connecting to payment service
2024-03-15 14:31:10 INFO [auth] Successful login for user bob from 10.0.0.51
2024-03-15 14:31:15 ERROR [auth] Failed login for user admin from 192.168.1.100
2024-03-15 14:31:20 INFO [api] Health check passed
2024-03-15 14:31:30 ERROR [db] Connection pool exhausted
""".strip()

log_pattern = re.compile(
    r'(?P\d{4}-\d{2}-\d{2}) '
    r'(?P

Output:

Log level counts:
  ERROR: 4
  INFO: 3
  WARNING: 1

Failed login attempts: 2
Suspicious IPs:
  192.168.1.100: 2 failed attempts

This parser combines compiled regex patterns, named groups, and Python’s Counter class to analyze log files efficiently. You can extend it to read from actual log files, send alerts for suspicious activity, or export metrics to a dashboard.

Frequently Asked Questions

What is the difference between greedy and lazy matching?

By default, quantifiers like + and * are greedy — they match as much as possible. Adding ? makes them lazy, matching as little as possible. For example, <.+> on “<b>bold</b>” matches the entire string, while <.+?> matches just “<b>”.

How do I match across multiple lines?

Use the re.DOTALL flag to make . match newlines, or re.MULTILINE to make ^ and $ match at line boundaries instead of string boundaries. Pass these as the flags parameter: re.search(pattern, text, flags=re.MULTILINE).

When do I need to escape special characters?

Escape regex metacharacters (. * + ? [ ] ( ) { } ^ $ | \) with a backslash when you want to match them literally. Use re.escape() to automatically escape a string: re.escape("price is $9.99") returns "price\ is\ \$9\.99".

Are regular expressions slow?

For simple pattern matching, string methods like str.startswith(), str.endswith(), and in are faster. Use regex when you need pattern matching that string methods cannot handle. Compile patterns with re.compile() if you use them repeatedly.

When should I NOT use regex?

Do not use regex to parse HTML or XML — use BeautifulSoup or lxml instead. Do not use regex for JSON — use the json module. Regex is best for flat text patterns, not nested structures. If your pattern needs recursive matching, you need a proper parser.

Conclusion

Regular expressions are one of the most powerful tools in a Python developer’s toolkit. We covered the core syntax, the main re module functions, capture groups, compiled patterns, common real-world patterns, and building a complete log parser.

For the full re module documentation, visit docs.python.org/3/library/re. For interactive regex testing, try regex101.com which provides real-time explanations of your patterns.

Continue Learning Python

Tutorials you might also find useful:

How To Use SQLite with Python: A Complete Guide

How To Use SQLite with Python: A Complete Guide

Last Updated: June 01, 2026

Beginner

You need a database for your Python project, but setting up MySQL or PostgreSQL feels like overkill. SQLite is a lightweight, serverless database that comes built into Python — no installation, no configuration, no separate server process. Just import sqlite3 and start storing data in a single file.

Python’s built-in sqlite3 module provides a full-featured interface to SQLite databases. It supports SQL queries, transactions, parameterized statements, and works identically on every platform. Your database is just a file on disk that you can copy, backup, or share like any other file.

In this tutorial, you will learn how to create databases and tables, insert and query data, use parameterized queries to prevent SQL injection, handle transactions, work with context managers, and build a complete project. By the end, you will be able to add persistent data storage to any Python project in minutes.

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 →

SQLite in Python: Quick Example

Here is a complete working example that creates a database, inserts data, and queries it — all in under 15 lines.

# quick_sqlite.py
import sqlite3

conn = sqlite3.connect("example.db")
cursor = conn.cursor()

cursor.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)")
cursor.execute("INSERT INTO users (name, email) VALUES (?, ?)", ("Alice", "alice@example.com"))
cursor.execute("INSERT INTO users (name, email) VALUES (?, ?)", ("Bob", "bob@example.com"))
conn.commit()

cursor.execute("SELECT * FROM users")
for row in cursor.fetchall():
    print(row)

conn.close()

Output:

(1, 'Alice', 'alice@example.com')
(2, 'Bob', 'bob@example.com')

The ? placeholders in the INSERT statement are parameterized queries — they prevent SQL injection by keeping data separate from SQL code. Never use f-strings or string concatenation to build SQL queries.

What Is SQLite and When Should You Use It?

SQLite is a self-contained, file-based relational database engine. Unlike MySQL or PostgreSQL, it does not require a separate server process. The entire database lives in a single file on your filesystem.

Feature SQLite PostgreSQL MySQL
Server required No Yes Yes
Setup complexity Zero Medium Medium
Concurrent writers Limited Excellent Good
Max database size 281 TB Unlimited Unlimited
Best for Apps, prototypes, scripts Web apps, analytics Web apps, CMS

Use SQLite when you need persistent storage for a single-user application, prototype, CLI tool, data processing script, or any project where simplicity matters more than concurrent access. Switch to PostgreSQL when you need multiple simultaneous writers or a web application with many users.

SQLite: a database in a file. Embedded. Reliable.
SQLite: a database in a file. Embedded. Reliable.

Creating Databases and Tables

Connecting to a database file creates it automatically if it does not exist. Use CREATE TABLE IF NOT EXISTS to make your scripts idempotent.

# create_tables.py
import sqlite3

conn = sqlite3.connect("bookstore.db")
cursor = conn.cursor()

cursor.execute("""
    CREATE TABLE IF NOT EXISTS books (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        author TEXT NOT NULL,
        price REAL DEFAULT 0.0,
        published_year INTEGER,
        in_stock INTEGER DEFAULT 1
    )
""")

cursor.execute("""
    CREATE TABLE IF NOT EXISTS categories (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT UNIQUE NOT NULL
    )
""")

conn.commit()
print("Tables created successfully")

# Verify tables exist
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = cursor.fetchall()
print("Tables:", [t[0] for t in tables])
conn.close()

Output:

Tables created successfully
Tables: ['books', 'categories']

The AUTOINCREMENT keyword makes SQLite assign a unique incrementing ID to each new row. NOT NULL prevents inserting rows with missing required fields.

Inserting Data Safely

Always use parameterized queries (the ? placeholder pattern) when inserting data. This prevents SQL injection and handles special characters in your data automatically.

# insert_data.py
import sqlite3

conn = sqlite3.connect("bookstore.db")
cursor = conn.cursor()

# Single insert with parameterized query
cursor.execute(
    "INSERT INTO books (title, author, price, published_year) VALUES (?, ?, ?, ?)",
    ("Python Crash Course", "Eric Matthes", 35.99, 2023)
)

# Bulk insert with executemany
books = [
    ("Fluent Python", "Luciano Ramalho", 55.99, 2022),
    ("Automate the Boring Stuff", "Al Sweigart", 29.99, 2019),
    ("Clean Code", "Robert C. Martin", 39.99, 2008),
]
cursor.executemany(
    "INSERT INTO books (title, author, price, published_year) VALUES (?, ?, ?, ?)",
    books
)

conn.commit()
print(f"Inserted {cursor.rowcount + 1} books")
conn.close()

Output:

Inserted 4 books

The executemany() method is significantly faster than calling execute() in a loop because it batches the operations into a single transaction.

SQLite for prototypes. Postgres for production. Usually.
SQLite for prototypes. Postgres for production. Usually.

Querying Data

SQLite supports full SQL query syntax. Use fetchall() for all results, fetchone() for a single row, or iterate directly over the cursor.

# query_data.py
import sqlite3

conn = sqlite3.connect("bookstore.db")
conn.row_factory = sqlite3.Row  # Enable column name access
cursor = conn.cursor()

# Select all books
cursor.execute("SELECT * FROM books ORDER BY price DESC")
print("All books by price:")
for row in cursor.fetchall():
    print(f"  {row['title']} by {row['author']} - ${row['price']}")

# Filtered query with parameter
cursor.execute("SELECT * FROM books WHERE price < ? ORDER BY title", (40.0,))
print("\nBooks under $40:")
for row in cursor.fetchall():
    print(f"  {row['title']} (${row['price']})")

# Aggregate query
cursor.execute("SELECT COUNT(*) as count, AVG(price) as avg_price FROM books")
stats = cursor.fetchone()
print(f"\nTotal: {stats['count']} books, Average price: ${stats['avg_price']:.2f}")
conn.close()

Output:

All books by price:
  Fluent Python by Luciano Ramalho - $55.99
  Clean Code by Robert C. Martin - $39.99
  Python Crash Course by Eric Matthes - $35.99
  Automate the Boring Stuff by Al Sweigart - $29.99

Books under $40:
  Automate the Boring Stuff ($29.99)
  Clean Code ($39.99)
  Python Crash Course ($35.99)

Total: 4 books, Average price: $40.49

Setting conn.row_factory = sqlite3.Row lets you access columns by name instead of index. This makes your code more readable and resilient to schema changes.

Using Context Managers

Context managers ensure your database connection is properly closed even if an error occurs. They also handle transaction commits automatically on success and rollbacks on failure.

# context_manager.py
import sqlite3

# The connection as context manager handles commits/rollbacks
with sqlite3.connect("bookstore.db") as conn:
    conn.row_factory = sqlite3.Row
    cursor = conn.cursor()

    cursor.execute(
        "INSERT INTO books (title, author, price, published_year) VALUES (?, ?, ?, ?)",
        ("Learning Python", "Mark Lutz", 59.99, 2013)
    )
    # Commit happens automatically when exiting the with block

    cursor.execute("SELECT COUNT(*) as count FROM books")
    print(f"Total books: {cursor.fetchone()['count']}")
# Connection is committed and closed here

Output:

Total books: 5

The with statement commits the transaction when the block exits normally and rolls it back if an exception occurs. This is the recommended pattern for all database operations.

Single-process, zero-network. Faster than you think.
Single-process, zero-network. Faster than you think.

Real-Life Example: Building a Contact Book CLI

# contact_book.py
import sqlite3

class ContactBook:
    def __init__(self, db_path="contacts.db"):
        self.conn = sqlite3.connect(db_path)
        self.conn.row_factory = sqlite3.Row
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS contacts (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL,
                phone TEXT,
                email TEXT,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        """)
        self.conn.commit()

    def add(self, name, phone=None, email=None):
        self.conn.execute(
            "INSERT INTO contacts (name, phone, email) VALUES (?, ?, ?)",
            (name, phone, email)
        )
        self.conn.commit()
        print(f"Added: {name}")

    def search(self, query):
        cursor = self.conn.execute(
            "SELECT * FROM contacts WHERE name LIKE ? OR email LIKE ?",
            (f"%{query}%", f"%{query}%")
        )
        return cursor.fetchall()

    def list_all(self):
        cursor = self.conn.execute("SELECT * FROM contacts ORDER BY name")
        return cursor.fetchall()

    def delete(self, contact_id):
        self.conn.execute("DELETE FROM contacts WHERE id = ?", (contact_id,))
        self.conn.commit()

    def close(self):
        self.conn.close()

# Demo usage
book = ContactBook(":memory:")
book.add("Alice Smith", "555-0101", "alice@example.com")
book.add("Bob Jones", "555-0102", "bob@example.com")
book.add("Charlie Brown", "555-0103", "charlie@example.com")

print("\nAll contacts:")
for c in book.list_all():
    print(f"  {c['name']} - {c['phone']} - {c['email']}")

print("\nSearch 'ali':")
for c in book.search("ali"):
    print(f"  {c['name']} - {c['email']}")

book.close()

Output:

Added: Alice Smith
Added: Bob Jones
Added: Charlie Brown

All contacts:
  Alice Smith - 555-0101 - alice@example.com
  Bob Jones - 555-0102 - bob@example.com
  Charlie Brown - 555-0103 - charlie@example.com

Search 'ali':
  Alice Smith - alice@example.com

Using ":memory:" creates an in-memory database for testing. Replace it with a filename like "contacts.db" for persistent storage. The class encapsulates all database operations, making it easy to extend with update, export, or import features.

Frequently Asked Questions

Is SQLite thread-safe in Python?

By default, a SQLite connection should only be used in the thread that created it. Set check_same_thread=False if you need to share a connection across threads, but be aware that concurrent writes can cause issues. For multi-threaded applications, give each thread its own connection.

How much data can SQLite handle?

SQLite can handle databases up to 281 terabytes. In practice, it works well for databases up to a few gigabytes. Performance depends more on query patterns than raw size -- proper indexing makes a bigger difference than database engine choice for most workloads.

How do I back up a SQLite database?

The simplest backup is to copy the database file when no writes are happening. For live databases, use Python's conn.backup() method which creates a consistent snapshot even during active writes. Schedule backups with cron or the schedule library.

How do I change the schema of an existing table?

SQLite has limited ALTER TABLE support. You can add columns with ALTER TABLE books ADD COLUMN rating REAL, but you cannot remove or rename columns in older SQLite versions. For complex changes, create a new table, copy data over, drop the old table, and rename the new one.

Should I use SQLite directly or through an ORM?

For simple scripts and small projects, the built-in sqlite3 module is perfect. For larger projects with complex queries and relationships, consider SQLAlchemy which provides an ORM layer. Start with raw SQL to understand the fundamentals, then add an ORM if the project grows.

Conclusion

SQLite with Python gives you a full relational database with zero configuration. We covered creating databases and tables, safe data insertion with parameterized queries, querying with filtering and aggregation, context managers for clean resource handling, and building a complete contact book application.

For the full SQLite documentation, visit docs.python.org/3/library/sqlite3. For SQLite SQL syntax, see sqlite.org/lang.

Parameterized Queries (Never Concatenate)

SQL injection is the #1 security mistake in database code. Never build queries with f-strings or string concatenation. Use parameterized queries — sqlite3 substitutes safely:

import sqlite3

con = sqlite3.connect("app.db")
cur = con.cursor()

# WRONG — injection vulnerability
user_input = "alice'; DROP TABLE users; --"
cur.execute(f"SELECT * FROM users WHERE name = '{user_input}'")

# RIGHT — placeholder + tuple
cur.execute("SELECT * FROM users WHERE name = ?", (user_input,))
print(cur.fetchall())

# Named parameters work too
cur.execute(
    "INSERT INTO users (name, email, age) VALUES (:name, :email, :age)",
    {"name": "Bob", "email": "bob@x.com", "age": 30},
)
con.commit()

The ? style is portable across DB-API drivers (psycopg2, mysql, etc.). The named :name style works in sqlite3 and reads better for queries with many parameters.

Context Managers for Transactions

Forgetting commit() after writes is the #2 mistake. The connection object is a context manager that auto-commits on exit, auto-rollbacks on exception:

import sqlite3

con = sqlite3.connect("app.db")

# Auto-commit on clean exit, auto-rollback on error
with con:
    con.execute("INSERT INTO users (name, age) VALUES (?, ?)", ("Carol", 25))
    con.execute("UPDATE accounts SET balance = balance - 100 WHERE user_id = 1")
    con.execute("UPDATE accounts SET balance = balance + 100 WHERE user_id = 2")
    # If any line raises, all three are rolled back atomically

# Standalone connection cleanup
con.close()

This is the safest write pattern — either the whole transaction commits, or nothing changes. Use it for any multi-statement update.

Row Factory for Named Columns

By default, fetchall() returns tuples — fine for two columns, painful for ten. Set row_factory = sqlite3.Row to get dict-like access:

con = sqlite3.connect("app.db")
con.row_factory = sqlite3.Row     # rows now act like namedtuples

cur = con.execute("SELECT id, name, email, created_at FROM users LIMIT 5")
for row in cur:
    # Access by column name OR index
    print(row["name"], row["email"])
    print(row[1], row[2])

    # Convert to dict if needed
    print(dict(row))

SQLite-Specific Features

SQLite has a few features other databases lack — worth knowing:

# INSERT OR REPLACE — upsert in one statement
cur.execute(
    "INSERT OR REPLACE INTO users (id, name) VALUES (?, ?)",
    (1, "Alice"),
)

# JSON support (built into modern SQLite)
cur.execute("CREATE TABLE events (id INTEGER PRIMARY KEY, data TEXT)")
cur.execute(
    "INSERT INTO events (data) VALUES (json(?))",
    ('{"user_id": 42, "action": "login"}',),
)
cur.execute("SELECT json_extract(data, '$.user_id') FROM events WHERE id = 1")

# Full-text search via FTS5
cur.executescript('''
    CREATE VIRTUAL TABLE docs USING fts5(title, body);
    INSERT INTO docs VALUES ('Python', 'A programming language');
    INSERT INTO docs VALUES ('SQLite', 'An embedded database');
''')
cur.execute("SELECT title FROM docs WHERE docs MATCH ?", ("programming",))

Performance: Indexes and EXPLAIN QUERY PLAN

SQLite is fast — but only with the right indexes. Every column you filter or join on should be indexed:

# Create indexes
cur.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_orders_user ON orders(user_id, created_at)")

# Diagnose slow queries
cur.execute("EXPLAIN QUERY PLAN SELECT * FROM users WHERE email = ?", ("a@b.com",))
print(cur.fetchall())
# Look for "SEARCH USING INDEX" — good
# Look for "SCAN" — slow, missing index

WAL Mode for Concurrent Reads

The default SQLite journaling mode (rollback) blocks readers during writes. Switch to WAL (Write-Ahead Logging) for better concurrency:

con = sqlite3.connect("app.db")
con.execute("PRAGMA journal_mode = WAL")
con.execute("PRAGMA synchronous = NORMAL")    # slightly less durable, much faster
con.execute("PRAGMA foreign_keys = ON")        # enforce FK constraints

# WAL is per-database — run once, persists across connections

WAL mode lets readers and writers operate without blocking each other (within reasonable limits). This is the single biggest performance unlock for read-heavy SQLite workloads.

Common Pitfalls

  • Forgetting commit() without context manager. If you don't use with con:, you must call con.commit() after writes — or they're lost when the connection closes.
  • Using one connection across threads. sqlite3 connections aren't thread-safe by default. Open one connection per thread, or use check_same_thread=False with a lock.
  • String concatenation in queries. SQL injection is a real risk even in single-user apps — never build queries with user input. Parameterize everything.
  • No foreign-key enforcement. SQLite doesn't enforce FK constraints by default. Set PRAGMA foreign_keys = ON on every connection.
  • Path issues. A relative path to connect() resolves against the cwd, not the script's directory. Use an absolute path or pathlib.Path(__file__).parent / "app.db".

FAQ

Q: SQLite or PostgreSQL?
A: SQLite for embedded, single-process, prototype, or low-write apps. Postgres for multi-process, concurrent writes, or anything needing real network access. SQLite is shockingly fast for read-heavy workloads.

Q: How big can a SQLite database get?
A: 281 TB theoretical maximum, but practically anything over 100 GB is pushing it. Multi-GB databases work fine; multi-TB is when you should evaluate PostgreSQL or DuckDB.

Q: How do I back up a SQLite database?
A: While the DB is in use, run VACUUM INTO 'backup.db' for a consistent snapshot. Or use the built-in backup API: src.backup(dst) in the sqlite3 module.

Q: Can multiple processes write to the same SQLite file?
A: Yes, with WAL mode, but writes serialize. For high write concurrency, switch to Postgres. For occasional concurrent writes (a few per second), SQLite is fine.

Q: SQLAlchemy or raw sqlite3?
A: Raw sqlite3 for small scripts where SQL is the simplest expression of intent. SQLAlchemy when you have many tables, migrations, complex relationships, or want portability across databases.

Wrapping Up

SQLite is the most-deployed database in the world (every smartphone has hundreds of SQLite databases). For Python, it's built in — no install, no service to run, no network. Master parameterized queries, transactions via with con:, row factories for ergonomics, indexes for speed, and WAL mode for concurrency. Those five skills cover 90% of SQLite work and prevent 99% of the foot-guns.

Continue Learning Python

Tutorials you might also find useful:


How To Handle Anti-Scraping Measures with Python

How To Handle Anti-Scraping Measures with Python

Last Updated: June 01, 2026

Advanced

You have built a scraper that works perfectly on practice sites, but real-world websites fight back. Rate limiting, CAPTCHAs, IP blocking, and browser fingerprinting are all common defenses designed to stop automated access. Understanding these measures — and the ethical ways to work around them — is essential for any serious scraping project.

Python gives you several tools to handle anti-scraping defenses ethically: rotating User-Agent headers, managing request timing, using sessions to maintain cookies, and respecting robots.txt files. The key principle is to make your scraper behave like a polite human visitor rather than an aggressive bot.

In this tutorial, you will learn about common anti-scraping techniques websites use, how to add proper headers and User-Agents, manage request timing and rate limits, handle cookies and sessions, check and respect robots.txt, and deal with common blocking scenarios. By the end, you will know how to build scrapers that work reliably without abusing the websites you scrape.

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 Blocks: Quick Example

Many websites block requests that do not include a User-Agent header. Adding one is the simplest fix for “403 Forbidden” responses.

# quick_headers.py
import requests

url = "https://httpbin.org/headers"

# Without User-Agent (might get blocked on real sites)
response_bare = requests.get(url)
print("Status:", response_bare.status_code)

# With proper headers
headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
    "Accept": "text/html,application/xhtml+xml",
    "Accept-Language": "en-US,en;q=0.9",
}
response = requests.get(url, headers=headers)
data = response.json()
print("Sent headers:", list(data["headers"].keys()))

Output:

Status: 200
Sent headers: ['Accept', 'Accept-Language', 'Host', 'User-Agent', 'X-Amzn-Trace-Id']

The httpbin.org/headers endpoint echoes back the headers your request sent, which is perfect for verifying that your headers are being sent correctly.

Common Anti-Scraping Measures

Understanding what defenses websites use helps you build scrapers that handle them correctly. Here are the most common anti-scraping measures you will encounter.

Measure How It Works Your Response
Missing User-Agent block Rejects requests without browser-like headers Add realistic headers
Rate limiting Limits requests per time period per IP Add delays between requests
IP blocking Bans IPs making too many requests Slow down, use proxies ethically
CAPTCHA Requires human verification Respect it — do not automate CAPTCHA solving
JavaScript rendering Content loads only via JS Use Playwright or Selenium
robots.txt Declares which paths bots should avoid Always respect it
CAPTCHAs, rate limits, fingerprinting. The web fights back.
CAPTCHAs, rate limits, fingerprinting. The web fights back.

Respecting robots.txt

Every ethical scraper should check robots.txt before scraping. Python’s built-in urllib.robotparser module handles this for you.

# check_robots.py
from urllib.robotparser import RobotFileParser

rp = RobotFileParser()
rp.set_url("https://quotes.toscrape.com/robots.txt")
rp.read()

# Check if we can scrape specific paths
paths = ["/", "/page/2/", "/login", "/admin/"]
for path in paths:
    allowed = rp.can_fetch("*", path)
    print(f"{path}: {'Allowed' if allowed else 'Blocked'}")

# Check crawl delay
delay = rp.crawl_delay("*")
print(f"Crawl delay: {delay or 'Not specified'}")

Output:

/: Allowed
/page/2/: Allowed
/login: Allowed
/admin/: Allowed
Crawl delay: Not specified

The can_fetch() method returns True if the specified user agent is allowed to access the path according to the site’s robots.txt rules. Always check this before scraping any new website.

Managing Request Timing

The most common reason scrapers get blocked is making too many requests too quickly. Smart timing makes your scraper both polite and resilient.

# rate_limiting.py
import requests
import time
import random

def polite_get(url, session, min_delay=1.0, max_delay=3.0):
    """Make a request with random delay to avoid detection."""
    delay = random.uniform(min_delay, max_delay)
    time.sleep(delay)
    response = session.get(url)
    print(f"[{response.status_code}] {url} (waited {delay:.1f}s)")
    return response

session = requests.Session()
session.headers.update({
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
})

urls = [
    "https://httpbin.org/get",
    "https://httpbin.org/headers",
    "https://httpbin.org/ip",
]

for url in urls:
    response = polite_get(url, session)

Output:

[200] https://httpbin.org/get (waited 1.7s)
[200] https://httpbin.org/headers (waited 2.3s)
[200] https://httpbin.org/ip (waited 1.2s)

Random delays between 1-3 seconds mimic human browsing patterns. Using requests.Session() maintains cookies across requests, which also makes your scraper behave more like a real browser.

Browser fingerprints: 32 dimensions of who-you-are.
Browser fingerprints: 32 dimensions of who-you-are.

Implementing Retry Logic

Network errors and temporary blocks happen. Retry logic with exponential backoff handles these gracefully without hammering the server.

# retry_logic.py
import requests
import time
from requests.exceptions import RequestException

def fetch_with_retry(url, max_retries=3, backoff_factor=2):
    """Fetch URL with exponential backoff retry."""
    session = requests.Session()
    session.headers.update({
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
    })

    for attempt in range(max_retries):
        try:
            response = session.get(url, timeout=10)
            if response.status_code == 200:
                return response
            elif response.status_code == 429:
                wait = backoff_factor ** attempt
                print(f"Rate limited. Waiting {wait}s before retry...")
                time.sleep(wait)
            else:
                print(f"Got status {response.status_code}")
                return response
        except RequestException as e:
            wait = backoff_factor ** attempt
            print(f"Error: {e}. Retrying in {wait}s...")
            time.sleep(wait)

    print("All retries exhausted")
    return None

result = fetch_with_retry("https://httpbin.org/get")
if result:
    print(f"Success: {result.status_code}")

Output:

Success: 200

Exponential backoff doubles the wait time with each retry (1s, 2s, 4s). This prevents your scraper from making the situation worse when a server is already under load. The timeout=10 parameter prevents hanging on unresponsive servers.

Session and Cookie Management

Using sessions properly is critical. Sessions maintain cookies across requests, which many websites require for normal browsing. Without proper session management, websites may treat each request as a new visitor and trigger anti-bot measures.

# session_management.py
import requests

session = requests.Session()
session.headers.update({
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9",
    "Accept-Language": "en-US,en;q=0.9",
})

# First request sets cookies
response = session.get("https://httpbin.org/cookies/set/session_id/abc123")
print(f"Cookies after first request: {dict(session.cookies)}")

# Subsequent requests send cookies automatically
response = session.get("https://httpbin.org/cookies")
print(f"Server sees cookies: {response.json()['cookies']}")

Output:

Cookies after first request: {'session_id': 'abc123'}
Server sees cookies: {'session_id': 'abc123'}

Always use requests.Session() instead of bare requests.get() for multi-page scraping. It handles cookies, connection pooling, and header persistence automatically.

Respect robots.txt. Rotate user agents. Throttle. Be a good citizen.
Respect robots.txt. Rotate user agents. Throttle. Be a good citizen.

Real-Life Example: Building a Resilient Scraper

# resilient_scraper.py
import requests
import time
import random
from urllib.robotparser import RobotFileParser
from bs4 import BeautifulSoup

class ResilientScraper:
    def __init__(self, base_url, min_delay=1.0, max_delay=3.0):
        self.base_url = base_url
        self.min_delay = min_delay
        self.max_delay = max_delay
        self.session = requests.Session()
        self.session.headers.update({
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
            "Accept": "text/html,application/xhtml+xml",
            "Accept-Language": "en-US,en;q=0.9",
        })
        self.robot_parser = RobotFileParser()
        self.robot_parser.set_url(f"{base_url}/robots.txt")
        self.robot_parser.read()

    def can_scrape(self, path):
        return self.robot_parser.can_fetch("*", path)

    def get_page(self, path, max_retries=3):
        if not self.can_scrape(path):
            print(f"Blocked by robots.txt: {path}")
            return None
        url = f"{self.base_url}{path}"
        for attempt in range(max_retries):
            delay = random.uniform(self.min_delay, self.max_delay)
            time.sleep(delay)
            try:
                response = self.session.get(url, timeout=10)
                if response.status_code == 200:
                    return BeautifulSoup(response.text, "html.parser")
                elif response.status_code == 429:
                    wait = 2 ** attempt
                    print(f"Rate limited on {path}. Waiting {wait}s...")
                    time.sleep(wait)
            except Exception as e:
                print(f"Error on {path}: {e}")
        return None

scraper = ResilientScraper("https://quotes.toscrape.com")
all_quotes = []
for page_num in range(1, 4):
    soup = scraper.get_page(f"/page/{page_num}/")
    if soup:
        quotes = soup.find_all("div", class_="quote")
        for q in quotes:
            text = q.find("span", class_="text")
            author = q.find("small", class_="author")
            if text and author:
                all_quotes.append({"text": text.text, "author": author.text})
        print(f"Page {page_num}: {len(quotes)} quotes")

print(f"Total scraped: {len(all_quotes)} quotes")

Output:

Page 1: 10 quotes
Page 2: 10 quotes
Page 3: 10 quotes
Total scraped: 30 quotes

This ResilientScraper class combines every technique from this tutorial: robots.txt checking, session management, random delays, retry logic with exponential backoff, and defensive parsing.

Frequently Asked Questions

Is it ethical to bypass anti-scraping measures?

Adding headers and managing timing are standard practices that mimic normal browser behavior. However, bypassing CAPTCHAs, breaking authentication, or ignoring robots.txt crosses ethical lines. Always respect the website’s terms of service and only scrape publicly available data.

Should I use proxy rotation?

Proxy rotation can help distribute requests across multiple IPs, but it should not be used to circumvent explicit blocking. If a website is actively blocking you, that is a signal to stop rather than escalate. Proxies are more appropriate for geographic content access.

What does HTTP 429 mean?

HTTP 429 means “Too Many Requests.” The server is rate limiting you. The correct response is to slow down with exponential backoff, not to try harder. Check the Retry-After header if present — it tells you exactly how long to wait.

Do I need to rotate User-Agent strings?

For most scraping projects, a single realistic User-Agent string is sufficient. Rotating User-Agents is only necessary for high-volume scraping where a single agent string might get flagged. The more important factor is request timing.

Web scraping laws vary by jurisdiction. In the US, scraping publicly available data is generally legal, but violating a website’s terms of service could have legal consequences. In the EU, GDPR adds restrictions around personal data. When in doubt, consult a legal professional.

Conclusion

Handling anti-scraping measures responsibly is about being a good citizen of the web. We covered checking robots.txt, adding proper headers, managing request timing, implementing retry logic, session management, and building a resilient scraper class that combines all these techniques.

The ResilientScraper class gives you a production-ready foundation. Extend it with logging, database storage, or email alerts for long-running scraping projects.


How To Use Playwright for Web Scraping in Python

How To Use Playwright for Web Scraping in Python

Last Updated: June 01, 2026

Intermediate

Some websites load their content dynamically with JavaScript, which means a simple HTTP request with requests only gets you an empty shell. Playwright solves this by controlling a real browser — Chromium, Firefox, or WebKit — letting you scrape pages that rely on JavaScript rendering, single-page applications, and content loaded behind user interactions.

Microsoft’s Playwright library for Python provides a clean async and sync API for browser automation. It installs its own browser binaries, handles waits automatically, and runs headless by default. Combined with BeautifulSoup for HTML parsing, it gives you the power to scrape virtually any website.

In this tutorial, you will learn how to install Playwright, launch a browser, navigate to pages, wait for dynamic content, extract data from JavaScript-rendered pages, handle clicks and scrolling, and build a complete scraper for dynamic websites.

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 →

Scraping a Dynamic Page: Quick Example

Here is a minimal example that scrapes quotes from a JavaScript-rendered page that returns empty HTML to regular HTTP requests.

# quick_playwright.py
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto("https://quotes.toscrape.com/js/")
    page.wait_for_selector("div.quote")

    quotes = page.query_selector_all("div.quote span.text")
    for quote in quotes[:5]:
        print(quote.text_content())
    browser.close()

Output:

"The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking."
"It is our choices, Harry, that show what we truly are, far more than our abilities."
"There are only two ways to live your life. One is as though nothing is a miracle. The other is as though everything is a miracle."
"The person, be it gentleman or lady, who has not pleasure in a good novel, must be intolerably stupid."
"Imperfection is beauty, madness is genius and it's better to be absolutely ridiculous than absolutely boring."

The key difference from BeautifulSoup is that Playwright actually executes the page’s JavaScript before you extract data. The wait_for_selector() call ensures the dynamic content has loaded before scraping.

What Is Playwright and When Do You Need It?

Playwright is a browser automation library that controls real browsers programmatically. Unlike requests + BeautifulSoup which only works with static HTML, Playwright can handle any page a human can see in a browser — including single-page applications built with React, Vue, or Angular.

Feature requests + BeautifulSoup Playwright
JavaScript rendering No Yes
Speed Very fast Slower (browser overhead)
Memory usage Low Higher
Login/cookies Manual Automatic
Click/scroll No Yes
Best for Static HTML pages Dynamic/JS-heavy pages

Use Playwright when the page you need to scrape loads content with JavaScript. If the page works with JavaScript disabled, stick with requests and BeautifulSoup — it will be much faster.

Playwright: <a href=Selenium‘s modern younger sibling. Faster, better.” style=”max-width:100%;height:auto;border-radius:8px;” />
Playwright: Selenium’s modern younger sibling. Faster, better.

Installing Playwright for Python

Playwright requires two installation steps: the Python package and the browser binaries.

# install_playwright.sh
pip install playwright
playwright install chromium

Output:

Successfully installed playwright-1.42.0
Downloading Chromium 123.0.6312.4 - 140.2 Mb
Chromium downloaded to /home/user/.cache/ms-playwright/

The playwright install chromium command downloads a specific Chromium build. You can also install firefox or webkit if you need cross-browser testing.

Sync vs Async API

Playwright offers both synchronous and asynchronous APIs. The sync API is simpler for scripts and scraping. The async API is better for high-concurrency applications.

# sync_example.py
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("https://quotes.toscrape.com/js/")
    title = page.title()
    print(f"Page title: {title}")
    browser.close()

Output:

Page title: Quotes to Scrape
# async_example.py
import asyncio
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()
        await page.goto("https://quotes.toscrape.com/js/")
        title = await page.title()
        print(f"Page title: {title}")
        await browser.close()

asyncio.run(main())

Output:

Page title: Quotes to Scrape

For most scraping projects, the sync API is perfectly fine. Use async only when you need to scrape multiple pages concurrently.

Headless Chrome, Firefox, WebKit. One API, three browsers.
Headless Chrome, Firefox, WebKit. One API, three browsers.

Waiting for Dynamic Content

The most common mistake with browser-based scraping is trying to extract data before it has loaded. Playwright provides several waiting strategies to handle this.

# waiting_strategies.py
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("https://quotes.toscrape.com/js/")

    # Wait for a specific element to appear
    page.wait_for_selector("div.quote", timeout=10000)

    # Wait for network to be idle (all AJAX calls done)
    page.wait_for_load_state("networkidle")

    quotes = page.query_selector_all("div.quote")
    print(f"Found {len(quotes)} quotes after waiting")
    browser.close()

Output:

Found 10 quotes after waiting

The wait_for_selector() method pauses execution until the element appears in the DOM. The timeout parameter (in milliseconds) prevents infinite waiting if the element never appears.

Extracting Data from the Page

Playwright gives you two approaches for extracting data: using Playwright’s built-in selectors, or passing the rendered HTML to BeautifulSoup.

# extract_with_playwright.py
from playwright.sync_api import sync_playwright
from bs4 import BeautifulSoup

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("https://quotes.toscrape.com/js/")
    page.wait_for_selector("div.quote")

    # Method 1: Playwright selectors
    first_quote = page.query_selector("span.text")
    print("Playwright:", first_quote.text_content()[:60] + "...")

    # Method 2: Pass HTML to BeautifulSoup
    html = page.content()
    soup = BeautifulSoup(html, "html.parser")
    bs_quote = soup.select_one("span.text")
    print("BeautifulSoup:", bs_quote.text[:60] + "...")

    browser.close()

Output:

Playwright: "The world as we have created it is a process of our thinking...
BeautifulSoup: "The world as we have created it is a process of our thinking...

The BeautifulSoup approach is often better for complex parsing because BeautifulSoup has richer navigation methods. Use page.content() to get the fully rendered HTML and parse it with BeautifulSoup.

Auto-waiting selectors: no more sleep(5) hacks.
Auto-waiting selectors: no more sleep(5) hacks.

Real-Life Example: Scraping a JavaScript-Rendered Quote Page

# js_quote_scraper.py
from playwright.sync_api import sync_playwright
from bs4 import BeautifulSoup
import json

def scrape_js_quotes():
    all_quotes = []
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()

        for page_num in range(1, 11):
            url = f"https://quotes.toscrape.com/js/page/{page_num}/"
            page.goto(url)
            page.wait_for_selector("div.quote", timeout=5000)

            html = page.content()
            soup = BeautifulSoup(html, "html.parser")

            for quote_div in soup.find_all("div", class_="quote"):
                text = quote_div.find("span", class_="text")
                author = quote_div.find("small", class_="author")
                tags_div = quote_div.find("div", class_="tags")
                tags = [t.text for t in tags_div.find_all("a")] if tags_div else []

                all_quotes.append({
                    "text": text.text if text else "Unknown",
                    "author": author.text if author else "Unknown",
                    "tags": tags
                })
            print(f"Page {page_num}: {len(soup.find_all('div', class_='quote'))} quotes")
        browser.close()
    return all_quotes

quotes = scrape_js_quotes()
print(f"Total: {len(quotes)} quotes from JS-rendered pages")
with open("js_quotes.json", "w", encoding="utf-8") as f:
    json.dump(quotes, f, indent=2, ensure_ascii=False)
print("Saved to js_quotes.json")

Output:

Page 1: 10 quotes
Page 2: 10 quotes
...
Page 10: 10 quotes
Total: 100 quotes from JS-rendered pages
Saved to js_quotes.json

This scraper combines Playwright for rendering with BeautifulSoup for parsing. Reusing the same browser instance across pages is significantly faster than launching a new browser for each page.

Frequently Asked Questions

What does headless mode mean?

Headless mode means the browser runs without a visible window. This is the default and preferred mode for scraping because it uses less memory and runs faster. Set headless=False during development to see what the browser is doing.

How does Playwright compare to Selenium?

Playwright is newer and generally faster than Selenium. It has better auto-waiting, built-in support for multiple browser contexts, and a cleaner API. Selenium has a larger community and longer track record. For new scraping projects, Playwright is the recommended choice.

Can websites detect Playwright?

Yes, some websites use bot detection that can identify automated browsers. Playwright provides stealth options, but sophisticated anti-bot systems can still detect automation. Always respect website terms of service and consider whether scraping a particular site is appropriate.

How can I make Playwright scraping faster?

Disable images and CSS loading with route interception to speed up page loads. Reuse browser instances across multiple pages. Use networkidle wait state only when necessary. For multiple pages, use async mode with concurrent page objects.

Does Playwright use a lot of memory?

Yes, each browser instance uses 50-200MB of RAM. For large scraping jobs, process pages sequentially and close browser contexts when done. Monitor memory usage and restart the browser periodically for long-running scrapers.

Conclusion

Playwright opens up scraping possibilities that static HTTP libraries cannot touch. We covered installation, sync and async APIs, waiting for dynamic content, extracting data with both Playwright selectors and BeautifulSoup, and building a complete scraper for JavaScript-rendered pages.

For the full Playwright Python documentation, visit playwright.dev/python.

Playwright Setup

Playwright installs the browser binaries alongside the Python package — no separate driver dance like Selenium. One install command brings Chrome, Firefox, and WebKit:

# pip install playwright
# python -m playwright install   (downloads browser binaries)

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto("https://example.com")
    print(page.title())
    browser.close()

The sync_playwright() context manager handles the entire browser-process lifecycle. Use browser.close() in production code to free resources promptly.

Async API for Concurrent Scraping

Playwright’s killer feature for scrapers: native async. Crawl 100 URLs concurrently without spinning up 100 browser processes:

import asyncio
from playwright.async_api import async_playwright

async def fetch_title(url, page):
    await page.goto(url, wait_until="networkidle")
    return url, await page.title()

async def main(urls):
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context()
        # One page per concurrent task
        results = await asyncio.gather(*[
            fetch_title(url, await context.new_page())
            for url in urls
        ])
        await browser.close()
        for url, title in results:
            print(url, "->", title)

asyncio.run(main([
    "https://example.com",
    "https://python.org",
    "https://github.com",
]))

The shared context reuses cookies, local storage, and authentication state across all tabs — efficient when scraping a logged-in site.

Auto-Waiting Selectors

Playwright’s selectors auto-wait for the target to be ready before acting. No more WebDriverWait + EC.presence_of_element_located:

page.goto("https://shop.example.com")

# Click — waits for element to be visible AND enabled
page.click("button#add-to-cart")

# Fill — waits for the input to be present
page.fill("input[name='email']", "user@example.com")

# Select option — waits for dropdown
page.select_option("select#country", "US")

# Wait for navigation after click
with page.expect_navigation():
    page.click("a.checkout")

# Wait for a specific selector to appear
page.wait_for_selector("text=Order confirmed", timeout=10000)

The implicit waits are why Playwright tests are dramatically less flaky than Selenium tests. No time.sleep(2) hacks.

Locator API and Best Practices

For complex pages, use the locator() API instead of raw selectors. Locators are lazy and re-resolve on each action — perfect for SPAs that re-render constantly:

# Reusable locator
cart_button = page.locator("button#add-to-cart")
cart_button.click()
cart_button.wait_for(state="hidden")  # waits until removed from DOM

# Chained locators (parent -> child)
form = page.locator("form#checkout")
form.locator("input[name='email']").fill("alice@example.com")
form.locator("input[name='zip']").fill("90210")
form.locator("button[type='submit']").click()

# Text-based selector — robust against class-name changes
page.get_by_text("Sign in").click()
page.get_by_role("button", name="Submit").click()
page.get_by_label("Email address").fill("alice@example.com")

The get_by_role and get_by_label helpers are accessibility-first — they target the same semantic elements that screen readers find, making them resilient to CSS / class changes.

Network Interception

Playwright lets you intercept and modify network requests — useful for blocking ads, mocking APIs, or extracting JSON responses:

# Block all images (faster scraping)
page.route("**/*.{png,jpg,jpeg,gif,svg}", lambda route: route.abort())

# Mock an API response
def handle(route):
    route.fulfill(json={"status": "ok", "data": [1, 2, 3]})

page.route("**/api/users", handle)

# Capture all XHR responses
page.on("response", lambda resp: print(resp.url, resp.status) if "api" in resp.url else None)

Common Pitfalls

  • Forgetting to install browsers. pip install playwright doesn’t bring the browser binaries. Run python -m playwright install once after install.
  • Using time.sleep instead of wait_for. Sleeps make tests flaky and slow. page.wait_for_selector, page.wait_for_load_state, and locator’s implicit waits handle every case.
  • One context per page when you should share. Each context costs memory. For 100 pages of the same site, use one context and 100 pages.
  • Ignoring page.on(“dialog”). JavaScript alert() / confirm() dialogs block Playwright forever unless you register a handler that dismisses them.
  • Headless != real. Some sites detect headless Chrome by missing browser features. Use headless=False for debugging, then test in headless mode separately.

FAQ

Q: Playwright or Selenium?
A: Playwright for new projects — faster, less flaky, better API. Selenium when you need broader browser support (Edge, IE legacy) or you have existing Selenium tests.

Q: Sync or async API?
A: Async for scrapers that hit many URLs concurrently. Sync for test suites that already use synchronous frameworks (pytest, unittest). The APIs are nearly identical; switching later is easy.

Q: Does Playwright handle bot protection?
A: Better than Selenium, but not perfectly. For Cloudflare / DataDome, use the playwright-stealth plugin and rotate proxies. Some sites you simply can’t scrape reliably without residential IPs.

Q: How do I take a screenshot for debugging?
A: page.screenshot(path="screenshot.png", full_page=True). For video recording, set context = browser.new_context(record_video_dir="videos/").

Q: Can I reuse browser state across runs?
A: Yes — save context state with context.storage_state(path="state.json") and load with browser.new_context(storage_state="state.json"). Cookies and local storage persist between sessions.

Wrapping Up

Playwright fixes nearly every Selenium pain point — driver dance, flaky waits, slow startup, awkward async. For new web-scraping or browser-automation projects in Python, it’s the right default. The locator API + auto-waiting + native async covers 95% of use cases. The remaining 5% (Cloudflare bypass, mobile emulation, headless detection) needs specialty tools regardless of framework.


How To Scrape Websites with Python and BeautifulSoup

How To Scrape Websites with Python and BeautifulSoup

Last Updated: June 01, 2026

Intermediate

You need data from a website, but there is no API available. Maybe you want to track product prices, collect research data, or aggregate job listings from multiple sources. Web scraping with Python and BeautifulSoup lets you extract structured data from HTML pages quickly and reliably — and in 2026, the fundamentals remain as relevant as ever.

Python’s requests library handles HTTP connections while BeautifulSoup (from the bs4 package) parses the HTML and lets you navigate the document tree with simple, readable methods. Both are pure Python, install in seconds with pip, and work on every platform.

In this tutorial, you will learn how to fetch web pages, parse HTML with BeautifulSoup, extract text and attributes, work with tables, handle multiple pages, and build a complete scraping project. By the end, you will have a reusable scraping toolkit you can adapt to any static website.

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 →

Scraping a Web Page: Quick Example

Let us start with a minimal working example that fetches quotes from a practice website and prints them out.

# quick_scrape.py
import requests
from bs4 import BeautifulSoup

url = "https://quotes.toscrape.com"
response = requests.get(url)
soup = BeautifulSoup(response.text, "html.parser")

quotes = soup.find_all("span", class_="text")
for quote in quotes[:5]:
    print(quote.text)

Output:

"The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking."
"It is our choices, Harry, that show what we truly are, far more than our abilities."
"There are only two ways to live your life. One is as though nothing is a miracle. The other is as though everything is a miracle."
"The person, be it gentleman or lady, who has not pleasure in a good novel, must be intolerably stupid."
"Imperfection is beauty, madness is genius and it's better to be absolutely ridiculous than absolutely boring."

Three lines do the heavy lifting: requests.get() fetches the page, BeautifulSoup() parses the HTML, and find_all() extracts matching elements. The rest of this tutorial builds on these patterns.

What Is BeautifulSoup and Why Use It?

BeautifulSoup is a Python library that parses HTML and XML documents into a tree structure you can search and navigate with Python code. Think of it like a smart search tool for web pages — instead of working with raw text strings, you work with structured elements that know about their tags, attributes, parents, and children.

The library handles malformed HTML gracefully, which matters because real-world web pages are often messy. Missing closing tags, inconsistent indentation, and mixed encoding are all things BeautifulSoup handles without crashing.

Feature BeautifulSoup Regular Expressions lxml
Learning curve Low High Medium
Handles broken HTML Yes No Partially
Speed Moderate Fast Very fast
CSS selectors Yes No Yes
Best for Most scraping tasks Simple patterns Large documents

For most web scraping projects, BeautifulSoup with the html.parser backend gives you the best balance of simplicity and reliability.

Finding Elements: find() and find_all()

The two methods you will use most are find() (returns the first match) and find_all() (returns all matches). Both accept tag names, CSS classes, IDs, and attribute dictionaries.

# finding_elements.py
import requests
from bs4 import BeautifulSoup

url = "https://quotes.toscrape.com"
response = requests.get(url)
soup = BeautifulSoup(response.text, "html.parser")

first_quote = soup.find("span", class_="text")
print("First quote:", first_quote.text[:60] + "...")

authors = soup.find_all("small", class_="author")
print("Authors on page:", [a.text for a in authors[:5]])

tags_div = soup.find("div", attrs={"class": "tags"})
if tags_div:
    tag_links = tags_div.find_all("a", class_="tag")
    print("Tags:", [t.text for t in tag_links])

Output:

First quote: "The world as we have created it is a process of our...
Authors on page: ['Albert Einstein', 'J.K. Rowling', 'Albert Einstein', 'Jane Austen', 'Marilyn Monroe']
Tags: ['change', 'deep-thoughts', 'thinking', 'world']

Notice the defensive pattern with if tags_div: before calling find_all(). Real websites are messy — elements might be missing, classes might change, or content might be empty. Defensive parsing separates a scraper that crashes on page 3 from one that runs reliably across thousands of pages.

Using CSS Selectors with select()

If you already know CSS, the select() method lets you use CSS selector syntax to find elements. This is often more concise than chaining find() calls.

# css_selectors.py
import requests
from bs4 import BeautifulSoup

url = "https://quotes.toscrape.com"
response = requests.get(url)
soup = BeautifulSoup(response.text, "html.parser")

quotes = soup.select("div.quote span.text")
print(f"Found {len(quotes)} quotes using CSS selector")

author_links = soup.select("div.quote span a")
print("Author pages:", [a["href"] for a in author_links[:3]])

first_tag = soup.select_one("a.tag")
print("First tag:", first_tag.text if first_tag else "None found")

Output:

Found 10 quotes using CSS selector
Author pages: ['/author/Albert-Einstein', '/author/J-K-Rowling', '/author/Albert-Einstein']
First tag: change

The select() method returns a list while select_one() returns the first match or None. CSS selectors are particularly useful when elements are deeply nested.

Extracting Text, Attributes, and Links

Once you have found an element, you need to extract useful data from it. BeautifulSoup gives you .text for visible text content, .get() for attributes, and .attrs for the full attribute dictionary.

# extracting_data.py
import requests
from bs4 import BeautifulSoup

url = "https://quotes.toscrape.com"
response = requests.get(url)
soup = BeautifulSoup(response.text, "html.parser")

quote_div = soup.find("div", class_="quote")
if quote_div:
    quote_text = quote_div.find("span", class_="text")
    print("Quote:", quote_text.text if quote_text else "Unknown")

    author_link = quote_div.find("a")
    href = author_link.get("href", "#") if author_link else "#"
    print("Author page:", href)

    tags_container = quote_div.find("div", class_="tags")
    if tags_container:
        tags = [tag.text for tag in tags_container.find_all("a")]
        print("Tags:", tags)

Output:

Quote: "The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking."
Author page: /author/Albert-Einstein
Tags: ['change', 'deep-thoughts', 'thinking', 'world']

The .get() method with a default value is safer than direct dictionary access like element["href"] — it returns your default instead of raising a KeyError if the attribute is missing.

Handling Pagination

Most websites split content across multiple pages. To scrape all pages, find the “next page” link and follow it until there are no more pages.

# pagination.py
import requests
from bs4 import BeautifulSoup
import time

base_url = "https://quotes.toscrape.com"
all_quotes = []
url = base_url + "/page/1/"

while url:
    print(f"Scraping: {url}")
    response = requests.get(url)
    soup = BeautifulSoup(response.text, "html.parser")

    for quote_div in soup.find_all("div", class_="quote"):
        text = quote_div.find("span", class_="text")
        author = quote_div.find("small", class_="author")
        if text and author:
            all_quotes.append({"text": text.text, "author": author.text})

    next_btn = soup.find("li", class_="next")
    if next_btn:
        next_link = next_btn.find("a")
        url = base_url + next_link["href"] if next_link else None
    else:
        url = None
    time.sleep(1)

print(f"Total quotes scraped: {len(all_quotes)}")
print(f"Sample: {all_quotes[0]['author']}: {all_quotes[0]['text'][:50]}...")

Output:

Scraping: https://quotes.toscrape.com/page/1/
Scraping: https://quotes.toscrape.com/page/2/
...
Scraping: https://quotes.toscrape.com/page/10/
Total quotes scraped: 100
Sample: Albert Einstein: "The world as we have created it is a process of o...

The time.sleep(1) between requests is essential. Without it, you risk overwhelming the server and getting your IP blocked.

Saving Scraped Data to CSV and JSON

Once you have extracted data, you typically want to save it for analysis. Python’s built-in csv and json modules handle this without extra dependencies.

# save_data.py
import csv
import json

quotes = [
    {"text": "Be yourself; everyone else is already taken.", "author": "Oscar Wilde"},
    {"text": "Two things are infinite: the universe and human stupidity.", "author": "Albert Einstein"},
    {"text": "Be the change that you wish to see in the world.", "author": "Mahatma Gandhi"},
]

with open("quotes.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=["text", "author"])
    writer.writeheader()
    writer.writerows(quotes)
print("Saved to quotes.csv")

with open("quotes.json", "w", encoding="utf-8") as f:
    json.dump(quotes, f, indent=2, ensure_ascii=False)
print("Saved to quotes.json")

Output:

Saved to quotes.csv
Saved to quotes.json

Always specify encoding="utf-8" when opening files for scraped data. Web content often contains special characters that will cause errors with the default system encoding.

Real-Life Example: Building a Job Listing Scraper

Let us build a complete scraper that extracts job listings from the Fake Jobs practice site, processes the data, and saves it as both CSV and JSON.

# job_scraper.py
import requests
from bs4 import BeautifulSoup
import csv
import json

def scrape_jobs(url):
    response = requests.get(url)
    response.raise_for_status()
    soup = BeautifulSoup(response.text, "html.parser")
    jobs = []
    cards = soup.find_all("div", class_="card-content")

    for card in cards:
        title_elem = card.find("h2", class_="title")
        company_elem = card.find("h3", class_="company")
        location_elem = card.find("p", class_="location")
        date_elem = card.find("time")

        job = {
            "title": title_elem.text.strip() if title_elem else "Unknown",
            "company": company_elem.text.strip() if company_elem else "Unknown",
            "location": location_elem.text.strip() if location_elem else "Unknown",
            "date_posted": date_elem.text.strip() if date_elem else "Unknown",
        }
        apply_link = card.find("a", string="Apply")
        job["apply_url"] = apply_link["href"] if apply_link else "N/A"
        jobs.append(job)
    return jobs

def save_results(jobs, csv_path, json_path):
    with open(csv_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=jobs[0].keys())
        writer.writeheader()
        writer.writerows(jobs)
    with open(json_path, "w", encoding="utf-8") as f:
        json.dump(jobs, f, indent=2, ensure_ascii=False)

url = "https://realpython.github.io/fake-jobs/"
jobs = scrape_jobs(url)
print(f"Scraped {len(jobs)} job listings")
save_results(jobs, "jobs.csv", "jobs.json")
print("Data saved to jobs.csv and jobs.json")

Output:

Scraped 100 job listings
Data saved to jobs.csv and jobs.json

This scraper uses defensive parsing throughout — every find() result is checked before accessing .text, and default values handle missing elements. You can extend this by adding pagination, filtering by location, or scheduling it to run daily.

Continue Learning Python

Tutorials you might also find useful: