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_atThe 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 TrueThe 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 fiveThis 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 AppLog 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: 30sTable 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… errorQuick 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.