I spent an embarrassing amount of time early in my career manually wrestling with strings. Slicing at character 80, searching for the last space before a limit, writing loops to add prefixes to each line. It all worked, but it was fragile and ugly.

Then during a code review, someone pointed at my hand-rolled text wrapping function and said, "You know textwrap exists, right?"

I did not know. Here's everything I've learned since.

The Basics: wrap() and fill()

The two functions you'll use most often are wrap() and fill(). They do essentially the same thing—break text to fit a width—but return different formats.

import textwrap
 
description = "This is a detailed description of the feature that explains what it does, why it matters, and how users should interact with it."
 
# wrap() returns a list of lines
lines = textwrap.wrap(description, width=50)
print(lines)
# ['This is a detailed description of the feature',
#  'that explains what it does, why it matters, and',
#  'how users should interact with it.']
 
# fill() returns a single string with newlines
formatted = textwrap.fill(description, width=50)
print(formatted)
# This is a detailed description of the feature
# that explains what it does, why it matters, and
# how users should interact with it.

When do you use which? Use wrap() when you need to process each line individually (adding bullets, numbering, etc.). Use fill() when you just want the final formatted text.

The width parameter is smarter than you'd think

I assumed width was a hard character limit. It's not—it's a target. The module won't break in the middle of a word by default, so lines can technically be slightly shorter than the width. This is actually what you want for readable output.

# Words won't be split mid-word
text = "Supercalifragilisticexpialidocious is a word"
print(textwrap.fill(text, width=20))
# Supercalifragilisticexpialidocious
# is a word
 
# The long word stays intact (unless you tell it otherwise)

dedent(): The Multiline String Savior

This one changed how I write code. Before dedent(), I had a constant struggle with multiline strings:

# Option 1: No indentation (ugly in code)
sql = """SELECT name, email
FROM users
WHERE active = true
ORDER BY created_at"""
 
# Option 2: Indentation (ugly in output)
def get_query():
    sql = """
        SELECT name, email
        FROM users
        WHERE active = true
        ORDER BY created_at
    """
    return sql  # Now has tons of leading whitespace!

dedent() solves this perfectly:

import textwrap
 
def get_query():
    sql = textwrap.dedent("""
        SELECT name, email
        FROM users
        WHERE active = true
        ORDER BY created_at
    """).strip()
    return sql
 
print(get_query())
# SELECT name, email
# FROM users
# WHERE active = true
# ORDER BY created_at

The magic: dedent() finds the common leading whitespace across all lines and removes it. Your code stays cleanly indented, your output stays clean.

The pattern I use everywhere

import textwrap
 
def make_message(name: str, items: list[str]) -> str:
    item_list = "\n".join(f"  - {item}" for item in items)
    return textwrap.dedent(f"""
        Hello {name},
 
        Your order contains:
        {item_list}
 
        Thanks for your purchase!
    """).strip()
 
print(make_message("Alice", ["Widget", "Gadget", "Sprocket"]))
# Hello Alice,
#
# Your order contains:
#   - Widget
#   - Gadget
#   - Sprocket
#
# Thanks for your purchase!

I use this pattern constantly for email templates, error messages, help text—anything multiline.

indent(): Adding Prefixes to Lines

indent() is dedent()'s opposite—it adds a prefix to each line:

import textwrap
 
code = """def hello():
    print("world")
    return True"""
 
# Add 4 spaces to every line
indented = textwrap.indent(code, "    ")
print(indented)
#     def hello():
#         print("world")
#         return True
 
# Add quote markers for email replies
quoted = textwrap.indent(code, "> ")
print(quoted)
# > def hello():
# >     print("world")
# >     return True

The predicate parameter

Here's a detail I didn't discover for months: indent() takes an optional predicate function that controls which lines get indented:

import textwrap
 
text = """Line one
 
Line three
 
Line five"""
 
# Default: indent all lines (including blank ones)
print(textwrap.indent(text, "> "))
# > Line one
# > 
# > Line three
# > 
# > Line five
 
# Skip blank lines
print(textwrap.indent(text, "> ", predicate=lambda line: line.strip()))
# > Line one
 
# > Line three
 
# > Line five

This matters for things like log output where you want blank lines to stay blank.

shorten(): Truncating with Ellipsis

When you need to fit text into a limited space—a notification, a table cell, a tweet preview—shorten() handles it:

import textwrap
 
headline = "Python 3.12 Released with Major Performance Improvements and New Syntax Features"
 
# Truncate to 50 characters
short = textwrap.shorten(headline, width=50)
print(short)  # 'Python 3.12 Released with Major Performance [...]'
 
# Custom placeholder
short = textwrap.shorten(headline, width=50, placeholder="...")
print(short)  # 'Python 3.12 Released with Major Performance...'
 
# Shorter placeholder = more room for text
short = textwrap.shorten(headline, width=50, placeholder="…")
print(short)  # 'Python 3.12 Released with Major Performance…'

shorten() is smarter than slicing

It doesn't just cut at character 50. It finds the last complete word that fits:

# Naive approach breaks words
text = "Hello wonderful world"
print(text[:15] + "...")  # 'Hello wonderful...'  (lucky this time)
 
text = "Hello incredibly beautiful world"
print(text[:20] + "...")  # 'Hello incredibly bea...'  (yikes)
 
# shorten() keeps words intact
print(textwrap.shorten(text, width=23, placeholder="..."))
# 'Hello incredibly...'

TextWrapper: When You Need Control

For one-off wrapping, the module functions work great. But when you're formatting lots of text with the same settings, or need options beyond the defaults, create a TextWrapper instance:

import textwrap
 
# Create a reusable wrapper
wrapper = textwrap.TextWrapper(
    width=60,
    initial_indent="  ",      # First line gets this
    subsequent_indent="    ", # Other lines get this
    break_long_words=False,   # Don't split URLs, etc.
)
 
# Use it repeatedly
for paragraph in paragraphs:
    print(wrapper.fill(paragraph))
    print()

The options that actually matter

Here are the TextWrapper options I find myself using:

wrapper = textwrap.TextWrapper(
    # Core settings
    width=70,
    
    # Indentation
    initial_indent="• ",       # Bullet for first line
    subsequent_indent="  ",    # Continuation indent
    
    # Word handling
    break_long_words=False,    # Keep long words intact
    break_on_hyphens=True,     # Break "self-documenting" at hyphen
    
    # For shorten()-like behavior
    max_lines=3,               # Limit to 3 lines
    placeholder=" [more]",    # What to show when truncated
)

Real-World Patterns

Here's where it all comes together.

CLI Help Text Formatting

When building command-line tools, you want help text that wraps nicely and aligns properly:

import textwrap
import shutil
 
def print_command_help(commands: dict[str, str]):
    """Print help for a dict of command: description pairs."""
    term_width = shutil.get_terminal_size().columns
    
    for cmd, description in commands.items():
        # Wrap description with hanging indent
        wrapper = textwrap.TextWrapper(
            width=min(term_width, 80),
            initial_indent=f"  {cmd:<12} ",
            subsequent_indent="               ",  # 15 spaces
        )
        print(wrapper.fill(description))
 
commands = {
    "init": "Initialize a new project with the default configuration files and directory structure.",
    "build": "Build the project, compiling source files and running any preprocessors.",
    "deploy": "Deploy to production. Requires valid credentials in ~/.config/myapp/credentials.",
}
 
print_command_help(commands)
#   init         Initialize a new project with the default
#                configuration files and directory structure.
#   build        Build the project, compiling source files
#                and running any preprocessors.
#   deploy       Deploy to production. Requires valid
#                credentials in ~/.config/myapp/credentials.

Message Templates with dedent

For user-facing messages, I combine dedent() with f-strings:

import textwrap
from datetime import datetime
 
def format_notification(user: str, event: str, details: str) -> str:
    return textwrap.dedent(f"""
        Hi {user},
 
        {event}
 
        Details:
        {textwrap.indent(details, "  ")}
 
        Time: {datetime.now().strftime("%Y-%m-%d %H:%M")}
        
        —Your App
    """).strip()
 
notification = format_notification(
    "Alice",
    "Your backup completed successfully.",
    "Files backed up: 1,234\nTotal size: 2.3 GB\nDuration: 4m 32s"
)
print(notification)
# Hi Alice,
#
# Your backup completed successfully.
#
# Details:
#   Files backed up: 1,234
#   Total size: 2.3 GB
#   Duration: 4m 32s
#
# Time: 2026-03-22 19:30
#
# —Your App

Log Message Formatting

For log output that needs to be readable:

import textwrap
 
def log_error(message: str, context: dict | None = None):
    wrapper = textwrap.TextWrapper(
        width=80,
        initial_indent="[ERROR] ",
        subsequent_indent="        ",  # 8 spaces
    )
    print(wrapper.fill(message))
    
    if context:
        for key, value in context.items():
            print(f"        {key}: {value}")
 
log_error(
    "Failed to connect to database after 3 retries. The connection was refused, which usually indicates the server is not running or the port is blocked.",
    {"host": "db.example.com", "port": 5432, "timeout": "30s"}
)
# [ERROR] Failed to connect to database after 3 retries. The connection was
#         refused, which usually indicates the server is not running or the
#         port is blocked.
#         host: db.example.com
#         port: 5432
#         timeout: 30s

Table Cell Truncation

When displaying data in fixed-width columns:

import textwrap
 
def format_table_row(name: str, description: str, status: str):
    short_desc = textwrap.shorten(description, width=40, placeholder="…")
    return f"{name:<15} {short_desc:<42} {status}"
 
rows = [
    ("user-service", "Handles authentication, authorization, and user profile management", "running"),
    ("api-gateway", "Routes requests to microservices", "running"),
    ("payment-proc", "Processes payments including credit cards, PayPal, and cryptocurrency", "error"),
]
 
print(f"{'SERVICE':<15} {'DESCRIPTION':<42} STATUS")
print("-" * 65)
for name, desc, status in rows:
    print(format_table_row(name, desc, status))
# SERVICE         DESCRIPTION                                STATUS
# -----------------------------------------------------------------
# user-service    Handles authentication, authorization,…    running
# api-gateway     Routes requests to microservices           running
# payment-proc    Processes payments including credit…       error

Quick Reference

import textwrap
 
# Wrap to list of lines
lines = textwrap.wrap(text, width=70)
 
# Wrap to string with newlines
formatted = textwrap.fill(text, width=70)
 
# Remove common indentation
cleaned = textwrap.dedent(multiline_string).strip()
 
# Add prefix to each line
quoted = textwrap.indent(text, "> ")
 
# Truncate with ellipsis
short = textwrap.shorten(text, width=50, placeholder="...")
 
# Reusable wrapper with custom settings
wrapper = textwrap.TextWrapper(width=60, initial_indent="• ")
output = wrapper.fill(text)

The textwrap module isn't flashy, but once you know it exists, you'll reach for it constantly. No more manual string slicing, no more hand-rolled wrapping loops, no more fighting with indentation in multiline strings.

Just clean, readable text output.

React to this post: