How To Use Python datetime Module for Date and Time Operations

How To Use Python datetime Module for Date and Time Operations

Last Updated: June 01, 2026

Beginner

Dates and times appear in almost every real-world Python project — logging when an event occurred, scheduling tasks, calculating how long something took, or displaying timestamps to users. Without proper tools, working with dates in code becomes a nightmare of string parsing, timezone confusion, and off-by-one-day errors. Python’s built-in datetime module solves all of this cleanly and consistently.

The datetime module is part of Python’s standard library — no installation required. It provides several classes: date for calendar dates, time for time-of-day values, datetime for combined date and time, timedelta for representing durations, and timezone for handling timezone offsets. Together these classes cover the vast majority of date/time tasks you’ll encounter.

In this tutorial, you’ll learn how to create and manipulate date and datetime objects, format dates for display and parse them from strings, do date arithmetic with timedelta, work with timezones, and apply everything in a practical project that calculates age and upcoming birthdays. By the end you’ll handle dates in Python with confidence.

Pubs - Python How To Program
Written by Pubs

Python developer and educator with 15+ years building production systems across data engineering, web APIs, and AI tooling. Founder of Python How To Program — 270+ in-depth tutorials covering the modern Python stack.

View all tutorials by Pubs →

Working with Dates: Quick Example

Here’s a quick working example that covers the most common operations — getting today’s date, formatting it, and calculating a future date:

# datetime_quick.py
from datetime import date, datetime, timedelta

# Today's date
today = date.today()
print("Today:", today)

# Current date and time
now = datetime.now()
print("Now:", now.strftime("%Y-%m-%d %H:%M:%S"))

# Date arithmetic: 30 days from now
future = today + timedelta(days=30)
print("30 days from now:", future)

# Days until end of year
end_of_year = date(today.year, 12, 31)
days_left = (end_of_year - today).days
print(f"Days until end of year: {days_left}")

Output:

Today: 2026-04-17
Now: 2026-04-17 09:23:45
30 days from now: 2026-05-17
Days until end of year: 258

The key insight here is that subtracting two date objects returns a timedelta object, and you access its .days attribute to get the integer count. The strftime method formats datetimes into readable strings — we’ll cover all the format codes later in this tutorial.

What Is the datetime Module?

The datetime module provides classes for working with dates and times in Python. Think of it as Python’s built-in calendar and clock library. Unlike Unix timestamps (which are just large integers), datetime objects are human-readable, support arithmetic, and can be converted to and from formatted strings.

Here’s how the main classes relate to each other:

ClassWhat It RepresentsExample
dateA calendar date (year, month, day)date(2026, 4, 17)
timeA time of day (hour, minute, second, microsecond)time(9, 30, 0)
datetimeA specific moment in time (date + time combined)datetime(2026, 4, 17, 9, 30)
timedeltaA duration or difference between two momentstimedelta(days=7, hours=2)
timezoneA fixed UTC offset for timezone-aware datetimestimezone(timedelta(hours=5))

In most everyday code, you’ll use date, datetime, and timedelta most often. The timezone class becomes important when your application serves users in multiple regions or interacts with APIs that return UTC timestamps.

datetime basics and creating dates
datetime.now() is only accurate until you deploy to another timezone.

Creating Date and Datetime Objects

There are several ways to create date and datetime objects depending on whether you know the specific values or need the current moment.

Getting the Current Date and Time

The most common starting point is getting today’s date or the current datetime. Use date.today() for just the date, or datetime.now() for date plus time:

# create_dates.py
from datetime import date, datetime

# Just the date (no time component)
today = date.today()
print(f"date.today(): {today}")
print(f"Year: {today.year}, Month: {today.month}, Day: {today.day}")

# Date + time (uses system's local time)
now = datetime.now()
print(f"\ndatetime.now(): {now}")
print(f"Hour: {now.hour}, Minute: {now.minute}, Second: {now.second}")
print(f"Microsecond: {now.microsecond}")

# UTC time (timezone-naive but in UTC)
utc_now = datetime.utcnow()
print(f"\ndatetime.utcnow(): {utc_now}")

Output:

date.today(): 2026-04-17
Year: 2026, Month: 4, Day: 17

datetime.now(): 2026-04-17 09:23:45.123456
Hour: 9, Minute: 23, Second: 45
Microsecond: 123456

datetime.utcnow(): 2026-04-17 07:23:45.123456

Creating Specific Dates

When you need to represent a fixed date (a birthday, a deadline, a historical event), pass the year, month, and day directly to the constructor. The datetime constructor accepts the same arguments plus optional hour, minute, second, and microsecond:

# specific_dates.py
from datetime import date, datetime

# Create a specific date
python_release = date(1991, 2, 20)  # Python's first public release
print(f"Python released: {python_release}")

# Create a specific datetime
meeting = datetime(2026, 5, 1, 14, 30, 0)  # May 1, 2026 at 2:30 PM
print(f"Meeting scheduled: {meeting}")

# Access individual components
print(f"Meeting day of week (0=Mon): {meeting.weekday()}")
print(f"Meeting ISO weekday (1=Mon): {meeting.isoweekday()}")

Output:

Python released: 1991-02-20
Meeting scheduled: 2026-05-01 14:30:00
Meeting day of week (0=Mon): 3
Meeting ISO weekday (1=Mon): 4

The weekday() method returns 0 for Monday through 6 for Sunday. isoweekday() returns 1 for Monday through 7 for Sunday — which one you use depends on your preference and how the day numbering will appear in your output.

Formatting dates with strftime
strftime gives you dates your users can actually read.

Date Arithmetic with timedelta

One of the most powerful features of the datetime module is the ability to add and subtract time using timedelta objects. A timedelta represents a fixed duration — it can hold days, seconds, and microseconds internally (though you can specify it in any combination of units).

Creating and Using timedelta

Create a timedelta by specifying the duration, then add or subtract it from a date or datetime object:

# timedelta_basics.py
from datetime import date, datetime, timedelta

today = date.today()

# Create timedeltas
one_week = timedelta(weeks=1)
two_days = timedelta(days=2)
ninety_days = timedelta(days=90)

print(f"Today: {today}")
print(f"One week from now: {today + one_week}")
print(f"Two days ago: {today - two_days}")
print(f"90 days from now: {today + ninety_days}")

# Timedelta from subtraction
deadline = date(2026, 12, 31)
days_remaining = deadline - today
print(f"\nDays until Dec 31: {days_remaining.days}")

# Timedelta with hours/minutes (use datetime)
start = datetime(2026, 4, 17, 9, 0, 0)
duration = timedelta(hours=2, minutes=30)
end = start + duration
print(f"\nMeeting start: {start.strftime('%H:%M')}")
print(f"Meeting end:   {end.strftime('%H:%M')}")

Output:

Today: 2026-04-17
One week from now: 2026-04-24
Two days ago: 2026-04-15
90 days from now: 2026-07-16

Days until Dec 31: 258

Meeting start: 09:00
Meeting end:   11:30

When you subtract two dates, Python returns a timedelta object. Access its .days attribute for the integer count of days. For timedeltas involving hours, work with datetime objects instead of bare date objects — date has no concept of hours or minutes.

Formatting and Parsing Dates

Dates need to be displayed to users and parsed from user input, config files, API responses, and databases. Python provides strftime for formatting (datetime to string) and strptime for parsing (string to datetime).

Formatting with strftime

The strftime method formats a datetime using format codes. The most important codes to know:

CodeMeaningExample
%Y4-digit year2026
%m2-digit month (01-12)04
%d2-digit day (01-31)17
%HHour (00-23, 24-hr)14
%IHour (01-12, 12-hr)02
%MMinute (00-59)30
%SSecond (00-59)00
%pAM or PMPM
%AFull weekday nameFriday
%BFull month nameApril
%ZTimezone nameUTC
# strftime_examples.py
from datetime import datetime

now = datetime(2026, 4, 17, 14, 30, 0)

print(now.strftime("%Y-%m-%d"))           # ISO format
print(now.strftime("%d/%m/%Y"))           # UK format
print(now.strftime("%B %d, %Y"))          # Human-readable
print(now.strftime("%A, %B %d, %Y"))      # Full weekday
print(now.strftime("%I:%M %p"))           # 12-hour clock
print(now.strftime("%Y-%m-%dT%H:%M:%S"))  # ISO 8601 / API format

Output:

2026-04-17
17/04/2026
April 17, 2026
Friday, April 17, 2026
02:30 PM
2026-04-17T14:30:00

Parsing with strptime

When you receive a date as a string (from a form input, a CSV file, or an API response), use strptime to convert it into a datetime object. The format string must match the input exactly:

# strptime_examples.py
from datetime import datetime

# Parse common date formats
date_str1 = "2026-04-17"
dt1 = datetime.strptime(date_str1, "%Y-%m-%d")
print(f"Parsed ISO: {dt1}")

date_str2 = "April 17, 2026"
dt2 = datetime.strptime(date_str2, "%B %d, %Y")
print(f"Parsed long form: {dt2}")

date_str3 = "17/04/2026 14:30:00"
dt3 = datetime.strptime(date_str3, "%d/%m/%Y %H:%M:%S")
print(f"Parsed with time: {dt3}")

# Now you can do arithmetic on parsed dates
delta = dt1 - dt2  # Both represent 2026-04-17
print(f"Difference: {delta.days} days")

Output:

Parsed ISO: 2026-04-17 00:00:00
Parsed long form: 2026-04-17 00:00:00
Parsed with time: 2026-04-17 14:30:00
Difference: 0 days

A common mistake is mismatching the format string with the actual string. If the format doesn’t match, Python raises a ValueError. Always test your format string against real data before deploying to production.

Working with timezones in Python
UTC is the one true timezone. Everything else is just an opinion.

Working with Timezones

By default, datetime objects created with datetime.now() are “naive” — they have no timezone information. This is fine for local scripts, but problematic for applications that serve global users or interact with APIs. Python’s timezone class provides simple fixed-offset timezone support:

# timezone_examples.py
from datetime import datetime, timezone, timedelta

# UTC-aware datetime
utc_now = datetime.now(timezone.utc)
print(f"UTC now: {utc_now}")
print(f"UTC offset: {utc_now.utcoffset()}")

# Create specific timezone offsets
eastern = timezone(timedelta(hours=-5))   # EST (UTC-5)
india = timezone(timedelta(hours=5, minutes=30))  # IST (UTC+5:30)

# Convert UTC to other timezones
eastern_time = utc_now.astimezone(eastern)
india_time = utc_now.astimezone(india)

print(f"\nEastern time: {eastern_time.strftime('%Y-%m-%d %H:%M %Z')}")
print(f"India time:   {india_time.strftime('%Y-%m-%d %H:%M %Z')}")

# Compare aware datetimes
is_same = eastern_time == india_time
print(f"\nSame moment? {is_same}")  # True -- same instant, different display

Output:

UTC now: 2026-04-17 07:23:45.123456+00:00
UTC offset: 0:00:00

Eastern time: 2026-04-17 02:23 UTC-05:00
India time:   2026-04-17 12:53 IST

Same moment? True

For production applications with complex timezone requirements (daylight saving time, historical timezone data), consider using the zoneinfo module (Python 3.9+) or the third-party pytz library, which include full IANA timezone database support.

Real-Life Example: Birthday Countdown Calculator

Let’s build a practical birthday calculator that tells you a person’s current age, how many days until their next birthday, and what day of the week it falls on:

# birthday_calculator.py
from datetime import date

def calculate_age(birthdate):
    """Calculate age in years from a birthdate."""
    today = date.today()
    age = today.year - birthdate.year
    # Adjust if birthday hasn't occurred yet this year
    if (today.month, today.day) < (birthdate.month, birthdate.day):
        age -= 1
    return age

def days_until_birthday(birthdate):
    """Return days until next birthday and the date it falls on."""
    today = date.today()
    # Next birthday this year
    next_birthday = birthdate.replace(year=today.year)

    # If birthday already passed this year, use next year
    if next_birthday < today:
        next_birthday = birthdate.replace(year=today.year + 1)

    days_left = (next_birthday - today).days
    return days_left, next_birthday

def birthday_report(name, birthdate_str):
    """Print a full birthday report for a person."""
    birthdate = date.fromisoformat(birthdate_str)  # Parses YYYY-MM-DD

    age = calculate_age(birthdate)
    days_left, next_bday = days_until_birthday(birthdate)

    weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday',
                'Friday', 'Saturday', 'Sunday']
    bday_weekday = weekdays[next_bday.weekday()]

    print(f"--- Birthday Report for {name} ---")
    print(f"Birthdate:  {birthdate.strftime('%B %d, %Y')}")
    print(f"Age:        {age} years old")
    print(f"Next bday:  {next_bday.strftime('%B %d, %Y')} ({bday_weekday})")
    print(f"Countdown:  {days_left} days to go")
    if days_left == 0:
        print("           ** Happy Birthday! **")
    print()

birthday_report("Alice", "1990-07-15")
birthday_report("Bob", "1985-04-20")
birthday_report("Carol", "2000-12-31")

Output:

--- Birthday Report for Alice ---
Birthdate:  July 15, 1990
Age:        35 years old
Next bday:  July 15, 2026 (Wednesday)
Countdown:  89 days to go

--- Birthday Report for Bob ---
Birthdate:  April 20, 1985
Age:        40 years old
Next bday:  April 20, 2026 (Monday)
Countdown:  3 days to go

--- Birthday Report for Carol ---
Birthdate:  December 31, 2000
Age:        25 years old
Next bday:  December 31, 2026 (Thursday)
Countdown:  258 days to go

This project demonstrates several key concepts: using date.fromisoformat() to parse ISO-formatted date strings (a cleaner alternative to strptime for YYYY-MM-DD), using replace() to adjust a date's year while keeping month and day, subtracting dates to get day counts, and the subtle off-by-one logic needed to correctly calculate age. You could extend this by reading birthdays from a CSV file or sending email notifications when a birthday is approaching.

Calculating date differences with timedelta
timedelta does the calendar math so you dont have to count on your fingers.

Frequently Asked Questions

How do I compare two dates in Python?

Use standard comparison operators (<, >, ==, <=, >=) directly on date or datetime objects. For example, if date1 < date2: works exactly as you'd expect. Just make sure both objects are the same type -- comparing a naive datetime with a timezone-aware one raises a TypeError.

How do I convert a Unix timestamp to a datetime?

Use datetime.fromtimestamp(ts) to convert a Unix timestamp (seconds since epoch) to a local datetime, or datetime.utcfromtimestamp(ts) for UTC. For a timezone-aware result, use datetime.fromtimestamp(ts, tz=timezone.utc). To go the other direction, call dt.timestamp() on any datetime object.

What is the easiest way to get an ISO 8601 formatted date string?

Call .isoformat() on any date or datetime object. This returns a standard ISO 8601 string like "2026-04-17" for dates or "2026-04-17T14:30:00" for datetimes. To parse ISO strings back into date objects, use date.fromisoformat() or datetime.fromisoformat() -- both were added in Python 3.7.

How do I find the first or last day of a month?

For the first day, use dt.replace(day=1). For the last day, use the calendar module: import calendar; last_day = calendar.monthrange(year, month)[1]. Then create the date with date(year, month, last_day). This handles the varying lengths of months (and leap years) correctly.

How do I measure elapsed time in seconds or milliseconds?

Subtract two datetime objects to get a timedelta, then call .total_seconds() on the result. For example: elapsed = (end_time - start_time).total_seconds(). For high-precision timing of code execution, use time.perf_counter() from the time module instead -- it's designed for benchmarking with sub-millisecond precision.

Conclusion

The datetime module gives you everything you need to work with dates and times in Python without installing third-party libraries. In this tutorial, you learned how to create date and datetime objects with date.today(), datetime.now(), and constructor calls; do date arithmetic using timedelta; format datetimes into strings with strftime and its format codes; parse date strings back to datetime objects with strptime and fromisoformat; and handle timezones with the built-in timezone class.

The birthday calculator project shows how these pieces fit together in a real application. Try extending it: read birthdays from a CSV file using the csv module, sort the list by upcoming birthday, or send a Telegram notification when a birthday is fewer than 7 days away.

For full documentation and additional classes, see the official Python datetime documentation. For complex timezone requirements, explore the zoneinfo module added in Python 3.9.

How To Use Python Regular Expressions with the re Module

How To Use Python Regular Expressions with the re Module

Last Updated: June 01, 2026

Intermediate

Some text problems are impossible to solve with split(), replace(), and in checks. Extracting all email addresses from a document. Validating that a phone number matches any of fifteen regional formats. Finding every date that appears in a 10,000-line log file. These are pattern-matching problems, and regular expressions — regex — are built exactly for them. Once you understand regex, a problem that would take 50 lines of string manipulation collapses into a single well-crafted pattern.

Python’s re module is built into the standard library and provides a full regex engine. You write a pattern that describes what you’re looking for, and re finds it — in strings of any length, with any number of matches, extracted as individual strings or as named groups. No installation required.

In this article we’ll cover the essential regex syntax (character classes, quantifiers, anchors, groups), the five core re functions (match, search, findall, sub, split), named groups and compiled patterns, lookaheads and lookbehinds, common real-world patterns (email, phone, URL, date), and a practical log file parser. By the end, you’ll be able to write and read regex confidently for most everyday text parsing tasks.

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 Regex: Quick Example

Here’s how to extract all email addresses from a block of text in two lines:

# quick_regex.py
import re

text = "Contact us at support@example.com or sales@company.org for help. Spam: fake@.com"

emails = re.findall(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', text)
print(emails)

Output:

['support@example.com', 'sales@company.org']

re.findall() returns a list of all non-overlapping matches. The pattern [a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,} matches the local part of an email ([a-zA-Z0-9._%+-]+), then @, then a domain ([a-zA-Z0-9.-]+), then a dot (\.), then a TLD of 2+ letters ([a-zA-Z]{2,}). Notice fake@.com wasn’t matched — the domain part requires at least one character before the dot.

What Are Regular Expressions?

A regular expression is a sequence of characters that defines a search pattern. The pattern \d{3}-\d{4} matches “555-1234” — exactly three digits, a hyphen, and four digits. Patterns can describe fixed strings, ranges of characters, repetition, alternatives, and complex structures like “a word followed by a number followed by an optional suffix.”

PatternMatchesWhat It Means
.Any character except newlineWildcard
\dAny digit (0-9)Digit shorthand
\wWord character (a-z, A-Z, 0-9, _)Word char shorthand
\sWhitespace (space, tab, newline)Space shorthand
^Start of string (or line with MULTILINE)Anchor
$End of string (or line with MULTILINE)Anchor
[abc]Any of a, b, or cCharacter class
[^abc]Any character NOT a, b, or cNegated class
+One or more of the precedingQuantifier
*Zero or more of the precedingQuantifier
?Zero or one (optional)Quantifier
{n,m}Between n and m repetitionsQuantifier
a|bEither a or bAlternation
(abc)Capture groupGrouping

Always use raw strings (r'...') for regex patterns in Python. Without the r prefix, backslashes like \d and \w would need to be doubled (\\d, \\w) because Python treats \d as a string escape sequence. Raw strings pass the backslash through unchanged, making patterns cleaner and less error-prone.

Pattern matching with re
Pattern matching is detective work for your data.

The Five Core re Functions

re.match() — Match at the Start

re.match() only checks for a match at the very beginning of the string. It’s useful for validating format when you expect the string to start with a specific pattern.

# re_match.py
import re

# Only matches if the pattern is at the START of the string
result = re.match(r'\d{4}-\d{2}-\d{2}', '2026-04-16 09:00:00')
if result:
    print('Matched date:', result.group())
else:
    print('No match')

# Does NOT match -- pattern not at start
result2 = re.match(r'\d{4}-\d{2}-\d{2}', 'Log entry: 2026-04-16')
print('Match with prefix:', result2)  # None

Output:

Matched date: 2026-04-16
Match with prefix: None

re.search() scans the entire string and returns the first match wherever it appears. This is the function to use when you’re looking for a pattern that might appear anywhere in the text.

# re_search.py
import re

log_line = 'ERROR 2026-04-16 09:23:45 - Connection timeout on port 5432'

# Find the timestamp anywhere in the string
ts_match = re.search(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}', log_line)
if ts_match:
    print('Timestamp found:', ts_match.group())
    print('At position:', ts_match.start(), 'to', ts_match.end())

# Find the port number
port_match = re.search(r'port (\d+)', log_line)
if port_match:
    print('Port:', port_match.group(1))  # group(1) = first capture group

Output:

Timestamp found: 2026-04-16 09:23:45
At position: 6 to 25
Port: 5432

The match.group() method returns the full matched string. match.group(1) returns the first capture group (the content inside the first set of parentheses). match.start() and match.end() give you the character positions of the match in the original string.

re.findall() — Find All Matches

re.findall() returns a list of all non-overlapping matches. If the pattern has no groups, it returns a list of matched strings. If it has one group, it returns the group contents. If it has multiple groups, it returns a list of tuples.

# re_findall.py
import re

text = '''
Server logs for 2026-04-16:
  192.168.1.10 -> request 200 OK
  10.0.0.5 -> request 404 Not Found
  172.16.0.1 -> request 200 OK
  192.168.1.10 -> request 500 Internal Server Error
'''

# Find all IP addresses
ips = re.findall(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', text)
print('IPs found:', ips)

# Find all status codes
codes = re.findall(r'request (\d{3})', text)
print('Status codes:', codes)

# Count 404s and 500s
errors = [c for c in codes if c.startswith(('4', '5'))]
print('Error responses:', len(errors))

Output:

IPs found: ['192.168.1.10', '10.0.0.5', '172.16.0.1', '192.168.1.10']
Status codes: ['200', '404', '200', '500']
Error responses: 2

re.sub() — Replace Matches

re.sub() replaces all occurrences of a pattern with a replacement string or the result of a function. This is the regex-powered version of str.replace().

# re_sub.py
import re

# Normalize phone numbers to a consistent format
phones = [
    'Call us: (02) 9876-5432',
    'Mobile: 0412 345 678',
    'Fax: 02-9876-5432',
]

for phone_text in phones:
    # Remove all non-digit characters except leading country code
    digits_only = re.sub(r'[^\d]', '', re.search(r'[\d\s\-()]+', phone_text).group())
    print(f'{phone_text:30} -> {digits_only}')

# Redact sensitive data: replace card numbers
text = 'Card: 4532-1234-5678-9012, expires 04/28'
redacted = re.sub(r'\d{4}-\d{4}-\d{4}-\d{4}', '[REDACTED]', text)
print('\nRedacted:', redacted)

Output:

Call us: (02) 9876-5432        -> 0298765432
Mobile: 0412 345 678           -> 0412345678
Fax: 02-9876-5432              -> 0298765432

Redacted: Card: [REDACTED], expires 04/28
Regex substitution and replacement
re.sub replaces what re.search finds. Division of labor.

Named Groups and Compiled Patterns

For complex patterns you’ll reuse frequently, named capture groups make the code self-documenting. Instead of match.group(1), you write match.group('year'). Compiled patterns (re.compile()) also avoid re-parsing the pattern on every call, which is important in loops.

# named_groups.py
import re

# Compile a pattern with named groups
log_pattern = re.compile(
    r'(?P<level>DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+'
    r'(?P<date>\d{4}-\d{2}-\d{2})\s+'
    r'(?P<time>\d{2}:\d{2}:\d{2})\s+-\s+'
    r'(?P<message>.+)'
)

log_lines = [
    'INFO 2026-04-16 09:00:01 - Application started',
    'WARNING 2026-04-16 09:15:32 - High memory usage: 85%',
    'ERROR 2026-04-16 09:23:45 - Database connection failed',
]

for line in log_lines:
    m = log_pattern.match(line)
    if m:
        print(f"Level:   {m.group('level')}")
        print(f"Time:    {m.group('date')} {m.group('time')}")
        print(f"Message: {m.group('message')}")
        print()

Output:

Level:   INFO
Time:    2026-04-16 09:00:01
Message: Application started

Level:   WARNING
Time:    2026-04-16 09:15:32
Message: High memory usage: 85%

Level:   ERROR
Time:    2026-04-16 09:23:45
Message: Database connection failed

Named groups use the syntax (?P<name>pattern). The ?P<name> is Python-specific regex syntax (the P stands for “Python extension”). You can also access named groups as a dict via match.groupdict(), which returns {'level': 'INFO', 'date': '2026-04-16', ...} — very useful for feeding parsed log data into data structures.

Real-Life Example: Server Log Analyzer

Validating data with regex
When your input data has trust issues, regex is the bouncer.

Here’s a complete log file analyzer that parses Apache/nginx-style access logs, extracts metrics, and generates a summary report.

# log_analyzer.py
import re
from collections import Counter, defaultdict

# Sample nginx-style access log data
ACCESS_LOG = """
192.168.1.10 - - [16/Apr/2026:09:00:01 +0000] "GET /index.html HTTP/1.1" 200 1234
10.0.0.5 - - [16/Apr/2026:09:00:05 +0000] "GET /api/users HTTP/1.1" 200 892
192.168.1.10 - - [16/Apr/2026:09:01:12 +0000] "POST /api/login HTTP/1.1" 401 145
10.0.0.7 - - [16/Apr/2026:09:02:30 +0000] "GET /images/logo.png HTTP/1.1" 200 45678
192.168.1.25 - - [16/Apr/2026:09:03:11 +0000] "GET /api/data HTTP/1.1" 500 312
10.0.0.5 - - [16/Apr/2026:09:04:00 +0000] "GET /api/users/42 HTTP/1.1" 200 456
192.168.1.10 - - [16/Apr/2026:09:05:22 +0000] "DELETE /api/users/42 HTTP/1.1" 403 88
10.0.0.7 - - [16/Apr/2026:09:06:45 +0000] "GET /api/data HTTP/1.1" 200 789
""".strip()

# Compile the access log pattern
LOG_PATTERN = re.compile(
    r'(?P<ip>\S+) \S+ \S+ \[(?P<datetime>[^\]]+)\] '
    r'"(?P<method>\S+) (?P<path>\S+) HTTP/[\d.]+" '
    r'(?P<status>\d{3}) (?P<bytes>\d+)'
)

def analyze_logs(log_text):
    """Parse log entries and return summary statistics."""
    ip_counter = Counter()
    status_counter = Counter()
    path_counter = Counter()
    method_counter = Counter()
    total_bytes = 0
    errors = []

    for line in log_text.splitlines():
        line = line.strip()
        if not line:
            continue

        m = LOG_PATTERN.match(line)
        if not m:
            errors.append(f'Could not parse: {line}')
            continue

        ip_counter[m.group('ip')] += 1
        status_counter[m.group('status')] += 1
        path_counter[m.group('path')] += 1
        method_counter[m.group('method')] += 1
        total_bytes += int(m.group('bytes'))

    return {
        'total_requests': sum(ip_counter.values()),
        'unique_ips': len(ip_counter),
        'top_ips': ip_counter.most_common(3),
        'status_codes': dict(sorted(status_counter.items())),
        'top_paths': path_counter.most_common(3),
        'methods': dict(method_counter),
        'total_bytes': total_bytes,
        'parse_errors': errors
    }

stats = analyze_logs(ACCESS_LOG)

print(f'Total Requests:  {stats["total_requests"]}')
print(f'Unique IPs:      {stats["unique_ips"]}')
print(f'Total Data:      {stats["total_bytes"] / 1024:.1f} KB')
print(f'\nStatus Codes:')
for code, count in stats['status_codes'].items():
    label = 'OK' if code.startswith('2') else 'ERR' if code.startswith('5') else ''
    print(f'  {code}: {count:3}  {label}')
print(f'\nTop IPs:')
for ip, count in stats['top_ips']:
    print(f'  {ip:15} {count} requests')
print(f'\nHTTP Methods: {stats["methods"]}')
if stats['parse_errors']:
    print(f'\nParse errors: {len(stats["parse_errors"])}')

Output:

Total Requests:  8
Unique IPs:      3
Total Data:      47.5 KB

Status Codes:
  200: 5  OK
  401: 1
  403: 1
  500: 1  ERR

Top IPs:
  192.168.1.10    3 requests
  10.0.0.5        2 requests
  10.0.0.7        2 requests

HTTP Methods: {'GET': 6, 'POST': 1, 'DELETE': 1}

The compiled LOG_PATTERN with named groups is the heart of this analyzer — it extracts all seven fields from each log line in a single match() call. Calling re.compile() once outside the loop means the pattern is parsed only once, which matters when processing millions of log lines.

Frequently Asked Questions

What is greedy vs non-greedy matching?

By default, quantifiers like + and * are greedy — they match as much text as possible. re.search(r'<.+>', '<b>text</b>') matches the entire string <b>text</b>, not just <b>. Add ? after the quantifier to make it non-greedy (lazy): r'<.+?>' matches <b> and stops. Use non-greedy quantifiers when you want the shortest possible match between two delimiters.

How do I match across multiple lines?

By default, . doesn’t match newlines. Pass re.DOTALL as a flag to make . match any character including newlines: re.search(r'START.+END', text, re.DOTALL). For patterns where ^ and $ should match line boundaries (not just string boundaries), use re.MULTILINE. Both flags can be combined: re.DOTALL | re.MULTILINE.

When should I use re.compile()?

Use re.compile() when you’re calling the same pattern multiple times — in a loop, or in a function that’s called repeatedly. Compiled patterns cache the parsed regex, avoiding redundant work. For one-off searches in simple scripts, the module-level functions (re.search(), re.findall(), etc.) are fine — they also cache internally under the hood.

How do I match literal special characters like dot or parenthesis?

Escape them with a backslash: \. matches a literal dot (not the “any character” wildcard), \( matches a literal opening parenthesis. In raw strings, that’s r'\.' and r'\('. Common characters that need escaping: . ^ $ * + ? { } [ ] \ | ( ). Use re.escape(your_string) to automatically escape all special characters in a variable you want to match literally.

My regex is running slowly. What can I do?

Several patterns cause catastrophic backtracking: nested quantifiers like (a+)+, alternations with overlapping patterns, or very long strings with no match. Solutions: compile the pattern once with re.compile(), use anchors (^ and $) to limit where Python searches, make quantifiers as specific as possible (use [a-z]+ instead of .+ when you know the character set), and test with tools like regex101.com which shows match steps and warnings about slow patterns.

re.fullmatch beats re.match. Almost always.
re.fullmatch beats re.match. Almost always.

Conclusion

Python’s re module gives you a full regex engine for any text processing challenge. We covered the core syntax (character classes, quantifiers, anchors, groups), the five main functions (match, search, findall, sub, split), named capture groups for readable code, compiled patterns for performance, greedy vs non-greedy matching, and a complete server log analyzer. Regular expressions have a reputation for being hard to read, but well-named groups and small, focused patterns keep them maintainable.

Extend the log analyzer to write hourly request rate breakdowns, flag IPs that generate more than 10 errors per hour, or parse a different log format by updating only the compiled pattern. The (?P<name>) named group system makes updating patterns clean because the code downstream references groups by name, not by index.

For the full syntax reference, flag descriptions, and advanced features like conditional matching, see the official re module documentation. The interactive regex101.com is invaluable for testing and debugging patterns.

How To Read and Write CSV Files in Python

How To Read and Write CSV Files in Python

Last Updated: June 01, 2026

Beginner

CSV (Comma-Separated Values) files are the most universal format for tabular data. Excel exports CSV. Databases export CSV. Every data analytics tool imports CSV. When a colleague sends you “the data,” there’s a good chance it’s a .csv file. If you work with spreadsheets, databases, or any form of tabular data, you’ll read and write CSV files all the time.

Python’s built-in csv module handles CSV reading and writing cleanly. It manages quoting, delimiters, line endings, and encoding edge cases that would break a naive split(',') approach — like fields that contain commas inside quotes, or newlines inside values. Just import csv and you’re ready.

In this article we’ll cover reading with csv.reader and csv.DictReader, writing with csv.writer and csv.DictWriter, handling different delimiters and encodings, dealing with real-world CSV quirks, filtering and transforming CSV data, and a complete sales report generator as a practical example. By the end, you’ll be fluent with CSV handling in Python for both simple and complex files.

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 →

Reading CSV in Python: Quick Example

Let’s read a CSV file and print its contents in three lines of code:

# quick_csv.py
import csv

# Create a sample CSV file to read
with open('people.csv', 'w', newline='', encoding='utf-8') as f:
    writer = csv.writer(f)
    writer.writerow(['Name', 'Age', 'City'])
    writer.writerow(['Alice', '30', 'Sydney'])
    writer.writerow(['Bob', '25', 'Melbourne'])
    writer.writerow(['Charlie', '35', 'Brisbane'])

# Read and print it
with open('people.csv', 'r', encoding='utf-8') as f:
    reader = csv.reader(f)
    for row in reader:
        print(row)

Output:

['Name', 'Age', 'City']
['Alice', '30', 'Sydney']
['Bob', '25', 'Melbourne']
['Charlie', '35', 'Brisbane']

Note the newline='' argument when opening files for writing with the csv module — this is required on Windows to prevent extra blank lines between rows. The csv.reader returns each row as a list of strings. We’ll look at csv.DictReader shortly, which gives you named fields as dicts instead of positional lists.

What Is CSV and Why Use Python’s csv Module?

A CSV file stores tabular data as plain text with values separated by a delimiter (usually a comma, but sometimes a tab, semicolon, or pipe). The first row is typically a header row with column names. While it looks simple, CSV has many edge cases that a naive line.split(',') approach gets wrong.

CSV Edge CaseExampleWhat Breaks split(‘,’)
Field contains comma"Smith, John",30Splits in the wrong place
Field contains newline"line1\nline2",30Breaks row detection
Field contains quotes"say ""hello""",30Escaping ignored
Tab-separated valuesAlice\t30\tSydneyDelimiter mismatch
Different encodingsAccented chars in latin-1UnicodeDecodeError

Python’s csv module handles all of these correctly. It follows RFC 4180 (the CSV standard) by default and lets you configure delimiters, quoting, and line terminators through the dialect system.

Reading CSV data in Python
Commas separate your data. Misplaced quotes separate you from your sanity.

Reading CSV with DictReader

csv.DictReader is the preferred way to read CSV files for most use cases. It reads the header row and returns each subsequent row as an OrderedDict (or regular dict in Python 3.8+) with column names as keys. No more remembering which index is which.

# dict_reader.py
import csv

# Create a sample CSV with product data
csv_data = """id,product,price,stock,category
1,Python Handbook,29.99,150,Books
2,USB-C Hub,49.99,75,Electronics
3,Mechanical Keyboard,89.99,40,Electronics
4,Standing Desk Pad,24.99,200,Office
5,Monitor Light,35.00,90,Electronics
"""

with open('products.csv', 'w', newline='', encoding='utf-8') as f:
    f.write(csv_data)

# Read with DictReader
with open('products.csv', 'r', encoding='utf-8') as f:
    reader = csv.DictReader(f)
    products = list(reader)

print(f'Loaded {len(products)} products\n')

# Access by column name
for p in products:
    name = p['product']
    price = float(p['price'])
    stock = int(p['stock'])
    print(f'  {name}: ${price:.2f} ({stock} in stock)')

# Filter: electronics under $60
print('\nElectronics under $60:')
electronics = [p for p in products
               if p['category'] == 'Electronics' and float(p['price']) < 60]
for p in electronics:
    print(f'  {p["product"]} - ${p["price"]}')

Output:

Loaded 5 products

  Python Handbook: $29.99 (150 in stock)
  USB-C Hub: $49.99 (75 in stock)
  Mechanical Keyboard: $89.99 (40 in stock)
  Standing Desk Pad: $24.99 (200 in stock)
  Monitor Light: $35.00 (90 in stock)

Electronics under $60:
  USB-C Hub - $49.99
  Monitor Light - $35.00

Remember that all values from DictReader are strings -- use int() or float() to convert numeric fields before arithmetic. A common defensive pattern: wrap conversions in try/except or use a helper like safe_float = lambda x: float(x) if x else 0.0 to handle empty or malformed fields.

Writing CSV with DictWriter

csv.DictWriter lets you write dicts to CSV rows without tracking column order manually. You specify the field names once and then write rows as dicts.

# dict_writer.py
import csv
from datetime import date

# Data to write
orders = [
    {'order_id': 'ORD-001', 'customer': 'Alice', 'amount': 149.97, 'date': '2026-04-16', 'status': 'shipped'},
    {'order_id': 'ORD-002', 'customer': 'Bob', 'amount': 89.99, 'date': '2026-04-16', 'status': 'pending'},
    {'order_id': 'ORD-003', 'customer': 'Charlie', 'amount': 24.99, 'date': '2026-04-15', 'status': 'delivered'},
]

fieldnames = ['order_id', 'customer', 'amount', 'date', 'status']

with open('orders.csv', 'w', newline='', encoding='utf-8') as f:
    writer = csv.DictWriter(f, fieldnames=fieldnames)
    writer.writeheader()         # Write the header row
    writer.writerows(orders)     # Write all rows at once

print('Written to orders.csv')

# Verify: read it back
with open('orders.csv', 'r', encoding='utf-8') as f:
    print(f.read())

Output:

Written to orders.csv
order_id,customer,amount,date,status
ORD-001,Alice,149.97,2026-04-16,shipped
ORD-002,Bob,89.99,2026-04-16,pending
ORD-003,Charlie,24.99,2026-04-15,delivered

writer.writeheader() writes the field names as the first row. writer.writerows(orders) writes all rows in one call, which is more efficient than looping and calling writer.writerow() for each item. If a dict has extra keys not in fieldnames, DictWriter ignores them by default (or raises an error with extrasaction='raise').

Writing structured CSV with DictWriter
DictWriter gives your rows names so you dont have to count columns.

Handling Different Delimiters and Encodings

Not all "CSV" files use commas. Tab-separated files (.tsv) are common in bioinformatics. Semicolon-separated files appear frequently in European locales (where commas are decimal separators). The delimiter parameter handles all of these.

# custom_delimiter.py
import csv

# Write a tab-separated file
tsv_data = [
    ['gene_id', 'chromosome', 'start', 'end', 'strand'],
    ['BRCA1', 'chr17', '43044295', '43125482', '-'],
    ['TP53', 'chr17', '7661779', '7687538', '-'],
    ['EGFR', 'chr7', '55086725', '55275031', '+'],
]

with open('genes.tsv', 'w', newline='', encoding='utf-8') as f:
    writer = csv.writer(f, delimiter='\t')
    writer.writerows(tsv_data)

# Read it back
with open('genes.tsv', 'r', encoding='utf-8') as f:
    reader = csv.reader(f, delimiter='\t')
    for row in reader:
        print(' | '.join(f'{col:15}' for col in row))

Output:

gene_id         | chromosome     | start          | end            | strand
BRCA1           | chr17          | 43044295       | 43125482       | -
TP53            | chr17          | 7661779        | 7687538        | -
EGFR            | chr7           | 55086725       | 55275031       | +

For Windows-generated CSV files, you may encounter cp1252 encoding instead of UTF-8. If you get a UnicodeDecodeError, try encoding='cp1252' or encoding='latin-1'. For files where you don't know the encoding, the chardet library (install via pip) can detect it automatically.

Real-Life Example: Monthly Sales Report Generator

Analyzing CSV data
csv.reader handles the parsing. You handle the business logic.

Here's a complete script that reads a raw sales CSV, filters and aggregates the data, and writes a formatted monthly summary report.

# sales_report.py
import csv
from collections import defaultdict
from datetime import datetime

# Create sample sales data
raw_sales = [
    ['date', 'product', 'category', 'quantity', 'unit_price', 'region'],
    ['2026-04-01', 'Python Handbook', 'Books', '3', '29.99', 'NSW'],
    ['2026-04-01', 'USB-C Hub', 'Electronics', '2', '49.99', 'VIC'],
    ['2026-04-02', 'Standing Desk Pad', 'Office', '5', '24.99', 'QLD'],
    ['2026-04-03', 'Python Handbook', 'Books', '1', '29.99', 'NSW'],
    ['2026-04-03', 'Monitor Light', 'Electronics', '4', '35.00', 'VIC'],
    ['2026-04-04', 'Mechanical Keyboard', 'Electronics', '2', '89.99', 'NSW'],
    ['2026-04-05', 'USB-C Hub', 'Electronics', '3', '49.99', 'WA'],
    ['2026-04-06', 'Python Handbook', 'Books', '2', '29.99', 'VIC'],
]

with open('sales_raw.csv', 'w', newline='', encoding='utf-8') as f:
    csv.writer(f).writerows(raw_sales)

# --- Process the data ---
category_totals = defaultdict(float)
region_totals = defaultdict(float)
product_units = defaultdict(int)
grand_total = 0.0

with open('sales_raw.csv', 'r', encoding='utf-8') as f:
    reader = csv.DictReader(f)
    for row in reader:
        try:
            qty = int(row['quantity'])
            price = float(row['unit_price'])
            revenue = qty * price
        except (ValueError, KeyError) as e:
            print(f'Skipping malformed row: {row} | Error: {e}')
            continue

        category_totals[row['category']] += revenue
        region_totals[row['region']] += revenue
        product_units[row['product']] += qty
        grand_total += revenue

# --- Write the summary report ---
report_rows = []
report_rows.append(['=== MONTHLY SALES REPORT: April 2026 ===', '', ''])
report_rows.append(['', '', ''])

report_rows.append(['REVENUE BY CATEGORY', '', ''])
report_rows.append(['Category', 'Revenue', '% of Total'])
for cat, rev in sorted(category_totals.items(), key=lambda x: -x[1]):
    pct = rev / grand_total * 100
    report_rows.append([cat, f'${rev:.2f}', f'{pct:.1f}%'])

report_rows.append(['', '', ''])
report_rows.append(['REVENUE BY REGION', '', ''])
report_rows.append(['Region', 'Revenue', '% of Total'])
for region, rev in sorted(region_totals.items(), key=lambda x: -x[1]):
    pct = rev / grand_total * 100
    report_rows.append([region, f'${rev:.2f}', f'{pct:.1f}%'])

report_rows.append(['', '', ''])
report_rows.append([f'GRAND TOTAL', f'${grand_total:.2f}', '100.0%'])

with open('sales_report.csv', 'w', newline='', encoding='utf-8') as f:
    csv.writer(f).writerows(report_rows)

# Print summary to console
print('Sales Report Generated\n')
print('Revenue by Category:')
for cat, rev in sorted(category_totals.items(), key=lambda x: -x[1]):
    print(f'  {cat:20} ${rev:8.2f}')
print(f'\n  {"TOTAL":20} ${grand_total:8.2f}')
print('\nReport written to sales_report.csv')

Output:

Sales Report Generated

Revenue by Category:
  Electronics          $529.90
  Books                $179.94
  Office               $124.95

  TOTAL                $834.79

Report written to sales_report.csv

This script demonstrates the complete CSV pipeline: reading raw data row by row using DictReader, aggregating with defaultdict, handling malformed rows gracefully, and writing a structured multi-section report using csv.writer. The same pattern scales to millions of rows with minimal modification.

Frequently Asked Questions

Why does my CSV file have blank lines between rows on Windows?

This happens when you open the file without newline=''. Without it, Python's universal newline handling adds an extra \r\n, and the csv module adds another, resulting in double line endings. Always use open('file.csv', 'w', newline='', encoding='utf-8') when writing CSV files to prevent this issue.

My CSV file has garbled characters. What's wrong?

The file was probably saved with a different encoding -- most commonly Windows' cp1252 or latin-1. Try encoding='cp1252' or encoding='latin-1' when opening the file. Excel in particular often exports as cp1252 rather than UTF-8. If you're not sure, install the chardet library and run chardet.detect(open('file.csv', 'rb').read()) to detect the encoding automatically.

How do I handle a very large CSV file without running out of memory?

The csv.reader and csv.DictReader objects are iterators -- they read one row at a time, so they don't load the entire file into memory. Don't call list(reader) on a huge file; instead, process rows in a for loop. For multi-gigabyte files, this approach uses only a few KB of memory regardless of file size.

How do I handle fields that contain commas or newlines?

The csv module handles this automatically. When you write a field that contains a comma, quote, or newline, the writer automatically wraps it in double quotes. When you read it back, the reader correctly identifies the entire quoted field as a single value. You don't need to do any manual escaping -- just let the csv module handle it.

Should I use the csv module or pandas for CSV?

For simple reading/writing and lightweight processing, the built-in csv module is perfect -- no dependencies, fast startup, and minimal memory overhead. For heavy data manipulation (filtering, grouping, joining, sorting large datasets), pandas is faster and more expressive. The csv module is the right choice for scripts that need to run on any machine without installing dependencies.

Conclusion

Python's csv module is a reliable, production-ready tool for all CSV work. We covered csv.reader and csv.writer for row-level access, csv.DictReader and csv.DictWriter for named-field access, handling custom delimiters like tabs and semicolons, always using newline='' on Windows, encoding issues and how to debug them, defensive parsing with try/except, and a complete aggregation-based sales report generator. The csv module handles all the quoting and escaping edge cases that would trip up a naive split-based approach.

Try extending the sales report example to generate a pivot table by region and category, or add a chart using matplotlib based on the aggregated data. CSV processing and data visualization are a natural combination.

For the complete API including dialect configuration and custom quoting behavior, see the official csv module documentation.

How To Read and Write JSON Files in Python

How To Read and Write JSON Files in Python

Last Updated: June 01, 2026

Beginner

JSON (JavaScript Object Notation) is the universal language of data exchange on the web. REST APIs return JSON. Configuration files are written in JSON. NoSQL databases store JSON. When you fetch data from any web service — weather APIs, payment processors, social media platforms — you’re almost certainly receiving JSON. Knowing how to read and write JSON in Python is one of the most practical skills you can have.

Python makes JSON handling easy with its built-in json module. You can parse a JSON string into a Python dictionary with one function call, and serialize Python data back to JSON with another. No installation required — import json is all you need. The module handles the translation between Python types (dicts, lists, strings, numbers, booleans) and their JSON equivalents automatically.

In this article we’ll cover reading JSON from strings and files, writing JSON to strings and files, pretty-printing, handling nested structures, working with real API data, customizing serialization for Python objects, and error handling. By the end, you’ll be comfortable parsing any JSON structure you encounter and serializing your Python data to clean, readable JSON output.

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 →

Reading JSON in Python: Quick Example

Here’s how to parse a JSON string and work with the resulting Python data in just a few lines:

# quick_json.py
import json

json_string = '{"name": "Alice", "age": 30, "languages": ["Python", "SQL"]}'

# Parse JSON string -> Python dict
data = json.loads(json_string)

print(type(data))           # 
print(data['name'])         # Alice
print(data['languages'])    # ['Python', 'SQL']

# Serialize Python dict -> JSON string
output = json.dumps(data, indent=2)
print(output)

Output:

<class 'dict'>
Alice
['Python', 'SQL']
{
  "name": "Alice",
  "age": 30,
  "languages": [
    "Python",
    "SQL"
  ]
}

json.loads() parses a JSON string (the “s” stands for “string”), while json.dumps() serializes to a string. The indent=2 argument to dumps() pretty-prints with 2-space indentation. For reading and writing files directly, use json.load() and json.dump() (without the “s”).

What Is JSON and How Does It Map to Python?

JSON is a text-based data format derived from JavaScript object syntax. It stores data as key-value pairs (objects), ordered lists (arrays), strings, numbers, booleans, and null. Python’s json module automatically converts between JSON types and Python types.

JSON TypePython TypeExample
objectdict{"key": "value"}
arraylist[1, 2, 3]
stringstr"hello"
number (int)int42
number (float)float3.14
true / falseTrue / Falsetrue
nullNonenull

One important difference: JSON only supports string keys in objects, while Python dicts can have any hashable key. When you serialize a Python dict with integer keys, the json module automatically converts them to strings. Keep this in mind when working with round-trip serialization.

Loading JSON data in Python
json.loads turns a string into a dictionary. json.dumps does the reverse. Pick the right one.

Reading JSON from a File

Reading JSON from a file is extremely common — configuration files, data exports, and API response caches are often stored as JSON files. Use json.load() (no “s”) with a file object.

# read_json_file.py
import json

# First, create a sample JSON file to read
sample_data = {
    "app": "MyApp",
    "version": "2.1.0",
    "database": {
        "host": "localhost",
        "port": 5432,
        "name": "myapp_db"
    },
    "features": ["auth", "notifications", "analytics"]
}

with open('config.json', 'w', encoding='utf-8') as f:
    json.dump(sample_data, f, indent=2)

# Now read it back
with open('config.json', 'r', encoding='utf-8') as f:
    config = json.load(f)

print('App:', config['app'])
print('DB host:', config['database']['host'])
print('DB port:', config['database']['port'])
print('Features:', ', '.join(config['features']))

Output:

App: MyApp
DB host: localhost
DB port: 5432
Features: auth, notifications, analytics

Always open files with encoding='utf-8' — JSON is defined as UTF-8 by default and many JSON files use Unicode characters. The with statement ensures the file is properly closed even if an error occurs during parsing.

Writing JSON to a File

Serializing Python data to a JSON file is just as straightforward. The json.dump() function writes directly to a file object, which is more efficient than creating a string with json.dumps() and then writing it.

# write_json_file.py
import json
from datetime import date

# Python data to serialize
user_data = {
    "users": [
        {"id": 1, "name": "Alice", "active": True, "score": 98.5},
        {"id": 2, "name": "Bob", "active": False, "score": 72.0},
        {"id": 3, "name": "Charlie", "active": True, "score": 85.25},
    ],
    "total": 3,
    "generated": "2026-04-16"
}

# Write with pretty printing and sorted keys
with open('users.json', 'w', encoding='utf-8') as f:
    json.dump(user_data, f, indent=2, sort_keys=True)

print('Written to users.json')

# Verify by reading it back
with open('users.json', 'r', encoding='utf-8') as f:
    content = f.read()

print(content[:300])

Output:

Written to users.json
{
  "generated": "2026-04-16",
  "total": 3,
  "users": [
    {
      "active": true,
      "id": 1,
      "name": "Alice",
      "score": 98.5
    },
    ...
  ]
}

The sort_keys=True option outputs keys in alphabetical order, which makes JSON diffs much cleaner in version control — you won’t see spurious changes just because Python iterated dict keys in a different order. Use it for any JSON file that will be committed to a git repository.

Exchanging JSON via APIs
JSON is the universal language of APIs. Speak it fluently.

Working with Real API Data

The most common use of JSON in Python is parsing data from REST APIs. Here’s how to fetch and parse real JSON data from a public practice API:

# api_json.py
import json
import urllib.request

# Fetch a list of users from JSONPlaceholder (a free practice REST API)
url = 'https://jsonplaceholder.typicode.com/users'

with urllib.request.urlopen(url) as response:
    raw = response.read().decode('utf-8')

users = json.loads(raw)

print(f'Fetched {len(users)} users\n')

for user in users[:3]:  # Show first 3 users
    name = user.get('name', 'Unknown')
    email = user.get('email', 'Unknown')
    city = user.get('address', {}).get('city', 'Unknown')
    company = user.get('company', {}).get('name', 'Unknown')
    print(f'{name} | {email} | {city} | {company}')

Output:

Fetched 10 users

Leanne Graham | Sincere@april.biz | Gwenborough | Romaguera-Crona
Ervin Howell | Shanna@melissa.tv | Wisokyburgh | Deckow-Crist
Clementine Bauch | Nathan@yesenia.net | McKenziehaven | Romaguera-Jacobson

The .get('key', default) pattern is defensive JSON parsing — it returns the default value if the key is missing rather than raising a KeyError. For nested structures like address.city, chain the .get() calls: user.get('address', {}).get('city', 'Unknown'). If 'address' is missing, the inner .get() runs on an empty dict and safely returns 'Unknown' instead of crashing.

Navigating Nested JSON

Real-world API responses are often deeply nested. Here’s how to extract data from a complex nested structure safely:

# nested_json.py
import json

# Simulate a complex API response
response_text = '''
{
  "status": "success",
  "data": {
    "post": {
      "id": 42,
      "title": "Understanding Python JSON",
      "author": {"id": 7, "name": "Sam Dev"},
      "tags": ["python", "json", "tutorial"],
      "stats": {"views": 1250, "likes": 87, "comments": 14}
    }
  }
}
'''

data = json.loads(response_text)

# Defensive nested access
post = data.get('data', {}).get('post', {})
title = post.get('title', 'Unknown')
author_name = post.get('author', {}).get('name', 'Unknown')
views = post.get('stats', {}).get('views', 0)
tags = post.get('tags', [])

print(f'Title: {title}')
print(f'Author: {author_name}')
print(f'Views: {views:,}')
print(f'Tags: {", ".join(tags)}')

Output:

Title: Understanding Python JSON
Author: Sam Dev
Views: 1,250
Tags: python, json, tutorial

The chained .get() approach is much safer than writing data['data']['post']['title'] — any missing key in the chain would raise a KeyError and crash your script. With .get(), you control the default at every level.

Custom Serialization for Python Objects

The json module can’t serialize Python objects like datetime by default — they’re not JSON-native types. You have two options: use a custom encoder class or use the default parameter of json.dumps().

# custom_json.py
import json
from datetime import datetime, date

# Option 1: default function for simple cases
def json_default(obj):
    if isinstance(obj, (datetime, date)):
        return obj.isoformat()
    raise TypeError(f'Object of type {type(obj).__name__} is not JSON serializable')

data = {
    'event': 'User signup',
    'timestamp': datetime(2026, 4, 16, 9, 30, 0),
    'date_only': date(2026, 4, 16),
    'user_id': 123
}

result = json.dumps(data, default=json_default, indent=2)
print(result)

Output:

{
  "event": "User signup",
  "timestamp": "2026-04-16T09:30:00",
  "date_only": "2026-04-16",
  "user_id": 123
}

The default function is called whenever json.dumps() encounters an object it can’t serialize natively. Return a JSON-serializable value (a string, number, list, or dict), and json.dumps() will use it in place of the original object. The ISO 8601 format for datetime strings (2026-04-16T09:30:00) is the widely-accepted standard.

Handling JSON Errors

JSON parsing fails when the input is malformed. Always wrap json.loads() in a try/except when dealing with data from external sources.

# json_errors.py
import json

def safe_parse(json_string):
    """Parse JSON safely, returning None on failure."""
    try:
        return json.loads(json_string)
    except json.JSONDecodeError as e:
        print(f'JSON parse error at line {e.lineno}, col {e.colno}: {e.msg}')
        print(f'Bad input: {json_string[:100]}')
        return None

# Valid JSON
result = safe_parse('{"name": "Alice", "age": 30}')
print('Valid:', result)

# Invalid JSON (missing closing brace)
result2 = safe_parse('{"name": "Bob"')
print('Invalid:', result2)

# Invalid JSON (trailing comma -- not valid in JSON)
result3 = safe_parse('{"key": "value",}')
print('Trailing comma:', result3)

Output:

Valid: {'name': 'Alice', 'age': 30}
JSON parse error at line 1, col 15: Expecting property name enclosed in double quotes
Bad input: {"name": "Bob"
Invalid: None
JSON parse error at line 1, col 17: Expecting property name enclosed in double quotes
Bad input: {"key": "value",}
Trailing comma: None

json.JSONDecodeError is a subclass of ValueError and carries the line number, column, and a descriptive message about what went wrong. Always check for this error when parsing API responses, user-provided input, or files from external sources — any of these can contain malformed JSON.

Real-Life Example: JSON Config Manager

JSON configuration files
Config files in JSON keep your settings out of your code.

Here’s a complete configuration manager that reads a JSON config file, applies defaults for missing keys, validates required fields, and writes updated config back to disk.

# config_manager.py
import json
import os

DEFAULTS = {
    "debug": False,
    "log_level": "INFO",
    "database": {
        "host": "localhost",
        "port": 5432,
        "pool_size": 5
    },
    "cache": {
        "enabled": True,
        "ttl_seconds": 300
    }
}

REQUIRED_KEYS = ["database.host", "database.port"]

def deep_merge(base, override):
    """Merge override into base dict recursively."""
    result = base.copy()
    for key, val in override.items():
        if key in result and isinstance(result[key], dict) and isinstance(val, dict):
            result[key] = deep_merge(result[key], val)
        else:
            result[key] = val
    return result

def get_nested(d, dotted_key, default=None):
    """Access nested dict value using dot notation."""
    keys = dotted_key.split('.')
    for key in keys:
        if not isinstance(d, dict) or key not in d:
            return default
        d = d[key]
    return d

def load_config(config_path):
    """Load config from file, merging with defaults."""
    if os.path.exists(config_path):
        try:
            with open(config_path, 'r', encoding='utf-8') as f:
                user_config = json.load(f)
        except json.JSONDecodeError as e:
            print(f'Error reading config: {e}')
            user_config = {}
    else:
        print(f'Config file not found at {config_path}, using defaults')
        user_config = {}

    config = deep_merge(DEFAULTS, user_config)

    # Validate required keys
    missing = [k for k in REQUIRED_KEYS if get_nested(config, k) is None]
    if missing:
        raise ValueError(f'Missing required config keys: {missing}')

    return config

def save_config(config, config_path):
    """Write config to JSON file."""
    with open(config_path, 'w', encoding='utf-8') as f:
        json.dump(config, f, indent=2, sort_keys=True)
    print(f'Config saved to {config_path}')

# Demo
config = load_config('app_config.json')
config['debug'] = True
config['database']['pool_size'] = 10
save_config(config, 'app_config.json')

print(f"Debug mode: {config['debug']}")
print(f"DB pool size: {config['database']['pool_size']}")
print(f"Cache TTL: {config['cache']['ttl_seconds']}s")

Output:

Config file not found at app_config.json, using defaults
Config saved to app_config.json
Debug mode: True
DB pool size: 10
Cache TTL: 300s

The deep_merge() function recursively merges user settings into defaults, so users only need to specify the keys they want to override. The dot-notation accessor get_nested() makes validation and access of nested keys clean and readable. This pattern is used in virtually every production application that uses a JSON config file.

Frequently Asked Questions

What is the difference between json.loads and json.load?

json.loads() parses a JSON string (the “s” = string). json.load() reads from a file object. Similarly, json.dumps() serializes to a string and json.dump() writes to a file. This naming convention is consistent across Python’s standard library (e.g., pickle.loads/pickle.load follows the same pattern).

How do I pretty-print JSON?

Use json.dumps(data, indent=2) for 2-space indentation or indent=4 for 4-space. Add sort_keys=True to sort keys alphabetically. From the command line, you can pretty-print a JSON file with: python -m json.tool myfile.json. This is built into Python and works on any valid JSON file.

How does Python handle Unicode in JSON?

By default, json.dumps() escapes non-ASCII characters as \uXXXX escape sequences. To output them directly as Unicode (which is valid JSON and more readable), use json.dumps(data, ensure_ascii=False). Always open JSON files with encoding='utf-8' to handle any Unicode content correctly.

Why can’t I serialize datetime objects?

The JSON spec only defines six data types: object, array, string, number, boolean, and null. Python’s datetime doesn’t map to any of these, so the json module raises TypeError. The idiomatic solution is to convert to ISO 8601 strings using dt.isoformat(). Pass a default function to json.dumps() that handles these conversions, as shown in the custom serialization section above.

How do I handle very large JSON files efficiently?

For JSON files too large to load into memory at once, use the ijson library (install via pip install ijson) for streaming incremental JSON parsing. It parses the file as it reads, yielding items one at a time. The standard json module always loads the entire file into memory — fine for files up to hundreds of MB, but not for multi-GB JSON datasets.

Conclusion

Python’s json module makes JSON handling simple and reliable. We covered json.loads()/json.load() for parsing, json.dumps()/json.dump() for serialization, pretty-printing with indent and sort_keys, defensive nested access with chained .get(), parsing real API responses, custom serialization for datetime objects, and robust error handling with json.JSONDecodeError. JSON fluency is an essential Python skill — you’ll use it in almost every project that touches the internet or stores configuration.

Try extending the config manager to support environment variable overrides (keys from os.environ take precedence over the file) or to validate values against a schema using jsonschema (available via pip). Both are common patterns in production-grade config management.

For the full API reference and additional encoder/decoder customization options, see the official json module documentation.

How To Use the Python logging Module for Application Logging

How To Use the Python logging Module for Application Logging

Last Updated: June 01, 2026

Intermediate

Every production Python application needs logging. Not print() statements that vanish when your script closes — real, structured logs with timestamps, severity levels, file rotation, and the ability to turn verbosity up or down without touching your code. When something goes wrong at 2am on a production server, your log file is the only witness. If all you left behind are a few print("here") calls, you’re debugging blind.

Python ships with a powerful, flexible logging module in its standard library. It’s built around a hierarchy of loggers, handlers, and formatters that you configure once and use everywhere. The learning curve is a bit steeper than print(), but the payoff — structured, timestamped, level-filtered, file-backed logs — is enormous. No third-party packages are required to get started.

In this article we’ll cover the five log levels, the basicConfig shortcut, named loggers and the logger hierarchy, handlers (console, file, rotating), formatters, logging from multiple modules, and a complete real-world logging setup for a data pipeline application. By the end, you’ll have a professional logging setup you can drop into any project.

Pubs - Python How To Program
Written by Pubs

Python developer and educator with 15+ years building production systems across data engineering, web APIs, and AI tooling. Founder of Python How To Program — 270+ in-depth tutorials covering the modern Python stack.

View all tutorials by Pubs →

Python Logging: Quick Example

Here’s the fastest way to get meaningful logging output with timestamps and levels:

# quick_logging.py
import logging

logging.basicConfig(
    level=logging.DEBUG,
    format='%(asctime)s - %(levelname)s - %(message)s',
    datefmt='%Y-%m-%d %H:%M:%S'
)

logging.debug('This is a debug message')
logging.info('Application started')
logging.warning('Disk space running low')
logging.error('Failed to connect to database')
logging.critical('Application cannot continue')

Output:

2026-04-16 09:00:01 - DEBUG - This is a debug message
2026-04-16 09:00:01 - INFO - Application started
2026-04-16 09:00:01 - WARNING - Disk space running low
2026-04-16 09:00:01 - ERROR - Failed to connect to database
2026-04-16 09:00:01 - CRITICAL - Application cannot continue

basicConfig() sets up the root logger — the single logging object all other loggers inherit from if not configured themselves. The format string uses special tokens like %(asctime)s, %(levelname)s, and %(message)s. In the sections below we’ll go beyond the root logger to set up named, per-module loggers and file handlers.

What Is the logging Module and Why Use It?

The logging module provides a standardized way to emit messages from your application at different severity levels. Unlike print(), log messages carry metadata (timestamp, level, logger name, file, line number), can be routed to multiple destinations simultaneously (console AND file), and can be filtered by level without changing any code.

LevelNumeric ValueWhen to Use
DEBUG10Detailed diagnostic info during development
INFO20Confirmation that things are working as expected
WARNING30Something unexpected happened, but the app continues
ERROR40A serious problem — part of the app couldn’t run
CRITICAL50A severe error — the app may not be able to continue

The level you set on a logger or handler acts as a filter: only messages at that level or higher are processed. Set to DEBUG in development to see everything; set to WARNING or ERROR in production to reduce noise. No code changes required — just a config change.

Understanding logging levels
Five logging levels. Choose wisely or drown in noise.

Named Loggers and the Logger Hierarchy

The best practice is to create a named logger for each module using __name__. This gives every log message a module-level identifier and lets you control logging granularity per module in large applications.

# named_logger.py
import logging

# Create a logger named after this module
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)

# Create a console handler with formatting
handler = logging.StreamHandler()
handler.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)

# Use the logger
logger.info('Module initialized')
logger.debug('Loading configuration from file')
logger.warning('Config file not found, using defaults')

Output:

2026-04-16 09:00:01 - __main__ - INFO - Module initialized
2026-04-16 09:00:01 - __main__ - DEBUG - Loading configuration from file
2026-04-16 09:00:01 - __main__ - WARNING - Config file not found, using defaults

Loggers form a hierarchy based on their names. A logger named myapp.database is a child of myapp, which is a child of the root logger. Messages propagate up the hierarchy by default — so configuring handlers on the root logger or a parent logger affects all children. This hierarchy is what makes it possible to configure logging once in your main module and have it work across all your imports.

Logging to a File

Writing logs to a file ensures you have a record of what happened, even after the terminal session closes. The FileHandler writes log messages to a file you specify.

# file_logging.py
import logging

logger = logging.getLogger('myapp')
logger.setLevel(logging.DEBUG)

# Console handler -- only show WARNING and above in the terminal
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.WARNING)
console_handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))

# File handler -- write everything DEBUG and above to a file
file_handler = logging.FileHandler('app.log', encoding='utf-8')
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(
    logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
)

logger.addHandler(console_handler)
logger.addHandler(file_handler)

# Now emit messages
logger.debug('Processing item 1')       # Only in file
logger.info('Item 1 processed OK')     # Only in file
logger.warning('Item 2 skipped')        # Console AND file
logger.error('Item 3 failed: timeout') # Console AND file

Terminal output:

WARNING: Item 2 skipped
ERROR: Item 3 failed: timeout

app.log contents:

2026-04-16 09:00:01 - myapp - DEBUG - Processing item 1
2026-04-16 09:00:01 - myapp - INFO - Item 1 processed OK
2026-04-16 09:00:01 - myapp - WARNING - Item 2 skipped
2026-04-16 09:00:01 - myapp - ERROR - Item 3 failed: timeout

This dual-handler pattern is extremely common in production: the console shows only what operators need to see in real time (warnings and errors), while the file captures the full diagnostic history for post-mortem debugging.

Configuring log handlers
Handlers decide where your logs go. Choose file, console, or both.

Rotating Log Files

Log files grow indefinitely if nothing manages them. The RotatingFileHandler automatically rotates log files when they hit a size limit, keeping a configurable number of backup files. The TimedRotatingFileHandler rotates on a schedule (daily, hourly, etc.).

# rotating_logs.py
import logging
from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler

logger = logging.getLogger('rotating_demo')
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')

# Rotate when file hits 1MB, keep 5 backups
size_handler = RotatingFileHandler(
    'app_size.log',
    maxBytes=1_000_000,   # 1 MB
    backupCount=5,
    encoding='utf-8'
)
size_handler.setFormatter(formatter)

# Rotate daily at midnight, keep 7 days of logs
time_handler = TimedRotatingFileHandler(
    'app_daily.log',
    when='midnight',
    interval=1,
    backupCount=7,
    encoding='utf-8'
)
time_handler.setFormatter(formatter)

logger.addHandler(size_handler)
logger.addHandler(time_handler)

for i in range(100):
    logger.info(f'Processing record {i}')

print('Logging complete. Check app_size.log and app_daily.log')

Output:

Logging complete. Check app_size.log and app_daily.log

When app_size.log reaches 1MB, it’s renamed to app_size.log.1, then app_size.log.2, and so on up to the backupCount. Older backups are deleted automatically. For long-running services like web servers or data pipelines, TimedRotatingFileHandler with when='midnight' and backupCount=30 gives you a month of daily logs with zero maintenance.

Logging Exceptions

One of the most valuable logging features is capturing full exception tracebacks. Use logger.exception() inside an except block — it logs the message at ERROR level and automatically appends the full traceback.

# exception_logging.py
import logging

logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

def divide(a, b):
    try:
        result = a / b
        logger.info(f'Divided {a} / {b} = {result}')
        return result
    except ZeroDivisionError:
        logger.exception(f'Failed to divide {a} by {b}')
        return None

divide(10, 2)
divide(10, 0)  # Will log the traceback

Output:

2026-04-16 09:00:01 - INFO - Divided 10 / 2 = 5.0
2026-04-16 09:00:01 - ERROR - Failed to divide 10 by 0
Traceback (most recent call last):
  File "exception_logging.py", line 8, in divide
    result = a / b
ZeroDivisionError: division by zero

logger.exception() is equivalent to logger.error(msg, exc_info=True). The traceback is automatically included — you don’t need to call traceback.format_exc() or format it yourself. This is the pattern every production application should use inside exception handlers.

Real-Life Example: Data Pipeline Logger

Monitoring log pipelines
Multiple loggers, multiple destinations, one clean pipeline.

Here’s a complete logging setup for a data pipeline that processes records from a source file, transforms them, and writes them to an output file — with full logging at every stage.

# data_pipeline.py
import logging
import logging.config
import json
import os
from logging.handlers import RotatingFileHandler

def setup_logger(name, log_file, level=logging.DEBUG):
    """Create a configured logger with console and rotating file handlers."""
    logger = logging.getLogger(name)
    logger.setLevel(level)

    if logger.handlers:
        return logger  # Prevent duplicate handlers on re-import

    formatter = logging.Formatter(
        '%(asctime)s | %(name)-20s | %(levelname)-8s | %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S'
    )

    # Console: WARNING and above
    console = logging.StreamHandler()
    console.setLevel(logging.WARNING)
    console.setFormatter(formatter)

    # File: everything, rotate at 500KB, keep 3 backups
    fh = RotatingFileHandler(log_file, maxBytes=500_000, backupCount=3, encoding='utf-8')
    fh.setLevel(logging.DEBUG)
    fh.setFormatter(formatter)

    logger.addHandler(console)
    logger.addHandler(fh)
    return logger

def process_records(records, logger):
    """Process a list of record dicts. Returns (success_count, error_count)."""
    success = 0
    errors = 0

    for i, record in enumerate(records):
        try:
            if 'name' not in record:
                raise ValueError(f'Missing required field: name')
            if not isinstance(record.get('age', 0), int):
                raise TypeError(f'age must be an integer, got {type(record["age"]).__name__}')

            # Simulate transform
            transformed = {
                'id': i + 1,
                'name': record['name'].strip().title(),
                'age': record['age'],
                'status': 'active'
            }
            logger.debug(f'Processed record {i+1}: {transformed["name"]}')
            success += 1

        except (ValueError, TypeError) as e:
            logger.warning(f'Skipping record {i+1}: {e}')
            errors += 1
        except Exception as e:
            logger.exception(f'Unexpected error on record {i+1}')
            errors += 1

    return success, errors

def run_pipeline(input_records, output_path):
    logger = setup_logger('pipeline', 'pipeline.log')

    logger.info(f'Pipeline started. Input records: {len(input_records)}')

    success, errors = process_records(input_records, logger)

    logger.info(f'Pipeline complete. Success: {success}, Errors: {errors}')
    if errors > 0:
        logger.warning(f'{errors} records skipped due to errors')

    return success, errors

# Run the pipeline
sample_data = [
    {'name': 'alice johnson', 'age': 30},
    {'name': 'bob smith', 'age': 25},
    {'age': 40},                         # Missing name -- will warn
    {'name': 'charlie brown', 'age': 'thirty'},  # Wrong type -- will warn
    {'name': 'diana prince', 'age': 28},
]

success, errors = run_pipeline(sample_data, 'output.json')
print(f'\nFinal result: {success} processed, {errors} skipped')
print('Check pipeline.log for full details')

Output:

WARNING | pipeline             | WARNING  | Skipping record 3: Missing required field: name
WARNING | pipeline             | WARNING  | Skipping record 4: age must be an integer, got str

Final result: 3 processed, 2 skipped
Check pipeline.log for full details

The setup_logger() function uses a guard (if logger.handlers: return logger) to prevent duplicate handlers when the function is called multiple times, which is a common gotcha in larger projects. The pipeline logs every step to the file (DEBUG level) while showing only warnings and errors on the console, giving operators a clean output while preserving the full diagnostic trail in the log file.

Frequently Asked Questions

When should I use basicConfig vs named loggers?

Use basicConfig() for scripts and quick tools where you just need output to the console. For any application with multiple modules, use named loggers (logging.getLogger(__name__)) so you can identify which module emitted each message and control logging per module. Named loggers are the standard for libraries and production code.

Why am I seeing duplicate log messages?

This almost always happens because you added a handler to a logger that also propagates to the root logger, which has its own handler. Fix it either by setting logger.propagate = False on your named logger, or by removing the handler from the root logger. The pattern if logger.handlers: return logger in a setup function also prevents duplicate handlers when the function is called more than once.

How do I turn off all logging in production?

Set the root logger level to logging.CRITICAL + 1 or logging.NOTSET and remove all handlers: logging.disable(logging.CRITICAL) silences everything at CRITICAL and below (effectively everything). More typically, just set the production handler level to WARNING or ERROR rather than disabling logging entirely — you want errors logged even in production.

How do I configure logging from a config file?

Use logging.config.fileConfig('logging.ini') for INI-format config files, or logging.config.dictConfig(config_dict) for dictionary-based config (which you can load from a JSON or YAML file). Dictionary config is the modern approach — it’s more flexible and easier to version-control alongside your application code.

Does logging slow down my application?

At DEBUG level with many log messages, yes, logging adds overhead — especially if writing to disk. In production, set the level to WARNING or ERROR so most logging.debug() and logging.info() calls return immediately without any I/O. For extremely hot code paths, check if logger.isEnabledFor(logging.DEBUG): before constructing expensive log messages.

Conclusion

Python’s logging module gives you a production-grade observability system built into the standard library. We covered the five log levels (DEBUG through CRITICAL), setting up named loggers with getLogger(__name__), combining console and file handlers with different levels, automatic log rotation with RotatingFileHandler and TimedRotatingFileHandler, capturing exception tracebacks with logger.exception(), and building a complete pipeline logger. Replace every print() statement in your applications with the appropriate logging call — your future debugging self will thank you.

Extend the data pipeline example by loading the logging configuration from a JSON file so it can be adjusted without code changes, or add a SMTPHandler to email you when a CRITICAL event fires. The logging module’s handler ecosystem is extensive.

See the official logging documentation and the logging cookbook for advanced patterns including thread-safe logging and multiprocessing log handlers.

How To Use Python subprocess Module to Run System Commands

How To Use Python subprocess Module to Run System Commands

Last Updated: June 01, 2026

Intermediate

Sometimes Python alone isn’t enough — you need to run a shell command, launch another program, or query your operating system directly from a script. Maybe you want to zip a directory, ping a server, run a linter, or call a tool that only exists as a command-line binary. Python’s subprocess module is exactly what you need for all of these tasks, and it’s built right into the standard library.

The subprocess module lets you spawn new processes, connect to their input/output/error pipes, and obtain their return codes. It replaced the older os.system() and os.popen() functions with a cleaner, more powerful API. The primary function you’ll use is subprocess.run(), introduced in Python 3.5, which covers the vast majority of use cases. No third-party packages required — just import subprocess.

In this article we’ll cover the core subprocess.run() function, capturing standard output and error, handling return codes and exceptions, running commands with shell features, using Popen for advanced control, and building a real-world disk usage scanner. By the end, you’ll be able to integrate shell commands seamlessly into any Python script.

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 →

Running a Command: Quick Example

Let’s get a win immediately. Here’s how to run a simple command and capture its output in three lines of Python:

# quick_subprocess.py
import subprocess

result = subprocess.run(['echo', 'Hello from subprocess!'], capture_output=True, text=True)
print(result.stdout)
print('Return code:', result.returncode)

Output:

Hello from subprocess!

Return code: 0

We pass the command as a list of strings (the command and its arguments separately), set capture_output=True to capture stdout and stderr, and text=True to decode the output as a string instead of bytes. A return code of 0 means the command succeeded. We’ll dive deeper into each of these parameters below.

What Is subprocess and When Should You Use It?

The subprocess module is Python’s interface for spawning external processes — programs that run independently from your Python interpreter but whose output and status you can monitor and collect. Think of it as Python holding a terminal in one hand, running a command, and handing you the results.

Common use cases include: running shell utilities (ls, grep, curl), calling compiled binaries or other language tools, automating build pipelines, interacting with version control (git commands), and checking system status (disk usage, process lists, network info).

ApproachWhen to UseNotes
subprocess.run()Most use casesWaits for process to finish, returns CompletedProcess
subprocess.Popen()Need streaming I/O or async controlLow-level, non-blocking
os.system()Legacy code onlyNo output capture, avoid in new code
os.popen()Legacy code onlyDeprecated, no error handling

In virtually all new code, use subprocess.run(). The older os.system() and os.popen() functions are still available but offer no output capture, no error handling, and no return codes — they’re strictly inferior.

Running system commands with subprocess
subprocess.run() is Python phoning a friend outside the interpreter.

Capturing stdout and stderr

Capturing command output is the most common subprocess task. You need to see what a command printed before your script can act on it — whether you’re parsing a list of files, checking a version number, or validating command output.

# capture_output.py
import subprocess

# Run 'python --version' and capture the output
result = subprocess.run(['python3', '--version'], capture_output=True, text=True)

print('stdout:', result.stdout.strip())
print('stderr:', result.stderr.strip())
print('returncode:', result.returncode)

Output:

stdout: Python 3.12.0
stderr:
returncode: 0

When capture_output=True, both stdout and stderr are captured as strings (with text=True) and stored in the result.stdout and result.stderr attributes. Without capture_output=True, output goes directly to the terminal and you can’t read it in Python.

Capturing Error Output

Some commands write their useful output to stderr (like ffmpeg, git, and many C tools). Always capture both streams:

# capture_stderr.py
import subprocess

# A command that writes to stderr -- 'ls' on a nonexistent directory
result = subprocess.run(
    ['ls', '/nonexistent/path'],
    capture_output=True,
    text=True
)

print('stdout:', repr(result.stdout))
print('stderr:', repr(result.stderr))
print('returncode:', result.returncode)

Output:

stdout: ''
stderr: "ls: cannot access '/nonexistent/path': No such file or directory\n"
returncode: 2

A non-zero return code (here, 2) signals that the command failed. The error message landed in stderr, not stdout — which is why capturing both is important.

Handling Errors and Return Codes

Checking whether a subprocess succeeded is essential for any production script. There are two patterns: checking returncode manually, or using check=True to raise an exception automatically on failure.

# handle_errors.py
import subprocess

# Pattern 1: Check return code manually
result = subprocess.run(['ls', '/tmp'], capture_output=True, text=True)
if result.returncode == 0:
    print('Files in /tmp:')
    print(result.stdout[:200])
else:
    print('Error:', result.stderr)

# Pattern 2: Raise exception on failure (CalledProcessError)
try:
    subprocess.run(['ls', '/nonexistent'], capture_output=True, text=True, check=True)
except subprocess.CalledProcessError as e:
    print(f'Command failed with code {e.returncode}: {e.stderr.strip()}')

Output:

Files in /tmp:
tmpXYZ123
tmpabc456

Command failed with code 2: ls: cannot access '/nonexistent': No such file or directory

Use check=True when failure should abort your script. Use manual return code checking when you want to handle errors gracefully and keep running. The CalledProcessError exception carries .returncode, .stdout, and .stderr for full context.

Handling subprocess return codes
Return codes are the only honest answer your subprocess gives you.

Running Shell Commands with shell=True

Sometimes you need shell features: pipes (|), redirects (>), glob expansion (*.txt), or environment variable substitution. Passing a command string with shell=True runs it through the system shell, giving you access to all of these.

# shell_true.py
import subprocess

# Using shell=True to pipe commands together
result = subprocess.run(
    'echo "line1\nline2\nline3" | wc -l',
    shell=True,
    capture_output=True,
    text=True
)
print('Line count:', result.stdout.strip())

# Using shell=True for glob expansion
result2 = subprocess.run(
    'ls /tmp/*.log 2>/dev/null | head -5',
    shell=True,
    capture_output=True,
    text=True
)
print('Log files:', result2.stdout.strip() or 'None found')

Output:

Line count: 3
Log files: None found

When using shell=True, pass the command as a single string rather than a list. This is convenient for complex shell pipelines but carries a security risk: if any part of the string comes from user input, an attacker could inject malicious commands. For production code, always use the list form when possible and reserve shell=True for controlled, developer-written strings.

Setting Timeouts

Frequently Asked Questions

What’s the difference between subprocess.run and subprocess.Popen?

run() is the high-level convenience wrapper that starts the process, waits for it to finish, and returns a CompletedProcess. Use it for 95% of cases. Popen is the low-level primitive that returns immediately and lets you interact with the running process via .stdin/.stdout/.stderr pipes. Reach for Popen only when you need to send input mid-execution, stream output line by line, or run multiple processes concurrently.

Should I ever use shell=True?

Almost never. shell=True passes the command to /bin/sh, which means any user-controlled input becomes a shell injection vulnerability. Pass arguments as a list instead: subprocess.run([‘ls’, ‘-l’, user_path]) is safe; subprocess.run(f’ls -l {user_path}’, shell=True) is exploitable. Use shell=True only for genuine shell features like pipes and redirects, and never with untrusted input.

How do I capture both stdout and stderr?

Pass capture_output=True (Python 3.7+) for both, then read result.stdout and result.stderr. To merge them into one stream, pass stderr=subprocess.STDOUT, then everything appears on result.stdout. For text instead of bytes, add text=True.

How do I add a timeout?

Pass timeout=N as a parameter to run() or call .wait(timeout=N) on a Popen. A TimeoutExpired exception fires if the process is still alive at the deadline. Wrap the call in try/except and explicitly .kill() the process in the except block — TimeoutExpired only signals, it doesn’t terminate.

Why does my subprocess hang when piping large output?

Because subprocess.PIPE has a finite OS buffer (~64KB). If the child writes more before you read, the kernel blocks the write, and the child blocks waiting for you. The fix: use communicate() which reads stdout/stderr to completion, or iterate stdout line by line with Popen.stdout, or redirect to a file instead of PIPE if you don’t need the data in Python.

How To Build CLI Apps with Python Click

How To Build CLI Apps with Python Click

Last Updated: June 01, 2026

Intermediate

Every serious Python developer eventually needs to build a command-line interface. Whether it is a deployment tool, a data processing script, or a developer utility, a well-designed CLI makes the difference between a tool your team actually uses and one that sits forgotten. Python’s standard argparse module works, but it is verbose — you write 20 lines of setup code before you handle your first argument. Click is the modern alternative: decorator-based, expressive, and composable, it cuts that boilerplate in half and adds features argparse simply does not have.

Click was created by the team behind Flask and follows the same philosophy: explicit is better than implicit, but explicit does not have to be painful. You decorate a Python function with @click.command() and @click.option(), and Click handles argument parsing, help text, type conversion, validation, and error messages automatically. Install it with pip install click.

This article covers everything you need to build production-quality CLI tools with Click: basic commands and options, arguments, type validation, prompts, multi-command groups (subcommands), progress bars, and output formatting. By the end, we will build a complete file management CLI that demonstrates all these features working together.

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 →

Click Quick Example

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

Here is a complete Click CLI that greets a user, with an optional count parameter:

# quick_click.py
import click

@click.command()
@click.option('--name', default='World', help='Who to greet.')
@click.option('--count', default=1, type=int, help='Number of greetings.')
@click.option('--loud', is_flag=True, help='Use uppercase.')
def greet(name, count, loud):
    """A friendly greeting command."""
    for _ in range(count):
        message = f"Hello, {name}!"
        if loud:
            message = message.upper()
        click.echo(message)

if __name__ == '__main__':
    greet()

Run it from the terminal:

$ python quick_click.py --name Alice --count 3
Hello, Alice!
Hello, Alice!
Hello, Alice!

$ python quick_click.py --name Bob --loud
HELLO, BOB!

$ python quick_click.py --help
Usage: quick_click.py [OPTIONS]

  A friendly greeting command.

Options:
  --name TEXT     Who to greet.
  --count INTEGER  Number of greetings.
  --loud          Use uppercase.
  --help          Show this message and exit.

Click generated a complete help page automatically from the function’s docstring and decorator metadata. The --help flag, type validation, and default values all come for free.

Options vs Arguments

Click distinguishes between two kinds of inputs: options (named flags like --name Alice) and arguments (positional inputs like a filename). Options are optional by default; arguments are required by default.

FeatureOption (@click.option)Argument (@click.argument)
Syntax--flag valuePositional: cmd value
RequiredOptional by defaultRequired by default
Help textShown in --helpShown in usage line
Best forConfiguration, flagsPrimary inputs (files, names)
# options_arguments.py
import click

@click.command()
@click.argument('filename')                        # Required positional arg
@click.option('--output', '-o', default='-',       # -o is a short alias
              help='Output file (default: stdout)')
@click.option('--lines', '-n', default=10,
              type=int, help='Number of lines to show.')
@click.option('--verbose', '-v', is_flag=True,
              help='Show extra information.')
def head(filename, output, lines, verbose):
    """Show the first N lines of FILENAME."""
    if verbose:
        click.echo(f"Reading {filename}, showing {lines} lines")
    try:
        with open(filename) as f:
            for i, line in enumerate(f):
                if i >= lines:
                    break
                click.echo(line, nl=False)
    except FileNotFoundError:
        click.echo(f"Error: {filename} not found", err=True)
        raise SystemExit(1)

if __name__ == '__main__':
    head()

Run it as python options_arguments.py myfile.txt --lines 5 --verbose. The -o short alias for --output is defined right in the option decorator. Click handles both -o file.txt and --output file.txt automatically.

Building Click commands
Click turns your functions into CLI commands with one decorator.

Types and Validation

Click converts option and argument values to the specified Python type and shows a helpful error if the conversion fails. Beyond basic types, Click has specialized types like click.Path for file paths and click.Choice for enumerated values.

# types_demo.py
import click

@click.command()
@click.argument('input_file', type=click.Path(exists=True, readable=True))
@click.option('--format', 'output_format',
              type=click.Choice(['json', 'csv', 'text'], case_sensitive=False),
              default='text', help='Output format.')
@click.option('--max-size', type=click.IntRange(1, 1000),
              default=100, help='Max size (1-1000).')
@click.option('--scale', type=float, help='Scaling factor.')
def process(input_file, output_format, max_size, scale):
    """Process INPUT_FILE with validation."""
    click.echo(f"Processing: {input_file}")
    click.echo(f"Format: {output_format}")
    click.echo(f"Max size: {max_size}")
    if scale:
        click.echo(f"Scale: {scale}")

if __name__ == '__main__':
    process()

When you pass an invalid value, Click provides a clear error message:

$ python types_demo.py myfile.txt --format xml
Error: Invalid value for '--format': 'xml' is not one of 'json', 'csv', 'text'.

$ python types_demo.py nonexistent.txt
Error: Invalid value for 'INPUT_FILE': Path 'nonexistent.txt' does not exist.

click.Path(exists=True) validates the file exists before your function even runs. click.IntRange(1, 1000) ensures the integer is within bounds. These validations happen automatically and produce user-friendly error messages — no manual error handling needed.

Interactive Prompts and Confirmation

For destructive operations, you often want to confirm with the user. Click provides @click.confirmation_option(), @click.password_option(), and click.prompt() for interactive input collection.

# prompts_demo.py
import click

@click.command()
@click.option('--username', prompt='Username',
              help='Your username.')
@click.option('--password', prompt=True,
              hide_input=True, confirmation_prompt=True,
              help='Your password.')
@click.option('--database', prompt='Database name',
              default='mydb', show_default=True)
def setup_connection(username, password, database):
    """Set up a database connection."""
    click.echo(f"Connecting to {database} as {username}...")
    click.echo(f"Password length: {len(password)} chars")
    # In a real app, you'd use these to create a connection
    click.echo("Connection configured successfully!")

@click.command()
@click.argument('filename')
@click.confirmation_option(prompt='Are you sure you want to delete this file?')
def delete_file(filename):
    """Permanently delete FILENAME."""
    import os
    try:
        os.remove(filename)
        click.echo(f"Deleted: {filename}", err=False)
    except FileNotFoundError:
        click.echo(f"File not found: {filename}", err=True)

if __name__ == '__main__':
    setup_connection()

Run python prompts_demo.py and Click interactively prompts for each required value. The password is hidden during input (no echo to terminal) and asks for confirmation. The @click.confirmation_option adds a yes/no prompt before any destructive action — and automatically processes -y or --yes flags to skip the prompt in automated scripts.

Advanced Click features
Nested commands give your CLI the depth of a real tool.

Multi-Command Groups (Subcommands)

Real CLI tools like git and docker use subcommands: git commit, git push, docker build, docker run. Click’s @click.group() decorator creates this structure cleanly. Each subcommand is just another decorated function.

# groups_demo.py
import click

@click.group()
@click.option('--debug/--no-debug', default=False,
              help='Enable debug output.')
@click.pass_context
def cli(ctx, debug):
    """Project management tool."""
    ctx.ensure_object(dict)
    ctx.obj['DEBUG'] = debug

@cli.command()
@click.argument('name')
@click.option('--template', default='basic',
              type=click.Choice(['basic', 'flask', 'fastapi']),
              help='Project template.')
@click.pass_context
def create(ctx, name, template):
    """Create a new project."""
    if ctx.obj['DEBUG']:
        click.echo(f"[DEBUG] Creating {name} with template {template}")
    click.echo(f"Creating project '{name}'...")
    click.echo(f"Template: {template}")
    click.echo(f"Done! Run: cd {name} && python main.py")

@cli.command()
@click.argument('name')
@click.pass_context
def delete(ctx, name):
    """Delete a project."""
    if ctx.obj['DEBUG']:
        click.echo(f"[DEBUG] Deleting {name}")
    click.confirm(f"Delete project '{name}'? This cannot be undone.", abort=True)
    click.echo(f"Project '{name}' deleted.")

@cli.command()
@click.pass_context
def list_projects(ctx):
    """List all projects."""
    click.echo("Projects:")
    for project in ['api-service', 'data-pipeline', 'dashboard']:
        click.echo(f"  - {project}")

# Register the list command with a different name
cli.add_command(list_projects, name='list')

if __name__ == '__main__':
    cli()

Run it as:

$ python groups_demo.py --help
Usage: groups_demo.py [OPTIONS] COMMAND [ARGS]...

  Project management tool.

Options:
  --debug / --no-debug  Enable debug output.
  --help                Show this message and exit.

Commands:
  create  Create a new project.
  delete  Delete a project.
  list    List all projects.

$ python groups_demo.py create myapp --template flask
Creating project 'myapp'...
Template: flask
Done! Run: cd myapp && python main.py

$ python groups_demo.py --debug create myapp
[DEBUG] Creating myapp with template basic
Creating project 'myapp'...

The ctx.pass_context pattern passes a shared context object through all subcommands. The --debug flag is defined on the group level and passed down through context — this is the Click pattern for global flags that affect all subcommands.

Real-Life Example: A File Processing CLI

Here is a complete, practical CLI tool for processing text files — counting words, searching for patterns, and converting case — with progress bars for large files.

# filetools.py
import click
import re
from pathlib import Path

@click.group()
def cli():
    """File processing toolkit."""

@cli.command()
@click.argument('files', nargs=-1, type=click.Path(exists=True), required=True)
@click.option('--words/--no-words', default=True, help='Count words.')
@click.option('--lines/--no-lines', default=True, help='Count lines.')
@click.option('--chars/--no-chars', default=False, help='Count characters.')
def count(files, words, lines, chars):
    """Count words/lines/chars in FILES."""
    total_w, total_l, total_c = 0, 0, 0
    for filepath in files:
        content = Path(filepath).read_text()
        w = len(content.split())
        l = content.count('\n')
        c = len(content)
        total_w += w; total_l += l; total_c += c
        parts = []
        if lines: parts.append(f"{l:>8} lines")
        if words: parts.append(f"{w:>8} words")
        if chars: parts.append(f"{c:>8} chars")
        click.echo(f"{'  '.join(parts)}  {filepath}")
    if len(files) > 1:
        click.echo(f"{'':->40}")
        click.echo(f"{total_l:>8} lines  {total_w:>8} words  total")

@cli.command()
@click.argument('pattern')
@click.argument('files', nargs=-1, type=click.Path(exists=True), required=True)
@click.option('--ignore-case', '-i', is_flag=True, help='Case-insensitive.')
@click.option('--count-only', '-c', is_flag=True, help='Print match count only.')
def search(pattern, files, ignore_case, count_only):
    """Search for PATTERN in FILES."""
    flags = re.IGNORECASE if ignore_case else 0
    for filepath in files:
        content = Path(filepath).read_text()
        matches = [(i+1, line) for i, line in enumerate(content.splitlines())
                   if re.search(pattern, line, flags)]
        if count_only:
            click.echo(f"{len(matches):>5}  {filepath}")
        else:
            for lineno, line in matches:
                click.secho(f"{filepath}:{lineno}: ", nl=False, fg='cyan')
                # Highlight the match in yellow
                highlighted = re.sub(pattern,
                    lambda m: click.style(m.group(), fg='yellow', bold=True),
                    line, flags=flags)
                click.echo(highlighted)

if __name__ == '__main__':
    cli()

Run as:

$ python filetools.py count README.md
      45 lines      312 words  README.md

$ python filetools.py search "import" *.py --ignore-case
filetools.py:1: import click
filetools.py:2: import re
filetools.py:3: from pathlib import Path

The nargs=-1 pattern on FILES accepts any number of file arguments, like the Unix convention. click.secho() combines echo with styled output (colors). The --ignore-case short alias -i matches grep’s convention, making the tool feel natural to Unix users.

@click.command(). One decorator. Full CLI. Done.
@click.command(). One decorator. Full CLI. Done.

Frequently Asked Questions

When should I use Click instead of argparse?

Use Click for new CLI tools — it is less verbose and more composable. argparse is already in the standard library and requires no installation, so it is better for simple scripts that need zero dependencies. Click shines for multi-command CLIs with many options, complex validation, interactive prompts, and colored output. If you are building something beyond a simple script, Click’s developer experience wins decisively.

How does Click compare to Typer?

Typer is built on top of Click and generates Click CLI definitions from Python function type hints. If you use type annotations throughout your code, Typer reduces Click boilerplate further — you get options and arguments from type hints with no decorators. The trade-off: Typer adds a dependency and is less flexible than Click for complex CLI patterns. Click is more explicit; Typer is more magic. Both are excellent choices.

How do I test Click commands?

Click provides a CliRunner for testing. Use from click.testing import CliRunner; runner = CliRunner(); result = runner.invoke(my_command, ['--option', 'value']). The result object has exit_code, output, and exception attributes. This lets you test CLI behavior in pytest without spawning a subprocess, and it works with input prompts by passing input='yes\n' to invoke().

Can Click read options from environment variables?

Yes. Set auto_envvar_prefix='MYAPP' on the group, and Click automatically reads MYAPP_OPTION_NAME from the environment for any option not provided on the command line. You can also set it per-option: @click.option('--api-key', envvar='API_KEY'). This is the standard pattern for 12-factor applications where configuration comes from the environment.

How do I package a Click app as a proper CLI command?

Add an entry_points section to your pyproject.toml: [project.scripts] mytool = "mypackage.cli:main". After pip install -e ., running mytool in the terminal invokes your Click function directly. This is the standard way to distribute CLI tools on PyPI — users install your package and get the command available system-wide.

Conclusion

We covered the full Click toolkit: defining commands with @click.command(), options with @click.option(), arguments with @click.argument(), type validation with click.Path and click.Choice, interactive prompts, multi-command groups with shared context using @click.pass_context, and colored output with click.secho(). The file processing CLI showed how to compose these features into a tool that feels like a native Unix command.

From here, explore Click’s progress bar support (click.progressbar()), file path handling with lazy file opening, and the CliRunner for testing. Click’s plugin system also allows distributing CLI extensions as separate packages — the same pattern used by Flask extensions.

Official documentation: click.palletsprojects.com

Click Basics

Click turns Python functions into CLIs via decorators. The simplest CLI is two decorators on a function:

# pip install click

import click

@click.command()
@click.option("--name", default="World", help="Whom to greet")
@click.option("--count", default=1, type=int, help="Number of greetings")
def greet(name, count):
    """Print a friendly greeting."""
    for _ in range(count):
        click.echo(f"Hello, {name}!")

if __name__ == "__main__":
    greet()

Save as hello.py and run python hello.py --name Alice --count 3. Click generates --help automatically from the docstring and option help text.

Commands and Subcommands

For multi-command CLIs (think git commit, git push), use a group:

import click

@click.group()
@click.option("--verbose", is_flag=True)
@click.pass_context
def cli(ctx, verbose):
    ctx.ensure_object(dict)
    ctx.obj["verbose"] = verbose

@cli.command()
@click.argument("path")
@click.pass_context
def status(ctx, path):
    """Show repo status."""
    click.echo(f"Status of {path}")

@cli.command()
@click.argument("message")
@click.pass_context
def commit(ctx, message):
    """Make a commit."""
    click.echo(f"Committing: {message}")

if __name__ == "__main__":
    cli(obj={})

Run: python tool.py --verbose status . or python tool.py commit "fix bug".

Type Conversion and Validation

Click’s types validate and convert arguments before they hit your function:

@click.command()
@click.option("--port", type=click.IntRange(1, 65535), default=8080)
@click.option("--path", type=click.Path(exists=True, dir_okay=False))
@click.option("--format", type=click.Choice(["json", "yaml", "toml"]))
@click.option("--threshold", type=click.FloatRange(0, 1))
@click.option("--cert", type=click.File("rb"))
@click.option("--config-dir", type=click.Path(file_okay=False, writable=True))
def serve(port, path, format, threshold, cert, config_dir):
    ...

Each type rejects bad input at the CLI boundary with a clean error message — saves you from writing validation by hand.

Confirmation and Prompts

For dangerous commands, prompt for confirmation. For missing values, prompt interactively:

@click.command()
@click.argument("project")
@click.confirmation_option(prompt="Are you sure you want to delete?")
def delete(project):
    click.echo(f"Deleted {project}")

@click.command()
@click.option("--username", prompt="Username")
@click.option("--password", prompt="Password", hide_input=True, confirmation_prompt=True)
def login(username, password):
    click.echo(f"Logging in as {username}")

Output: echo, secho, progressbar

Use click.echo instead of print() — handles platform encoding correctly, can be styled, and respects --quiet flags:

click.echo("Plain message")
click.secho("Success!", fg="green", bold=True)
click.secho("Error!", fg="red", err=True)   # to stderr

# Built-in progress bar
import time
with click.progressbar(range(1000), label="Processing") as items:
    for i in items:
        time.sleep(0.001)

# Pager output (like `git log`)
click.echo_via_pager("\n".join(f"Line {i}" for i in range(1000)))

Distributing as a Real Command

To make mytool available system-wide (not python tool.py), use the entry-points pattern in pyproject.toml:

# pyproject.toml
[project]
name = "mytool"
version = "0.1.0"
dependencies = ["click"]

[project.scripts]
mytool = "mytool.cli:cli"

# Then: pip install -e .
# Now: mytool status . (anywhere on the system)

Common Pitfalls

  • Mixing argument and option names. Options start with -- (or -x for shortcuts). Arguments don’t. Get this wrong and Click complains at decoration time.
  • Forgetting @click.pass_context. If a subcommand needs the parent group’s settings, decorate it with @click.pass_context and accept ctx as the first parameter.
  • Calling sys.exit() inside commands. Use click.Abort() or ctx.exit(code) — these clean up properly with Click’s machinery.
  • Testing CLIs manually. Use click.testing.CliRunner instead — runs the command in-process and gives you a result object with output and exit code.
  • Long help text. Docstrings get truncated. For long help, use @click.command(help="""...""") instead.

FAQ

Q: Click, Typer, or argparse?
A: Typer if you love type hints (it’s Click with Pydantic-style annotations). Click for the largest ecosystem and proven track record. argparse for stdlib-only constraints.

Q: How do I add tab completion?
A: Click ships with completion generators for bash, zsh, fish: _MYTOOL_COMPLETE=zsh_source mytool in your shell init.

Q: How do I write tests for a Click CLI?
A: from click.testing import CliRunner; result = CliRunner().invoke(cli, ["arg1", "--opt", "value"]). The result has output, exit_code, and exception for assertions.

Q: Can a CLI command return a value?
A: Click commands return None — they’re CLI entry points, not function returns. To pass data between subcommands, store it in ctx.obj.

Q: How do I make a command with optional flags AND positional arguments?
A: @click.argument("file", required=False, default=None) for an optional positional argument. Stack with @click.option for flags.

Wrapping Up

Click is the canonical Python CLI framework — battle-tested, well-documented, and friendly to both small scripts and complex multi-command tools. Start with @click.command + @click.option + @click.argument; graduate to groups when you need subcommands. Pair with entry-points in pyproject.toml to ship real shell commands. For type-hint enthusiasts, Typer wraps Click in a more modern API; the underlying ideas are identical.

How To Format Python Code with Black and isort

How To Format Python Code with Black and isort

Last Updated: June 01, 2026

Beginner

Code reviews should be about logic, architecture, and correctness — not about whether you put a space before a colon or how you sorted your imports. But without automated formatting, every team spends time on style debates, inconsistent diffs pollute git history, and onboarding new developers is a friction-filled process. Black and isort are the two tools that eliminate this problem entirely: Black reformats your Python code in one consistent opinionated style, and isort keeps your imports sorted and organized. Combined, they handle the vast majority of Python style decisions automatically.

Black calls itself “the uncompromising code formatter.” It has almost no configuration options by design — the goal is for every Black-formatted project to look the same, so developers can read any Python project without adjusting to a new style. isort sorts imports alphabetically within their sections (standard library, third-party, local), keeping them clean and diff-friendly. Install both with pip install black isort.

In this article, we will cover: how to use Black to format Python files from the command line, how to configure Black’s line length and target Python version, how isort organizes imports, how to combine Black and isort without conflicts, how to run both as pre-commit hooks so formatting is automatic on every commit, and how to integrate them into CI. By the end, you will have a fully automated formatting pipeline that requires zero style decisions from your team.

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 →

Black Quick Example

Here is some unformatted Python code and what Black does to it:

# unformatted.py (before Black)
import sys,os
def   calculate(x,y,   z):
    result=x+y*z
    if result>100: return True
    else:
      return    False

my_list=[1,2,   3,4,
  5,6]
d={'key1':'value1','key2':   'value2','key3':'value3'}

Run black unformatted.py and the file becomes:

# unformatted.py (after Black)
import sys, os


def calculate(x, y, z):
    result = x + y * z
    if result > 100:
        return True
    else:
        return False


my_list = [
    1,
    2,
    3,
    4,
    5,
    6,
]
d = {"key1": "value1", "key2": "value2", "key3": "value3"}

Black added consistent spacing, expanded the list to one-item-per-line (because it exceeded the line length), kept the dictionary on one line (because it fits), and enforced double quotes. Notice it also changed import sys,os but did not sort them — that is isort’s job.

What Is Black and What Does It Enforce?

Black is a PEP 8-compliant code formatter that enforces a specific subset of style choices. Unlike flake8 (which only reports style violations), Black actually rewrites your code. It makes these decisions for you:

Style AspectBlack’s ChoiceReason
QuotesDouble quotes alwaysConsistency; avoids escaping
Trailing commasAdded in multi-line structuresCleaner git diffs
Line length88 characters (configurable)Slightly more than PEP 8’s 79
Blank lines2 between top-level, 1 between methodsPEP 8 standard
Magic trailing commaRespects it — keeps multi-line if comma presentDeveloper intent preserved

The key insight is that Black removes the decision-making burden from developers. You do not debate whether to use single or double quotes — Black uses double. You do not argue about line wrapping — Black wraps at 88 characters. Once your team agrees to use Black, style debates disappear from code reviews.

Before and after formatting
Black formats your code. Arguments are not welcome.

Using Black on the Command Line

Black’s command-line interface is straightforward. You can format a single file, a directory, or use --check to preview what would change without modifying files.

# Install Black
# pip install black

# Format a single file (modifies in place)
# black my_script.py

# Format an entire directory
# black src/

# Check what would change (exit 1 if changes needed)
# black --check src/

# Show the diff without applying changes
# black --diff my_script.py

# Format with a custom line length (79 for strict PEP 8)
# black --line-length 79 src/

# Target a specific Python version
# black --target-version py311 src/

The --check flag is what you use in CI pipelines — it returns exit code 1 if any files need reformatting, which fails the CI build. This forces developers to run Black locally before pushing. The --diff flag shows exactly what would change, which is useful for understanding Black’s decisions.

# pyproject.toml -- configure Black project-wide
[tool.black]
line-length = 88
target-version = ['py311']
include = '\.pyi?$'
exclude = '''
/(
    \.git
  | \.venv
  | build
  | dist
)/
'''

Put pyproject.toml in your project root and Black will use these settings automatically. The target-version setting tells Black which Python syntax features are available — this affects magic trailing comma behavior and some string formatting decisions.

Using isort to Organize Imports

isort sorts Python imports into three sections separated by blank lines: standard library imports, third-party imports, and local imports. Within each section, it sorts alphabetically. This matches PEP 8 and makes diffs clean — changing one import only affects one line.

# messy_imports.py (before isort)
import json
from flask import Flask, request
import os
from myapp.models import User
import sys
from datetime import datetime
import requests
from myapp.utils import format_date

After running isort messy_imports.py:

# messy_imports.py (after isort)
import json
import os
import sys
from datetime import datetime

import requests
from flask import Flask, request

from myapp.models import User
from myapp.utils import format_date

Standard library imports (json, os, sys, datetime) come first. Third-party imports (requests, flask) come second. Local imports (myapp.*) come last. Within each section, everything is alphabetically sorted. The blank lines between sections are isort’s signature — they make the import structure visually clear.

Making Black and isort Work Together

Black and isort can conflict: Black sometimes reformats lines that isort just organized. The fix is to tell isort to use Black-compatible settings. isort has a built-in --profile black option that makes them cooperate perfectly.

# pyproject.toml -- configure isort for Black compatibility
[tool.isort]
profile = "black"
line_length = 88

With this configuration, isort will format multi-line imports the same way Black would. Run them in order — isort first, then Black — or use a pre-commit hook that runs both:

# Run both tools on the src/ directory
# isort src/
# black src/

# Verify both are satisfied (for CI)
# isort --check-only src/ && black --check src/
Configuring formatters
pyproject.toml is where Black and isort learn to agree.

Automating with Pre-Commit Hooks

The most effective way to use Black and isort is as pre-commit hooks — they run automatically every time you commit code, so formatting is never forgotten. The pre-commit framework makes this easy.

# Install pre-commit
# pip install pre-commit
# .pre-commit-config.yaml -- put this in your project root
repos:
  - repo: https://github.com/psf/black
    rev: 24.3.0
    hooks:
      - id: black
        language_version: python3.11

  - repo: https://github.com/PyCQA/isort
    rev: 5.13.2
    hooks:
      - id: isort
        args: ["--profile", "black"]

  - repo: https://github.com/PyCQA/flake8
    rev: 7.0.0
    hooks:
      - id: flake8
# Initialize pre-commit (run once after creating the config)
# pre-commit install

# Run manually on all files
# pre-commit run --all-files

After pre-commit install, every git commit automatically runs Black, isort, and flake8 on the staged files. If any formatting changes are needed, the commit is blocked and the files are auto-fixed — you just git add the changes and commit again. This means formatting violations never reach the repository.

black: opinionated formatting. Stop arguing about commas.
black: opinionated formatting. Stop arguing about commas.

Real-Life Example: Setting Up a Full Python Project

Here is a complete project setup script that installs Black, isort, and pre-commit and configures them to work together:

# project_setup.py
"""
Script to set up Black + isort + pre-commit for a Python project.
Run from your project root directory.
"""
import subprocess
import sys
from pathlib import Path

def run(cmd):
    """Run a shell command and print output."""
    print(f"Running: {' '.join(cmd)}")
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.stdout:
        print(result.stdout)
    if result.returncode != 0:
        print(f"ERROR: {result.stderr}")
    return result.returncode == 0

# Install tools
run([sys.executable, '-m', 'pip', 'install', 'black', 'isort', 'pre-commit', '--quiet'])

# Write pyproject.toml config
pyproject = Path('pyproject.toml')
if not pyproject.exists():
    pyproject.write_text('''[tool.black]
line-length = 88
target-version = ['py311']

[tool.isort]
profile = "black"
line_length = 88
''')
    print("Created pyproject.toml")

# Write pre-commit config
precommit = Path('.pre-commit-config.yaml')
precommit.write_text('''repos:
  - repo: https://github.com/psf/black
    rev: 24.3.0
    hooks:
      - id: black
  - repo: https://github.com/PyCQA/isort
    rev: 5.13.2
    hooks:
      - id: isort
        args: ["--profile", "black"]
''')
print("Created .pre-commit-config.yaml")

# Install pre-commit hooks
if Path('.git').exists():
    run(['pre-commit', 'install'])
    print("pre-commit hooks installed!")
else:
    print("Not a git repo -- skipping pre-commit install")
    print("Run 'git init' first, then 'pre-commit install'")

print("\nSetup complete! Run 'pre-commit run --all-files' to format existing code.")

Output:

Running: /usr/bin/python3 -m pip install black isort pre-commit --quiet
Created pyproject.toml
Created .pre-commit-config.yaml
pre-commit hooks installed!

Setup complete! Run 'pre-commit run --all-files' to format existing code.

This script sets up the entire formatting pipeline in under a minute. Once it runs, every commit in the project will be automatically formatted by Black and isort with no developer effort. You can extend the pre-commit config to add mypy for type checking or bandit for security scanning.

Frequently Asked Questions

How is Black different from autopep8?

autopep8 fixes PEP 8 violations but tries to be minimally invasive — it only changes what it must. Black is more opinionated and makes more sweeping changes to ensure a consistent style everywhere. Black produces deterministic output (running it twice gives the same result), while autopep8 may not. Most teams prefer Black because it eliminates more style decisions and produces cleaner diffs.

How do I introduce Black to a large existing codebase?

Run black . --diff first to see the scope. Then run black . in a single dedicated commit with the message “Apply Black formatting” — this isolates all formatting changes from logic changes. Configure git to exclude this commit from git blame with git blame --ignore-rev <commit-hash> or add the hash to .git-blame-ignore-revs. From that point, all new commits will be incrementally formatted.

Can I skip Black formatting for specific code?

Yes. Wrap code with # fmt: off and # fmt: on to disable Black for a specific block. This is useful for manually aligned code like lookup tables or matrix definitions where Black’s formatting would hurt readability. Use it sparingly — the value of Black comes from it being consistently applied everywhere.

How do I fail CI if code is not formatted?

Run black --check . and isort --check-only . in your CI pipeline. Both commands return exit code 1 if any files need formatting, which fails the CI build. In GitHub Actions, add a formatting job that runs before your tests. When it fails, developers must run Black and isort locally before the CI passes.

How do I set up VS Code to auto-format with Black?

Install the Black Formatter extension from the VS Code marketplace. Then in your settings.json: set "editor.formatOnSave": true, "[python]": { "editor.defaultFormatter": "ms-python.black-formatter" }, and install the isort extension for import sorting. Now every time you save a Python file, Black and isort run automatically.

Conclusion

We covered the complete Black and isort workflow: formatting files with black and isort on the command line, configuring both tools via pyproject.toml, using --profile black to make isort Black-compatible, automating everything with pre-commit hooks, and checking formatting in CI with --check flags. The project setup script ties it all together into a one-command installation.

The cumulative benefit of Black and isort is significant — teams report that code review time drops noticeably when formatting is no longer a discussion topic. Developers spend more mental energy on logic and less on whitespace. New contributors can get up to speed faster because the formatting standards are enforced automatically rather than documented in a style guide.

Official documentation: black.readthedocs.io and pycqa.github.io/isort

Continue Learning Python

Tutorials you might also find useful:

How To Use Hypothesis for Property-Based Testing in Python

How To Use Hypothesis for Property-Based Testing in Python

Last Updated: June 01, 2026

Intermediate

You wrote unit tests. You covered the happy path. You even tested a few edge cases — empty strings, zero values, negative numbers. But then production breaks on an input you never imagined: a Unicode string with a zero-width space, a list with 2 billion elements, or a float that is technically not-a-number. Property-based testing is the approach that finds these bugs before your users do. Instead of you specifying test inputs, the library generates hundreds of random inputs automatically and searches for ones that break your code.

Hypothesis is Python’s leading property-based testing library. You describe the shape of valid inputs using strategies, and Hypothesis generates inputs of that shape, tries to break your code, and if it finds a failing case, automatically shrinks it to the smallest possible example that still fails. This gives you a precise, minimal reproduction case instead of a random mess. Install it with pip install hypothesis. It works alongside pytest and unittest with zero configuration.

In this article, we will cover: what property-based testing is and when to use it, how to write your first Hypothesis test, how to use built-in strategies for common types, how to compose strategies for custom data structures, how to use stateful testing for sequences of operations, and how to apply Hypothesis to real code to find real bugs. By the end, you will have a new tool that makes your test suite dramatically more thorough.

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 →

Hypothesis Quick Example

Here is a Hypothesis test that checks a property of Python’s built-in sorted() function — that sorting a list and then reversing it should equal sorting in reverse order:

# quick_hypothesis.py
from hypothesis import given
from hypothesis import strategies as st

@given(st.lists(st.integers()))
def test_sort_reverse_equivalent(numbers):
    """sorted then reversed == sorted with reverse=True"""
    sorted_then_reversed = list(reversed(sorted(numbers)))
    sorted_reversed = sorted(numbers, reverse=True)
    assert sorted_then_reversed == sorted_reversed

# Run with pytest: pytest quick_hypothesis.py -v
# Or call directly:
test_sort_reverse_equivalent()
print("All tests passed!")

Output:

All tests passed!

Hypothesis ran this function hundreds of times with randomly generated lists — empty lists, lists with one element, lists with thousands of integers, lists with negative numbers, lists with duplicates. It found no counterexample, so the property holds. If the property had been wrong, Hypothesis would show you the smallest list that breaks it.

What Is Property-Based Testing?

Traditional unit tests are example-based: you write specific inputs and expected outputs. Property-based tests are contract-based: you describe invariants that must hold for ANY valid input. The library’s job is to find inputs that violate those invariants.

AspectExample-Based (pytest)Property-Based (Hypothesis)
Input sourceYou write it manuallyLibrary generates it
CoverageOnly cases you thought ofHundreds of random cases
Bug discoveryKnown edge casesUnknown edge cases
Failure outputThe input you wroteSmallest failing example
Best forKoown requirementsAlgorithmic invariants

Property-based testing does not replace example-based tests — it complements them. Use both together. Write example-based tests for known requirements, add property-based tests for algorithmic invariants and data transformations.

Test strategies and data generation
Hypothesis generates test cases your brain never would.

Understanding Strategies

A strategy tells Hypothesis how to generate values for a particular type. The hypothesis.strategies module (conventionally imported as st) provides strategies for all Python built-in types, plus tools to compose them into complex structures.

# strategies_demo.py
from hypothesis import given, settings
from hypothesis import strategies as st

# Basic strategies
@given(st.integers(min_value=0, max_value=100))
def test_squares_are_positive(n):
    assert n * n >= 0

# Text strategies
@given(st.text(min_size=1, max_size=50))
def test_strip_never_longer(s):
    assert len(s.strip()) <= len(s)

# Float strategies
@given(st.floats(allow_nan=False, allow_infinity=False))
def test_abs_never_negative(f):
    assert abs(f) >= 0

# Lists of specific type
@given(st.lists(st.integers(), min_size=1))
def test_max_is_in_list(lst):
    assert max(lst) in lst

# Run all tests
test_squares_are_positive()
test_strip_never_longer()
test_abs_never_negative()
test_max_is_in_list()
print("All strategy tests passed!")

Output:

All strategy tests passed!

The constraints in strategies are important. st.floats(allow_nan=False, allow_infinity=False) excludes the special IEEE 754 values that would break most arithmetic. min_size=1 on a list ensures max() does not raise a ValueError on an empty list — though you might want to test that case separately.

Composing Custom Strategies

Real applications use complex data structures, not just integers. Hypothesis lets you compose strategies using st.fixed_dict(), st.builds(), and the @st.composite decorator to generate custom objects.

# custom_strategies.py
from hypothesis import given
from hypothesis import strategies as st
from dataclasses import dataclass

@dataclass
class Product:
    name: str
    price: float
    quantity: int

# Strategy for valid products
product_strategy = st.builds(
    Product,
    name=st.text(alphabet=st.characters(whitelist_categories=('Lu', 'Ll', 'Nd', 'Zs')),
                 min_size=1, max_size=50),
    price=st.floats(min_value=0.01, max_value=10000.0, allow_nan=False),
    quantity=st.integers(min_value=0, max_value=10000)
)

def calculate_total(products):
    """Calculate total value of product inventory."""
    return sum(p.price * p.quantity for p in products)

@given(st.lists(product_strategy, min_size=1, max_size=20))
def test_total_always_non_negative(products):
    """Total inventory value must never be negative."""
    total = calculate_total(products)
    assert total >= 0, f"Negative total: {total}"

@given(st.lists(product_strategy, min_size=2))
def test_adding_strategy-product_increases_total(products):
    """Adding a product with positive price and quantity increases total."""
    base_total = calculate_total(products[:-1])
    last = products[-1]
    if last.price > 0 and last.quantity > 0:
        full_total = calculate_total(products)
        assert full_total > base_total

test_total_always_non_negative()
test_adding_strategy-product_increases_total()
print("Custom strategy tests passed!")

Output:

Custom strategy tests passed!

The st.builds() strategy calls the Product constructor with generated values for each field. You can nest strategies arbitrarily — a list of products, each with a composed strategy for its fields. This mirrors how your real application data is structured, so Hypothesis generates realistic test data automatically.

Finding edge case failures
When Hypothesis finds a bug, it shrinks the input to the smallest reproducer.

Finding Real Bugs with Hypothesis

The real value of property-based testing shows when Hypothesis finds a bug you would never have written a test for. Here is an example with a buggy encoding function:

# finding_bugs.py
from hypothesis import given
from hypothesis import strategies as st

def encode(data: list) -> str:
    """Run-length encode a list of integers. E.g., [1,1,2,3,3] -> '2x1,1x2,2x3'"""
    if not data:
        return ''
    result = []
    count = 1
    for i in range(1, len(data)):
        if data[i] == data[i-1]:
            count += 1
        else:
            result.append(f"{count}x{data[i-1]}")
            count = 1
    result.append(f"{count}x{data[-1]}")
    return ','.join(result)

def decode(encoded: str) -> list:
    """Decode run-length encoded string back to list."""
    if not encoded:
        return []
    result = []
    for part in encoded.split(','):
        count_str, val_str = part.split('x')
        result.extend([int(val_str)] * int(count_str))
    return result

# Property: encode then decode should give back the original
@given(st.lists(st.integers(min_value=-100, max_value=100)))
def test_encode_decode_roundtrip(data):
    encoded = encode(data)
    decoded = decode(encoded)
    assert decoded == data, f"Roundtrip failed: {data} -> '{encoded}' -> {decoded}"

test_encode_decode_roundtrip()
print("Roundtrip test passed!")

Output:

Roundtrip test passed!

Hypothesis tested this function with hundreds of inputs including empty lists, single-element lists, all-same lists, and alternating values — and the roundtrip property held for all of them. If decode() had a bug (say, only handling positive integers), Hypothesis would immediately find a minimal failing input like [-1] and show you the exact failing case with the encoded string.

Controlling Hypothesis Settings

Hypothesis provides a settings decorator to control how many examples are generated, the maximum shrink time, and the verbosity of output. You can also use @example() to always include specific cases alongside the generated ones.

# settings_demo.py
from hypothesis import given, settings, example
from hypothesis import strategies as st

def divide(a: int, b: int) -> float:
    """Divide a by b, raise ValueError if b is zero."""
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

# Always test these specific cases, plus 200 random ones
@settings(max_examples=200)
@example(a=0, b=1)
@example(a=-10, b=2)
@given(
    a=st.integers(),
    b=st.integers().filter(lambda x: x != 0)
)
def test_divide_properties(a, b):
    result = divide(a, b)
    # Property 1: result times b should equal a (within float precision)
    assert abs(result * b - a) < 1e-9
    # Property 2: dividing by positive number preserves sign
    if b > 0:
        assert (result >= 0) == (a >= 0)

test_divide_properties()
print("Divide properties verified over 200+ examples!")

Output:

Divide properties verified over 200+ examples!

The .filter(lambda x: x != 0) call on the strategy excludes zero from the generated values. The @example() decorator guarantees that specific cases always run, even if Hypothesis would not randomly generate them. Combined with max_examples=200, this gives you a thorough test with precise control.

Hypothesis generates the inputs you didn't think of.
Hypothesis generates the inputs you didn’t think of.

Real-Life Example: Testing a Sorted Data Structure

We will test a custom SortedList class that maintains elements in sorted order. Hypothesis will verify several invariants hold under all valid operations.

# sorted_list_test.py
from hypothesis import given, assume
from hypothesis import strategies as st
import bisect

class SortedList:
    """A list that maintains sorted order on insert."""
    def __init__(self):
        self._data = []

    def insert(self, value):
        bisect.insort(self._data, value)

    def remove(self, value):
        idx = bisect.bisect_left(self._data, value)
        if idx < len(self._data) and self._data[idx] == value:
            self._data.pop(idx)
        else:
            raise ValueError(f"{value} not in list")

    def contains(self, value) -> bool:
        idx = bisect.bisect_left(self._data, value)
        return idx < len(self._data) and self._data[idx] == value

    def to_list(self) -> list:
        return list(self._data)

    def __len__(self):
        return len(self._data)

# Property 1: after inserting a value, the list is always sorted
@given(st.lists(st.integers()), st.integers())
def test_insert_preserves_order(existing, new_value):
    sl = SortedList()
    for v in existing:
        sl.insert(v)
    sl.insert(new_value)
    lst = sl.to_list()
    assert lst == sorted(lst), f"Not sorted after insert: {lst}"

# Property 2: after inserting, the value is always present
@given(st.integers())
def test_insert_then_contains(value):
    sl = SortedList()
    sl.insert(value)
    assert sl.contains(value)

# Property 3: length matches number of inserts
@given(st.lists(st.integers()))
def test_length_matches_inserts(values):
    sl = SortedList()
    for v in values:
        sl.insert(v)
    assert len(sl) == len(values)

test_insert_preserves_order()
test_insert_then_contains()
test_length_matches_inserts()
print("SortedList: all 3 properties verified!")

Output:

SortedList: all 3 properties verified!

These three properties — sorted order preserved, inserted value found, length consistent — form a behavioral contract for SortedList. Any implementation that passes all three is correct by definition. You can refactor the internals (swap bisect.insort for a balanced BST, for example) and use these property tests as your regression suite — they will catch any violation of the contract.

Frequently Asked Questions

Hypothesis makes my test suite slow. How do I speed it up?

Hypothesis caches its database of found examples between runs, so later runs are faster. Use @settings(max_examples=50) for fast CI runs and max_examples=1000 for deeper local testing. The suppress_health_check option disables specific health checks if they are triggering false positives. In CI, set the environment variable HYPOTHESIS_DATABASE_DIRECTORY to a cached location to preserve learned examples across runs.

What is shrinking and why does it matter?

When Hypothesis finds a failing input, it automatically tries to shrink it — finding a smaller or simpler input that still triggers the same failure. Instead of showing you a list of 1,000 random integers that caused a bug, it will show you the 2-element list [0, -1] that triggers the same bug. This makes debugging dramatically easier, because you can immediately understand what property of the input caused the failure.

What is stateful testing?

Stateful testing (also called model-based testing) lets you test sequences of operations, not just single function calls. Use hypothesis.stateful.RuleBasedStateMachine to define rules (operations like insert, delete, query) and invariants. Hypothesis generates sequences of these operations and checks invariants after each step. This is powerful for testing state machines, databases, queues, and any system where order of operations matters.

Should I replace my existing tests with Hypothesis?

No — use both. Example-based tests document specific known behaviors and are fast to run. Property-based tests explore unknown edge cases and validate invariants. A typical approach: write a few example-based tests to nail down the specification, then add property-based tests to verify invariants hold broadly. Your test suite becomes both precise (example-based) and thorough (property-based).

How does Hypothesis remember failing examples?

Hypothesis stores its discovered failing examples in a database directory (by default, .hypothesis/ in your project root). When you run the tests again, it tries the previously failing examples first. This means once a bug is found, it is retested every run — even if the main random generation would not have generated that input again. Add .hypothesis/ to .gitignore for local databases, or commit it to retain team-shared learned examples.

Conclusion

Property-based testing with Hypothesis changes how you think about test coverage. Instead of asking “did I test this specific case?” you ask “what properties must always hold?” We covered the basics: the @given decorator and strategies for common types, composing custom strategies with st.builds(), writing meaningful properties (roundtrip, ordering, length consistency), and controlling test settings. The real payoff comes when Hypothesis finds a minimal failing input you never would have written yourself.

From here, explore Hypothesis’s stateful testing with RuleBasedStateMachine for testing complex state machines, and the st.data() strategy for dynamic input generation within tests. The Hypothesis documentation includes a gallery of real-world bugs found by property testing that is worth reading for inspiration.

Official documentation: hypothesis.readthedocs.io

How To Analyze Networks and Graphs with NetworkX in Python

How To Analyze Networks and Graphs with NetworkX in Python

Last Updated: June 01, 2026

Intermediate

Networks are everywhere: social connections, airline routes, software dependencies, financial transactions, biological pathways. When you need to find the shortest route between two cities, detect communities in a social network, or identify the most influential node in a dependency graph, you need graph analysis tools. Python’s NetworkX library provides all of this in a clean, expressive API that integrates naturally with NumPy, SciPy, and Matplotlib.

NetworkX represents graphs as Python objects, so you can build networks programmatically and then apply powerful algorithms without writing them yourself. It supports undirected graphs, directed graphs (digraphs), multigraphs, and weighted graphs. Installation is simple: pip install networkx matplotlib. The Matplotlib package is needed for visualization.

This article covers everything you need to start working with graphs in Python: creating graphs and adding nodes and edges, calculating basic graph metrics, finding shortest paths, running centrality analysis, detecting connected components, and visualizing networks. By the end, we will analyze a real social network dataset to find the most connected people in a group.

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 →

NetworkX Quick Example

Here is a complete example building a small social network and running basic analysis on it:

# quick_networkx.py
import networkx as nx

# Create an undirected graph
G = nx.Graph()

# Add edges (nodes are created automatically)
G.add_edges_from([
    ('Alice', 'Bob'), ('Alice', 'Carol'),
    ('Bob', 'Carol'), ('Bob', 'Dave'),
    ('Carol', 'Eve'), ('Dave', 'Eve')
])

print("Nodes:", list(G.nodes()))
print("Edges:", list(G.edges()))
print("Number of nodes:", G.number_of_nodes())
print("Number of edges:", G.number_of_edges())

# Shortest path between Alice and Eve
path = nx.shortest_path(G, source='Alice', target='Eve')
print("Shortest path Alice -> Eve:", path)

# Degree of each node (number of connections)
for node, degree in G.degree():
    print(f"  {node}: {degree} connections")

Output:

Nodes: ['Alice', 'Bob', 'Carol', 'Dave', 'Eve']
Edges: [('Alice', 'Bob'), ('Alice', 'Carol'), ('Bob', 'Carol'), ('Bob', 'Dave'), ('Carol', 'Eve'), ('Dave', 'Eve')]
Number of nodes: 6
Number of edges: 6
Shortest path Alice -> Eve: ['Alice', 'Carol', 'Eve']

  Alice: 2 connections
  Bob: 3 connections
  Carol: 3 connections
  Dave: 2 connections
  Eve: 2 connections

NetworkX found the shortest social path from Alice to Eve through Carol in two hops. Bob and Carol have the most connections (degree 3), making them the most central figures in this small network. We built this in about 10 lines of code.

Graph Types in NetworkX

NetworkX supports four main graph types, each suited to different real-world scenarios. Choosing the right type matters because it changes which algorithms are available and what the edges mean.

ClassDirectionMulti-edgesUse Case
GraphUndirectedNoFriendships, protein interactions
DiGraphDirectedNoTwitter follows, dependency graphs
MultiGraphUndirectedYesMulti-lane roads, parallel networks
MultiDiGraphDirectedYesFinancial transactions, flight routes
# graph_types.py
import networkx as nx

# Undirected graph: friendship is mutual
G = nx.Graph()
G.add_edge('Alice', 'Bob')
print("Alice in Bob's neighbors:", 'Alice' in G.neighbors('Bob'))  # True

# Directed graph: following is one-way
DG = nx.DiGraph()
DG.add_edge('Alice', 'Bob')  # Alice follows Bob
print("Bob follows Alice:", DG.has_edge('Bob', 'Alice'))  # False
print("Alice follows Bob:", DG.has_edge('Alice', 'Bob'))  # True

# Weighted graph: distances or strengths
WG = nx.Graph()
WG.add_edge('NYC', 'Chicago', weight=790)
WG.add_edge('Chicago', 'LA', weight=2015)
WG.add_edge('NYC', 'LA', weight=2800)
print("NYC-Chicago distance:", WG['NYC']['Chicago']['weight'])

Output:

Alice in Bob's neighbors: True
Bob follows Alice: False
Alice follows Bob: True
NYC-Chicago distance: 790

Edge weights can represent anything — distance, cost, bandwidth, similarity, or strength of relationship. The weight key is the standard convention in NetworkX, and most shortest-path algorithms use it automatically.

Building network graphs
Graphs are just nodes and edges. NetworkX handles the math in between.

Centrality Analysis

Centrality measures answer the question “which nodes are most important in this network?” Different centrality metrics define “important” in different ways. Degree centrality looks at number of direct connections. Betweenness centrality identifies nodes that bridge different parts of the network. PageRank (the algorithm behind Google Search) measures influence based on the quality of incoming connections.

# centrality_demo.py
import networkx as nx

# Build a slightly larger network
edges = [
    ('Alice', 'Bob'), ('Alice', 'Carol'), ('Alice', 'Dave'),
    ('Bob', 'Eve'), ('Carol', 'Frank'), ('Dave', 'Grace'),
    ('Eve', 'Frank'), ('Frank', 'Grace'), ('Grace', 'Bob')
]
G = nx.Graph()
G.add_edges_from(edges)

# Degree centrality: fraction of nodes connected to this node
degree_c = nx.degree_centrality(G)
print("Degree centrality:")
for node, val in sorted(degree_c.items(), key=lambda x: -x[1]):
    print(f"  {node}: {val:.3f}")

# Betweenness centrality: fraction of shortest paths passing through node
between_c = nx.betweenness_centrality(G)
print("\nBetweenness centrality (bridges):")
for node, val in sorted(between_c.items(), key=lambda x: -x[1])[:3]:
    print(f"  {node}: {val:.3f}")

Output:

Degree centrality:
  Alice: 0.500
  Frank: 0.500
  Grace: 0.500
  Bob: 0.375
  Carol: 0.250
  Dave: 0.250
  Eve: 0.250

Betweenness centrality (bridges):
  Frank: 0.286
  Alice: 0.238
  Grace: 0.214

Frank and Grace are the key bridges in this network — removing them would disconnect the graph into separate clusters. This kind of analysis is invaluable for understanding vulnerability in supply chains, critical nodes in infrastructure, or key connectors in organizational charts.

Shortest Paths and Weighted Routing

Finding the shortest path between nodes is one of the most common graph problems. NetworkX implements Dijkstra’s algorithm, Bellman-Ford, and A* for weighted graphs. For unweighted graphs, it uses BFS. All of these are available with a single function call.

# shortest_path_demo.py
import networkx as nx

# City distance network (weights in miles)
G = nx.Graph()
G.add_weighted_edges_from([
    ('NYC', 'Philadelphia', 95),
    ('Philadelphia', 'Baltimore', 100),
    ('Baltimore', 'DC', 45),
    ('NYC', 'Boston', 215),
    ('Boston', 'Providence', 50),
    ('NYC', 'DC', 230),
    ('DC', 'Richaond', 100)
])

# Shortest path by number of hops (ignoring weight)
hop_path = nx.shortest_path(G, 'NYC', 'Richaond')
print("Shortest by hops:", hop_path)
print("Hops:", len(hop_path) - 1)

# Shortest path by distance (using weights)
dist_path = nx.shortest_path(G, 'NYC', 'Richmond', weight='weight')
dist_len = nx.shortest_path_length(G, 'NYC', 'Richmond', weight='weight')
print("\nShortest by distance:", dist_path)
print("Total distance:", dist_len, "miles")

# All shortest paths from NYC
all_paths = dict(nx.single_source_shortest_path_length(G, 'NYC'))
print("\nAll cities reachable from NYC and hop count:")
for city, hops in sorted(all_paths.items()):
    print(f"  {city}: {hops} hops")

Output:

Shortest by hops: ['NYC', 'DC', 'Richmond']
Hops: 2

Shortest by distance: ['NYC', 'Philadelphia', 'Baltimore', 'DC', 'Richaond']
Total distance: 340 miles

All cities reachable from NYC and hop count:
  NYC: 0 hops
  Boston: 1 hops
  DC: 1 hops
  Philadelphia: 1 hops
  Baltimore: 2 hops
  Providence: 2 hops
  Richmond: 2 hops

Notice that the shortest route by hops (NYC -> DC -> Richmond, 2 stops) is actually 340 miles via DC, the same distance as the longer hop path via Philadelphia. But this is because the direct NYC->DC edge (230 miles) plus DC->Richaond (100) equals 330 miles — actually shorter! NetworkX correctly identifies this using Dijkstra’s algorithm on weighted edges.

Analyzing graph properties
Shortest path, centrality, clustering. NetworkX has an algorithm for that.

Connected Components and Clustering

A connected component is a subgraph where every node can reach every other node. Identifying components helps you find isolated clusters, detect when a network has split, or understand the community structure of a social network.

# components_demo.py
import networkx as nx

# Build a graph with two separate clusters
G = nx.Graph()
# Cluster 1: Python developers
G.add_edges_from([('Alice', 'Bob'), ('Bob', 'Carol'), ('Carol', 'Alice')])
# Cluster 2: Data scientists (not connected to cluster 1 yet)
G.add_edges_from([('Dave', 'Eve'), ('Eve', 'Frank')])
# Isolated node
G.add_node('Greg')

# Find connected components
components = list(nx.connected_components(G))
print(f"Number of components: {len(components)}")
for i, comp in enumerate(components, 1):
    print(f"  Component {i}: {comp}")

# Is the graph connected?
print("Fully connected:", nx.is_connected(G))

# Add a bridge between clusters
G.add_edge('Carol', 'Dave')
print("After bridge -- fully connected:", nx.is_connected(G))

# Clustering coefficient: how tightly knit is each node's neighborhood?
clustering = nx.clustering(G)
print("\nClustering coefficients:")
for node, coef in clustering.items():
    print(f"  {node}: {coef:.3f}")

Output:

Number of components: 3
  Component 1: {'Alice', 'Bob', 'Carol'}
  Component 2: {'Dave', 'Eve', 'Frank'}
  Component 3: {'Greg'}
Fully connected: False
After bridge -- fully connected: True

Clustering coefficients:
  Alice: 1.000
  Bob: 1.000
  Carol: 0.333
  Dave: 0.000
  Eve: 0.000
  Frank: 0.000
  Greg: 0.000

Alice and Bob have clustering coefficient 1.0 — all their neighbors are also connected to each other (a perfect triangle). Carol’s coefficient drops to 0.333 after she bridges to Dave, because Dave and Alice/Bob are not connected. High clustering indicates tight-knit groups; low clustering indicates bridging roles.

Real-Life Example: Analyzing a Software Package Dependency Graph

We will build a directed dependency graph for a Python project, find circular dependencies, and identify the most critical packages by centrality.

# dependency_graph.py
import networkx as nx

# Define package dependencies (A depends on B means edge A -> B)
dependencies = {
    'app': ['flask', 'sqlalchemy', 'celery'],
    'flask': ['werkzeug', 'jinja2', 'click'],
    'sqlalchemy': ['greenlet'],
    'celery': ['kombu', 'billiard', 'redis'],
    'kombu': ['redis', 'amqp'],
    'amqp': ['vine'],
    'jinja2': ['markupsafe'],
    'click': [],
    'werkzeug': [],
    'greenlet': [],
    'billiard': [],
    'redis': [],
    'vine': [],
    'markupsafe': []
}

# Build directed graph
DG = nx.DiGraph()
for package, deps in dependencies.items():
    DG.add_node(package)
    for dep in deps:
        DG.add_edge(package, dep)

print(f"Total packages: {DG.number_of_nodes()}")
print(f"Total dependencies: {DG.number_of_edges()}")

# Find packages with no dependencies (leaf nodes)
leaves = [n for n, d in DG.out_degree() if d == 0]
print(f"\nLeaf packages (no dependencies): {leaves}")

# Find the most depended-upon packages (high in-degree)
print("\nMost required packages:")
for pkg, in_deg in sorted(DG.in_degree(), key=lambda x: -x[1])[:3]:
    print(f"  {pkg}: required by {in_deg} others")

# Topological sort: valid installation order
try:
    install_order = list(nx.topological_sort(DG))
    print(f"\nInstall order (reversed): {install_order[:5]}...")
except nx.NetworkXUnfeasible:
    print("Circular dependency detected!")

# Check for cycles
cycles = list(nx.simple_cycles(DG))
if cycles:
    print(f"Circular dependencies: {cycles}")
else:
    print("\nNo circular dependencies found.")

Output:

Total packages: 14
Total dependencies: 14

Leaf packages (no dependencies): ['click', 'werkzeug', 'greenlet', 'billiard', 'redis', 'vine', 'markupsafe']

Most required packages:
  redis: required by 2 others
  click: required by 1 others
  jinja2: required by 1 others

Install order (reversed): ['app', 'flask', 'jinja2', 'markupsafe', 'click']...

No circular dependencies found.

This analysis immediately reveals that redis is a shared dependency (required by both celery and kombu), making it a critical package to monitor for version conflicts. The topological sort gives us a valid installation order — exactly what pip’s dependency resolver computes internally. You can extend this to parse actual requirements.txt or pyproject.toml files using the pip library or packaging module.

Communities, centrality, shortest path. NetworkX has the algorithms.
Communities, centrality, shortest path. NetworkX has the algorithms.

Frequently Asked Questions

Can NetworkX handle large graphs with millions of nodes?

NetworkX works well for graphs up to a few hundred thousand nodes on a modern machine. For graphs with millions of nodes, consider graph-parallel frameworks like graph-tool (C++ backend) or NetworKit. For distributed processing of billion-scale graphs, Apache Spark’s GraphX or GraphFrames are the standard choice.

How do I visualize larger networks properly?

For small graphs (under ~100 nodes), nx.draw(G, with_labels=True) with Matplotlib works well. For larger graphs, use nx.spring_layout() for force-directed layouts, or export to Gephi with nx.write_gexf(G, 'graph.gexf') for interactive exploration. The pyvis library creates interactive HTML visualizations that work in Jupyter notebooks.

How do I load graph data from files?

NetworkX supports many formats: nx.read_edgelist('edges.txt') for simple edge lists, nx.read_gml() for GML format, nx.read_graphml() for GraphML, and nx.from_pandas_edgelist(df) for Pandas DataFrames. For large datasets, edge list files are the most efficient — each line is a pair of node IDs.

How do weighted shortest paths differ from unweighted?

Unweighted shortest paths minimize the number of edges (hops). Weighted shortest paths (using Dijkstra’s algorithm) minimize the total edge weight. Always pass weight='weight' (or your custom weight attribute name) to nx.shortest_path() when you want distance-based routing. Without this parameter, NetworkX ignores weights and counts hops only.

How do I detect communities in a network?

NetworkX includes several community detection algorithms. The most popular for undirected graphs is the Louvain algorithm, available via the community module: from networkx.algorithms.community import louvain_communities; communities = louvain_communities(G). For smaller graphs, Girvan-Newman algorithm works well: from networkx.algorithms.community import girvan_newman. Community detection is useful for finding friend groups, topic clusters, or organizational units.

Conclusion

We have covered the NetworkX fundamentals: creating graphs with Graph, DiGraph, and weighted edges; calculating centrality metrics with degree_centrality() and betweenness_centrality(); finding shortest paths with shortest_path() and shortest_path_length(); identifying connected components with connected_components(); and applying topological sorting with topological_sort() for dependency resolution. The dependency graph example showed how to translate a real-world engineering problem into a graph analysis workflow.

From here, explore NetworkX’s community detection algorithms for social network analysis, or try exporting your graphs to Gephi for interactive visualization. The nx.generate_random_graphs module provides benchmark graphs for testing your algorithms at scale.

Official documentation: networkx.org/documentation

Creating and Modifying Graphs

NetworkX represents graphs as objects with add/remove methods for nodes and edges. The four graph types cover most needs:

import networkx as nx

# Undirected — friendships, transit lines
G = nx.Graph()
G.add_node("Alice")
G.add_nodes_from(["Bob", "Carol", "Dave"])
G.add_edge("Alice", "Bob")
G.add_edges_from([("Alice", "Carol"), ("Bob", "Dave")])

# Directed — Twitter follows, dependency graphs
D = nx.DiGraph()
D.add_edge("user1", "user2")    # user1 follows user2

# Weighted — road networks, network capacity
W = nx.Graph()
W.add_edge("A", "B", weight=10)
W.add_edge("B", "C", weight=5)
W.add_edge("A", "C", weight=20)

# Multi — multiple edges between same nodes (parallel routes)
M = nx.MultiGraph()
M.add_edge("X", "Y", route="highway")
M.add_edge("X", "Y", route="back-road")

print(G.number_of_nodes(), G.number_of_edges())   # 4 3
print(list(G.neighbors("Alice")))                 # ['Bob', 'Carol']

Reading Graphs from Files

For real-world graphs, load from files instead of building manually:

# Edge list — one edge per line, space-separated
G = nx.read_edgelist("friendships.txt")

# CSV with attributes
import pandas as pd
df = pd.read_csv("edges.csv")
G = nx.from_pandas_edgelist(df, source="from", target="to", edge_attr="weight")

# GraphML — preserves all attributes
G = nx.read_graphml("network.graphml")
nx.write_graphml(G, "out.graphml")

# JSON (for D3.js visualization)
import json
data = nx.node_link_data(G)
with open("graph.json", "w") as f:
    json.dump(data, f)

Shortest Paths and Distances

One of the most-used graph operations — find the shortest route between two nodes:

G = nx.Graph()
G.add_weighted_edges_from([
    ("Home", "Work", 30),
    ("Home", "Gym", 10),
    ("Gym", "Work", 25),
    ("Home", "Cafe", 5),
    ("Cafe", "Work", 35),
])

# Dijkstra with edge weights
path = nx.dijkstra_path(G, "Home", "Work", weight="weight")
print(path)        # ['Home', 'Gym', 'Work']
distance = nx.dijkstra_path_length(G, "Home", "Work", weight="weight")
print(distance)    # 35

# BFS for unweighted graphs (faster)
print(nx.shortest_path(G, "Home", "Work"))

# All shortest paths from one node
print(dict(nx.shortest_path_length(G, "Home", weight="weight")))

Centrality: Who’s Important?

Centrality measures rank nodes by importance. Four common ones:

# Degree centrality — most connections
nx.degree_centrality(G)
# {'Alice': 0.8, 'Bob': 0.4, ...}

# Betweenness — most common "bridge" between others
nx.betweenness_centrality(G)

# Closeness — quickest to reach everyone
nx.closeness_centrality(G)

# PageRank — Google's algorithm
nx.pagerank(G, alpha=0.85)

For a social network, PageRank or betweenness identifies influencers. For a transit network, closeness identifies hubs.

Community Detection

Communities are dense clusters of connected nodes — groups, neighborhoods, market segments:

import networkx as nx
from networkx.algorithms.community import greedy_modularity_communities, louvain_communities

communities = louvain_communities(G, seed=42)
for i, c in enumerate(communities):
    print(f"Community {i}: {len(c)} nodes — {list(c)[:5]}")

# Compare community quality
print(nx.community.modularity(G, communities))

Visualizing Graphs

NetworkX uses matplotlib for plots. For small graphs, the defaults are fine; for larger ones, use a proper graph viz tool (Gephi, Graphistry):

import matplotlib.pyplot as plt

pos = nx.spring_layout(G, seed=42)
nx.draw_networkx_nodes(G, pos, node_color="lightblue", node_size=400)
nx.draw_networkx_edges(G, pos, alpha=0.5)
nx.draw_networkx_labels(G, pos)
plt.axis("off")
plt.savefig("graph.png", dpi=150)

Common Pitfalls

  • Wrong graph type. Using Graph when you have directed data hides the directionality. Pick DiGraph for follows, citations, dependencies.
  • Forgetting weight parameter. Many algorithms accept a weight argument to use edge weights. Default ignores them — your “shortest path” may not be shortest.
  • Slow on large graphs. NetworkX is pure Python — fine to ~100K nodes. For millions, switch to graph-tool (C++) or networkit.
  • Naming algorithm wrong. Some algorithms only work on connected graphs. Check nx.is_connected(G) first or operate on subgraphs.
  • Mutating during iteration. Modifying a graph (add/remove nodes) while iterating over it raises RuntimeError. Build a snapshot first.

FAQ

Q: NetworkX, graph-tool, or igraph?
A: NetworkX for general use — pure Python, clean API, comprehensive algorithm library. graph-tool for performance (compiled). igraph for R interop or specific algorithms NetworkX lacks.

Q: How do I scale to millions of nodes?
A: NetworkX is too slow. Use cuGraph (GPU-accelerated), graph-tool, or specialized graph databases (Neo4j, Memgraph).

Q: Can I run graph algorithms on a DataFrame directly?
A: nx.from_pandas_edgelist converts; nx.to_pandas_edgelist goes back. For graph operations, you need the NetworkX object — keeping it pandas-only doesn’t work.

Q: How do I find cycles in a directed graph?
A: nx.simple_cycles(D) for all simple cycles. nx.is_directed_acyclic_graph(D) to check if a graph is a DAG. nx.topological_sort(D) orders DAG nodes.

Q: Best layout algorithm for visualization?
A: spring_layout for general use, kamada_kawai_layout for smaller graphs (better quality), circular_layout for highly connected graphs.

Wrapping Up

NetworkX is the Swiss Army knife of graph analysis in Python — undirected, directed, weighted, multi-edge graphs all behave consistently. Add a graph from edges, ask shortest-path or centrality questions, and you’re already 80% of the way to solving real network problems. For graphs too large for pure Python (millions of nodes), specialized libraries take over; for everything else NetworkX is the right tool.

Continue Learning Python

Tutorials you might also find useful:

How To Use SymPy for Symbolic Mathematics in Python

How To Use SymPy for Symbolic Mathematics in Python

Last Updated: June 01, 2026

Intermediate

Have you ever solved an algebra problem only to get a decimal approximation when you wanted the exact symbolic answer? Python’s SymPy library solves this problem by treating mathematics the way a mathematician does — symbolically. Instead of computing pi as 3.14159..., SymPy keeps it as the exact symbol pi. Instead of approximating a square root, it returns sqrt(2). This is symbolic computation, and it transforms Python into a full-featured computer algebra system.

SymPy is a pure Python library — no C extensions, no compiled code — so installation is straightforward with pip install sympy. It works alongside NumPy and SciPy but solves a different problem: those libraries compute numerical answers fast, while SymPy computes exact symbolic answers. You can use SymPy to solve equations, expand polynomials, compute derivatives and integrals, factor expressions, and even generate LaTeX output for publication-quality math.

In this article, we will cover the fundamentals of SymPy from the ground up: how to define symbolic variables, simplify and expand expressions, solve equations, compute limits and derivatives, evaluate integrals, and apply SymPy to a practical calculus problem. By the end, you will be able to use Python as a complete algebra and calculus tool.

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 →

SymPy Quick Example

Here is a self-contained example that shows SymPy solving a quadratic equation and computing a derivative — both with exact results:

# quick_sympy.py
from sympy import symbols, solve, diff, expand

x = symbols('x')

# Solve a quadratic equation
equation = x**2 - 5*x + 6
solutions = solve(equation, x)
print("Solutions:", solutions)

# Compute the derivative of x^3 - 2x + 1
expr = x**3 - 2*x + 1
derivative = diff(expr, x)
print("Derivative:", derivative)

# Expand a factored expression
expanded = expand((x + 2) * (x - 3))
print("Expanded:", expanded)

Output:

Solutions: [2, 3]
Derivative: 3*x**2 - 2
Expanded: x**2 - x - 6

These are exact symbolic results. solve() found the roots of the quadratic as integers, not decimals. diff() computed the derivative using the power rule. expand() multiplied out the factors. Every result is a SymPy expression that can be further manipulated, printed as LaTeX, or evaluated numerically.

Symbolic limits. SymPy does L'Hôpital so you don't have to.
Symbolic limits. SymPy does L’Hôpital so you don’t have to.

What Is SymPy and Why Use It?

SymPy is a computer algebra system (CAS) written entirely in Python. A CAS is software that manipulates mathematical expressions in symbolic form, the way a human mathematician writes on paper, rather than computing numerical approximations. SymPy’s closest analogues are Mathematica and Maple, but SymPy is free, open source, and integrates naturally with the Python data science ecosystem.

LibraryPurposeResult TypeBest For
NumPyNumerical computationFloat approximationFast array math
SciPyScientific algorithmsFloat approximationOptimization, stats
SymPySymbolic computationExact expressionAlgebra, calculus proofs

Use SymPy when you need exact answers — factoring polynomials, solving equations analytically, or deriving formulas before implementing them numerically.

Defining Symbolic Variables

The foundation of SymPy is the Symbol class. Before using any variable in a symbolic expression, you declare it with symbols(). This tells SymPy “this letter stands for a mathematical unknown, not a Python value.”

# symbols_demo.py
from sympy import symbols, Symbol

# Single symbol
x = symbols('x')

# Multiple symbols at once
a, b, c = symbols('a b c')

# Symbol with assumptions (positive, real, integer, etc.)
n = symbols('n', integer=True, positive=True)
t = symbols('t', real=True)

print(type(x))        # 
print(x + x)              # 2*x
print(x * x)         # x**2
print(a + b + a)      # 2*a + b

Output:

<class 'sympy.core.symbol.Symbol'>
2*x
x**2
2*a + b

Assumptions like positive=True or integer=True help SymPy simplify expressions correctly. For example, sqrt(x**2) only simplifies to x when SymPy knows x is non-negative.

Solving equations with SymPy
SymPy solves equations symbolically. No floating point rounding. No approximations.

Simplifying and Manipulating Expressions

Once you have symbols, you can build expressions and simplify them. SymPy provides simplify(), expand(), factor(), cancel(), and collect() — each targeting different transformation goals.

# simplify_demo.py
from sympy import symbols, simplify, expand, factor, cancel, trigsimp, sin, cos

x, y = symbols('x y')

# simplify -- general-purpose simplification
expr1 = (x**2 - 1) / (x - 1)
print("cancel:", cancel(expr1))           # x + 1

# expand -- distribute multiplication
expr2 = (x + y)**3
print("expand:", expand(expr2))           # x**3 + 3*x**2*y + 3*x*y**2 + y**3

# factor -- reverse of expand
expr3 = x**3 + 3*x**2*y + 3*x*y**2 + y**3
print("factor:", factor(expr3))           # (x + y)**3

# trigsimp -- simplify trig expressions
trig_expr = sin(x)**2 + cos(x)**2
print("trigsimp:", trigsimp(trig_expr))   # 1

Output:

cancel: x + 1
expand: x**3 + 3*x**2*y + 3*x*y**2 + y**3
factor: (x + y)**3
trigsimp: 1

Notice that cancel() correctly identified that (x**2 - 1)/(x - 1) simplifies to x + 1 by canceling the common factor (x - 1). This is exact symbolic cancellation — floating-point arithmetic would have struggled near x = 1.

Solving Equations

The solve() function finds the values of unknowns that satisfy an equation. You can solve for one variable in terms of others, solve systems of equations, and even solve inequalities. Pass the expression (or a list of expressions) and the variable(s) to solve for.

# solve_demo.py
from sympy import symbols, solve, Eq, sqrt
	x, y = symbols('x y')

# Solve x^2 = 9
solutions = solve(x**2 - 9, x)
print("x^2 = 9:", solutions)           # [-3, 3]

# Use Eq() for equations with both sides
eq = Eq(2*x + 3, 11)
print("2x + 3 = 11:", solve(eq, x))      # [4]

# System of equations
eq1 = Eq(x + y, 7)
eq2 = Eq(2*x - y, 2)
print("System:", solve([eq1, eq2], [x, y]))  # {x: 3, y: 4}

# Solve quadratic formula symbolically
a, b, c = symbols('a b c')
quad = a*x**2 + b*x + c
print("Quadratic formula:", solve(quad, x))

Output:

x^2 = 9: [-3, 3]
2x + 3 = 11: [4]
System: {x: 3, y: 4}
Quadratic formula: [(-b - sqrt(-4*a*c + b**2))/(2*a), (-b + sqrt(-4*a*c + b**2))/(2*a)]

The last result is the closed-form quadratic formula — SymPy returns both roots as exact symbolic expressions. No numerical approximation, no round-off error. This is the kind of thing you’d otherwise look up in a reference; SymPy derives it from a*x**2 + b*x + c = 0 in one call.

Calculus: Derivatives, Integrals, Limits

SymPy’s calculus toolbox is what makes it indispensable for physics, engineering, and machine-learning gradient work. The four core functions you’ll use most are diff() (derivatives), integrate() (integrals), limit() (limits), and series() (Taylor expansion).

# calculus_demo.py
from sympy import symbols, diff, integrate, limit, series, sin, cos, oo, exp

x = symbols('x')

# Derivatives
f = x**3 + 2*x**2 - 5*x + 1
print("f(x)    =", f)
print("f'(x)   =", diff(f, x))         # first derivative
print("f''(x)  =", diff(f, x, 2))      # second derivative

# Partial derivatives
y = symbols('y')
g = x**2 * y + y**3
print("dg/dx  =", diff(g, x))          # treat y as constant
print("dg/dy  =", diff(g, y))

# Indefinite integral (antiderivative)
print("∫(2x + 3) dx =", integrate(2*x + 3, x))

# Definite integral
print("∫₀^π sin(x) dx =", integrate(sin(x), (x, 0, 3.14159)))

# Limits
print("lim x→0 sin(x)/x =", limit(sin(x)/x, x, 0))
print("lim x→∞ 1/x      =", limit(1/x, x, oo))

# Taylor series — first 5 terms of e^x around 0
print("e^x series:", series(exp(x), x, 0, 5))

Output:

f(x)    = x**3 + 2*x**2 - 5*x + 1
f'(x)   = 3*x**2 + 4*x - 5
f''(x)  = 6*x + 4
dg/dx  = 2*x*y
dg/dy  = x**2 + 3*y**2
∫(2x + 3) dx = x**2 + 3*x
∫₀^π sin(x) dx = 1.99999999...
lim x→0 sin(x)/x = 1
lim x→∞ 1/x      = 0
e^x series: 1 + x + x**2/2 + x**3/6 + x**4/24 + O(x**5)

oo is SymPy’s infinity. Notice limit(sin(x)/x, x, 0) returns exactly 1, not a numerical estimate — SymPy applies L’Hôpital’s rule symbolically. The Taylor series O(x**5) is the “big-O” remainder, exact to the term you asked for.

Linear Algebra with Matrices

SymPy ships with a Matrix class that works just like NumPy’s arrays but holds symbolic entries. You can compute determinants, inverses, eigenvalues, and reduced row echelon form symbolically:

# matrix_demo.py
from sympy import Matrix, symbols, eye

a, b = symbols('a b')

M = Matrix([
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 10],   # 10 instead of 9 so the matrix is invertible
])

print("Determinant:", M.det())
print("Inverse:")
print(M.inv())
print("Eigenvalues:", M.eigenvals())
print("Rank:", M.rank())

# Symbolic 2x2 matrix
S = Matrix([[a, b], [b, a]])
print("S squared:", S * S)
print("S inverse:", S.inv())

Output:

Determinant: -3
Inverse:
Matrix([[-2/3, -4/3, 1], [-2/3, 11/3, -2], [1, -2, 1]])
Eigenvalues: {16/3 + ...: 1, 16/3 - ...: 1, -1: 1}
Rank: 3
S squared: Matrix([[a**2 + b**2, 2*a*b], [2*a*b, a**2 + b**2]])
S inverse: Matrix([[a/(a**2 - b**2), -b/(a**2 - b**2)], [-b/(a**2 - b**2), a/(a**2 - b**2)]])

For numerical linear algebra at scale, use NumPy or SciPy — SymPy is slower because every cell carries its symbolic structure. SymPy shines when you want the closed form, not a number.

lambdify(): symbolic to numeric, in milliseconds.
lambdify(): symbolic to numeric, in milliseconds.

From Symbolic to Numerical: evalf() and lambdify()

Symbolic math is great, but eventually you want numbers. Two functions bridge the gap:

evalf() — evaluate a symbolic expression to a numeric value with arbitrary precision:

from sympy import pi, sqrt, E

print(pi.evalf())                  # 3.14159265358979
print(pi.evalf(50))                # 50 digits of pi
print(sqrt(2).evalf())             # 1.41421356237310
print((E**2).evalf())              # 7.38905609893065

lambdify() — convert a symbolic expression into a fast NumPy/SciPy-compatible function. This is the bridge to numerical work, plotting, and ML:

from sympy import symbols, lambdify, sin, cos
import numpy as np

x = symbols('x')
expr = sin(x) + cos(x) * x**2

# Build a numeric function from the symbolic expression
f = lambdify(x, expr, modules='numpy')

# Now call it like any vectorized function
xs = np.linspace(0, 6, 7)
print(f(xs))

lambdify compiles the symbolic expression into Python source and then turns it into a callable. The first call is the slowest because of compilation; subsequent calls are pure NumPy speed. This pattern is the standard way to derive a gradient symbolically with SymPy, then plug it into a numerical optimizer like scipy.optimize.

Common Pitfalls and Gotchas

  • Forgetting to declare symbols. solve(x**2 - 1, x) fails with NameError if x isn’t declared first via symbols('x'). SymPy doesn’t treat Python variable names as symbolic — you have to opt in.
  • Integer division surprises. 1/2 in Python 3 is 0.5 (a float), but inside SymPy expressions you often want the exact fraction 1/2. Wrap literals with Rational(1, 2) when exactness matters: integrate(x, (x, 0, Rational(1, 2))).
  • Confusing = and Eq. solve(x**2 = 9, x) is a Python syntax error. Use either solve(x**2 - 9, x) (move everything to one side, SymPy assumes = 0) or solve(Eq(x**2, 9), x) (build an explicit equation object).
  • Slowness on large expressions. SymPy is exact, not fast. Expressions with hundreds of nested symbols can take minutes to simplify. For hot loops, lambdify the expression to NumPy and run the loop there.
  • Assumptions matter. sqrt(x**2) returns sqrt(x**2) by default because SymPy doesn’t know if x is positive. Declare it: x = symbols('x', positive=True) and now sqrt(x**2) simplifies to x.

FAQ

Q: When should I use SymPy vs NumPy?
A: Use SymPy when you need exact answers, closed-form expressions, derivatives, or to prove an identity. Use NumPy when you have numeric arrays and need speed. They complement each other — SymPy derives the math, NumPy runs the math.

Q: Why does integrate() return an unevaluated Integral?
A: SymPy returns the unevaluated form when it can’t find a closed-form antiderivative. Some integrals genuinely don’t have an elementary antiderivative (Gaussian, error function, etc.). Try a definite integral with explicit bounds, or use nintegrate for numerical integration via mpmath.

Q: How do I plot a SymPy expression?
A: Either use SymPy’s built-in sympy.plotting.plot() (good for quick plots), or lambdify the expression and pass it to matplotlib. The matplotlib path gives you full styling control.

Q: Can SymPy solve differential equations?
A: Yes — dsolve() handles ordinary differential equations symbolically. It works for linear ODEs, separable equations, and many classical forms. For PDEs or messy nonlinear ODEs, fall back to scipy.integrate.solve_ivp with a lambdified RHS.

Q: Is SymPy thread-safe?
A: Mostly yes for read-only operations, but the assumption system has global state. If you’re running symbolic math in multiple threads, give each thread its own set of symbols and don’t share simplification caches.

Wrapping Up

SymPy gives Python the same symbolic-math power that Mathematica and Maple have offered for decades, in pure Python with a clean API. Start with symbols(), simplify(), solve(), diff(), and integrate() — those five functions cover 80% of everyday symbolic work. When you need a number, reach for evalf() or lambdify() and hand off to NumPy.

The SymPy documentation has the complete reference, including the more specialized modules (statistics, geometry, combinatorics, physics, quantum). For tutorials on related Python topics, see below.

How To Use SciPy for Scientific Computing in Python

How To Use SciPy for Scientific Computing in Python

Last Updated: June 01, 2026

Intermediate

NumPy handles arrays. Pandas handles tables. But when you need to solve a system of equations, find the minimum of a complex function, integrate a curve, or run a statistical test, you need SciPy. It is the Swiss Army knife of scientific computing in Python, built on top of NumPy and packed with algorithms that scientists, engineers, and data professionals use every day.

SciPy is organised into subpackages, each covering a different domain: optimization, linear algebra, statistics, signal processing, interpolation, and more. You rarely import all of SciPy at once — instead, you pull in just the subpackage you need, keeping your code clean and your imports explicit.

In this tutorial, you will learn how to install SciPy, solve optimization problems, perform statistical tests, work with linear algebra, integrate functions numerically, interpolate data points, and process signals. By the end, you will have a practical toolkit for tackling real scientific and engineering problems in Python.

Setting up SciPy environment
SciPy installs in one pip command. The math takes a bit longer.
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 →

SciPy for Scientific Computing: Quick Example

Let us start with a common task: finding the minimum of a mathematical function. This comes up everywhere from machine learning (gradient descent) to engineering (minimising material cost).

# quick_scipy.py
from scipy.optimize import minimize
import numpy as np

# Define a function: the Rosenbrock function (a classic test problem)
def rosenbrock(x):
    return (1 - x[0])**2 + 100 * (x[1] - x[0]**2)**2

# Find the minimum starting from an initial guess
result = minimize(rosenbrock, x0=[0, 0], method='Nelder-Mead')

print(f"Minimum found at: x={result.x[0]:.4f}, y={result.x[1]:.4f}")
print(f"Minimum value: {result.fun:.6f}")
print(f"Converged: {result.success}")
print(f"Iterations: {result.nit}")

Output:

Minimum found at: x=1.0000, y=1.0000
Minimum value: 0.000000
Converged: True
Iterations: 141

What this does step by step: The Rosenbrock function is a famous test problem with a global minimum at (1, 1). We pass it to scipy.optimize.minimize with an initial guess of (0, 0) and the Nelder-Mead algorithm (a gradient-free method). SciPy iterates 141 times and finds the exact minimum. This same function can minimise any callable Python function, making it invaluable for fitting models, calibrating parameters, and solving engineering problems.

Installing SciPy

SciPy depends on NumPy and ships with precompiled binaries for all major platforms, so installation is straightforward.

# Install SciPy
pip install scipy

# Verify installation and check version
python -c "import scipy; print(scipy.__version__)"

# SciPy subpackages are imported individually:
# from scipy import optimize, stats, linalg, integrate, interpolate, signal

Unlike some scientific libraries, SciPy installs cleanly on Windows, macOS, and Linux without needing a C compiler. The pip package includes optimised BLAS and LAPACK routines, so you get near-Fortran performance out of the box. If you are using Anaconda, SciPy comes preinstalled.

Statistical analysis with SciPy
scipy.stats speaks fluent probability so you dont have to.

Optimization: Finding Minimums and Solving Equations

The scipy.optimize module is one of the most-used parts of SciPy. It handles curve fitting, root finding, and general optimization.

Curve Fitting with curve_fit

When you have experimental data and want to find the best parameters for a model, curve_fit is your go-to tool.

import numpy as np
from scipy.optimize import curve_fit
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt

# Generate noisy exponential decay data
np.random.seed(42)
x_data = np.linspace(0, 10, 50)
y_data = 3.5 * np.exp(-0.7 * x_data) + np.random.normal(0, 0.2, 50)

# Define the model function
def exponential_decay(x, amplitude, decay_rate):
    return amplitude * np.exp(-decay_rate * x)

# Fit the model to data
params, covariance = curve_fit(exponential_decay, x_data, y_data)
amplitude, decay_rate = params

print(f"Fitted amplitude: {amplitude:.3f} (true: 3.500)")
print(f"Fitted decay rate: {decay_rate:.3f} (true: 0.700)")

# Calculate uncertainties from covariance matrix
uncertainties = np.sqrt(np.diag(covariance))
print(f"Amplitude uncertainty: +/- {uncertainties[0]:.3f}")
print(f"Decay rate uncertainty: +/- {uncertainties[1]:.3f}")

curve_fit returns two things: the optimal parameters and a covariance matrix. The diagonal of the covariance matrix gives you the variance of each parameter, and taking the square root gives you the standard error. This tells you not just the best fit, but how confident you should be in each parameter.

Root Finding

Finding where a function equals zero is fundamental to solving equations. SciPy offers several root-finding algorithms.

from scipy.optimize import brentq, fsolve
import numpy as np

# Find where x^3 - 2x - 5 = 0 using Brent's method (bracketed)
def cubic(x):
    return x**3 - 2*x - 5

root = brentq(cubic, 1, 3)  # Root must be between 1 and 3
print(f"Root of x^3 - 2x - 5: {root:.6f}")
print(f"Verification: f({root:.4f}) = {cubic(root):.2e}")

# Solve a system of nonlinear equations with fsolve
def system(variables):
    x, y = variables
    eq1 = x**2 + y**2 - 4    # Circle of radius 2
    eq2 = x - y**2 + 1        # Parabola
    return [eq1, eq2]

solution = fsolve(system, x0=[1, 1])
print(f"\nSystem solution: x={solution[0]:.4f}, y={solution[1]:.4f}")
print(f"Check circle: {solution[0]**2 + solution[1]**2:.4f} (should be 4)")

brentq is the fastest bracketed root finder — you give it an interval where the function changes sign, and it guarantees finding the root. fsolve handles systems of nonlinear equations, taking an initial guess and using Newton-type iterations to converge on the solution.

Statistical Analysis with scipy.stats

The scipy.stats module contains over 100 probability distributions plus a comprehensive collection of statistical tests.

from scipy import stats
import numpy as np

# Generate two samples
np.random.seed(42)
group_a = np.random.normal(loc=75, scale=10, size=100)  # Mean 75, std 10
group_b = np.random.normal(loc=78, scale=10, size=100)  # Mean 78, std 10

# Descriptive statistics
desc_a = stats.describe(group_a)
print(f"Group A: mean={desc_a.mean:.2f}, variance={desc_a.variance:.2f}")
print(f"  skewness={desc_a.skewness:.3f}, kurtosis={desc_a.kurtosis:.3f}")

# Independent samples t-test
t_stat, p_value = stats.ttest_ind(group_a, group_b)
print(f"\nT-test: t={t_stat:.3f}, p-value={p_value:.4f}")
print(f"Significant at 0.05? {'Yes' if p_value < 0.05 else 'No'}")

# Mann-Whitney U test (non-parametric alternative)
u_stat, p_mann = stats.mannwhitneyu(group_a, group_b, alternative='two-sided')
print(f"\nMann-Whitney U: U={u_stat:.1f}, p-value={p_mann:.4f}")

# Shapiro-Wilk normality test
w_stat, p_normal = stats.shapiro(group_a)
print(f"\nShapiro-Wilk (Group A): W={w_stat:.4f}, p-value={p_normal:.4f}")
print(f"Normal distribution? {'Yes' if p_normal > 0.05 else 'No'}")

# Pearson correlation
correlation, p_corr = stats.pearsonr(group_a[:50], group_b[:50])
print(f"\nPearson correlation: r={correlation:.3f}, p-value={p_corr:.4f}")

The t-test tells you whether two groups have significantly different means. The Mann-Whitney U test does the same thing but does not assume normal distributions. The Shapiro-Wilk test checks whether your data is normally distributed (important for choosing the right test). Always check your assumptions before picking a statistical test.

Working with Probability Distributions

from scipy import stats
import numpy as np

# Normal distribution
normal = stats.norm(loc=100, scale=15)  # IQ distribution: mean=100, std=15
print(f"P(IQ > 130): {1 - normal.cdf(130):.4f}")
print(f"P(85 < IQ < 115): {normal.cdf(115) - normal.cdf(85):.4f}")
print(f"IQ at 95th percentile: {normal.ppf(0.95):.1f}")

# Generate random samples
samples = normal.rvs(size=1000, random_state=42)
print(f"\nSample mean: {np.mean(samples):.1f}, std: {np.std(samples):.1f}")

# Fit a distribution to data
params = stats.norm.fit(samples)
print(f"Fitted params: mean={params[0]:.1f}, std={params[1]:.1f}")

# Chi-squared goodness of fit
observed = [18, 22, 20, 25, 15]
expected = [20, 20, 20, 20, 20]
chi2, p_chi = stats.chisquare(observed, expected)
print(f"\nChi-squared test: chi2={chi2:.2f}, p-value={p_chi:.4f}")

Every distribution in SciPy has the same interface: .pdf() for probability density, .cdf() for cumulative probability, .ppf() for percentiles (inverse CDF), .rvs() for random samples, and .fit() for parameter estimation. Once you learn one distribution, you know them all.

Optimization with SciPy
scipy.optimize finds the minimum your gradient descent missed.

Linear Algebra with scipy.linalg

While NumPy has basic linear algebra, scipy.linalg adds decompositions, matrix functions, and specialised solvers that go well beyond the basics.

from scipy import linalg
import numpy as np

# Solve a system of linear equations: Ax = b
A = np.array([[3, 1, -1],
              [1, 4, 2],
              [2, 1, 3]])
b = np.array([4, 17, 13])

x = linalg.solve(A, b)
print(f"Solution: {x}")
print(f"Verification Ax: {A @ x}")

# LU decomposition
P, L, U = linalg.lu(A)
print(f"\nLU decomposition:")
print(f"L (lower triangular):\n{np.round(L, 3)}")
print(f"U (upper triangular):\n{np.round(U, 3)}")

# Eigenvalues and eigenvectors
eigenvalues, eigenvectors = linalg.eig(A)
print(f"\nEigenvalues: {eigenvalues.real.round(3)}")

# Matrix determinant and inverse
det = linalg.det(A)
inv = linalg.inv(A)
print(f"\nDeterminant: {det:.1f}")
print(f"A @ inv(A) (should be identity):\n{np.round(A @ inv, 1)}")

# Singular Value Decomposition
U_svd, s, Vt = linalg.svd(A)
print(f"\nSingular values: {s.round(3)}")

linalg.solve is faster and more numerically stable than computing the inverse and multiplying. LU decomposition breaks a matrix into lower and upper triangular parts, which is useful when you need to solve the same system with different right-hand sides. SVD is the workhorse behind dimensionality reduction, recommendation systems, and image compression.

Numerical Integration

When you cannot find an analytical solution to an integral, numerical integration gives you an accurate approximation.

from scipy import integrate
import numpy as np

# Integrate a simple function: integral of sin(x) from 0 to pi
result, error = integrate.quad(lambda x: np.sin(x), 0, np.pi)
print(f"Integral of sin(x) from 0 to pi: {result:.6f} (exact: 2.0)")
print(f"Estimated error: {error:.2e}")

# Integrate a more complex function: Gaussian integral
result, error = integrate.quad(
    lambda x: np.exp(-x**2),
    -np.inf, np.inf  # Infinite limits are supported!
)
print(f"\nGaussian integral: {result:.6f} (exact: sqrt(pi) = {np.sqrt(np.pi):.6f})")

# Double integral: integral of x*y over the unit circle
def integrand(y, x):  # Note: y first, then x
    return x * y

def y_lower(x):
    return -np.sqrt(1 - x**2)

def y_upper(x):
    return np.sqrt(1 - x**2)

result, error = integrate.dblquad(integrand, -1, 1, y_lower, y_upper)
print(f"\nDouble integral of xy over unit circle: {result:.6f} (exact: 0)")

# Solve an ODE: dy/dt = -2y, y(0) = 1 (exponential decay)
def decay(t, y):
    return -2 * y

t_span = (0, 5)
t_eval = np.linspace(0, 5, 100)
solution = integrate.solve_ivp(decay, t_span, [1.0], t_eval=t_eval)

print(f"\ny(0) = {solution.y[0][0]:.4f}")
print(f"y(5) = {solution.y[0][-1]:.6f} (exact: {np.exp(-10):.6f})")

quad handles single integrals and even supports infinite limits. dblquad handles double integrals. solve_ivp solves initial value problems for ordinary differential equations, which is essential for modelling anything that changes over time: population growth, chemical reactions, mechanical systems, or circuit dynamics.

Interpolation: Filling in the Gaps

When you have data at discrete points and need values between them, interpolation creates a smooth function that passes through your data.

from scipy.interpolate import interp1d, CubicSpline
import numpy as np

# Known data points (e.g., temperature readings every 3 hours)
hours = np.array([0, 3, 6, 9, 12, 15, 18, 21, 24])
temps = np.array([15, 13, 14, 18, 24, 26, 23, 19, 16])

# Linear interpolation
linear_interp = interp1d(hours, temps, kind='linear')

# Cubic spline interpolation (smoother)
cubic_interp = CubicSpline(hours, temps)

# Evaluate at every half hour
fine_hours = np.linspace(0, 24, 49)
linear_temps = linear_interp(fine_hours)
cubic_temps = cubic_interp(fine_hours)

# Compare at hour 7.5
print(f"Temperature at 7:30 AM:")
print(f"  Linear interpolation: {linear_interp(7.5):.1f} C")
print(f"  Cubic spline: {cubic_interp(7.5):.1f} C")

# Cubic spline also gives derivatives
print(f"\nRate of change at noon: {cubic_interp(12, 1):.2f} C/hour")
print(f"Rate of change at 6 PM: {cubic_interp(18, 1):.2f} C/hour")

Linear interpolation draws straight lines between points -- simple but produces sharp corners. Cubic splines create smooth curves that pass through every point and have continuous first and second derivatives. The CubicSpline object can also compute derivatives at any point, which is useful for finding rates of change.

Interpolation and curve fitting
Connecting dots is easy. Connecting them correctly is scipy.interpolate.

Signal Processing Basics

The scipy.signal module handles filtering, spectral analysis, and signal manipulation -- essential for audio processing, sensor data, and time series analysis.

from scipy import signal
import numpy as np

# Create a noisy signal: clean sine wave + noise
np.random.seed(42)
fs = 1000  # Sampling frequency (Hz)
t = np.linspace(0, 1, fs, endpoint=False)
clean_signal = np.sin(2 * np.pi * 5 * t) + 0.5 * np.sin(2 * np.pi * 50 * t)
noisy_signal = clean_signal + 0.5 * np.random.randn(len(t))

# Design a low-pass Butterworth filter (remove frequencies above 20 Hz)
nyquist = fs / 2
cutoff = 20 / nyquist  # Normalize to Nyquist frequency
b, a = signal.butter(N=4, Wn=cutoff, btype='low')

# Apply the filter
filtered = signal.filtfilt(b, a, noisy_signal)

print(f"Original signal RMS: {np.sqrt(np.mean(noisy_signal**2)):.3f}")
print(f"Filtered signal RMS: {np.sqrt(np.mean(filtered**2)):.3f}")
print(f"Noise reduced by: {(1 - np.sqrt(np.mean((filtered - clean_signal[:len(filtered)])**2)) / np.sqrt(np.mean((noisy_signal - clean_signal)**2))) * 100:.1f}%")

# Find peaks in the filtered signal
peaks, properties = signal.find_peaks(filtered, height=0.5, distance=50)
print(f"\nPeaks found: {len(peaks)}")
print(f"Peak heights: {properties['peak_heights'][:5].round(3)}")

signal.butter designs a Butterworth filter with a smooth frequency response. signal.filtfilt applies it forward and backward to eliminate phase distortion. find_peaks locates local maxima in a signal, with parameters to control minimum height and distance between peaks. This pipeline -- design filter, apply filter, find features -- is the backbone of most signal processing workflows.

Real-World Example: Analysing Experimental Data

Let us combine multiple SciPy tools to analyse a realistic dataset: fitting a model to noisy measurements, testing hypotheses, and quantifying uncertainty.

import numpy as np
from scipy import stats, optimize, integrate

# Simulate experimental data: drug dosage vs response
np.random.seed(42)
doses = np.array([0, 0.5, 1, 2, 5, 10, 20, 50, 100])
true_response = 100 * doses / (10 + doses)  # Hill equation (pharmacology)
measured_response = true_response + np.random.normal(0, 5, len(doses))
measured_response = np.clip(measured_response, 0, 100)

# Fit the Hill equation to data
def hill_equation(dose, v_max, k_half):
    return v_max * dose / (k_half + dose)

params, cov = optimize.curve_fit(
    hill_equation, doses, measured_response,
    p0=[100, 10],  # Initial guesses
    bounds=([0, 0], [200, 100])  # Parameter bounds
)
v_max, k_half = params
errors = np.sqrt(np.diag(cov))

print("Hill Equation Fit Results:")
print(f"  V_max = {v_max:.1f} +/- {errors[0]:.1f} (true: 100)")
print(f"  K_half = {k_half:.1f} +/- {errors[1]:.1f} (true: 10)")

# Calculate R-squared
predicted = hill_equation(doses, *params)
ss_res = np.sum((measured_response - predicted)**2)
ss_tot = np.sum((measured_response - np.mean(measured_response))**2)
r_squared = 1 - ss_res / ss_tot
print(f"  R-squared = {r_squared:.4f}")

# Calculate Area Under the Curve (AUC) using integration
auc, auc_error = integrate.quad(
    lambda d: hill_equation(d, *params), 0, 100
)
print(f"\nArea Under Curve (0-100): {auc:.1f} +/- {auc_error:.2e}")

# Statistical test: is the response at dose=50 significantly different from dose=5?
np.random.seed(42)
samples_dose5 = hill_equation(5, *params) + np.random.normal(0, 5, 30)
samples_dose50 = hill_equation(50, *params) + np.random.normal(0, 5, 30)

t_stat, p_value = stats.ttest_ind(samples_dose5, samples_dose50)
print(f"\nDose 5 vs Dose 50 comparison:")
print(f"  Mean at dose 5: {np.mean(samples_dose5):.1f}")
print(f"  Mean at dose 50: {np.mean(samples_dose50):.1f}")
print(f"  t-statistic: {t_stat:.3f}")
print(f"  p-value: {p_value:.2e}")
print(f"  Significant difference: {'Yes' if p_value < 0.05 else 'No'}")

This example mirrors a real pharmacology workflow: fit a dose-response curve, quantify parameter uncertainty, calculate the area under the curve (a standard efficacy metric), and test whether two dosage levels produce significantly different responses. The same pattern applies to any field where you need to model data and draw statistical conclusions.

Frequently Asked Questions

What is the difference between NumPy and SciPy?

NumPy provides the fundamental array data structure and basic operations like element-wise math, reshaping, and basic linear algebra. SciPy builds on NumPy and adds higher-level scientific algorithms: optimization, statistics, signal processing, integration, interpolation, and advanced linear algebra. Think of NumPy as the foundation and SciPy as the specialised toolkit.

Should I use scipy.linalg or numpy.linalg?

scipy.linalg is a superset of numpy.linalg with additional decompositions and solvers. It also always uses BLAS and LAPACK, which can be faster. For basic operations like dot or norm, either works fine. For decompositions (LU, Cholesky, SVD) or specialised solvers, prefer scipy.linalg.

How do I choose between different optimization methods?

If your function is smooth and differentiable, use L-BFGS-B or BFGS (fast, gradient-based). If you cannot compute gradients, use Nelder-Mead or Powell. If your function has many local minima, consider differential_evolution (global optimizer). For bounded problems, L-BFGS-B handles box constraints natively.

Can SciPy handle large datasets?

SciPy works with NumPy arrays, so it handles arrays that fit in memory efficiently. For very large sparse matrices, use scipy.sparse, which stores only non-zero elements. For datasets larger than memory, consider chunked processing or libraries like Dask that parallelize SciPy operations.

How do I choose the right statistical test?

For comparing two group means with normal data, use the t-test (ttest_ind). For non-normal data, use Mann-Whitney U (mannwhitneyu). For more than two groups, use one-way ANOVA (f_oneway) or Kruskal-Wallis (kruskal). Always check normality with shapiro first and check equal variances with levene before choosing a parametric test.

Wrapping Up

SciPy gives you access to decades of scientific computing algorithms through a clean, consistent Python interface. You have learned how to optimise functions and fit models with scipy.optimize, run statistical tests and work with distributions using scipy.stats, solve linear algebra problems with scipy.linalg, integrate functions numerically with scipy.integrate, interpolate data with scipy.interpolate, and process signals with scipy.signal. The key to using SciPy effectively is knowing which subpackage to reach for and understanding the assumptions behind each algorithm. Start with the examples in this tutorial, adapt them to your own data, and you will find that SciPy handles the mathematical heavy lifting while you focus on the science.

Related Articles

How To Create Data Visualizations with Seaborn in Python

How To Create Data Visualizations with Seaborn in Python

Last Updated: June 01, 2026

Intermediate

Raw numbers in a spreadsheet rarely tell a compelling story. A well-crafted chart, on the other hand, can reveal patterns in seconds that would take minutes of scanning rows and columns. Python’s seaborn library sits on top of matplotlib and turns complex statistical visualizations into one-line function calls with beautiful default styling. Whether you need a quick histogram, a correlation heatmap, or a multi-faceted regression plot, Seaborn handles the heavy lifting so you can focus on understanding your data.

In this tutorial, you will learn how to install Seaborn, create common chart types including scatter plots, bar charts, histograms, and heatmaps, customise styles and colour palettes, work with real-world datasets, build multi-plot grids with FacetGrid, and export publication-ready figures. By the end, you will have the tools to turn any pandas DataFrame into a visual story.

Getting started with Seaborn
Seaborn makes matplotlib pretty. Thats the whole pitch.
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 →

Data Visualizations with Seaborn: Quick Example

Let us start with a scatter plot that reveals the relationship between two variables in a built-in dataset. This gets you from zero to a polished chart in four lines.

# quick_seaborn.py
import seaborn as sns
import matplotlib.pyplot as plt

tips = sns.load_dataset("tips")
sns.scatterplot(data=tips, x="total_bill", y="tip", hue="time")
plt.title("Tips by Total Bill")
plt.tight_layout()
plt.savefig("tips_scatter.png", dpi=150)
plt.show()

What this does step by step:

sns.load_dataset("tips") fetches a built-in DataFrame with restaurant tipping data. sns.scatterplot creates a scatter plot with total_bill on the x-axis and tip on the y-axis, automatically colouring points by time (Lunch vs Dinner). The plt.tight_layout() call prevents labels from being clipped, and savefig exports the chart at 150 DPI. You should see a clear upward trend: larger bills tend to produce larger tips, with dinner and lunch points neatly separated by colour.

Installing Seaborn and Its Dependencies

Seaborn requires matplotlib, pandas, and numpy, but pip handles all of them automatically.

# Install seaborn (pulls matplotlib, pandas, numpy)
pip install seaborn

# Verify the installation
python -c "import seaborn as sns; print(sns.__version__)"

If you are using Jupyter notebooks, Seaborn works out of the box. For scripts, remember to call plt.show() to display charts in a window, or use plt.savefig() to save them directly to files. Seaborn version 0.12 and above uses a new interface with the objects module, but the classic functional API we use here remains fully supported and is still the most common approach you will find in tutorials and production code.

Creating plots with Seaborn
One line of code, one beautiful plot. Seaborn delivers.

Essential Chart Types in Seaborn

Seaborn organises its plotting functions into categories based on the type of relationship you want to show. Understanding which function to use for your data is half the battle.

Scatter Plots and Line Plots for Relationships

Use sns.scatterplot when you want to see how two continuous variables relate. Use sns.lineplot when your x-axis represents a sequence like time.

import seaborn as sns
import matplotlib.pyplot as plt

# Scatter plot with size encoding
tips = sns.load_dataset("tips")
sns.scatterplot(
    data=tips,
    x="total_bill",
    y="tip",
    hue="day",
    size="size",
    sizes=(20, 200),
    alpha=0.7
)
plt.title("Tip Amount vs Total Bill by Day")
plt.show()

# Line plot for time series
fmri = sns.load_dataset("fmri")
sns.lineplot(
    data=fmri,
    x="timepoint",
    y="signal",
    hue="region",
    style="event",
    errorbar="sd"
)
plt.title("FMRI Signal Over Time")
plt.show()

The hue parameter assigns colours by category, size maps a numeric column to marker diameter, and alpha controls transparency so overlapping points remain visible. For line plots, errorbar="sd" adds a shaded band showing the standard deviation, giving you a sense of how much the data varies at each time point.

Bar Charts and Count Plots for Categories

When one of your axes represents a category rather than a continuous number, bar charts are the right choice.

# Average tip by day
sns.barplot(data=tips, x="day", y="tip", hue="sex", errorbar="sd")
plt.title("Average Tip by Day and Gender")
plt.show()

# Count occurrences in each category
sns.countplot(data=tips, x="day", hue="smoker", palette="Set2")
plt.title("Visits per Day by Smoker Status")
plt.show()

sns.barplot automatically computes the mean and adds error bars. sns.countplot simply tallies how many rows fall into each category, which is perfect for understanding the distribution of categorical variables in your dataset.

Histograms and Distribution Plots

Understanding how a single variable is distributed is fundamental to any analysis. Seaborn gives you several options.

# Histogram with KDE overlay
sns.histplot(data=tips, x="total_bill", bins=25, kde=True, color="steelblue")
plt.title("Distribution of Total Bills")
plt.show()

# KDE plot comparing groups
sns.kdeplot(data=tips, x="total_bill", hue="time", fill=True, alpha=0.5)
plt.title("Bill Distribution: Lunch vs Dinner")
plt.show()

# Box plot for comparing distributions
sns.boxplot(data=tips, x="day", y="total_bill", hue="smoker", palette="coolwarm")
plt.title("Bill Amounts by Day and Smoker Status")
plt.show()

Setting kde=True overlays a kernel density estimate curve on the histogram, smoothing out the bars into a continuous shape. sns.kdeplot with fill=True creates shaded density curves, making it easy to compare two groups visually. Box plots show the median, quartiles, and outliers in a compact format that works well when you have multiple categories to compare side by side.

Customizing Seaborn charts
Colors, styles, themes. Make your data look like it deserves a gallery.

Building Heatmaps for Correlation Analysis

Heatmaps turn a matrix of numbers into a colour-coded grid, making it easy to spot strong correlations at a glance. They are one of Seaborn’s most popular features.

import seaborn as sns
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np

# Load a dataset and compute correlation matrix
penguins = sns.load_dataset("penguins").dropna()
numeric_cols = penguins.select_dtypes(include=[np.number])
corr_matrix = numeric_cols.corr()

# Create an annotated heatmap
plt.figure(figsize=(8, 6))
sns.heatmap(
    corr_matrix,
    annot=True,
    fmt=".2f",
    cmap="RdBu_r",
    center=0,
    square=True,
    linewidths=0.5,
    vmin=-1,
    vmax=1
)
plt.title("Penguin Measurements Correlation Matrix")
plt.tight_layout()
plt.show()

The annot=True parameter prints the correlation value inside each cell, while fmt=".2f" rounds it to two decimal places. cmap="RdBu_r" uses a red-blue diverging colour scheme where red means strong positive correlation and blue means strong negative. Setting center=0 ensures that zero correlation appears as white, making the pattern immediately interpretable.

Customising Styles and Colour Palettes

Seaborn comes with five built-in themes and a flexible palette system that lets you match your charts to any brand or presentation style.

# Set a global theme
sns.set_theme(style="whitegrid", font_scale=1.2)

# Compare available styles
fig, axes = plt.subplots(1, 4, figsize=(16, 4))
styles = ["darkgrid", "whitegrid", "dark", "white"]

for ax, style in zip(axes, styles):
    with sns.axes_style(style):
        sns.barplot(data=tips, x="day", y="tip", ax=ax, palette="viridis")
        ax.set_title(style)

plt.tight_layout()
plt.show()

The five styles are darkgrid, whitegrid, dark, white, and ticks. For colour palettes, you can use named palettes like "viridis", "Set2", or "coolwarm", or create your own with sns.color_palette("husl", 8) for 8 evenly spaced hues. The font_scale parameter is especially useful when preparing charts for presentations where you need larger text.

# Custom colour palette
custom_palette = sns.color_palette(["#2ecc71", "#e74c3c", "#3498db", "#f39c12"])
sns.barplot(data=tips, x="day", y="tip", palette=custom_palette)
plt.title("Tips by Day (Custom Colours)")
plt.show()

Multi-Plot Grids with FacetGrid

When you want to see how a pattern changes across different categories, FacetGrid creates a grid of small multiples, each showing the same chart type for a different subset of your data.

# FacetGrid: histogram for each day
g = sns.FacetGrid(tips, col="day", col_wrap=2, height=4)
g.map_dataframe(sns.histplot, x="total_bill", kde=True, color="steelblue")
g.set_titles("{col_name}")
g.set_axis_labels("Total Bill ($)", "Count")
g.tight_layout()
plt.show()

# PairGrid: scatter matrix for numeric columns
penguins = sns.load_dataset("penguins").dropna()
g = sns.pairplot(
    penguins,
    hue="species",
    diag_kind="kde",
    plot_kws={"alpha": 0.6, "s": 40}
)
g.fig.suptitle("Penguin Species Comparison", y=1.02)
plt.show()

FacetGrid takes a DataFrame and a column to split on (col for columns, row for rows). The col_wrap parameter controls how many plots fit in each row before wrapping. pairplot is a convenience function that creates a scatter matrix showing every numeric column against every other, with distribution plots on the diagonal. It is one of the fastest ways to explore a new dataset.

Multi-plot layouts with Seaborn
Subplots let you tell multiple stories on one canvas.

Real-World Example: Analysing Flight Delays

Let us put everything together with a real-world scenario. Suppose you have a dataset of flight information and want to understand seasonal passenger patterns.

import seaborn as sns
import matplotlib.pyplot as plt
import pandas as pd

# Load the flights dataset (monthly passenger counts 1949-1960)
flights = sns.load_dataset("flights")

# Pivot for heatmap
flights_pivot = flights.pivot(index="month", columns="year", values="passengers")

# Create a comprehensive dashboard
fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# 1. Heatmap of passengers by month and year
sns.heatmap(flights_pivot, annot=True, fmt="d", cmap="YlOrRd",
            ax=axes[0, 0], cbar_kws={"label": "Passengers"})
axes[0, 0].set_title("Monthly Passengers (1949-1960)")

# 2. Line plot showing yearly trends
sns.lineplot(data=flights, x="year", y="passengers", hue="month",
             palette="tab20", ax=axes[0, 1], legend=False)
axes[0, 1].set_title("Passenger Trends by Month")

# 3. Box plot of monthly distributions
sns.boxplot(data=flights, x="month", y="passengers",
            palette="coolwarm", ax=axes[1, 0])
axes[1, 0].set_title("Monthly Passenger Distribution")
axes[1, 0].tick_params(axis="x", rotation=45)

# 4. Bar plot of yearly totals
yearly = flights.groupby("year")["passengers"].sum().reset_index()
sns.barplot(data=yearly, x="year", y="passengers",
            palette="Blues_d", ax=axes[1, 1])
axes[1, 1].set_title("Total Passengers per Year")
axes[1, 1].tick_params(axis="x", rotation=45)

plt.suptitle("Flight Passenger Analysis Dashboard", fontsize=16, y=1.02)
plt.tight_layout()
plt.savefig("flight_dashboard.png", dpi=150, bbox_inches="tight")
plt.show()

This dashboard combines four chart types into a single figure. The heatmap reveals that summer months consistently have the highest passenger counts. The line plot shows a clear upward trend across all months over the years. The box plot highlights that July has the widest range of values, while the bar chart confirms that total yearly passengers grew steadily from 1949 to 1960. Building multi-panel dashboards like this is one of the most practical skills you can develop with Seaborn.

Saving and Exporting Publication-Ready Figures

Creating a great chart is only half the job. You also need to export it at the right resolution and format for your audience.

# Save as PNG at high resolution
fig, ax = plt.subplots(figsize=(10, 6))
sns.histplot(data=tips, x="total_bill", kde=True, ax=ax)
ax.set_title("Distribution of Total Bills")

# PNG for web and presentations
plt.savefig("chart.png", dpi=300, bbox_inches="tight", facecolor="white")

# SVG for scalable vector graphics (papers, reports)
plt.savefig("chart.svg", format="svg", bbox_inches="tight")

# PDF for LaTeX documents
plt.savefig("chart.pdf", format="pdf", bbox_inches="tight")

plt.close()
print("Charts saved successfully")

Use dpi=300 for print quality and dpi=150 for web use. The bbox_inches="tight" parameter trims whitespace around the chart. SVG format is ideal for reports because it scales without pixelation. Always call plt.close() after saving to free memory, especially when generating many charts in a loop.

Exporting Seaborn visualizations
Export your masterpiece before the kernel crashes.

Frequently Asked Questions

What is the difference between Seaborn and Matplotlib?

Matplotlib is the foundation that handles the actual drawing of axes, lines, and shapes. Seaborn sits on top of matplotlib and provides a higher-level interface with better default styles, built-in statistical aggregation, and simpler syntax for common chart types. You can mix both in the same script since every Seaborn function returns a matplotlib axes object.

Can I use Seaborn with data that is not in a pandas DataFrame?

Yes, most Seaborn functions accept numpy arrays, Python lists, or dictionaries in addition to DataFrames. However, the DataFrame interface is the most powerful because it allows you to reference column names directly for parameters like hue, size, and style. Converting your data to a DataFrame first is almost always worth the extra line of code.

How do I change the figure size in Seaborn?

For axes-level functions like sns.scatterplot, create the figure first with plt.figure(figsize=(10, 6)) or fig, ax = plt.subplots(figsize=(10, 6)) and pass the ax parameter. For figure-level functions like sns.catplot, use the height and aspect parameters directly.

Why do my Seaborn charts look different from the examples online?

Seaborn version 0.12 changed several default behaviours, including the default theme and some function names. Run sns.set_theme() at the start of your script to apply the modern defaults, and check your version with sns.__version__. Also note that some older tutorials use deprecated functions like distplot which was replaced by histplot and kdeplot.

How do I add labels and annotations to Seaborn plots?

Since Seaborn returns matplotlib axes, you can use all matplotlib annotation functions. Call ax.set_xlabel(), ax.set_ylabel(), and ax.set_title() for basic labels. For annotations pointing to specific data points, use ax.annotate("text", xy=(x, y), xytext=(x2, y2), arrowprops=dict(arrowstyle="->")).

Wrapping Up

Seaborn transforms the often tedious process of data visualization into something fast and enjoyable. You have learned how to create scatter plots, bar charts, histograms, heatmaps, and multi-plot grids, all with Seaborn’s clean one-line syntax. The key to mastering Seaborn is understanding which chart type matches your data: use scatter plots for two continuous variables, bar charts for categorical comparisons, histograms and KDE plots for distributions, and heatmaps for correlation matrices. Combined with FacetGrid for multi-panel layouts and Seaborn’s built-in themes for consistent styling, you now have a complete toolkit for turning raw data into compelling visual stories. Start with the built-in datasets to practice, then apply these patterns to your own data.

Related Articles

How To Use Python Metaclasses for Advanced OOP

How To Use Python Metaclasses for Advanced OOP

Last Updated: June 01, 2026

Advanced

Every Python class is secretly created by another class. When you write class Dog: and hit enter, Python does not just parse your code — it calls type() to construct a brand new class object. That constructor, type, is a metaclass, and understanding metaclasses gives you the power to customize how classes themselves are created, validated, and modified.

Metaclasses are sometimes called “the class of a class.” While regular classes define how instances behave, metaclasses define how classes behave. This sounds abstract, but the practical applications are concrete: automatic registration of plugins, enforcing coding standards across a codebase, auto-generating methods, and building ORMs like Django‘s Model system.

In this tutorial, you will learn how Python creates classes with type(), how to write your own metaclasses using __new__ and __init__, the __init_subclass__ hook for simpler use cases, and real patterns like plugin registries and interface enforcement. By the end, you will know both when to use metaclasses and — equally important — when not to.

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 Metaclasses: Quick Example

Here is a metaclass that automatically adds a created_at class attribute to every class that uses it.

# quick_metaclass.py
from datetime import datetime

class TimestampMeta(type):
    def __new__(mcs, name, bases, namespace):
        namespace['created_at'] = datetime.now().isoformat()
        namespace['class_name'] = name
        return super().__new__(mcs, name, bases, namespace)

class User(metaclass=TimestampMeta):
    def __init__(self, name):
        self.name = name

class Product(metaclass=TimestampMeta):
    def __init__(self, title):
        self.title = title

print(f"User class created at: {User.created_at}")
print(f"Product class name: {Product.class_name}")
user = User("Alice")
print(f"Instance still works: {user.name}")

Output:

User class created at: 2026-04-14T10:30:00.123456
Product class name: Product
Instance still works: Alice

The metaclass intercepts class creation and injects attributes before the class even exists. Every class using TimestampMeta automatically gets a created_at timestamp and a class_name string — no manual work needed in each class definition. Let us understand how this works from the ground up.

Understanding metaclass basics
type() creates classes. Metaclasses create the rules classes follow.

What Are Metaclasses and How Does type() Work?

In Python, everything is an object — including classes. When you define a class, Python creates a class object. The thing that creates that class object is the metaclass. By default, the metaclass is type.

# type_basics.py
class Dog:
    sound = "Woof"

# These are equivalent:
print(type(Dog))
print(type(42))
print(type("hello"))

# You can create classes dynamically with type()
Cat = type('Cat', (), {'sound': 'Meow', 'legs': 4})
print(f"Cat sound: {Cat.sound}, legs: {Cat.legs}")
print(f"Cat type: {type(Cat)}")

Output:

<class 'type'>
<class 'int'>
<class 'str'>
Cat sound: Meow, legs: 4
Cat type: <class 'type'>

The type() function serves two purposes: with one argument, it returns the type of an object; with three arguments (name, bases, namespace), it creates a new class. Every class statement in Python is syntactic sugar for a type() call. A metaclass is simply a subclass of type that overrides how this creation process works.

LevelCreatesExample
MetaclassClassestype or custom metaclass
ClassInstancesDog, User
InstanceNothing (leaf)my_dog, alice

Writing Your Own Metaclass

A metaclass is a class that inherits from type and overrides __new__ or __init__. The __new__ method is called before the class is created (so you can modify its namespace), while __init__ is called after the class is created (so you can modify the finished class object).

# custom_metaclass.py
class ValidatedMeta(type):
    def __new__(mcs, name, bases, namespace):
        # Skip validation for the base class itself
        if bases:
            # Enforce that all subclasses must define a 'validate' method
            if 'validate' not in namespace:
                raise TypeError(f"Class '{name}' must define a 'validate' method")
            
            # Enforce that class names follow PascalCase
            if not name[0].isupper():
                raise TypeError(f"Class name '{name}' must start with an uppercase letter")
        
        cls = super().__new__(mcs, name, bases, namespace)
        return cls
    
    def __init__(cls, name, bases, namespace):
        super().__init__(name, bases, namespace)
        # Add a registry of all classes using this metaclass
        if not hasattr(cls, '_registry'):
            cls._registry = []
        else:
            cls._registry.append(cls)

class BaseModel(metaclass=ValidatedMeta):
    def validate(self):
        pass

class UserModel(BaseModel):
    def validate(self):
        return len(self.name) > 0 if hasattr(self, 'name') else False

class ProductModel(BaseModel):
    def validate(self):
        return self.price > 0 if hasattr(self, 'price') else False

print(f"Registered models: {[c.__name__ for c in BaseModel._registry]}")

# This would raise TypeError:
# class bad_model(BaseModel):  # lowercase name
#     def validate(self): pass

Output:

Registered models: ['UserModel', 'ProductModel']

The metaclass enforces two rules at class definition time — not at runtime, but the moment you try to define a class that breaks the rules, Python raises a TypeError. It also automatically builds a registry of all model classes. This pattern is used by Django, SQLAlchemy, and many plugin systems.

Creating custom metaclasses
Custom metaclasses let you rewrite the rules of class creation.

The __init_subclass__ Alternative

Python 3.6 introduced __init_subclass__, which covers many common metaclass use cases with much simpler syntax. If you just need to run code when a class is subclassed, you do not need a full metaclass — __init_subclass__ is enough.

# init_subclass_demo.py
class Plugin:
    _plugins = {}
    
    def __init_subclass__(cls, plugin_name=None, **kwargs):
        super().__init_subclass__(**kwargs)
        name = plugin_name or cls.__name__.lower()
        cls._plugins[name] = cls
        cls.plugin_name = name
        print(f"Registered plugin: {name}")

class JSONExporter(Plugin, plugin_name="json"):
    def export(self, data):
        return f"Exporting {len(data)} items as JSON"

class CSVExporter(Plugin, plugin_name="csv"):
    def export(self, data):
        return f"Exporting {len(data)} items as CSV"

class XMLExporter(Plugin):  # Uses class name as plugin name
    def export(self, data):
        return f"Exporting {len(data)} items as XML"

print(f"\nAll plugins: {list(Plugin._plugins.keys())}")

# Use the registry to instantiate plugins by name
exporter = Plugin._plugins["csv"]()
print(exporter.export([1, 2, 3]))

Output:

Registered plugin: json
Registered plugin: csv
Registered plugin: xmlexporter
All plugins: ['json', 'csv', 'xmlexporter']
Exporting 3 items as CSV
Use CaseMetaclass__init_subclass__
Plugin registrationWorks but overkillPerfect fit
Modify class namespace before creationRequiredCannot do this
Enforce method signaturesWorksWorks (simpler)
Custom class creation logicRequiredCannot do this
Auto-generate methodsRequiredLimited

The rule of thumb: start with __init_subclass__. Only reach for a full metaclass when you need to modify the class namespace before the class is created, or when __init_subclass__ cannot express your requirements.

Metaclass design patterns
When inheritance isnt enough, metaclasses change the game entirely.

Practical Metaclass Patterns

Singleton Pattern

A metaclass can ensure that only one instance of a class ever exists — useful for configuration managers, database connections, or logging systems.

# singleton_meta.py
class SingletonMeta(type):
    _instances = {}
    
    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            instance = super().__call__(*args, **kwargs)
            cls._instances[cls] = instance
        return cls._instances[cls]

class DatabaseConnection(metaclass=SingletonMeta):
    def __init__(self, host="localhost", port=5432):
        self.host = host
        self.port = port
        print(f"Connecting to {host}:{port}")

# First call creates the instance
db1 = DatabaseConnection("prod-server", 5432)
# Second call returns the same instance
db2 = DatabaseConnection("other-server", 3306)

print(f"Same instance? {db1 is db2}")
print(f"Host: {db2.host}")  # Still prod-server

Output:

Connecting to prod-server:5432
Same instance? True
Host: prod-server

Interface Enforcement

A metaclass can enforce that subclasses implement specific methods — similar to abstract base classes but with custom error messages and additional checks.

# interface_meta.py
class InterfaceMeta(type):
    required_methods = []
    
    def __new__(mcs, name, bases, namespace):
        cls = super().__new__(mcs, name, bases, namespace)
        
        # Only check concrete classes (those with bases that use this metaclass)
        if bases and hasattr(bases[0], '_required'):
            missing = []
            for method_name in bases[0]._required:
                method = namespace.get(method_name)
                if method is None or not callable(method):
                    missing.append(method_name)
            if missing:
                raise TypeError(
                    f"Class '{name}' is missing required methods: {', '.join(missing)}"
                )
        return cls

class Serializable(metaclass=InterfaceMeta):
    _required = ['to_dict', 'from_dict']

class UserRecord(Serializable):
    def __init__(self, name, email):
        self.name = name
        self.email = email
    
    def to_dict(self):
        return {"name": self.name, "email": self.email}
    
    @classmethod
    def from_dict(cls, data):
        return cls(data["name"], data["email"])

user = UserRecord("Alice", "alice@example.com")
data = user.to_dict()
print(f"Serialized: {data}")

restored = UserRecord.from_dict(data)
print(f"Restored: {restored.name}, {restored.email}")

Output:

Serialized: {'name': 'Alice', 'email': 'alice@example.com'}
Restored: Alice, alice@example.com

Real-Life Example: Building a Mini ORM with Metaclasses

Let us build a simplified ORM (Object-Relational Mapper) that uses metaclasses to automatically create table schemas from class definitions — similar to how Django and SQLAlchemy work under the hood.

# mini_orm.py
class Field:
    def __init__(self, field_type, required=True, default=None):
        self.field_type = field_type
        self.required = required
        self.default = default
        self.name = None  # Set by metaclass

class ModelMeta(type):
    def __new__(mcs, name, bases, namespace):
        fields = {}
        for key, value in namespace.items():
            if isinstance(value, Field):
                value.name = key
                fields[key] = value
        
        namespace['_fields'] = fields
        namespace['_table_name'] = name.lower() + 's'
        cls = super().__new__(mcs, name, bases, namespace)
        return cls

class Model(metaclass=ModelMeta):
    def __init__(self, **kwargs):
        for field_name, field in self._fields.items():
            if field_name in kwargs:
                value = kwargs[field_name]
                if not isinstance(value, field.field_type):
                    raise TypeError(
                        f"Field '{field_name}' expects {field.field_type.__name__}, "
                        f"got {type(value).__name__}"
                    )
                setattr(self, field_name, value)
            elif field.default is not None:
                setattr(self, field_name, field.default)
            elif field.required:
                raise ValueError(f"Field '{field_name}' is required")
    
    def to_dict(self):
        return {name: getattr(self, name) for name in self._fields}
    
    @classmethod
    def describe(cls):
        lines = [f"Table: {cls._table_name}"]
        for name, field in cls._fields.items():
            req = "required" if field.required else "optional"
            lines.append(f"  {name}: {field.field_type.__name__} ({req})")
        return "\n".join(lines)

class User(Model):
    name = Field(str)
    email = Field(str)
    age = Field(int, required=False, default=0)

class Product(Model):
    title = Field(str)
    price = Field(float)
    in_stock = Field(bool, default=True)

# Describe schemas
print(User.describe())
print()
print(Product.describe())
print()

# Create instances with validation
alice = User(name="Alice", email="alice@example.com", age=30)
print(f"User: {alice.to_dict()}")

laptop = Product(title="MacBook Pro", price=2499.99)
print(f"Product: {laptop.to_dict()}")

Output:

Table: users
  name: str (required)
  email: str (required)
  age: int (optional)

Table: products
  title: str (required)
  price: float (required)
  in_stock: bool (optional)

User: {'name': 'Alice', 'email': 'alice@example.com', 'age': 30}
Product: {'title': 'MacBook Pro', 'price': 2499.99, 'in_stock': True}

The ModelMeta metaclass scans each class definition for Field descriptors, collects them into a _fields dictionary, and generates a table name automatically. The Model base class then uses _fields for validation and serialization. This is exactly the pattern Django uses for its model system — the metaclass does the heavy lifting so that defining a new model is as simple as listing fields.

Advanced metaclass techniques
With great metaclass power comes great debugging responsibility.

Frequently Asked Questions

When should I actually use a metaclass?

Metaclasses are appropriate when you need to enforce rules across many classes (like an ORM or plugin system), when you need to modify the class namespace before the class is created, or when you are building a framework that other developers will use. For application code, __init_subclass__, decorators, or descriptors are almost always sufficient.

Can a class have multiple metaclasses?

No. If you inherit from two classes with different metaclasses, Python raises a TypeError. The solution is to create a new metaclass that inherits from both metaclasses. This is rarely needed in practice and is usually a sign that your design is too complex.

How do I debug metaclass issues?

Add print statements to your metaclass’s __new__ and __init__ methods to see exactly when and how classes are being created. The namespace argument to __new__ shows you everything the class definition contains. You can also use type(MyClass) to verify which metaclass is being used.

Do metaclasses affect runtime performance?

Metaclass code runs at class definition time (when the module is imported), not at instance creation time. So the performance cost is a one-time cost during import, not a per-instance cost. Instance creation uses the same __call__ mechanism regardless of whether you use a custom metaclass.

What are alternatives to metaclasses?

Python offers several lighter alternatives: __init_subclass__ for subclass hooks, class decorators for modifying classes after creation, descriptors (like property) for attribute behavior, and abstract base classes (abc.ABC) for interface enforcement. Use the simplest tool that solves your problem.

Conclusion

You have learned how Python’s class creation works under the hood — from type() as the default metaclass, through custom metaclasses with __new__ and __init__, to the simpler __init_subclass__ alternative. You built practical examples including a singleton pattern, interface enforcement, and a mini ORM that mirrors how Django models work.

The most important takeaway is knowing when NOT to use metaclasses. Tim Peters (author of The Zen of Python) once said that metaclasses are deeper magic than 99% of users should ever worry about. Start with __init_subclass__ or class decorators. Reach for metaclasses only when you are building a framework that genuinely needs to control class creation.

For more on Python’s data model and class mechanics, see the official Python documentation on metaclasses.

Continue Learning Python

Tutorials you might also find useful:

How To Use Python Closures and Nested Functions

How To Use Python Closures and Nested Functions

Last Updated: June 01, 2026

Intermediate

You have probably written hundreds of Python functions, but have you ever wondered what happens when a function is defined inside another function — and the inner function remembers the outer function’s variables even after the outer function has finished running? That is a closure, and it is one of the most powerful and underused features in Python.

Closures let you create lightweight, stateful functions without defining a class. They are used extensively in decorators, callback systems, event handlers, and factory patterns. Understanding closures also unlocks a deeper understanding of how Python’s scoping rules actually work.

In this tutorial, you will learn how nested functions work, what closures are and how Python creates them, the LEGB scoping rule, the nonlocal keyword for modifying enclosed variables, and practical patterns where closures replace classes. By the end, you will be using closures confidently in your own projects.

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 Closures: Quick Example

Here is the simplest possible closure — a function that remembers a greeting prefix and uses it every time you call it.

# quick_closure.py
def make_greeter(prefix):
    def greet(name):
        return f"{prefix}, {name}!"
    return greet

hello = make_greeter("Hello")
howdy = make_greeter("Howdy")

print(hello("Alice"))
print(howdy("Bob"))
print(hello("Charlie"))

Output:

Hello, Alice!
Howdy, Bob!
Hello, Charlie!

The make_greeter function returns the inner greet function. Even though make_greeter has finished executing, the returned greet function still remembers the prefix value it was created with. That is a closure — the inner function “closes over” the variable from its enclosing scope. Let us explore how this works under the hood.

Understanding closure basics
A closure remembers its birth environment. Your functions should too.

What Are Closures and Nested Functions?

A nested function (also called an inner function) is simply a function defined inside another function. The outer function is sometimes called the enclosing function. In Python, functions are first-class objects — you can pass them as arguments, return them from other functions, and assign them to variables.

A closure is a special case of a nested function: it is a function object that remembers values from its enclosing lexical scope even when the enclosing function is no longer active. For a closure to exist, three conditions must be met: there must be a nested function, the nested function must reference a variable from the enclosing scope, and the enclosing function must return the nested function.

ConceptDefinitionExample
Nested functionFunction defined inside another functiondef outer(): def inner(): ...
Free variableVariable used in inner function but defined in outerprefix in the greeter example
ClosureInner function + its free variablesThe returned greet function
Cell objectPython’s internal storage for free variablesAccessible via __closure__

You can verify that a function is a closure by inspecting its __closure__ attribute. If it returns None, the function is not a closure. If it returns a tuple of cell objects, each cell contains one of the free variables the function has closed over.

Understanding LEGB Scoping Rules

Python resolves variable names using the LEGB rule, checking each scope in order until it finds the variable. Understanding this rule is essential for understanding how closures capture variables.

# legb_demo.py
x = "global"

def outer():
    x = "enclosing"
    
    def inner():
        x = "local"
        print(f"Inner sees: {x}")
    
    inner()
    print(f"Outer sees: {x}")

outer()
print(f"Module sees: {x}")

Output:

Inner sees: local
Outer sees: enclosing
Module sees: global

LEGB stands for Local, Enclosing, Global, Built-in. When Python encounters a variable name, it first checks the local scope (inside the current function), then the enclosing scope (any outer functions), then the global scope (module level), and finally the built-in scope (Python’s built-in names like print and len). A closure captures variables from the Enclosing scope — the “E” in LEGB.

The nonlocal Keyword

By default, you can read enclosed variables from within a closure, but you cannot reassign them. If you try to assign a new value to an enclosed variable, Python creates a new local variable instead. The nonlocal keyword tells Python to look in the enclosing scope for the variable, allowing you to modify it.

# nonlocal_demo.py
def make_counter():
    count = 0
    
    def increment():
        nonlocal count
        count += 1
        return count
    
    def get_count():
        return count
    
    return increment, get_count

increment, get_count = make_counter()
print(increment())
print(increment())
print(increment())
print(f"Final count: {get_count()}")

Output:

1
2
3
Final count: 3

Without nonlocal count, the line count += 1 would raise an UnboundLocalError because Python would treat count as a local variable being referenced before assignment. The nonlocal declaration explicitly tells Python that count lives in the enclosing scope and should be modified there. This is what makes closures stateful — they can maintain and update state across calls.

Variable capture in closures
Captured variables live on after the outer function dies. Spooky.

Practical Closure Patterns

Closures are not just a theoretical concept — they solve real problems more elegantly than alternatives. Here are three patterns you will use regularly.

Factory Functions

A factory function creates and returns specialized functions. This is cleaner than creating a class when you just need a callable with some configuration baked in.

# factory_demo.py
def make_multiplier(factor):
    def multiply(number):
        return number * factor
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)
to_cents = make_multiplier(100)

print(f"Double 5: {double(5)}")
print(f"Triple 5: {triple(5)}")
print(f"$4.99 in cents: {to_cents(4.99)}")

# Apply to a list
prices = [9.99, 14.50, 3.25]
prices_in_cents = list(map(to_cents, prices))
print(f"Prices in cents: {prices_in_cents}")

Output:

Double 5: 10
Triple 5: 15
$4.99 in cents: 499.0
Prices in cents: [999.0, 1450.0, 325.0]

Memoization Cache

Closures can maintain a cache dictionary that persists across calls, implementing memoization without global variables or classes.

# memoize_demo.py
def memoize(func):
    cache = {}
    
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
            print(f"  Computing {func.__name__}{args}")
        else:
            print(f"  Cache hit for {func.__name__}{args}")
        return cache[args]
    
    wrapper.cache = cache  # Expose cache for inspection
    return wrapper

@memoize
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(f"fib(6) = {fibonacci(6)}")
print(f"fib(4) = {fibonacci(4)}")
print(f"Cache size: {len(fibonacci.cache)} entries")

Output:

  Computing fibonacci(6,)
  Computing fibonacci(5,)
  Computing fibonacci(4,)
  Computing fibonacci(3,)
  Computing fibonacci(2,)
  Computing fibonacci(1,)
  Computing fibonacci(0,)
  Cache hit for fibonacci(1,)
  Cache hit for fibonacci(2,)
  Cache hit for fibonacci(3,)
  Cache hit for fibonacci(4,)
fib(6) = 8
  Cache hit for fibonacci(4,)
fib(4) = 3
Cache size: 7 entries
Common closure mistakes
The late binding trap catches everyone exactly once.

Event Handlers and Callbacks

Closures are perfect for callbacks that need context. Instead of creating a class just to hold one piece of state, create a closure.

# callback_demo.py
def make_logger(log_level):
    messages = []
    
    def log(message):
        entry = f"[{log_level.upper()}] {message}"
        messages.append(entry)
        print(entry)
    
    def get_logs():
        return messages.copy()
    
    def clear():
        nonlocal messages
        messages = []
    
    log.get_logs = get_logs
    log.clear = clear
    return log

error_log = make_logger("error")
info_log = make_logger("info")

error_log("Database connection failed")
error_log("Retry attempt 1")
info_log("Server started on port 8080")
error_log("Retry succeeded")

print(f"\nError log has {len(error_log.get_logs())} entries")
print(f"Info log has {len(info_log.get_logs())} entries")

Output:

[ERROR] Database connection failed
[ERROR] Retry attempt 1
[INFO] Server started on port 8080
[ERROR] Retry succeeded

Error log has 3 entries
Info log has 1 entries

Each logger maintains its own independent list of messages because each call to make_logger creates a new messages list in a new enclosing scope. This is the same isolation you would get from separate class instances, but with less boilerplate.

Closures vs Classes: When to Use Each

A common question is whether to use a closure or a class. Both can maintain state, but they have different strengths.

# closure_vs_class.py
# Closure approach
def make_accumulator_closure(initial=0):
    total = initial
    def add(value):
        nonlocal total
        total += value
        return total
    return add

# Class approach
class Accumulator:
    def __init__(self, initial=0):
        self.total = initial
    
    def add(self, value):
        self.total += value
        return self.total

# Both work the same way
closure_acc = make_accumulator_closure(10)
class_acc = Accumulator(10)

print(f"Closure: {closure_acc(5)}, {closure_acc(3)}")
print(f"Class:   {class_acc.add(5)}, {class_acc.add(3)}")

Output:

Closure: 15, 18
Class:   15, 18
CriteriaUse a ClosureUse a Class
State complexity1-3 variablesMany attributes
Methods needed1-2 functionsMultiple methods
InheritanceNot neededNeed to subclass
SerializationNot neededNeed pickle/JSON
DebuggingSimple stateNeed inspection
Use caseDecorators, callbacks, factoriesDomain objects, complex state

The rule of thumb: if your "object" has one main action and minimal state, a closure is simpler. If it has multiple methods, complex state, or needs to participate in inheritance, use a class.

Closure factory patterns
Factory functions use closures to mint custom behavior on demand.

Real-Life Example: Building a Rate Limiter with Closures

Let us build a practical rate limiter that tracks function calls and enforces a maximum number of calls per time window. This demonstrates closures maintaining complex state across multiple calls.

# rate_limiter.py
import time

def rate_limit(max_calls, window_seconds):
    call_timestamps = []
    
    def decorator(func):
        def wrapper(*args, **kwargs):
            nonlocal call_timestamps
            now = time.time()
            # Remove timestamps outside the window
            call_timestamps = [t for t in call_timestamps if now - t < window_seconds]
            
            if len(call_timestamps) >= max_calls:
                wait_time = window_seconds - (now - call_timestamps[0])
                print(f"Rate limited! Try again in {wait_time:.1f}s")
                return None
            
            call_timestamps.append(now)
            remaining = max_calls - len(call_timestamps)
            print(f"Call allowed ({remaining} remaining in window)")
            return func(*args, **kwargs)
        
        wrapper.get_usage = lambda: len([t for t in call_timestamps if time.time() - t < window_seconds])
        return wrapper
    return decorator

@rate_limit(max_calls=3, window_seconds=5)
def fetch_data(query):
    return f"Results for: {query}"

# Simulate rapid API calls
for i in range(5):
    result = fetch_data(f"query_{i}")
    if result:
        print(f"  Got: {result}")
    time.sleep(0.5)

print(f"\nCurrent usage: {fetch_data.get_usage()} calls in window")

Output:

Call allowed (2 remaining in window)
  Got: Results for: query_0
Call allowed (1 remaining in window)
  Got: Results for: query_1
Call allowed (0 remaining in window)
  Got: Results for: query_2
Rate limited! Try again in 3.5s
Rate limited! Try again in 3.0s

Current usage: 3 calls in window

This rate limiter uses three levels of closures: rate_limit captures the configuration (max_calls, window_seconds), decorator captures the function being decorated, and wrapper does the actual work using the call_timestamps list from the enclosing scope. Each decorated function gets its own independent rate limit state because each call to rate_limit creates a new call_timestamps list.

Advanced closure techniques
Closures are functions with backpacks full of captured state.

Frequently Asked Questions

What exactly makes a function a closure?

A function becomes a closure when it is defined inside another function and references variables from the enclosing function's scope. The closure "closes over" those variables, keeping them alive even after the enclosing function returns. You can check if a function is a closure by inspecting its __closure__ attribute -- if it is not None, the function is a closure.

Why do closures in loops all share the same variable?

This is the most common closure pitfall. When you create closures inside a loop, they all reference the same loop variable, not a copy of it. By the time the closures run, the loop variable has its final value. Fix this by using a default argument: lambda i=i: i captures the current value of i at each iteration.

Are closures slower than regular functions?

The overhead is negligible. Accessing a free variable through a cell object adds one extra pointer dereference compared to accessing a local variable. In practice, this difference is unmeasurable. Closures are used internally by Python for decorators, generators, and comprehensions, so they are highly optimized.

Do closures cause memory leaks?

Closures keep their free variables alive as long as the closure exists, which can prevent garbage collection of those variables. This is rarely a problem in practice, but if your closure captures a large object (like a database connection or a huge list), be aware that the object will not be garbage collected until the closure itself is collected.

Are decorators just closures?

Most decorators are implemented as closures, yes. The decorator function takes the original function as an argument and returns a wrapper closure that adds behavior before or after calling the original. However, decorators can also be implemented as classes with a __call__ method -- the decorator pattern is about the wrapping behavior, not the specific implementation technique.

Conclusion

You have learned how Python closures work from the ground up -- starting with nested functions and the LEGB scoping rule, through the nonlocal keyword for modifying enclosed state, to practical patterns like factory functions, memoization caches, and rate limiters. Closures give you stateful functions without the ceremony of defining a class.

Try refactoring one of your existing classes that has a single method and minimal state into a closure-based factory function. You will be surprised how much simpler the code becomes. For advanced closure patterns, explore Python's functools module, which provides closure-based utilities like lru_cache, partial, and wraps.

How To Build GraphQL APIs with Python and Strawberry

How To Build GraphQL APIs with Python and Strawberry

Last Updated: June 01, 2026

Intermediate

REST APIs have served us well, but if you have ever found yourself making three separate HTTP requests just to load a single page — or received a massive JSON payload when you only needed two fields — you already understand the problem GraphQL was designed to solve. With GraphQL, the client describes exactly the data it wants, and the server delivers precisely that. No over-fetching, no under-fetching.

Python has a fantastic library for building GraphQL APIs called strawberry. It uses Python type hints and dataclasses to define your schema, which means you get full IDE autocomplete and type checking for free. You will also need uvicorn to run the server, and both install in seconds with pip.

In this tutorial, you will learn how to install Strawberry, define GraphQL types and queries, add mutations for creating and updating data, handle input validation, and build a complete bookstore API. By the end, you will have a working GraphQL server you can query from any client.

Pubs - Python How To Program
Written by Pubs

Python developer and educator with 15+ years building production systems across data engineering, web APIs, and AI tooling. Founder of Python How To Program — 270+ in-depth tutorials covering the modern Python stack.

View all tutorials by Pubs →

Building a GraphQL API in Python: Quick Example

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

Let us start with the simplest possible GraphQL API — a single query that returns a greeting. This gets you from zero to a working server in under a minute.

# quick_graphql.py
import strawberry
from strawberry.asgi import GraphQL
from starlette.applications import Starlette
from starlette.routing import Route

@strawberry.type
class Query:
    @strawberry.field
    def hello(self, name: str = "World") -> str:
        return f"Hello, {name}! Welcome to GraphQL."

schema = strawberry.Schema(query=Query)
app = Starlette(routes=[Route("/graphql", GraphQL(schema))])

Run it with:

pip install strawberry-graphql uvicorn starlette
uvicorn quick_graphql:app --reload

Query it:

curl -X POST http://localhost:8000/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ hello(name: \"Python\") }"}'

Output:

{"data": {"hello": "Hello, Python! Welcome to GraphQL."}}

That is all it takes. You defined a Python class with type hints, Strawberry converted it into a GraphQL schema, and Starlette served it over HTTP. The built-in GraphiQL playground at http://localhost:8000/graphql lets you explore your API interactively in the browser. Let us now dig deeper into how GraphQL works and what makes Strawberry special.

Defining your GraphQL schema
GraphQL lets clients ask for exactly what they need. No more, no less.

What Is GraphQL and Why Use Strawberry?

GraphQL is a query language for APIs created by Facebook in 2012 and open-sourced in 2015. Instead of multiple REST endpoints that each return a fixed shape of data, GraphQL exposes a single endpoint where clients send queries describing exactly the fields they need. The server resolves those fields and returns a JSON response matching the query structure.

Strawberry is a Python-first GraphQL library that leverages dataclasses and type annotations. Unlike older Python GraphQL libraries that require you to define schemas using dictionaries or special DSL syntax, Strawberry lets you write plain Python classes with type hints. Your schema IS your Python code.

FeatureREST APIGraphQL (Strawberry)
Data fetchingMultiple endpoints, fixed responsesSingle endpoint, client picks fields
Over-fetchingCommon — server decides payloadEliminated — client requests only what it needs
Schema definitionOpenAPI/Swagger (separate file)Python type hints (code IS the schema)
Type safetyRuntime validation neededBuilt-in via Python typing
PlaygroundSwagger UI (separate setup)GraphiQL included automatically
Learning curveLowMedium — query language to learn

The key advantage is that GraphQL eliminates the “too many requests” and “too much data” problems simultaneously. If your application has complex, nested data relationships — like a bookstore with authors, books, and reviews — GraphQL shines because a single query can traverse all those relationships in one round trip.

Defining GraphQL Types with Strawberry

In Strawberry, every GraphQL type is a Python class decorated with @strawberry.type. The class fields become the GraphQL fields, and Python type hints become GraphQL types. This is where Strawberry feels natural — you are just writing Python dataclasses.

# types_demo.py
import strawberry
from typing import Optional

@strawberry.type
class Author:
    name: str
    bio: Optional[str] = None
    year_born: int = 0

@strawberry.type
class Book:
    title: str
    author: Author
    pages: int
    isbn: str
    rating: float = 0.0

# Create instances like regular Python objects
author = Author(name="Guido van Rossum", bio="Creator of Python", year_born=1956)
book = Book(title="Python Reference", author=author, pages=350, isbn="978-0-123456-78-9", rating=4.8)
print(f"{book.title} by {book.author.name} -- {book.pages} pages, rated {book.rating}")

Output:

Python Reference by Guido van Rossum -- 350 pages, rated 4.8

Notice how Book contains an Author field — this creates a nested relationship in your GraphQL schema automatically. When a client queries a book, they can choose to include or exclude the author details. Optional fields use Optional[str] and default values work exactly like Python dataclass defaults.

Writing GraphQL queries
Queries are the polite way to ask your API for data.

Building Queries That Return Data

Queries are the read operations of GraphQL. In Strawberry, you define them as methods on a Query class. Each method becomes a field that clients can request. Let us build a bookstore query system with an in-memory data store.

# queries_demo.py
import strawberry
from typing import Optional

# In-memory data store
BOOKS_DB = [
    {"id": 1, "title": "Fluent Python", "author": "Luciano Ramalho", "pages": 792, "genre": "Programming"},
    {"id": 2, "title": "Python Crash Course", "author": "Eric Matthes", "pages": 544, "genre": "Programming"},
    {"id": 3, "title": "Automate the Boring Stuff", "author": "Al Sweigart", "pages": 504, "genre": "Automation"},
]

@strawberry.type
class Book:
    id: int
    title: str
    author: str
    pages: int
    genre: str

@strawberry.type
class Query:
    @strawberry.field
    def books(self) -> list[Book]:
        return [Book(**b) for b in BOOKS_DB]

    @strawberry.field
    def book(self, id: int) -> Optional[Book]:
        for b in BOOKS_DB:
            if b["id"] == id:
                return Book(**b)
        return None

    @strawberry.field
    def books_by_genre(self, genre: str) -> list[Book]:
        return [Book(**b) for b in BOOKS_DB if b["genre"].lower() == genre.lower()]

schema = strawberry.Schema(query=Query)
result = schema.execute_sync('{ books { title author pages } }')
print(result.data)

Output:

{'books': [{'title': 'Fluent Python', 'author': 'Luciano Ramalho', 'pages': 792}, {'title': 'Python Crash Course', 'author': 'Eric Matthes', 'pages': 544}, {'title': 'Automate the Boring Stuff', 'author': 'Al Sweigart', 'pages': 504}]}

The execute_sync method lets you test queries without running a server. Notice how the query { books { title author pages } } only returns the three fields we asked for — the id and genre fields exist in the schema but are not included in the response because we did not request them. That is the power of GraphQL.

Adding Mutations for Write Operations

Mutations handle create, update, and delete operations. In Strawberry, you define a separate Mutation class with methods decorated using @strawberry.mutation. Input types use @strawberry.input to define the shape of data clients send.

# mutations_demo.py
import strawberry
from typing import Optional

BOOKS_DB = []
next_id = 1

@strawberry.type
class Book:
    id: int
    title: str
    author: str
    pages: int

@strawberry.input
class BookInput:
    title: str
    author: str
    pages: int

@strawberry.type
class Mutation:
    @strawberry.mutation
    def add_book(self, input: BookInput) -> Book:
        global next_id
        book = Book(id=next_id, title=input.title, author=input.author, pages=input.pages)
        BOOKS_DB.append({"id": next_id, "title": input.title, "author": input.author, "pages": input.pages})
        next_id += 1
        return book

    @strawberry.mutation
    def delete_book(self, id: int) -> bool:
        for i, b in enumerate(BOOKS_DB):
            if b["id"] == id:
                BOOKS_DB.pop(i)
                return True
        return False

@strawberry.type
class Query:
    @strawberry.field
    def books(self) -> list[Book]:
        return [Book(**b) for b in BOOKS_DB]

schema = strawberry.Schema(query=Query, mutation=Mutation)

# Add a book
result = schema.execute_sync(
    'mutation { addBook(input: {title: "Clean Code", author: "Robert Martin", pages: 464}) { id title } }'
)
print("Added:", result.data)

# Query all books
result2 = schema.execute_sync('{ books { id title author } }')
print("All books:", result2.data)

Output:

Added: {'addBook': {'id': 1, 'title': 'Clean Code'}}
All books: {'books': [{'id': 1, 'title': 'Clean Code', 'author': 'Robert Martin'}]}

The @strawberry.input decorator creates an input type specifically for mutation arguments. This keeps your API clean — the BookInput does not include id because the server generates that. The mutation returns the created Book object, so the client immediately gets back the server-assigned ID without a second query.

Handling mutations in GraphQL
Mutations change state. Handle them with care.

Custom Resolvers and Computed Fields

Sometimes a field value needs to be calculated rather than stored directly. Strawberry supports this with resolver functions — methods on your type class that compute values on the fly. This is useful for derived data like formatted strings, aggregations, or data from external sources.

# resolvers_demo.py
import strawberry

@strawberry.type
class Book:
    title: str
    pages: int
    price_cents: int

    @strawberry.field
    def price_display(self) -> str:
        return f"${self.price_cents / 100:.2f}"

    @strawberry.field
    def reading_time_hours(self) -> float:
        # Average reading speed: 250 words per page, 40 pages per hour
        return round(self.pages / 40, 1)

    @strawberry.field
    def is_long_read(self) -> bool:
        return self.pages > 500

@strawberry.type
class Query:
    @strawberry.field
    def featured_book(self) -> Book:
        return Book(title="Fluent Python", pages=792, price_cents=4999)

schema = strawberry.Schema(query=Query)
result = schema.execute_sync('{ featuredBook { title priceDisplay readingTimeHours isLongRead } }')
print(result.data)

Output:

{'featuredBook': {'title': 'Fluent Python', 'priceDisplay': '$49.99', 'readingTimeHours': 19.8, 'isLongRead': True}}

Notice that price_cents is stored as an integer (avoiding floating-point money issues), but the client can query priceDisplay to get a formatted string. The readingTimeHours field is computed from pages. Strawberry automatically converts Python snake_case method names to camelCase in the GraphQL schema, which follows GraphQL naming conventions.

Error Handling and Validation

Production APIs need proper error handling. Strawberry supports union types that let you return either a success result or an error — a pattern similar to Rust’s Result type. This gives clients structured error information instead of generic error messages.

# error_handling.py
import strawberry
from typing import Union

BOOKS_DB = [
    {"id": 1, "title": "Fluent Python", "author": "Luciano Ramalho", "pages": 792},
]

@strawberry.type
class Book:
    id: int
    title: str
    author: str
    pages: int

@strawberry.type
class BookNotFound:
    message: str
    requested_id: int

@strawberry.type
class ValidationError:
    message: str
    field: str

BookResult = strawberry.union("BookResult", [Book, BookNotFound])
AddBookResult = strawberry.union("AddBookResult", [Book, ValidationError])

@strawberry.input
class BookInput:
    title: str
    author: str
    pages: int

@strawberry.type
class Query:
    @strawberry.field
    def book(self, id: int) -> BookResult:
        for b in BOOKS_DB:
            if b["id"] == id:
                return Book(**b)
        return BookNotFound(message=f"No book with ID {id}", requested_id=id)

@strawberry.type
class Mutation:
    @strawberry.mutation
    def add_book(self, input: BookInput) -> AddBookResult:
        if len(input.title.strip()) == 0:
            return ValidationError(message="Title cannot be empty", field="title")
        if input.pages < 1:
            return ValidationError(message="Pages must be positive", field="pages")
        new_id = max(b["id"] for b in BOOKS_DB) + 1 if BOOKS_DB else 1
        book_data = {"id": new_id, "title": input.title, "author": input.author, "pages": input.pages}
        BOOKS_DB.append(book_data)
        return Book(**book_data)

schema = strawberry.Schema(query=Query, mutation=Mutation)

# Query existing book
r1 = schema.execute_sync('{ book(id: 1) { ... on Book { title } ... on BookNotFound { message } } }')
print("Found:", r1.data)

# Query missing book
r2 = schema.execute_sync('{ book(id: 99) { ... on Book { title } ... on BookNotFound { message requestedId } } }')
print("Missing:", r2.data)

Output:

Found: {'book': {'title': 'Fluent Python'}}
Missing: {'book': {'message': 'No book with ID 99', 'requestedId': 99}}

Union types force clients to handle both success and error cases explicitly using ... on TypeName fragments. This is much better than throwing exceptions or returning null -- the client always knows exactly what happened and can display appropriate UI feedback.

Error handling in GraphQL
Error handling in GraphQL is structured, not chaotic.

Real-Life Example: Building a Complete Bookstore API

Let us put everything together into a production-ready bookstore API with authors, books, reviews, and search functionality. This example combines types, queries, mutations, resolvers, and error handling into a single working application.

# bookstore_api.py
import strawberry
from typing import Optional
from datetime import datetime

# In-memory database
AUTHORS = [
    {"id": 1, "name": "Luciano Ramalho", "country": "Brazil"},
    {"id": 2, "name": "Eric Matthes", "country": "USA"},
]
BOOKS = [
    {"id": 1, "title": "Fluent Python", "author_id": 1, "pages": 792, "price_cents": 4999, "published": "2022-04-01"},
    {"id": 2, "title": "Python Crash Course", "author_id": 2, "pages": 544, "price_cents": 3599, "published": "2023-01-10"},
]
REVIEWS = [
    {"id": 1, "book_id": 1, "rating": 5, "comment": "Essential for intermediate Python developers"},
    {"id": 2, "book_id": 1, "rating": 4, "comment": "Dense but incredibly thorough"},
    {"id": 3, "book_id": 2, "rating": 5, "comment": "Perfect for beginners"},
]

@strawberry.type
class Author:
    id: int
    name: str
    country: str

    @strawberry.field
    def books(self) -> list["BookType"]:
        return [BookType(**b) for b in BOOKS if b["author_id"] == self.id]

    @strawberry.field
    def book_count(self) -> int:
        return sum(1 for b in BOOKS if b["author_id"] == self.id)

@strawberry.type
class Review:
    id: int
    book_id: int
    rating: int
    comment: str

@strawberry.type
class BookType:
    id: int
    title: str
    author_id: int
    pages: int
    price_cents: int
    published: str

    @strawberry.field
    def author(self) -> Optional[Author]:
        for a in AUTHORS:
            if a["id"] == self.author_id:
                return Author(**a)
        return None

    @strawberry.field
    def reviews(self) -> list[Review]:
        return [Review(**r) for r in REVIEWS if r["book_id"] == self.id]

    @strawberry.field
    def average_rating(self) -> Optional[float]:
        book_reviews = [r["rating"] for r in REVIEWS if r["book_id"] == self.id]
        if not book_reviews:
            return None
        return round(sum(book_reviews) / len(book_reviews), 1)

    @strawberry.field
    def price_display(self) -> str:
        return f"${self.price_cents / 100:.2f}"

@strawberry.type
class Query:
    @strawberry.field
    def books(self, min_pages: Optional[int] = None) -> list[BookType]:
        filtered = BOOKS
        if min_pages is not None:
            filtered = [b for b in filtered if b["pages"] >= min_pages]
        return [BookType(**b) for b in filtered]

    @strawberry.field
    def search(self, term: str) -> list[BookType]:
        term_lower = term.lower()
        return [BookType(**b) for b in BOOKS if term_lower in b["title"].lower()]

    @strawberry.field
    def authors(self) -> list[Author]:
        return [Author(**a) for a in AUTHORS]

schema = strawberry.Schema(query=Query)

# Complex nested query -- one request gets books + authors + reviews
query = """
{
  books {
    title
    priceDisplay
    averageRating
    author { name country }
    reviews { rating comment }
  }
}
"""
result = schema.execute_sync(query)
for book in result.data["books"]:
    author_name = book["author"]["name"] if book["author"] else "Unknown"
    review_count = len(book["reviews"])
    print(f"{book['title']} by {author_name} -- {book['priceDisplay']}, "
          f"avg rating: {book['averageRating']}, {review_count} reviews")

Output:

Fluent Python by Luciano Ramalho -- $49.99, avg rating: 4.5, 2 reviews
Python Crash Course by Eric Matthes -- $35.99, avg rating: 5.0, 1 reviews

This single query retrieved books with their prices, computed average ratings, author details, and all reviews -- in one round trip. With REST, this would have required separate calls to /books, /authors/:id, and /books/:id/reviews for each book. The resolver pattern (author(), reviews(), average_rating()) keeps each type responsible for fetching its own related data, making the code modular and easy to extend.

Production-ready GraphQL
Production GraphQL needs auth, rate limiting, and a prayer.

Frequently Asked Questions

When should I use GraphQL instead of REST?

GraphQL shines when your frontend needs flexible data fetching -- mobile apps that need minimal payloads, dashboards that aggregate data from multiple sources, or any situation where different views need different subsets of the same data. If your API is simple with fixed endpoints that rarely change, REST is perfectly fine and has less overhead.

Is GraphQL slower than REST?

Not inherently. GraphQL can actually be faster because it eliminates multiple round trips. However, poorly designed resolvers can cause the N+1 query problem -- fetching a list of books and then making a separate database query for each book's author. Strawberry supports DataLoader to batch these queries efficiently.

Why Strawberry over Graphene?

Graphene was the first major Python GraphQL library but uses an older, more verbose API style. Strawberry uses modern Python type hints and dataclasses, resulting in less boilerplate and better IDE support. Strawberry also has built-in support for async resolvers and integrates well with FastAPI, Django, and Flask.

How do I add authentication to a Strawberry API?

Strawberry provides a context system where you can pass request information (like auth tokens) to resolvers. Use the get_context parameter in your ASGI integration to extract the token from headers, validate it, and make the user object available to all resolvers via info.context.

Is Strawberry production-ready?

Yes. Strawberry is actively maintained, supports Python 3.8+, and is used in production by companies like Netflix and Deliveroo. It supports subscriptions (real-time data via WebSockets), file uploads, and custom scalars for types like DateTime and UUID.

Conclusion

You have learned how to build a complete GraphQL API using Python and Strawberry -- from defining types with @strawberry.type and queries with @strawberry.field, to mutations with @strawberry.mutation, custom resolvers for computed fields, and union types for structured error handling. The bookstore example showed how a single GraphQL query can fetch nested, related data that would require multiple REST calls.

Try extending the bookstore API with features like pagination (add limit and offset arguments to queries), subscriptions for real-time updates when new books are added, or a connection to a real database using SQLAlchemy. Strawberry's type-first approach makes these additions straightforward.

For the full API reference and advanced features like DataLoaders, permissions, and Django integration, visit the official Strawberry documentation.

How To Work with MongoDB in Python Using PyMongo

How To Work with MongoDB in Python Using PyMongo

Last Updated: June 01, 2026

How To Work with MongoDB in Python Using PyMongo

Quick Answer: MongoDB is a document-based NoSQL database that stores JSON-like data. Install PyMongo with pip install pymongo, then connect with client = MongoClient('mongodb://localhost:27017/'). Create databases and collections, perform CRUD operations with insert_one(), find(), update_one(), and delete_one(). Use aggregation pipelines for complex queries and GridFS for storing large files.
Setting up MongoDB connection
PyMongo speaks Python to your MongoDB. Fluently.
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 MongoDB and Document-Based Storage

MongoDB is a NoSQL database that stores data as flexible JSON-like documents instead of rigid table rows. This document-oriented approach allows you to store nested data structures without complex joins, making it ideal for applications with evolving schemas.

Key advantages of MongoDB:

  • Schema flexibility: Documents can have different structures
  • Nested data: Store complex hierarchical data naturally
  • Rich queries: Query and filter on any field
  • Horizontal scaling: Built-in sharding for distributing data
  • Indexing: Powerful indexing for fast queries
  • Aggregation: Complex data transformations in the database

Installing MongoDB and PyMongo

First, install MongoDB server. On macOS with Homebrew:

brew install mongodb-community
brew services start mongodb-community

On Ubuntu/Debian:

sudo apt-get install -y mongodb
sudo systemctl start mongod

Install the PyMongo Python driver:

pip install pymongo

Verify MongoDB is running:

mongosh --eval "db.adminCommand('ping')"
# Output: { ok: 1 }
CRUD operations with PyMongo
insert_one, find, update, delete. The four verbs of database life.

Connecting to MongoDB

Create a basic connection to MongoDB:

from pymongo import MongoClient

# Connect to local MongoDB
client = MongoClient('mongodb://localhost:27017/')

# Get database
db = client['blog_database']

# Get collection
posts = db['posts']

# Test connection
print(client.server_info())

For production with authentication and connection pooling:

from pymongo import MongoClient
from pymongo.errors import ConnectionFailure

# Connect with credentials
client = MongoClient(
    'mongodb://username:password@mongodb.example.com:27017/',
    maxPoolSize=50,
    minPoolSize=10,
    serverSelectionTimeoutMS=5000,
    connectTimeoutMS=5000
)

# Verify connection
try:
    client.admin.command('ping')
    print("Connected to MongoDB successfully")
except ConnectionFailure:
    print("Failed to connect to MongoDB")

Alternative connection methods:

from pymongo import MongoClient

# Connection string
uri = 'mongodb://user:pass@host1:27017,host2:27017,host3:27017/database?replicaSet=rs0'
client = MongoClient(uri)

# Access database and collection
db = client.get_database('mydb')
collection = db.get_collection('mycollection')

Creating and Inserting Documents

Insert documents into MongoDB collections:

from pymongo import MongoClient
import datetime

client = MongoClient('mongodb://localhost:27017/')
db = client['blog_database']
posts = db['posts']

# Insert single document
post = {
    'title': 'Getting Started with MongoDB',
    'author': 'John Doe',
    'content': 'MongoDB is a flexible NoSQL database...',
    'tags': ['mongodb', 'nosql', 'database'],
    'created_at': datetime.datetime.utcnow(),
    'views': 0,
    'published': True
}

result = posts.insert_one(post)
print(f"Inserted document ID: {result.inserted_id}")

# Insert multiple documents
documents = [
    {
        'title': 'Python Best Practices',
        'author': 'Jane Smith',
        'tags': ['python', 'best-practices'],
        'views': 150
    },
    {
        'title': 'Web Development with Flask',
        'author': 'Bob Johnson',
        'tags': ['python', 'flask', 'web'],
        'views': 200
    }
]

result = posts.insert_many(documents)
print(f"Inserted {len(result.inserted_ids)} documents")

# Insert with custom ID
post_custom = {
    '_id': 'post_001',
    'title': 'Custom ID Example',
    'author': 'Alice'
}
posts.insert_one(post_custom)
Optimizing MongoDB queries
Indexes turn your slow queries into fast ones. Use them.

Reading Documents with Find Operations

Query documents from MongoDB:

from pymongo import MongoClient
from bson.objectid import ObjectId

client = MongoClient('mongodb://localhost:27017/')
db = client['blog_database']
posts = db['posts']

# Find single document
post = posts.find_one({'author': 'John Doe'})
print(post)

# Find by ID
post_id = ObjectId('507f1f77bcf86cd799439011')
post = posts.find_one({'_id': post_id})

# Find all documents
all_posts = posts.find()
for post in all_posts:
    print(post['title'])

# Find with filters
python_posts = posts.find({'tags': {'contains': 'python'}})
python_posts = posts.find({'tags': 'python'})  # Simpler syntax

# Find with comparison operators
popular_posts = posts.find({'views': {'gt': 100}})
recent_posts = posts.find({'created_at': {'gte': datetime.datetime(2024, 1, 1)}})

# Multiple conditions
filtered = posts.find({
    'author': 'John Doe',
    'published': True
})

# Using OR operator
from pymongo import ASCENDING
query = {
    'author': {'in': ['John Doe', 'Jane Smith']}
}
posts_by_authors = posts.find(query)

# Find with projection (select specific fields)
titles_only = posts.find(
    {'published': True},
    {'title': 1, 'author': 1, '_id': 0}  # Include title and author, exclude ID
)

# Find with sorting
sorted_posts = posts.find().sort('views', -1).limit(5)  # Top 5 by views
recent = posts.find().sort('created_at', -1).limit(10)  # Latest 10

# Find with skip and limit (pagination)
page_size = 10
page_number = 2
skip = (page_number - 1) * page_size
posts_page = posts.find().skip(skip).limit(page_size)

Updating Documents

Modify existing documents in MongoDB:

from pymongo import MongoClient
from bson.objectid import ObjectId

client = MongoClient('mongodb://localhost:27017/')
db = client['blog_database']
posts = db['posts']

# Update single document
result = posts.update_one(
    {'_id': ObjectId('507f1f77bcf86cd799439011')},
    {'set': {'views': 500}}
)
print(f"Matched: {result.matched_count}, Modified: {result.modified_count}")

# Update with multiple fields
posts.update_one(
    {'author': 'John Doe'},
    {
        'set': {
            'title': 'Updated Title',
            'views': 999,
            'updated_at': datetime.datetime.utcnow()
        }
    }
)

# Increment views
posts.update_one(
    {'_id': ObjectId('507f1f77bcf86cd799439011')},
    {'inc': {'views': 1}}
)

# Push item to array
posts.update_one(
    {'_id': ObjectId('507f1f77bcf86cd799439011')},
    {'push': {'tags': 'new-tag'}}
)

# Update multiple documents
result = posts.update_many(
    {'author': 'John Doe'},
    {'set': {'verified': True}}
)
print(f"Modified {result.modified_count} documents")

# Replace entire document
new_post = {
    'title': 'Completely New Post',
    'author': 'Anonymous',
    'content': 'New content...'
}
posts.replace_one(
    {'_id': ObjectId('507f1f77bcf86cd799439011')},
    new_post
)

# Upsert: update or insert if not found
posts.update_one(
    {'title': 'MongoDB Guide'},
    {'set': {'author': 'Expert', 'views': 1000}},
    upsert=True  # Insert if not found
)
Data transformations in MongoDB
Aggregation pipelines transform data without pulling it into Python.

Deleting Documents

Remove documents from MongoDB:

from pymongo import MongoClient
from bson.objectid import ObjectId

client = MongoClient('mongodb://localhost:27017/')
db = client['blog_database']
posts = db['posts']

# Delete single document
result = posts.delete_one({'author': 'Anonymous'})
print(f"Deleted {result.deleted_count} document")

# Delete multiple documents
result = posts.delete_many({'views': {'lt': 10}})
print(f"Deleted {result.deleted_count} low-view posts")

# Delete all documents
posts.delete_many({})

# Delete by ID
posts.delete_one({'_id': ObjectId('507f1f77bcf86cd799439011')})

Indexing for Performance

Create indexes to speed up queries:

from pymongo import MongoClient, ASCENDING, DESCENDING

client = MongoClient('mongodb://localhost:27017/')
db = client['blog_database']
posts = db['posts']

# Create single field index
posts.create_index('author')
posts.create_index([('views', DESCENDING)])

# Create compound index
posts.create_index([
    ('author', ASCENDING),
    ('created_at', DESCENDING)
])

# Create unique index
posts.create_index('slug', unique=True)

# Create text search index
posts.create_index([('title', 'text'), ('content', 'text')])

# Text search using index
results = posts.find({'text': {'search': 'mongodb'}})

# List all indexes
indexes = posts.list_indexes()
for index in indexes:
    print(index['key'])

# Drop index
posts.drop_index('author_1')
posts.drop_index([('author', 1), ('created_at', -1)])

# Get index statistics
stats = db.command('collStats', 'posts')
print(f"Index size: {stats['totalIndexSize']}")
Debugging slow MongoDB queries
When your query takes seconds, the explain() plan takes you to the answer.

Aggregation Pipeline for Complex Queries

Perform complex data transformations using aggregation:

from pymongo import MongoClient

client = MongoClient('mongodb://localhost:27017/')
db = client['blog_database']
posts = db['posts']

# Basic aggregation: Group by author and count posts
pipeline = [
    {'group': {'_id': 'author', 'count': {'sum': 1}}}
]
result = posts.aggregate(pipeline)
for doc in result:
    print(f"{doc['_id']}: {doc['count']} posts")

# Match and group
pipeline = [
    {'match': {'published': True}},
    {'group': {'_id': 'author', 'total_views': {'sum': 'views'}}}
]

# Stage 1: Filter published posts
# Stage 2: Group by author
# Stage 3: Sort by views descending
# Stage 4: Limit to top 5
pipeline = [
    {'match': {'published': True}},
    {'group': {
        '_id': 'author',
        'total_views': {'sum': 'views'},
        'post_count': {'sum': 1}
    }},
    {'sort': {'total_views': -1}},
    {'limit': 5}
]
top_authors = posts.aggregate(pipeline)

# Project selected fields
pipeline = [
    {'match': {'views': {'gte': 100}}},
    {'project': {
        'title': 1,
        'author': 1,
        'views': 1,
        '_id': 0
    }}
]

# Unwind array field
pipeline = [
    {'unwind': 'tags'},
    {'group': {'_id': 'tags', 'count': {'sum': 1}}},
    {'sort': {'count': -1}}
]
tag_stats = posts.aggregate(pipeline)

# Lookup (join with another collection)
users_collection = db['users']
pipeline = [
    {'lookup': {
        'from': 'users',
        'localField': 'author',
        'foreignField': 'name',
        'as': 'author_info'
    }},
    {'unwind': 'author_info'},
    {'project': {
        'title': 1,
        'author': 1,
        'author_email': 'author_info.email'
    }}
]

# Faceted aggregation (multiple aggregations in one)
pipeline = [
    {'facet': {
        'by_author': [
            {'group': {'_id': 'author', 'count': {'sum': 1}}}
        ],
        'by_tag': [
            {'unwind': 'tags'},
            {'group': {'_id': 'tags', 'count': {'sum': 1}}}
        ],
        'stats': [
            {'group': {
                '_id': None,
                'total_posts': {'sum': 1},
                'avg_views': {'avg': 'views'}
            }}
        ]
    }}
]

GridFS for Large File Storage

Store files larger than 16MB in MongoDB using GridFS:

from pymongo import MongoClient
from gridfs import GridFS

client = MongoClient('mongodb://localhost:27017/')
db = client['blog_database']
fs = GridFS(db)

# Store file
with open('document.pdf', 'rb') as f:
    file_id = fs.put(f, filename='document.pdf', content_type='application/pdf')

print(f"File stored with ID: {file_id}")

# Retrieve file
with open('downloaded_document.pdf', 'wb') as f:
    f.write(fs.get(file_id).read())

# List all files
for grid_out in fs.find({'filename': 'document.pdf'}):
    print(f"File: {grid_out.filename}, Size: {grid_out.length}")

# Delete file
fs.delete(file_id)

# Store with metadata
with open('image.jpg', 'rb') as f:
    file_id = fs.put(
        f,
        filename='profile.jpg',
        content_type='image/jpeg',
        user_id='user_123',
        uploaded_by='John Doe'
    )

# Retrieve with metadata
grid_out = fs.get(file_id)
print(f"Uploaded by: {grid_out.uploaded_by}")
print(f"User ID: {grid_out.user_id}")

Troubleshooting Common MongoDB Issues

Issue Cause Solution
Connection refused MongoDB server not running Start MongoDB: brew services start mongodb-community or systemctl start mongod
Slow queries Missing indexes on frequently queried fields Create indexes: collection.create_index('field_name'). Check query plans with explain()
Duplicate key error Unique index constraint violation Ensure unique values or remove unique index constraint
Out of memory errors Aggregation pipeline processing too much data Add $match stage early, limit results with $limit, use allowDiskUse=True
Document too large Document exceeds 16MB size limit Use GridFS for large documents or split data across documents
Authentication failed Wrong credentials or database Verify username, password, and database name in connection string

Real-Life Example: Blog Content Management System

Here’s a complete blog CMS using MongoDB and PyMongo:

from pymongo import MongoClient, ASCENDING, DESCENDING
from bson.objectid import ObjectId
from datetime import datetime, timedelta
import json

class BlogCMS:
    def __init__(self):
        self.client = MongoClient('mongodb://localhost:27017/')
        self.db = self.client['blog_cms']
        self.posts = self.db['posts']
        self.comments = self.db['comments']
        self.users = self.db['users']
        self._create_indexes()

    def _create_indexes(self):
        """Create necessary indexes"""
        self.posts.create_index('slug', unique=True)
        self.posts.create_index([('author', ASCENDING), ('created_at', DESCENDING)])
        self.posts.create_index([('title', 'text'), ('content', 'text')])
        self.comments.create_index('post_id')

    def create_post(self, title, content, author, tags, excerpt=''):
        """Create new blog post"""
        slug = title.lower().replace(' ', '-')
        post = {
            'title': title,
            'content': content,
            'excerpt': excerpt,
            'author': author,
            'tags': tags,
            'slug': slug,
            'created_at': datetime.utcnow(),
            'updated_at': datetime.utcnow(),
            'published': False,
            'views': 0,
            'comments_count': 0
        }
        result = self.posts.insert_one(post)
        return result.inserted_id

    def publish_post(self, post_id):
        """Publish a draft post"""
        self.posts.update_one(
            {'_id': ObjectId(post_id)},
            {'set': {
                'published': True,
                'published_at': datetime.utcnow()
            }}
        )

    def get_published_posts(self, page=1, per_page=10):
        """Get published posts with pagination"""
        skip = (page - 1) * per_page
        posts = self.posts.find(
            {'published': True},
            sort=[('created_at', -1)]
        ).skip(skip).limit(per_page)
        return list(posts)

    def search_posts(self, query):
        """Full-text search in posts"""
        results = self.posts.find(
            {'text': {'search': query}},
            {'score': {'meta': 'textScore'}}
        ).sort([('score', {'meta': 'textScore'})])
        return list(results)

    def get_post_by_slug(self, slug):
        """Get post by slug and increment views"""
        self.posts.update_one(
            {'slug': slug},
            {'inc': {'views': 1}}
        )
        return self.posts.find_one({'slug': slug})

    def add_comment(self, post_id, author, content):
        """Add comment to post"""
        comment = {
            'post_id': ObjectId(post_id),
            'author': author,
            'content': content,
            'created_at': datetime.utcnow(),
            'approved': False
        }
        result = self.comments.insert_one(comment)

        # Update comment count
        self.posts.update_one(
            {'_id': ObjectId(post_id)},
            {'inc': {'comments_count': 1}}
        )
        return result.inserted_id

    def get_post_comments(self, post_id, approved_only=True):
        """Get comments for post"""
        query = {'post_id': ObjectId(post_id)}
        if approved_only:
            query['approved'] = True

        return list(self.comments.find(query).sort('created_at', -1))

    def get_trending_posts(self, days=7):
        """Get trending posts from last N days"""
        since = datetime.utcnow() - timedelta(days=days)
        return list(self.posts.find(
            {'created_at': {'gte': since}, 'published': True}
        ).sort('views', -1).limit(10))

    def get_author_stats(self, author):
        """Get statistics for an author"""
        pipeline = [
            {'match': {'author': author, 'published': True}},
            {'group': {
                '_id': author,
                'total_posts': {'sum': 1},
                'total_views': {'sum': 'views'},
                'avg_views': {'avg': 'views'}
            }}
        ]
        return list(self.posts.aggregate(pipeline))

    def delete_post(self, post_id):
        """Delete post and its comments"""
        # Delete comments
        self.comments.delete_many({'post_id': ObjectId(post_id)})
        # Delete post
        self.posts.delete_one({'_id': ObjectId(post_id)})

# Usage
cms = BlogCMS()

# Create post
post_id = cms.create_post(
    title='MongoDB Best Practices',
    content='MongoDB is a flexible...',
    excerpt='Learn MongoDB best practices',
    author='John Doe',
    tags=['mongodb', 'database', 'tutorial']
)

# Publish post
cms.publish_post(post_id)

# Get published posts
posts = cms.get_published_posts(page=1, per_page=10)

# Search
results = cms.search_posts('mongodb python')

# Get post by slug
post = cms.get_post_by_slug('mongodb-best-practices')

# Add comment
cms.add_comment(post_id, 'Jane Smith', 'Great article!')

# Get comments
comments = cms.get_post_comments(post_id)

# Get author stats
stats = cms.get_author_stats('John Doe')
print(stats)

This CMS demonstrates:

  • CRUD operations on multiple collections
  • Unique constraints with indexes
  • Full-text search capability
  • Aggregation for statistics
  • Pagination for large result sets
  • Relationship management between collections
  • Automatic counter updates

MongoDB Best Practices

Follow these guidelines for optimal MongoDB usage:

  • Design documents carefully: Plan your data structure before implementation
  • Use appropriate indexes: Index frequently queried fields
  • Avoid excessive nesting: Keep document depth reasonable
  • Use ObjectId for relationships: Reference documents with IDs
  • Implement validation: Use schema validation in MongoDB 3.6+
  • Monitor query performance: Use explain() to analyze queries
  • Configure backup: Enable oplog and regular snapshots
  • Use connection pooling: Reuse connections across requests

FAQ

Q: Should I use MongoDB or a relational database?

A: Use MongoDB for flexible schemas and hierarchical data. Use relational databases for structured data with complex relationships. Many applications use both.

Q: Does MongoDB support transactions?

A: Yes, MongoDB 4.0+ supports ACID transactions. Single document transactions are atomic by default. Multi-document transactions available in replica sets and sharded clusters.

Q: How do I backup MongoDB?

A: Use mongodump to export data and mongorestore to restore. Enable oplog for continuous backups, or use MongoDB Atlas automated backups.

Q: Can MongoDB handle joins like SQL databases?

A: MongoDB uses the $lookup aggregation stage for joins, or you can denormalize data by embedding related documents.

Q: What is the 16MB document size limit?

A: MongoDB documents cannot exceed 16MB. Use GridFS for larger data or split into multiple documents with references.

Aggregation Pipeline

MongoDB’s aggregation framework is its answer to SQL GROUP BY + JOIN + analytics. You build a pipeline of stages — each transforms the document stream. The pymongo API maps directly to MongoDB’s pipeline syntax:

from pymongo import MongoClient

client = MongoClient("mongodb://localhost:27017")
db = client.shop
orders = db.orders

# Total revenue per customer in the last 30 days
from datetime import datetime, timedelta
since = datetime.utcnow() - timedelta(days=30)

pipeline = [
    {"$match": {"created_at": {"$gte": since}, "status": "paid"}},
    {"$group": {
        "_id": "$customer_id",
        "total": {"$sum": "$amount"},
        "order_count": {"$sum": 1},
    }},
    {"$sort": {"total": -1}},
    {"$limit": 10},
]

for row in orders.aggregate(pipeline):
    print(row["_id"], row["total"], row["order_count"])

The pipeline runs entirely server-side — only the final aggregated rows come over the wire. For analytics over millions of documents, this is the right tool. Use .explain() on a sample call to verify your $match stage hits an index.

Indexing for Performance

MongoDB queries without indexes scan every document — fine at 1,000 docs, fatal at 1 million. Create indexes on every field you filter, sort, or group by:

orders.create_index("customer_id")
orders.create_index([("status", 1), ("created_at", -1)])  # compound index
orders.create_index("order_number", unique=True)
orders.create_index([("description", "text")])  # full-text search

# Inspect what queries are doing
explain = orders.find({"customer_id": "abc"}).explain()
print(explain["executionStats"]["totalDocsExamined"])

A query that scans every doc has totalDocsExamined equal to the collection size. With an index, it should match totalKeysExamined — orders of magnitude smaller.

Async MongoDB with Motor

For async applications (FastAPI, asyncio web crawlers), use Motor — same API as pymongo but coroutine-based:

# pip install motor

from motor.motor_asyncio import AsyncIOMotorClient
import asyncio

async def main():
    client = AsyncIOMotorClient("mongodb://localhost:27017")
    db = client.shop
    await db.orders.insert_one({"customer": "alice", "amount": 99})
    docs = await db.orders.find({"customer": "alice"}).to_list(length=100)
    print(docs)

asyncio.run(main())

Common Pitfalls

  • Forgetting to close clients. MongoClient holds a connection pool. Create one at app startup, reuse it, close on shutdown — never per-request.
  • Treating ObjectId as a string. _id is an ObjectId, not a string. JSON-serialize with json.dumps(doc, default=str) or use bson’s json_util.
  • Letting documents grow unbounded. Embedded arrays that grow forever (audit logs, comments) blow past the 16MB document limit. Move them into their own collection.
  • Skipping schema validation. MongoDB is schema-less — which means YOU enforce the schema. Use $jsonSchema at the collection level or validate in Python with pydantic before insert.
  • Heavy reads on the primary. Configure read preference to secondary for analytics queries; spare the primary for writes.

FAQ

Q: When should I use MongoDB instead of Postgres?
A: When your data is genuinely document-shaped — nested, variable per record, evolving schema. For relational data with joins, Postgres wins on both performance and developer experience.

Q: How do I handle transactions?
A: MongoDB 4.0+ supports multi-document transactions via client.start_session() + session.with_transaction(). But the philosophy is to model your data so transactions are rarely needed.

Q: pymongo or Motor?
A: pymongo for sync code (Django, Flask, scripts). Motor for async (FastAPI, asyncio). Don’t mix — pick one per service.

Q: How do I migrate schema in a schema-less database?
A: Two strategies. (1) Lazy migration: write code that handles both old and new shapes, update docs as they’re read. (2) Batch migration: a one-off script that walks the collection and rewrites each doc. Lazy scales better.

Q: Should I use MongoDB Atlas or self-host?
A: Atlas for almost everyone. Self-hosting MongoDB correctly (replica sets, backups, monitoring, security) is full-time work for a DBA. Atlas’s free tier is generous and the paid tiers are competitive.

Wrapping Up

MongoDB shines when documents are the natural shape of your data, when you need horizontal scaling, or when you want a quick start with flexible schema. The pymongo driver maps cleanly onto MongoDB’s idioms — once you know find, update_one, aggregate, and indexing, you’ve covered 80% of daily work. For async services, switch to Motor with no API relearning. The remaining 20% — replica sets, sharding, time-series collections — wait until you actually need them.

How To Connect Python to Redis for Caching and Queues

How To Connect Python to Redis for Caching and Queues

Last Updated: June 01, 2026

How To Connect Python to Redis for Caching and Queues

Quick Answer: Redis is a fast, in-memory data store perfect for caching and queues. Install the redis-py library with pip install redis, then connect with r = redis.Redis(host='localhost', port=6379, db=0). Use string operations like r.set() and r.get() for caching, list operations for queues, and pub/sub for real-time messaging. Redis data expires automatically using TTL settings.
Connecting Python to Redis
Redis connects in one line. The caching strategy takes longer.
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 Redis and Its Use Cases

Redis is an open-source, in-memory data structure store that operates at extremely high speeds. Unlike traditional databases that store data on disk, Redis keeps everything in RAM, making it ideal for applications requiring sub-millisecond response times.

Redis is particularly useful for:

  • Caching: Store frequently accessed data to reduce database load
  • Sessions: Store user sessions for web applications
  • Task Queues: Implement job queues with multiple workers
  • Pub/Sub Messaging: Build real-time messaging systems
  • Leaderboards: Track scores and rankings efficiently
  • Rate Limiting: Implement API rate limiting

Installing Redis and redis-py

First, install the Redis server. On macOS using Homebrew:

brew install redis
brew services start redis

On Ubuntu/Debian:

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

Next, install the Python redis library:

pip install redis

Verify Redis is running:

redis-cli ping
# Output: PONG

Check Redis version and info:

redis-cli --version
redis-cli info server
Caching data with Redis
SET and GET are the bread and butter of Redis caching.

Connecting to Redis from Python

Create a basic connection to Redis:

import redis

# Connect to local Redis instance
r = redis.Redis(
    host='localhost',
    port=6379,
    db=0,
    decode_responses=True  # Decode bytes to strings
)

# Test the connection
print(r.ping())  # Output: True
print(r.echo('Hello Redis!'))  # Output: Hello Redis!

For production with password authentication and connection pooling:

import redis
from redis import ConnectionPool

# Create connection pool for better performance
pool = ConnectionPool(
    host='redis.example.com',
    port=6379,
    db=0,
    password='your_password',
    max_connections=50,
    decode_responses=True
)

# Create Redis client from pool
r = redis.Redis(connection_pool=pool)

# Or use URL connection string
r = redis.from_url(
    'redis://:password@redis.example.com:6379/0',
    decode_responses=True
)

String Operations for Caching

Strings are the simplest Redis data type, perfect for caching:

import redis
import json
from datetime import timedelta

r = redis.Redis(decode_responses=True)

# SET and GET operations
r.set('user:1:name', 'John Doe')
name = r.get('user:1:name')
print(name)  # Output: John Doe

# SET with expiration
r.set('session:abc123', 'user_data', ex=3600)  # Expires in 1 hour

# GET with fallback
user_data = r.get('user:2:name')
if not user_data:
    print("User not in cache, fetch from database")
    user_data = 'Jane Smith'
    r.set('user:2:name', user_data, ex=3600)

# Cache JSON data
user_dict = {'id': 3, 'name': 'Bob', 'email': 'bob@example.com'}
r.set('user:3', json.dumps(user_dict), ex=7200)
cached_user = json.loads(r.get('user:3'))
print(cached_user)  # Output: {'id': 3, 'name': 'Bob', 'email': 'bob@example.com'}

# Multiple operations
r.mset({'key1': 'value1', 'key2': 'value2', 'key3': 'value3'})
values = r.mget(['key1', 'key2', 'key3'])
print(values)  # Output: ['value1', 'value2', 'value3']

# Atomic increment/decrement
r.set('page_views', 100)
r.incr('page_views')  # Increment by 1
r.incrby('page_views', 5)  # Increment by 5
r.decr('page_views')  # Decrement by 1
print(r.get('page_views'))  # Output: 105
Setting TTL and expiry
TTL expires your cache before it goes stale. Set it wisely.

Hash Operations for Complex Data

Hashes store multiple fields under a single key, ideal for object data:

import redis

r = redis.Redis(decode_responses=True)

# Store user object as hash
r.hset('user:100', mapping={
    'name': 'Alice Johnson',
    'email': 'alice@example.com',
    'age': '28',
    'city': 'New York'
})

# Get single field
email = r.hget('user:100', 'email')
print(email)  # Output: alice@example.com

# Get all fields
user_data = r.hgetall('user:100')
print(user_data)
# Output: {'name': 'Alice Johnson', 'email': 'alice@example.com', 'age': '28', 'city': 'New York'}

# Get multiple fields
info = r.hmget('user:100', ['name', 'email'])
print(info)  # Output: ['Alice Johnson', 'alice@example.com']

# Update single field
r.hset('user:100', 'age', '29')

# Increment hash field
r.hset('product:1', mapping={
    'name': 'Laptop',
    'price': '999.99',
    'stock': '50'
})
r.hincrbyfloat('product:1', 'price', 100.00)  # Increase price
print(r.hget('product:1', 'price'))  # Output: 1099.99

# Check if field exists
exists = r.hexists('user:100', 'city')
print(exists)  # Output: 1 (True)

# Get all field names
fields = r.hkeys('user:100')
print(fields)  # Output: ['name', 'email', 'age', 'city']

# Get all values
values = r.hvals('user:100')
print(values)  # Output: ['Alice Johnson', 'alice@example.com', '29', 'New York']

List Operations for Queues

Lists are perfect for implementing FIFO queues and job processing:

import redis
import json

r = redis.Redis(decode_responses=True)

# Push items to queue (FIFO)
r.rpush('tasks:queue', 'task1', 'task2', 'task3')

# Get queue length
queue_length = r.llen('tasks:queue')
print(f"Queue has {queue_length} tasks")  # Output: Queue has 3 tasks

# Pop item from queue (blocking, 0 = no timeout)
task = r.blpop('tasks:queue', timeout=0)
print(task)  # Output: ('tasks:queue', 'task1')

# Process with worker pattern
def worker():
    while True:
        # Block until task available
        result = r.blpop('tasks:queue', timeout=1)
        if result:
            queue_name, task = result
            print(f"Processing: {task}")
            # Process task here
        else:
            print("No tasks, waiting...")

# Get all items without removing
all_tasks = r.lrange('tasks:queue', 0, -1)
print(all_tasks)  # Output: ['task2', 'task3']

# Push email to processing queue
email_tasks = [
    json.dumps({'to': 'user1@example.com', 'subject': 'Welcome'}),
    json.dumps({'to': 'user2@example.com', 'subject': 'Newsletter'}),
    json.dumps({'to': 'user3@example.com', 'subject': 'Alert'})
]
for email_task in email_tasks:
    r.rpush('email:queue', email_task)

# Consumer processes emails
while r.llen('email:queue') > 0:
    email_json = r.lpop('email:queue')
    email = json.loads(email_json)
    print(f"Sending email to {email['to']}: {email['subject']}")
Message queues with Redis
Redis pub/sub turns your cache into a message broker.

Set Operations for Unique Data

Sets store unique values and are efficient for membership testing:

import redis

r = redis.Redis(decode_responses=True)

# Add items to set
r.sadd('online_users', 'user1', 'user2', 'user3', 'user4')

# Check membership
is_online = r.sismember('online_users', 'user1')
print(is_online)  # Output: 1 (True)

# Get all members
users = r.smembers('online_users')
print(users)  # Output: {'user1', 'user2', 'user3', 'user4'}

# Get set cardinality (size)
online_count = r.scard('online_users')
print(f"Online users: {online_count}")  # Output: Online users: 4

# Remove items
r.srem('online_users', 'user2')

# Set operations for user tags
r.sadd('interests:user1', 'python', 'javascript', 'databases')
r.sadd('interests:user2', 'python', 'django', 'web')

# Intersection: common interests
common = r.sinter('interests:user1', 'interests:user2')
print(f"Common interests: {common}")  # Output: Common interests: {'python'}

# Union: all interests
all_interests = r.sunion('interests:user1', 'interests:user2')
print(all_interests)

# Difference: unique to user1
unique_to_user1 = r.sdiff('interests:user1', 'interests:user2')
print(unique_to_user1)

Pub/Sub Messaging for Real-Time Communication

Publish/Subscribe pattern enables real-time messaging between applications:

import redis
import threading
import time

# Publisher
def publisher():
    r = redis.Redis(decode_responses=True)
    for i in range(5):
        message = f"Message {i+1}"
        r.publish('chat:room1', message)
        print(f"Published: {message}")
        time.sleep(1)

# Subscriber
def subscriber():
    r = redis.Redis(decode_responses=True)
    pubsub = r.pubsub()
    pubsub.subscribe('chat:room1')

    print("Listening for messages...")
    for message in pubsub.listen():
        if message['type'] == 'message':
            print(f"Received: {message['data']}")

# Run publisher and subscriber in threads
publisher_thread = threading.Thread(target=publisher)
subscriber_thread = threading.Thread(target=subscriber)

subscriber_thread.start()
time.sleep(1)  # Let subscriber start first
publisher_thread.start()

publisher_thread.join()
subscriber_thread.join(timeout=10)

Pattern-based subscriptions:

import redis

r = redis.Redis(decode_responses=True)
pubsub = r.pubsub()

# Subscribe to pattern
pubsub.psubscribe('notifications:*')

# Messages will match notifications:user1, notifications:user2, etc.
for message in pubsub.listen():
    if message['type'] == 'pmessage':
        print(f"Pattern: {message['pattern']}")
        print(f"Channel: {message['channel']}")
        print(f"Message: {message['data']}")
Monitoring Redis performance
Cache hit rates tell you if Redis is earning its keep.

Expiration and Time-To-Live (TTL)

Redis automatically deletes data when TTL expires:

import redis
import time

r = redis.Redis(decode_responses=True)

# Set with expiration in seconds
r.set('temp_session', 'session_data', ex=60)

# Set with expiration at specific timestamp
import datetime
expire_at = datetime.datetime.now() + datetime.timedelta(hours=1)
r.expireat('temp_session', expire_at)

# Set expiration on existing key
r.set('api_token', 'token_123')
r.expire('api_token', 3600)  # Expire in 1 hour

# Get remaining TTL
ttl = r.ttl('api_token')
print(f"TTL: {ttl} seconds")  # Output: TTL: 3599 seconds

# Get TTL in milliseconds
pttl = r.pttl('api_token')
print(f"PTTL: {pttl} ms")

# Make key persistent (remove expiration)
r.persist('api_token')

# Check if key has expiration
ttl = r.ttl('api_token')
print(ttl)  # Output: -1 (no expiration)

# Sliding window rate limiting
def rate_limit(user_id, max_requests=10, window=60):
    key = f"rate_limit:{user_id}"
    current = r.incr(key)
    if current == 1:
        r.expire(key, window)
    return current <= max_requests

# Test rate limiting
for i in range(15):
    allowed = rate_limit('user_123', max_requests=10, window=60)
    print(f"Request {i+1}: {'Allowed' if allowed else 'Blocked'}")

Troubleshooting Common Redis Issues

Issue Cause Solution
Connection refused error Redis server not running Start Redis: redis-server or brew services start redis
Slow performance Memory full or eviction policy misconfigured Check memory: redis-cli info memory. Adjust maxmemory policy
Data loss on restart RDB persistence not enabled Enable RDB in redis.conf or use BGSAVE
Memory usage increasing Keys not expiring or memory leaks Check TTL on keys, implement proper expiration policy
Network timeouts Connection pool exhausted Increase max_connections or reduce concurrent requests
High CPU usage Complex operations or too many clients Optimize operations, limit client connections

Real-Life Example: Session Caching for Web Apps

Here's a complete session management system using Redis:

import redis
import json
import secrets
from datetime import datetime, timedelta

class RedisSessionManager:
    def __init__(self, redis_host='localhost', redis_port=6379):
        self.r = redis.Redis(
            host=redis_host,
            port=redis_port,
            decode_responses=True
        )
        self.session_ttl = 3600  # 1 hour

    def create_session(self, user_id, user_data):
        """Create new session for user"""
        session_id = secrets.token_urlsafe(32)
        session_data = {
            'user_id': user_id,
            'user_data': json.dumps(user_data),
            'created_at': datetime.now().isoformat(),
            'ip_address': '192.168.1.1',
            'user_agent': 'Mozilla/5.0...'
        }

        # Store in Redis hash
        r.hset(f'session:{session_id}', mapping=session_data)
        r.expire(f'session:{session_id}', self.session_ttl)

        # Track user sessions
        r.sadd(f'user_sessions:{user_id}', session_id)

        return session_id

    def get_session(self, session_id):
        """Retrieve session data"""
        session = r.hgetall(f'session:{session_id}')
        if not session:
            return None

        # Refresh TTL on access
        r.expire(f'session:{session_id}', self.session_ttl)

        # Deserialize user data
        session['user_data'] = json.loads(session['user_data'])
        return session

    def update_session(self, session_id, key, value):
        """Update session field"""
        if r.hexists(f'session:{session_id}', 'user_id'):
            r.hset(f'session:{session_id}', key, value)
            r.expire(f'session:{session_id}', self.session_ttl)
            return True
        return False

    def destroy_session(self, session_id):
        """Delete session"""
        user_id = r.hget(f'session:{session_id}', 'user_id')
        r.delete(f'session:{session_id}')
        if user_id:
            r.srem(f'user_sessions:{user_id}', session_id)
        return True

    def get_user_sessions(self, user_id):
        """Get all sessions for user"""
        session_ids = r.smembers(f'user_sessions:{user_id}')
        sessions = []
        for sid in session_ids:
            session = r.hgetall(f'session:{sid}')
            if session:
                sessions.append({'id': sid, 'data': session})
        return sessions

    def invalidate_user_sessions(self, user_id):
        """Logout user from all devices"""
        session_ids = r.smembers(f'user_sessions:{user_id}')
        for sid in session_ids:
            r.delete(f'session:{sid}')
        r.delete(f'user_sessions:{user_id}')
        return True

# Usage
manager = RedisSessionManager()

# Create session
session_id = manager.create_session(
    user_id='user_123',
    user_data={'email': 'user@example.com', 'name': 'John'}
)
print(f"Session created: {session_id}")

# Retrieve session
session = manager.get_session(session_id)
print(f"Session data: {session}")

# Update session
manager.update_session(session_id, 'last_activity', datetime.now().isoformat())

# Get all user sessions
all_sessions = manager.get_user_sessions('user_123')
print(f"User has {len(all_sessions)} active sessions")

# Logout from all devices
manager.invalidate_user_sessions('user_123')

This example demonstrates:

  • Session creation with unique tokens
  • Automatic expiration using TTL
  • Tracking multiple sessions per user
  • Session refresh on access
  • Single logout and multi-device logout
  • JSON serialization for complex data

Redis Best Practices

Follow these guidelines for optimal Redis usage:

  • Use connection pooling: Share a connection pool across your application
  • Set appropriate TTLs: Prevent unbounded memory growth
  • Monitor memory usage: Configure maxmemory and eviction policies
  • Use pipelining: Batch multiple commands for better performance
  • Implement error handling: Handle connection failures gracefully
  • Use hashes for objects: More efficient than storing JSON strings
  • Enable persistence: Use RDB or AOF for durability in production
  • Encrypt sensitive data: Don't store passwords or tokens in plain text

FAQ

Q: Is Redis suitable for permanent data storage?

A: Redis is primarily for caching. Enable RDB or AOF persistence to save data to disk. For permanent data, use a traditional database alongside Redis.

Q: How much data can Redis store?

A: Redis capacity is limited by available RAM. Use Redis Cluster to distribute data across multiple nodes for larger datasets.

Q: Can Redis cluster across multiple machines?

A: Yes, Redis Cluster distributes data across nodes for scalability and high availability with automatic failover.

Q: What is the difference between Pub/Sub and task queues?

A: Pub/Sub broadcasts messages in real-time but loses undelivered messages. Queues store jobs persistently for durable processing. Choose based on your durability requirements.

Q: How do I secure Redis access?

A: Use password authentication, restrict network access via firewall, enable TLS encryption, use private networks, and implement ACLs. Never expose Redis to the internet.

Pipelining for Performance

Each redis-py command is a round-trip to the server. Doing 1,000 SETs takes 1,000 round-trips — at ~0.5ms each that's 500ms of pure latency. Pipelining batches commands into a single round-trip:

import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

# Slow way — 1000 round-trips
for i in range(1000):
    r.set(f"key:{i}", i)

# Fast way — 1 round-trip
pipe = r.pipeline()
for i in range(1000):
    pipe.set(f"key:{i}", i)
pipe.execute()

# 100x faster in practice

Use pipelines for bulk inserts, bulk reads, or any sequence of commands you want to send atomically. The transaction=True default wraps the pipeline in MULTI/EXEC so commands run as a unit; pass transaction=False when you just want batching without atomicity (faster).

Pub/Sub for Real-Time Messaging

Redis pub/sub turns Redis into a lightweight message bus. Producers publish to channels; subscribers receive messages in real time:

import redis
import threading

r = redis.Redis(decode_responses=True)

def subscriber():
    pubsub = r.pubsub()
    pubsub.subscribe("notifications", "alerts")
    for msg in pubsub.listen():
        if msg["type"] == "message":
            print(f"[{msg['channel']}] {msg['data']}")

threading.Thread(target=subscriber, daemon=True).start()

# Publish from anywhere
r.publish("notifications", "User 42 logged in")
r.publish("alerts", "Disk space below 10%")

import time; time.sleep(1)

Pub/sub is fire-and-forget — subscribers must be connected when the message is published. For durable queues use Redis Streams, RabbitMQ, or Kafka.

Caching Patterns with Redis

The most common Redis use case is caching expensive computations or database queries. Two patterns dominate:

Cache-aside: Check cache first, fall back to source, populate cache:

import json
import redis

r = redis.Redis(decode_responses=True)

def get_user(user_id: int) -> dict:
    key = f"user:{user_id}"
    cached = r.get(key)
    if cached:
        return json.loads(cached)
    user = db.query("SELECT * FROM users WHERE id = ?", user_id)
    r.setex(key, 300, json.dumps(user))  # 5-min TTL
    return user

Write-through: Update cache when you update source. Eliminates the stale-cache problem at the cost of slower writes.

def update_user(user_id: int, name: str):
    db.execute("UPDATE users SET name = ? WHERE id = ?", name, user_id)
    user = db.query("SELECT * FROM users WHERE id = ?", user_id)
    r.setex(f"user:{user_id}", 300, json.dumps(user))

Redis as a Queue: BLPOP / RPUSH

Lists make a serviceable queue. Producers RPUSH, consumers BLPOP (blocking pop with timeout):

import redis, json, time

r = redis.Redis(decode_responses=True)

# Producer
r.rpush("jobs", json.dumps({"task": "send_email", "to": "alice@x.com"}))
r.rpush("jobs", json.dumps({"task": "render_pdf", "doc_id": 99}))

# Consumer (in a worker process)
while True:
    item = r.blpop(["jobs"], timeout=10)
    if item is None: continue
    queue, payload = item
    job = json.loads(payload)
    print(f"Processing {job['task']}")

For more features (retries, dead-letter queues, monitoring), reach for RQ, Celery, or Dramatiq. They build on Redis but add the operational layer you'll want past 100 jobs/sec.

Common Pitfalls

  • Forgetting decode_responses. Without decode_responses=True, redis-py returns bytes, not strings. Most code expects strings — pick a side at client creation, not per call.
  • No TTL on cache keys. A cache without expiration is a memory leak. Always SETEX or SET ... EX rather than plain SET.
  • Using KEYS in production. KEYS * blocks Redis on every key in the keyspace. Use SCAN for production traversal.
  • Single connection, many threads. Without a connection pool, threads serialize on the connection. Use redis.ConnectionPool or redis.Redis(connection_pool=pool).
  • Treating pub/sub as durable. Subscribers that disconnect lose messages. If you need durability, use Redis Streams (XADD / XREAD) or a real broker.

FAQ

Q: redis-py or aioredis?
A: redis-py 4.2+ has built-in async support: redis.asyncio.Redis. aioredis was the legacy library; it's been merged into redis-py. Use the unified package.

Q: How big can a Redis value be?
A: 512MB per value. Practically, keep individual values under 1MB — anything bigger is a sign you should use object storage (S3) and cache the metadata.

Q: How do I persist Redis across restarts?
A: Two options — RDB (periodic snapshots) and AOF (append-only log). Default config uses both. For cache-only workloads, you can disable persistence entirely with save "".

Q: Redis vs Memcached?
A: Redis is richer (data structures, pub/sub, transactions, persistence). Memcached is simpler and slightly faster for the narrow get/set use case. Pick Redis for almost everything modern.

Q: Should I run Redis on my web server or separate it out?
A: Separate it once you have more than one web instance. Single-host setups can colocate Redis with the app server; multi-host setups need it on its own.

Wrapping Up

Redis is one of those infrastructure pieces that pays back tenfold once you have it running. Caching, queues, rate limiting, pub/sub, session storage, distributed locks — all in one tiny binary. Start with simple GET / SET against a managed Redis (Redis Cloud, ElastiCache, Upstash), graduate to pipelines and pub/sub when you need them. Don't over-engineer until the load actually demands it.